Skip to content

Commit 1f19ec6

Browse files
authored
Arm backend: Add support for building selective prim ops. (#21714)
Prim ops are rare, but take up 12KiB in our example runner. This is due to the executorch library consisting of executorch_core + prim ops, and it is linked with --whole-archive. Add an option to arm_runner_create_selected_ops_lib, PRIM_OPS, to generate prim ops. They are built differently than other operator libraries, so treat it as a special case. Outwards, the api should look the same. Use this in the example runner so that the executorch lib no longer needs --whole-archive for prim ops. Use this in the example executor_runner. For other builds, link to executorch_core instead of executorch. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani --------- Signed-off-by: Erik Lundell <erik.lundell@arm.com>
1 parent cc853ae commit 1f19ec6

8 files changed

Lines changed: 276 additions & 41 deletions

File tree

backends/arm/cmake/ArmRunnerUtils.cmake

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,77 @@ function(arm_runner_require_baremetal_targets)
3131

3232
endfunction()
3333

34+
function(verify_targets_exist)
35+
cmake_parse_arguments(ARG "" "CONTEXT" "TARGETS" ${ARGN})
36+
37+
set(_targets ${ARG_TARGETS} ${ARG_UNPARSED_ARGUMENTS})
38+
if(NOT ARG_CONTEXT)
39+
set(ARG_CONTEXT "verify_targets_exist")
40+
endif()
41+
42+
foreach(_target IN LISTS _targets)
43+
if(NOT TARGET ${_target})
44+
message(FATAL_ERROR "${ARG_CONTEXT} requires missing target ${_target}.")
45+
endif()
46+
endforeach()
47+
endfunction()
48+
49+
# Private function to isolate logic for building prim ops library.
50+
function(_arm_runner_create_prim_ops_lib)
51+
# Parse arguments.
52+
set(options INCLUDE_ALL_OPS)
53+
set(one_value_args LIB_NAME SELECTED_OPS_YAML)
54+
set(multi_value_args DEPS)
55+
cmake_parse_arguments(
56+
ARG "${options}" "${one_value_args}" "${multi_value_args}" ${ARGN}
57+
)
58+
59+
set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}/${ARG_LIB_NAME}")
60+
set(_sources ${EXECUTORCH_ROOT}/kernels/prim_ops/register_prim_ops.cpp)
61+
62+
# Generate selected operator list if needed.
63+
if(NOT ARG_INCLUDE_ALL_OPS)
64+
set(_selected_prim_ops_header "${_out_dir}/selected_prim_ops.h")
65+
set(_gen_selected_prim_ops_script
66+
"${EXECUTORCH_ROOT}/codegen/tools/gen_selected_prim_ops.py"
67+
)
68+
set(_gen_selected_prim_ops_command
69+
"${PYTHON_EXECUTABLE}" -m codegen.tools.gen_selected_prim_ops
70+
--op-selection-yaml-path=${ARG_SELECTED_OPS_YAML}
71+
--output-dir=${_out_dir}
72+
)
73+
add_custom_command(
74+
COMMENT "Generating selected_prim_ops.h for ${ARG_LIB_NAME}"
75+
OUTPUT ${_selected_prim_ops_header}
76+
COMMAND ${_gen_selected_prim_ops_command}
77+
DEPENDS ${ARG_SELECTED_OPS_YAML} ${_gen_selected_prim_ops_script}
78+
WORKING_DIRECTORY ${EXECUTORCH_ROOT}
79+
)
80+
list(APPEND _sources ${_selected_prim_ops_header})
81+
endif()
82+
83+
# Add prim ops registration library.
84+
add_library(${ARG_LIB_NAME} ${_sources})
85+
target_include_directories(
86+
${ARG_LIB_NAME} PRIVATE ${EXECUTORCH_ROOT}/kernels/prim_ops ${_out_dir}
87+
)
88+
if(NOT ARG_INCLUDE_ALL_OPS)
89+
target_compile_definitions(
90+
${ARG_LIB_NAME} PRIVATE ET_PRIM_OPS_SELECTIVE_BUILD
91+
EXECUTORCH_ENABLE_PRIM_OPS_SELECTIVE_BUILD
92+
)
93+
endif()
94+
target_link_libraries(${ARG_LIB_NAME} PRIVATE ${ARG_DEPS})
95+
executorch_target_link_options_shared_lib(${ARG_LIB_NAME})
96+
97+
# Add prim ops kernel library.
98+
add_library(
99+
${ARG_LIB_NAME}_impl ${EXECUTORCH_ROOT}/kernels/prim_ops/et_copy_index.cpp
100+
${EXECUTORCH_ROOT}/kernels/prim_ops/et_view.cpp
101+
)
102+
target_link_libraries(${ARG_LIB_NAME}_impl PRIVATE ${ARG_DEPS})
103+
endfunction()
104+
34105
#[[
35106
Create a selected operator registration library for one operator family.
36107
@@ -62,12 +133,16 @@ Arguments:
62133
INCLUDE_ALL_OPS: Generate a non-selective registration library for the
63134
selected operator family.
64135
136+
PRIM_OPS: Build a selective prim ops library instead of a codegen operators lib.
137+
65138
The lib will always be created, even when no operators are selected.
66139
OP_LIST and OPS_FROM_MODEL can be used additively.
140+
If neither OP_LIST nor OPS_FROM_MODEL is set, the generated registration
141+
library is empty. INCLUDE_ALL_OPS explicitly builds a full registration library.
67142
]]
68143
function(arm_runner_create_selected_ops_lib)
69144
# Parse arguments
70-
set(options INCLUDE_ALL_OPS)
145+
set(options INCLUDE_ALL_OPS PRIM_OPS)
71146
set(one_value_args LIB_NAME FUNCTIONS_YAML CUSTOM_OPS_YAML OP_LIST
72147
OPS_FROM_MODEL DTYPE_SELECTIVE_BUILD
73148
)
@@ -101,10 +176,13 @@ function(arm_runner_create_selected_ops_lib)
101176
)
102177
endif()
103178

104-
if(NOT ARG_FUNCTIONS_YAML AND NOT ARG_CUSTOM_OPS_YAML)
179+
if(NOT ARG_PRIM_OPS
180+
AND NOT ARG_FUNCTIONS_YAML
181+
AND NOT ARG_CUSTOM_OPS_YAML
182+
)
105183
message(
106184
FATAL_ERROR
107-
"${_arm_runner_create_selected_ops_lib_context} requires FUNCTIONS_YAML or CUSTOM_OPS_YAML."
185+
"${_arm_runner_create_selected_ops_lib_context} requires FUNCTIONS_YAML or CUSTOM_OPS_YAML unless PRIM_OPS is set."
108186
)
109187
endif()
110188

@@ -128,11 +206,25 @@ function(arm_runner_create_selected_ops_lib)
128206
DTYPE_SELECTIVE_BUILD
129207
"${ARG_DTYPE_SELECTIVE_BUILD}"
130208
)
209+
set(_arm_runner_include_all_ops OFF)
131210
if(ARG_INCLUDE_ALL_OPS)
211+
set(_arm_runner_include_all_ops ON)
132212
list(APPEND _arm_runner_selected_ops_args INCLUDE_ALL_OPS "ON")
133213
endif()
134214
gen_selected_ops(${_arm_runner_selected_ops_args})
135215

216+
if(ARG_PRIM_OPS)
217+
set(_arm_runner_prim_ops_args
218+
LIB_NAME "${ARG_LIB_NAME}" SELECTED_OPS_YAML
219+
"${gen_selected_ops_output_yaml}" DEPS ${ARG_DEPS}
220+
)
221+
if(_arm_runner_include_all_ops)
222+
list(APPEND _arm_runner_prim_ops_args INCLUDE_ALL_OPS)
223+
endif()
224+
_arm_runner_create_prim_ops_lib(${_arm_runner_prim_ops_args})
225+
return()
226+
endif()
227+
136228
# Codegen for operator library.
137229
set(_arm_ops_binding_args LIB_NAME "${ARG_LIB_NAME}")
138230
if(ARG_FUNCTIONS_YAML)
@@ -198,21 +290,6 @@ function(arm_runner_configure_runtime_output TARGET_NAME FALLBACK_DIR)
198290
endif()
199291
endfunction()
200292

201-
function(verify_targets_exist)
202-
cmake_parse_arguments(ARG "" "CONTEXT" "TARGETS" ${ARGN})
203-
204-
set(_targets ${ARG_TARGETS} ${ARG_UNPARSED_ARGUMENTS})
205-
if(NOT ARG_CONTEXT)
206-
set(ARG_CONTEXT "verify_targets_exist")
207-
endif()
208-
209-
foreach(_target IN LISTS _targets)
210-
if(NOT TARGET ${_target})
211-
message(FATAL_ERROR "${ARG_CONTEXT} requires missing target ${_target}.")
212-
endif()
213-
endforeach()
214-
endfunction()
215-
216293
# Link the provided target with minimal specs. This minimizes code size, but
217294
# comes with limited support. Notably, printing with %zu is not supported.
218295
function(arm_runner_link_minimal_specs TARGET_NAME)

backends/arm/test/test_arm_backend.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ test_run_ethos_u55() {
187187
# Cortex-M op tests
188188
echo "${TEST_SUITE_NAME}: Test target Cortex-M55 (on Ethos-U55)"
189189
examples/arm/run.sh --et_build_root=arm_test/test_run --target=ethos-u55-128 --model_name=add --bundleio --no_delegate --select_ops_list="aten::add.out"
190+
examples/arm/run.sh --et_build_root=arm_test/test_run --target=ethos-u55-128 --model_name=examples/arm/example_modules/prim_ops.py --bundleio --no_delegate --no_quantize --select_ops_list="aten::add.out,aten::select_copy.int_out"
190191
examples/arm/run.sh --et_build_root=arm_test/test_run --target=ethos-u55-128 --model_name=qadd --bundleio
191192
examples/arm/run.sh --et_build_root=arm_test/test_run --target=ethos-u55-128 --model_name=qops --bundleio
192193
examples/arm/run.sh --et_build_root=arm_test/test_run --target=ethos-u55-128 --model_name=qops --bundleio --no_delegate --select_ops_list="aten::sub.out,aten::add.out,aten::mul.out"

backends/cortex_m/CMakeLists.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ if(EXECUTORCH_BUILD_CORTEX_M)
128128
target_link_libraries(
129129
cortex_m_kernels
130130
PRIVATE cmsis-nn
131-
PRIVATE executorch
131+
PRIVATE executorch_core
132132
PRIVATE kernels_util_all_deps
133133
)
134134
target_compile_definitions(
@@ -143,7 +143,8 @@ if(EXECUTORCH_BUILD_CORTEX_M)
143143
)
144144

145145
gen_operators_lib(
146-
LIB_NAME "cortex_m_ops_lib" KERNEL_LIBS cortex_m_kernels DEPS executorch
146+
LIB_NAME "cortex_m_ops_lib" KERNEL_LIBS cortex_m_kernels DEPS
147+
executorch_core
147148
)
148149

149150
install(

codegen/tools/gen_selected_prim_ops.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Copyright (c) Meta Platforms, Inc. and affiliates.
22
# All rights reserved.
3+
# Copyright 2026 Arm Limited and/or its affiliates.
34
#
45
# This source code is licensed under the BSD-style license found in the
56
# LICENSE file in the root directory of this source tree.
@@ -8,9 +9,11 @@
89

910
import argparse
1011
import os
12+
import re
1113
import sys
1214
from typing import Any, List
1315

16+
import yaml
1417
from torchgen.code_template import CodeTemplate # type: ignore[import-not-found]
1518

1619

@@ -34,13 +37,32 @@ def normalize_op_name(op_name: str) -> str:
3437
normalized = op_name.replace("::", "_")
3538
# Replace dots with underscores
3639
normalized = normalized.replace(".", "_")
40+
# Collapse cases like aten::_local_scalar_dense to the macro spelling used
41+
# by register_prim_ops.cpp.
42+
normalized = re.sub("_+", "_", normalized)
3743
# Convert to uppercase
3844
normalized = normalized.upper()
3945
# Add INCLUDE_ prefix
4046
normalized = f"INCLUDE_{normalized}"
4147
return normalized
4248

4349

50+
def read_prim_op_names_from_yaml(op_selection_yaml_path: str) -> List[str]:
51+
with open(op_selection_yaml_path, "r") as f:
52+
selected_operators = yaml.safe_load(f) or {}
53+
54+
prim_op_names = set()
55+
operators = selected_operators.get("operators", {})
56+
if operators:
57+
prim_op_names.update(operators.keys())
58+
59+
et_kernel_metadata = selected_operators.get("et_kernel_metadata", {})
60+
if et_kernel_metadata:
61+
prim_op_names.update(et_kernel_metadata.keys())
62+
63+
return sorted(prim_op_names)
64+
65+
4466
def write_selected_prim_ops(prim_op_names: List[str], output_dir: str) -> None:
4567
"""
4668
Generate selected_prim_ops.h from a list of prim op names.
@@ -73,7 +95,13 @@ def main(argv: List[Any]) -> None:
7395
"--prim-op-names",
7496
"--prim_op_names",
7597
help="Comma-separated list of prim op names to include",
76-
required=True,
98+
required=False,
99+
)
100+
parser.add_argument(
101+
"--op-selection-yaml-path",
102+
"--op_selection_yaml_path",
103+
help="Path to selected_operators.yaml containing prim op names to include",
104+
required=False,
77105
)
78106
parser.add_argument(
79107
"--output-dir",
@@ -84,12 +112,21 @@ def main(argv: List[Any]) -> None:
84112

85113
options = parser.parse_args(argv)
86114

87-
# Parse comma-separated prim op names
88-
prim_op_names = [
89-
name.strip() for name in options.prim_op_names.split(",") if name.strip()
90-
]
91-
92-
write_selected_prim_ops(prim_op_names, options.output_dir)
115+
if options.prim_op_names is None and not options.op_selection_yaml_path:
116+
parser.error("one of --prim-op-names or --op-selection-yaml-path is required")
117+
118+
prim_op_names: List[str] = []
119+
if options.prim_op_names is not None:
120+
# Parse comma-separated prim op names
121+
prim_op_names.extend(
122+
name.strip() for name in options.prim_op_names.split(",") if name.strip()
123+
)
124+
if options.op_selection_yaml_path:
125+
prim_op_names.extend(
126+
read_prim_op_names_from_yaml(options.op_selection_yaml_path)
127+
)
128+
129+
write_selected_prim_ops(sorted(set(prim_op_names)), options.output_dir)
93130

94131

95132
if __name__ == "__main__":

codegen/tools/targets.bzl

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ def define_common_targets(is_fbcode = False):
172172
srcs = ["gen_selected_prim_ops.py"],
173173
base_module = "executorch.codegen.tools",
174174
visibility = ["//executorch/..."],
175+
deps = [
176+
"fbsource//third-party/pypi/pyyaml:pyyaml",
177+
],
175178
external_deps = ["torchgen"],
176179
)
177180

@@ -188,7 +191,21 @@ def define_common_targets(is_fbcode = False):
188191
_is_external_target = True,
189192
)
190193

191-
194+
runtime.python_test(
195+
name = "test_gen_selected_prim_ops",
196+
srcs = [
197+
"test/test_gen_selected_prim_ops.py",
198+
],
199+
package_style = "inplace",
200+
visibility = [
201+
"PUBLIC",
202+
],
203+
deps = [
204+
":gen_selected_prim_ops_lib",
205+
],
206+
_is_external_target = True,
207+
)
208+
192209
runtime.cxx_python_extension(
193210
name = "selective_build",
194211
srcs = [
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Copyright 2026 Arm Limited and/or its affiliates.
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
import tempfile
7+
import unittest
8+
from pathlib import Path
9+
10+
import yaml
11+
12+
from executorch.codegen.tools.gen_selected_prim_ops import (
13+
normalize_op_name,
14+
write_selected_prim_ops,
15+
)
16+
17+
18+
class TestGenSelectedPrimOps(unittest.TestCase):
19+
def test_normalizes_aten_op_with_leading_underscore(self) -> None:
20+
self.assertEqual(
21+
normalize_op_name("aten::_local_scalar_dense"),
22+
"INCLUDE_ATEN_LOCAL_SCALAR_DENSE",
23+
)
24+
25+
def test_writes_selected_prim_ops_from_yaml(self) -> None:
26+
with tempfile.TemporaryDirectory() as temp_dir:
27+
yaml_path = Path(temp_dir) / "selected_operators.yaml"
28+
yaml_path.write_text(
29+
yaml.safe_dump(
30+
{
31+
"operators": {
32+
"executorch_prim::et_view.default": {},
33+
"aten::_local_scalar_dense": {},
34+
},
35+
"et_kernel_metadata": {
36+
"aten::sym_size.int": ["default"],
37+
},
38+
}
39+
)
40+
)
41+
42+
from executorch.codegen.tools.gen_selected_prim_ops import main
43+
44+
main(
45+
[
46+
f"--op-selection-yaml-path={yaml_path}",
47+
f"--output-dir={temp_dir}",
48+
]
49+
)
50+
51+
header = (Path(temp_dir) / "selected_prim_ops.h").read_text()
52+
self.assertIn("#define INCLUDE_ATEN_LOCAL_SCALAR_DENSE", header)
53+
self.assertIn("#define INCLUDE_ATEN_SYM_SIZE_INT", header)
54+
self.assertIn("#define INCLUDE_EXECUTORCH_PRIM_ET_VIEW_DEFAULT", header)
55+
56+
def test_writes_empty_header_for_empty_op_list(self) -> None:
57+
with tempfile.TemporaryDirectory() as temp_dir:
58+
write_selected_prim_ops([], temp_dir)
59+
60+
header = (Path(temp_dir) / "selected_prim_ops.h").read_text()
61+
self.assertNotIn("#define INCLUDE_", header)
62+
63+
64+
if __name__ == "__main__":
65+
unittest.main()

0 commit comments

Comments
 (0)