From 5416595a0bb0e4c6e0fabd1b57094d78122ae8a5 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Thu, 30 Jul 2026 23:23:23 -0700 Subject: [PATCH 01/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 175 ++++++++++++++++ .ci/scripts/wheel/test_linux.py | 9 + .ci/scripts/wheel/test_linux_aarch64.py | 10 + .../workflows/build-wheels-aarch64-linux.yml | 2 + .github/workflows/build-wheels-linux.yml | 2 + .github/workflows/build-wheels-macos.yml | 2 + .github/workflows/build-wheels-windows.yml | 2 + CMakeLists.txt | 188 ++++++++++++------ codegen/tools/CMakeLists.txt | 16 +- devtools/bundled_program/CMakeLists.txt | 9 +- devtools/etdump/CMakeLists.txt | 14 +- extension/llm/custom_ops/CMakeLists.txt | 17 +- extension/llm/runner/CMakeLists.txt | 6 + extension/training/CMakeLists.txt | 27 ++- kernels/quantized/CMakeLists.txt | 5 + setup.py | 25 +++ tools/cmake/Codegen.cmake | 3 +- tools/cmake/Utils.cmake | 29 +++ tools/cmake/executorch-wheel-config.cmake | 73 ++++++- tools/cmake/preset/pybind.cmake | 5 + 20 files changed, 535 insertions(+), 84 deletions(-) create mode 100644 .ci/scripts/wheel/test_cpp_sdk.py diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py new file mode 100644 index 00000000000..c2e729eb1f2 --- /dev/null +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -0,0 +1,175 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Checks that the installed wheel is usable as a C++ SDK. + +The wheel ships a prebuilt runtime library plus a CMake package config, so a +standalone application can find_package(executorch) and link +executorch::runtime without building ExecuTorch from source. These checks run +against the installed wheel only; they never look at the source tree's build +directory. + +Two properties are verified: + +1. Exactly one shipped library defines the backend registry. Backends register + into a process-wide table owned by the runtime, so a second definition would + silently give the process two tables and let a backend register into the one + nobody reads. +2. A C++ consumer builds and runs against the wheel, and records a dependency + on the shipped runtime with a relocatable RUNPATH. +""" + +import os +import re +import shutil +import subprocess +from pathlib import Path + +# Registry entry points. A second definer of any of these means a second +# process-wide registry. +_REGISTRY_SYMBOLS = ( + "executorch::runtime::register_backend", + "executorch::runtime::get_num_registered_backends", + "executorch::runtime::get_backend_class", +) + +# `nm -DC` prints " " for a definition and +# " U " for an undefined reference. +_DEFINED = re.compile(r"^[0-9a-fA-F]+\s+(?P[A-Za-z])\s+(?P.+)$") + +# Symbol kinds that mean the object owns the code or storage. +_OWNING_KINDS = frozenset("TtBbDdGgSsRrWV") + +_CONSUMER_SOURCE = """\ +#include +#include + +#include + +int main() { + executorch::runtime::runtime_init(); + std::printf( + "registered backends: %zu\\n", + (size_t)executorch::runtime::get_num_registered_backends()); + return 0; +} +""" + +_CONSUMER_CMAKE = """\ +cmake_minimum_required(VERSION 3.24) +project(executorch_wheel_consumer CXX) +find_package(executorch REQUIRED) +add_executable(consumer consumer.cpp) +target_link_libraries(consumer PRIVATE executorch::runtime) +""" + + +def _installed_package_dir() -> Path: + """The installed executorch package, never the source checkout.""" + import executorch + + return Path(list(executorch.__path__)[0]).resolve() + + +def _shipped_shared_objects(package_dir: Path): + return [ + path + for path in sorted(package_dir.rglob("*.so*")) + if path.is_file() and not path.is_symlink() + ] + + +def _defines_symbol(library: Path, symbol: str) -> bool: + result = subprocess.run( + ["nm", "-DC", str(library)], capture_output=True, text=True, check=False + ) + for line in result.stdout.splitlines(): + if symbol not in line: + continue + match = _DEFINED.match(line) + if ( + match + and match.group("name").startswith(symbol) + and match.group("kind") in _OWNING_KINDS + ): + return True + return False + + +def test_single_backend_registry() -> None: + """Exactly one shipped library may define the backend registry.""" + assert shutil.which("nm") is not None, "nm is required to inspect the wheel" + + package_dir = _installed_package_dir() + libraries = _shipped_shared_objects(package_dir) + assert libraries, f"no shared libraries found under {package_dir}" + + for symbol in _REGISTRY_SYMBOLS: + definers = [lib for lib in libraries if _defines_symbol(lib, symbol)] + pretty = [str(lib.relative_to(package_dir)) for lib in definers] + assert len(definers) == 1, ( + f"expected exactly one library to define {symbol}, found " + f"{len(definers)}: {pretty}. More than one definition means the " + f"process has more than one backend registry." + ) + print(f"✓ single backend registry across {len(libraries)} shipped libraries") + + +def test_cpp_consumer(work_dir: Path) -> None: + """A standalone C++ app builds and runs against the installed wheel.""" + assert shutil.which("cmake") is not None, "cmake is required to build a consumer" + + package_dir = _installed_package_dir() + config = package_dir / "share" / "cmake" / "executorch-config.cmake" + assert config.is_file(), f"wheel is missing its CMake package config: {config}" + + source_dir = work_dir / "consumer" + build_dir = work_dir / "consumer-build" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "consumer.cpp").write_text(_CONSUMER_SOURCE) + (source_dir / "CMakeLists.txt").write_text(_CONSUMER_CMAKE) + + subprocess.run( + [ + "cmake", + "-S", + str(source_dir), + "-B", + str(build_dir), + f"-DCMAKE_PREFIX_PATH={config.parent}", + ], + check=True, + ) + subprocess.run(["cmake", "--build", str(build_dir)], check=True) + + consumer = build_dir / "consumer" + # No LD_LIBRARY_PATH: the imported target is responsible for making the + # shipped runtime findable. + environment = { + key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" + } + subprocess.run([str(consumer)], check=True, env=environment) + print("✓ C++ consumer builds and runs against the installed wheel") + + assert shutil.which("readelf") is not None, "readelf is required to check the ELF" + + dynamic = subprocess.run( + ["readelf", "-d", str(consumer)], capture_output=True, text=True, check=True + ).stdout + assert "libexecutorch.so" in dynamic, ( + "the consumer does not depend on the shipped runtime; " + f"dynamic section was:\n{dynamic}" + ) + assert "$ORIGIN" in dynamic, ( + "the consumer has no $ORIGIN-relative RUNPATH, so it is not " + f"relocatable; dynamic section was:\n{dynamic}" + ) + print("✓ consumer depends on the shipped runtime with a relocatable RUNPATH") + + +def run_tests(work_dir: Path) -> None: + test_single_backend_registry() + test_cpp_consumer(work_dir) diff --git a/.ci/scripts/wheel/test_linux.py b/.ci/scripts/wheel/test_linux.py index 812eec89215..532eb850d94 100644 --- a/.ci/scripts/wheel/test_linux.py +++ b/.ci/scripts/wheel/test_linux.py @@ -7,8 +7,11 @@ # LICENSE file in the root directory of this source tree. import platform +import tempfile +from pathlib import Path import test_base +import test_cpp_sdk from examples.models import Backend, Model if __name__ == "__main__": @@ -41,6 +44,12 @@ test_base.test_cmsis_nn_install() + # The wheel ships a prebuilt C++ runtime and a CMake package config, so + # check that a standalone application can actually link and run against + # them, and that the process still has a single backend registry. + with tempfile.TemporaryDirectory() as work_dir: + test_cpp_sdk.run_tests(Path(work_dir)) + test_base.run_tests( model_tests=[ test_base.ModelTest( diff --git a/.ci/scripts/wheel/test_linux_aarch64.py b/.ci/scripts/wheel/test_linux_aarch64.py index c0cca95b3fb..db7dd28f81e 100644 --- a/.ci/scripts/wheel/test_linux_aarch64.py +++ b/.ci/scripts/wheel/test_linux_aarch64.py @@ -5,7 +5,11 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +import tempfile +from pathlib import Path + import test_base +import test_cpp_sdk from examples.models import Backend, Model if __name__ == "__main__": @@ -26,6 +30,12 @@ ), f"OpenvinoBackend not found in registered backends: {registered}" print("✓ OpenvinoBackend is registered") + # The wheel ships a prebuilt C++ runtime and a CMake package config, so check + # that a standalone application can actually link and run against them, and + # that the process still has a single backend registry. + with tempfile.TemporaryDirectory() as work_dir: + test_cpp_sdk.run_tests(Path(work_dir)) + test_base.run_tests( model_tests=[ test_base.ModelTest( diff --git a/.github/workflows/build-wheels-aarch64-linux.yml b/.github/workflows/build-wheels-aarch64-linux.yml index b0b9a9c0fee..8adf4268228 100644 --- a/.github/workflows/build-wheels-aarch64-linux.yml +++ b/.github/workflows/build-wheels-aarch64-linux.yml @@ -6,9 +6,11 @@ on: paths: - .ci/**/* - .github/workflows/build-wheels-aarch64-linux.yml + - '**/CMakeLists.txt' - examples/**/* - pyproject.toml - setup.py + - tools/cmake/**/* push: branches: - nightly diff --git a/.github/workflows/build-wheels-linux.yml b/.github/workflows/build-wheels-linux.yml index 1a89079e428..7428b68a773 100644 --- a/.github/workflows/build-wheels-linux.yml +++ b/.github/workflows/build-wheels-linux.yml @@ -6,9 +6,11 @@ on: paths: - .ci/**/* - .github/workflows/build-wheels-linux.yml + - '**/CMakeLists.txt' - examples/**/* - pyproject.toml - setup.py + - tools/cmake/**/* push: branches: - nightly diff --git a/.github/workflows/build-wheels-macos.yml b/.github/workflows/build-wheels-macos.yml index 3fddb8e6d26..6ace109edf7 100644 --- a/.github/workflows/build-wheels-macos.yml +++ b/.github/workflows/build-wheels-macos.yml @@ -6,9 +6,11 @@ on: paths: - .ci/**/* - .github/workflows/build-wheels-macos.yml + - '**/CMakeLists.txt' - examples/**/* - pyproject.toml - setup.py + - tools/cmake/**/* push: branches: - nightly diff --git a/.github/workflows/build-wheels-windows.yml b/.github/workflows/build-wheels-windows.yml index 9b1f8663bd2..60c6520b944 100644 --- a/.github/workflows/build-wheels-windows.yml +++ b/.github/workflows/build-wheels-windows.yml @@ -5,9 +5,11 @@ on: paths: - .ci/**/* - .github/workflows/build-wheels-windows.yml + - '**/CMakeLists.txt' - examples/**/* - pyproject.toml - setup.py + - tools/cmake/**/* push: branches: - nightly diff --git a/CMakeLists.txt b/CMakeLists.txt index ff3b9e86f7e..78613f542fb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -932,6 +932,58 @@ if(EXECUTORCH_BUILD_PTHREADPOOL AND EXECUTORCH_BUILD_CPUINFO) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/extension/threadpool) endif() +# Consolidated shared library: bundles executorch_core plus commonly used +# extensions into a single libexecutorch.so. Defined before the pybind and +# kernel targets below so they can link this one runtime instead of embedding a +# private copy of the core, which would give the process a second backend +# registry. +if(EXECUTORCH_BUILD_SHARED) + executorch_add_shared_library(executorch_shared) + set_target_properties( + executorch_shared + PROPERTIES OUTPUT_NAME executorch + ARCHIVE_OUTPUT_NAME executorch_shared + EXPORT_NAME executorch-shared + ) + target_include_directories( + executorch_shared PUBLIC ${_common_include_directories} + ) + target_compile_definitions( + executorch_shared PUBLIC C10_USING_CUSTOM_GENERATED_MACROS + ) + # Link executorch without WHOLE_ARCHIVE because its INTERFACE link options + # (from executorch_target_link_options_shared_lib) already force + # whole-archive. Everything else is pulled in through link options rather than + # the WHOLE_ARCHIVE link feature, because these archives also reference each + # other plainly and CMake before 3.29 refuses to mix a feature with a plain + # reference to the same item. + target_link_libraries(executorch_shared PRIVATE executorch) + set(_executorch_shared_whole_archive executorch_core) + foreach(_ext_target + extension_data_loader extension_flat_tensor extension_named_data_map + extension_module_static extension_tensor + ) + if(TARGET ${_ext_target}) + list(APPEND _executorch_shared_whole_archive ${_ext_target}) + endif() + endforeach() + foreach(_whole_target ${_executorch_shared_whole_archive}) + target_link_options( + executorch_shared + PRIVATE + "SHELL:LINKER:--whole-archive $ LINKER:--no-whole-archive" + ) + add_dependencies(executorch_shared ${_whole_target}) + endforeach() + configure_file( + tools/cmake/executorch.pc.in ${CMAKE_CURRENT_BINARY_DIR}/executorch.pc + @ONLY + ) + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/executorch.pc + DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig + ) +endif() + if(EXECUTORCH_BUILD_KERNELS_TORCHAO) if(NOT TARGET cpuinfo) message( @@ -1016,10 +1068,19 @@ if(EXECUTORCH_BUILD_PYBIND) # Ensure bundled_module waits for bundled_program's generated headers add_dependencies(bundled_module bundled_program) - target_link_libraries(bundled_module PRIVATE extension_data_loader) - target_link_libraries( - bundled_module PUBLIC extension_module_static bundled_program - ) + # extension_module_static and the data loader are bundled into + # libexecutorch.so, so link that instead of pulling private static copies in + # through this target's PUBLIC interface. + if(EXECUTORCH_BUILD_SHARED) + target_link_libraries( + bundled_module PUBLIC executorch_shared bundled_program + ) + else() + target_link_libraries(bundled_module PRIVATE extension_data_loader) + target_link_libraries( + bundled_module PUBLIC extension_module_static bundled_program + ) + endif() target_include_directories( bundled_module PUBLIC ${_common_include_directories} @@ -1038,16 +1099,36 @@ if(EXECUTORCH_BUILD_PYBIND) TORCH_PYTHON_LIBRARY torch_python PATHS "${TORCH_INSTALL_PREFIX}/lib" ) - set(_dep_libs - ${TORCH_PYTHON_LIBRARY} - bundled_program - etdump - flatccrt - executorch - extension_data_loader - util - torch - ) + # When the consolidated shared runtime is built, the pybind extension links it + # instead of whole-archiving the static core, so Python and C++ consumers + # share one backend registry. `executorch` and the extensions bundled into + # libexecutorch.so must stay off this list: their INTERFACE link options force + # whole-archive, which would give this module a private second registry. + # executorch_shared is named here for its include directories and compile + # definitions; executorch_target_link_shared_runtime below is what fixes its + # position on the link line. + if(EXECUTORCH_BUILD_SHARED) + set(_dep_libs + ${TORCH_PYTHON_LIBRARY} + bundled_program + etdump + flatccrt + executorch_shared + util + torch + ) + else() + set(_dep_libs + ${TORCH_PYTHON_LIBRARY} + bundled_program + etdump + flatccrt + executorch + extension_data_loader + util + torch + ) + endif() # Build common AOTI functionality if needed by CUDA or Metal backends if(EXECUTORCH_BUILD_CUDA) @@ -1058,13 +1139,22 @@ if(EXECUTORCH_BUILD_PYBIND) list(APPEND _dep_libs aoti_common) endif() - # RPATH for _portable_lib.so + # RPATH for _portable_lib.so. It sits in + # /executorch/extension/pybindings, so torch is three levels up + # and the wheel's own lib/ directory is two. set(_portable_lib_rpath "$ORIGIN/../../../torch/lib") + if(EXECUTORCH_BUILD_SHARED) + string(APPEND _portable_lib_rpath ":$ORIGIN/../../lib") + endif() if(EXECUTORCH_BUILD_EXTENSION_MODULE) - # Always use static linking for pybindings to avoid runtime symbol - # resolution issues - list(APPEND _dep_libs extension_module_static) + # extension_module_static is already bundled into libexecutorch.so; linking + # it again here would whole-archive a second copy. + if(NOT EXECUTORCH_BUILD_SHARED) + # Always use static linking for pybindings to avoid runtime symbol + # resolution issues + list(APPEND _dep_libs extension_module_static) + endif() # Add bundled_module if available if(TARGET bundled_module) list(APPEND _dep_libs bundled_module) @@ -1150,7 +1240,11 @@ if(EXECUTORCH_BUILD_PYBIND) target_compile_definitions(util PUBLIC C10_USING_CUSTOM_GENERATED_MACROS) target_compile_options(util PUBLIC ${_pybind_compile_options}) - target_link_libraries(util PRIVATE torch c10 executorch extension_tensor) + if(EXECUTORCH_BUILD_SHARED) + target_link_libraries(util PRIVATE torch c10 executorch_shared) + else() + target_link_libraries(util PRIVATE torch c10 executorch extension_tensor) + endif() # pybind portable_lib pybind11_add_module(portable_lib SHARED extension/pybindings/pybindings.cpp) @@ -1167,6 +1261,7 @@ if(EXECUTORCH_BUILD_PYBIND) target_include_directories(portable_lib PRIVATE ${TORCH_INCLUDE_DIRS}) target_compile_options(portable_lib PUBLIC ${_pybind_compile_options}) target_link_libraries(portable_lib PRIVATE ${_dep_libs}) + executorch_target_link_shared_runtime(portable_lib) # Set RPATH to find PyTorch and backend libraries relative to the installation # location. This goes from executorch/extension/pybindings up to @@ -1199,7 +1294,17 @@ if(EXECUTORCH_BUILD_PYBIND) strip_python_lib(data_loader) target_include_directories(data_loader PRIVATE ${_common_include_directories}) target_compile_options(data_loader PUBLIC ${_pybind_compile_options}) - target_link_libraries(data_loader PRIVATE executorch) + if(EXECUTORCH_BUILD_SHARED) + if(NOT APPLE) + set_target_properties( + data_loader PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" + INSTALL_RPATH "$ORIGIN/../../lib" + ) + endif() + else() + target_link_libraries(data_loader PRIVATE executorch) + endif() + executorch_target_link_shared_runtime(data_loader) install(TARGETS data_loader LIBRARY DESTINATION executorch/extension/pybindings ) @@ -1246,49 +1351,6 @@ if(EXECUTORCH_BUILD_KERNELS_LLM) list(APPEND _executorch_kernels custom_ops_aot_lib) endif() -# Consolidated shared library: bundles executorch_core plus commonly used -# extensions into a single libexecutorch.so. -if(EXECUTORCH_BUILD_SHARED) - executorch_add_shared_library(executorch_shared) - set_target_properties( - executorch_shared - PROPERTIES OUTPUT_NAME executorch - ARCHIVE_OUTPUT_NAME executorch_shared - EXPORT_NAME executorch-shared - ) - target_include_directories( - executorch_shared PUBLIC ${_common_include_directories} - ) - target_compile_definitions( - executorch_shared PUBLIC C10_USING_CUSTOM_GENERATED_MACROS - ) - # Link executorch without WHOLE_ARCHIVE because its INTERFACE link options - # (from executorch_target_link_options_shared_lib) already force - # whole-archive. Link executorch_core explicitly since executorch only has a - # PRIVATE dep on it (symbols wouldn't propagate otherwise). - target_link_libraries( - executorch_shared PRIVATE executorch - $ - ) - foreach(_ext_target - extension_data_loader extension_flat_tensor extension_named_data_map - extension_module_static extension_tensor - ) - if(TARGET ${_ext_target}) - target_link_libraries( - executorch_shared PRIVATE $ - ) - endif() - endforeach() - configure_file( - tools/cmake/executorch.pc.in ${CMAKE_CURRENT_BINARY_DIR}/executorch.pc - @ONLY - ) - install(FILES ${CMAKE_CURRENT_BINARY_DIR}/executorch.pc - DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig - ) -endif() - if(EXECUTORCH_BUILD_KERNELS_QUANTIZED) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/kernels/quantized) executorch_target_link_options_shared_lib(quantized_ops_lib) diff --git a/codegen/tools/CMakeLists.txt b/codegen/tools/CMakeLists.txt index b829e83c340..3f3369cbd0d 100644 --- a/codegen/tools/CMakeLists.txt +++ b/codegen/tools/CMakeLists.txt @@ -43,7 +43,21 @@ if(TARGET bundled_program) target_compile_definitions(selective_build PRIVATE -DET_BUNDLE_IO) target_link_libraries(selective_build PRIVATE bundled_program) endif() -target_link_libraries(selective_build PRIVATE executorch_core program_schema) +if(EXECUTORCH_BUILD_SHARED) + # The runtime comes from libexecutorch.so instead of the static core. This + # module lands in /executorch/codegen/tools, two levels below + # the wheel's lib/ directory. + target_link_libraries(selective_build PRIVATE program_schema) + if(NOT APPLE) + set_target_properties( + selective_build PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" + INSTALL_RPATH "$ORIGIN/../../lib" + ) + endif() +else() + target_link_libraries(selective_build PRIVATE executorch_core program_schema) +endif() +executorch_target_link_shared_runtime(selective_build) # Install the module install(TARGETS selective_build LIBRARY DESTINATION executorch/codegen/tools) diff --git a/devtools/bundled_program/CMakeLists.txt b/devtools/bundled_program/CMakeLists.txt index 0c213d9a83c..375f7487f06 100644 --- a/devtools/bundled_program/CMakeLists.txt +++ b/devtools/bundled_program/CMakeLists.txt @@ -40,7 +40,14 @@ add_library( bundled_program ${_schema_outputs} ${CMAKE_CURRENT_SOURCE_DIR}/bundled_program.cpp ) -target_link_libraries(bundled_program PUBLIC executorch) +# The `executorch` target forces whole-archive of itself, which would duplicate +# the primitive operator registrations already inside libexecutorch.so and abort +# at load. Resolve them from the shared runtime instead when it is built. +if(EXECUTORCH_BUILD_SHARED) + target_link_libraries(bundled_program PUBLIC executorch_shared) +else() + target_link_libraries(bundled_program PUBLIC executorch) +endif() target_include_directories( bundled_program PUBLIC diff --git a/devtools/etdump/CMakeLists.txt b/devtools/etdump/CMakeLists.txt index 9ef3c8cd6f7..fe754d8129a 100644 --- a/devtools/etdump/CMakeLists.txt +++ b/devtools/etdump/CMakeLists.txt @@ -49,11 +49,15 @@ add_library( ${CMAKE_CURRENT_SOURCE_DIR}/data_sinks/file_data_sink.cpp ${CMAKE_CURRENT_SOURCE_DIR}/data_sinks/file_data_sink.h ) -target_link_libraries( - etdump - PUBLIC flatccrt - PRIVATE executorch -) +target_link_libraries(etdump PUBLIC flatccrt) +# As with bundled_program, avoid the whole-archive of the `executorch` target so +# the primitive operator registrations are not duplicated alongside the copy +# already inside libexecutorch.so. +if(EXECUTORCH_BUILD_SHARED) + target_link_libraries(etdump PRIVATE executorch_shared) +else() + target_link_libraries(etdump PRIVATE executorch) +endif() target_include_directories( etdump PUBLIC ${DEVTOOLS_INCLUDE_DIR} diff --git a/extension/llm/custom_ops/CMakeLists.txt b/extension/llm/custom_ops/CMakeLists.txt index 2cdfe547430..992c0223ea3 100644 --- a/extension/llm/custom_ops/CMakeLists.txt +++ b/extension/llm/custom_ops/CMakeLists.txt @@ -116,19 +116,32 @@ if(EXECUTORCH_BUILD_KERNELS_LLM_AOT) set(RPATH "@loader_path/../../pybindings") else() set(RPATH "$ORIGIN/../../pybindings") + if(EXECUTORCH_BUILD_SHARED) + # This library lands in + # /executorch/extension/llm/custom_ops, three levels below + # the wheel's lib/ directory. + string(APPEND RPATH ":$ORIGIN/../../../lib") + endif() endif() - set_target_properties(custom_ops_aot_lib PROPERTIES INSTALL_RPATH ${RPATH}) + # The wheel copies this library straight out of the build tree, so the runtime + # search path has to be set on the built artifact and not only on install. + set_target_properties( + custom_ops_aot_lib PROPERTIES BUILD_RPATH ${RPATH} INSTALL_RPATH ${RPATH} + ) if(TARGET portable_lib) # If we have portable_lib built, custom_ops_aot_lib gives the ability to use # the ops in PyTorch and ExecuTorch through pybind target_link_libraries(custom_ops_aot_lib PUBLIC portable_lib) - else() + elseif(NOT EXECUTORCH_BUILD_SHARED) # If no portable_lib, custom_ops_aot_lib still gives the ability to use the # ops in PyTorch target_link_libraries( custom_ops_aot_lib PUBLIC executorch_core kernels_util_all_deps ) + else() + target_link_libraries(custom_ops_aot_lib PUBLIC kernels_util_all_deps) endif() + executorch_target_link_shared_runtime(custom_ops_aot_lib) target_link_libraries( custom_ops_aot_lib PUBLIC cpublas torch extension_tensor diff --git a/extension/llm/runner/CMakeLists.txt b/extension/llm/runner/CMakeLists.txt index 5247a4ba0a6..6dbeaf6907e 100644 --- a/extension/llm/runner/CMakeLists.txt +++ b/extension/llm/runner/CMakeLists.txt @@ -123,6 +123,7 @@ if(EXECUTORCH_BUILD_PYBIND) _llm_runner PRIVATE extension_llm_runner tokenizers::tokenizers portable_lib ${TORCH_PYTHON_LIBRARY} ${TORCH_LIBRARIES} ) + executorch_target_link_shared_runtime(_llm_runner) set_target_properties( _llm_runner @@ -137,6 +138,11 @@ if(EXECUTORCH_BUILD_PYBIND) ) else() set(RPATH "$ORIGIN/../../pybindings:$ORIGIN/../../../../torch/lib") + if(EXECUTORCH_BUILD_SHARED) + # This module lands in /executorch/extension/llm/runner, + # three levels below the wheel's lib/ directory. + string(APPEND RPATH ":$ORIGIN/../../../lib") + endif() endif() set_target_properties( _llm_runner PROPERTIES BUILD_RPATH "${RPATH}" INSTALL_RPATH "${RPATH}" diff --git a/extension/training/CMakeLists.txt b/extension/training/CMakeLists.txt index e835ae0e0a3..2574d343462 100644 --- a/extension/training/CMakeLists.txt +++ b/extension/training/CMakeLists.txt @@ -49,10 +49,18 @@ target_link_libraries( target_compile_options(train_xor PUBLIC ${_common_compile_options}) if(EXECUTORCH_BUILD_PYBIND) - # Pybind library. - set(_pybind_training_dep_libs ${TORCH_PYTHON_LIBRARY} etdump executorch util - torch extension_training - ) + # Pybind library. When the consolidated shared runtime is built, the runtime + # is resolved from it rather than from the whole-archive-forcing static + # `executorch` target, so this module shares the one backend registry. + if(EXECUTORCH_BUILD_SHARED) + set(_pybind_training_dep_libs ${TORCH_PYTHON_LIBRARY} etdump util torch + extension_training + ) + else() + set(_pybind_training_dep_libs ${TORCH_PYTHON_LIBRARY} etdump executorch + util torch extension_training + ) + endif() if(EXECUTORCH_BUILD_XNNPACK) # need to explicitly specify XNNPACK and xnnpack-microkernels-prod here @@ -81,6 +89,17 @@ if(EXECUTORCH_BUILD_PYBIND) -fexceptions> ) target_link_libraries(_training_lib PRIVATE ${_pybind_training_dep_libs}) + executorch_target_link_shared_runtime(_training_lib) + + if(EXECUTORCH_BUILD_SHARED AND NOT APPLE) + # This module lands in + # /executorch/extension/training/pybindings, three levels + # below the wheel's lib/ directory. + set_target_properties( + _training_lib PROPERTIES BUILD_RPATH "$ORIGIN/../../../lib" + INSTALL_RPATH "$ORIGIN/../../../lib" + ) + endif() install(TARGETS _training_lib LIBRARY DESTINATION executorch/extension/training/pybindings diff --git a/kernels/quantized/CMakeLists.txt b/kernels/quantized/CMakeLists.txt index 2dac38205b4..48385641b20 100644 --- a/kernels/quantized/CMakeLists.txt +++ b/kernels/quantized/CMakeLists.txt @@ -131,6 +131,11 @@ if(NOT CMAKE_GENERATOR STREQUAL "Xcode" set(RPATH "@loader_path/../../extensions/pybindings") else() set(RPATH "$ORIGIN/../../extensions/pybindings") + if(EXECUTORCH_BUILD_SHARED) + # This library lands in /executorch/kernels/quantized, + # two levels below the wheel's lib/ directory. + string(APPEND RPATH ":$ORIGIN/../../lib") + endif() endif() set_target_properties( quantized_ops_aot_lib PROPERTIES BUILD_RPATH ${RPATH} INSTALL_RPATH diff --git a/setup.py b/setup.py index 8331deec8ad..f33e5478476 100644 --- a/setup.py +++ b/setup.py @@ -322,6 +322,18 @@ def get_dynamic_lib_name(name: str) -> str: return f"lib{name}.so" +def get_runtime_soname_major() -> str: + """The major version in the shared runtime's SONAME. + + CMake derives SOVERSION from version.txt, so read the major from the same + place. Version.string() is not usable here because BUILD_VERSION can + override it without changing what the linker recorded. + """ + root = os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(root, "version.txt")) as f: + return f.read().strip().split(".")[0] + + def get_executable_name(name: str) -> str: if _is_windows(): return name + ".exe" @@ -758,7 +770,11 @@ def run(self): "runtime/kernel/", "runtime/backend/", "runtime/platform/", + "extension/data_loader/", + "extension/flat_tensor/", "extension/kernel_util/", + "extension/module/", + "extension/named_data_map/", "extension/tensor/", "extension/threadpool/", ]: @@ -1089,6 +1105,15 @@ def run(self): # noqa C901 [] if _is_minimal_build() else [ + # Install the shared C++ runtime so a standalone application can + # link executorch::runtime from the wheel. Shipped under its + # SONAME so the DT_NEEDED a consumer records resolves at runtime. + BuiltFile( + src_dir="%CMAKE_CACHE_DIR%/", + src_name=f"libexecutorch.so.{get_runtime_soname_major()}.*", + dst=f"executorch/lib/libexecutorch.so.{get_runtime_soname_major()}", + dependent_cmake_flags=["EXECUTORCH_BUILD_SHARED"], + ), # Install the prebuilt pybindings extension wrapper for the runtime, # portable kernels, and a selection of backends. This lets users # load and execute .pte files from python. diff --git a/tools/cmake/Codegen.cmake b/tools/cmake/Codegen.cmake index 4253fa44dc5..66d72914e67 100644 --- a/tools/cmake/Codegen.cmake +++ b/tools/cmake/Codegen.cmake @@ -261,9 +261,10 @@ function(gen_custom_ops_aot_lib) executorch_target_link_options_shared_lib(${GEN_LIB_NAME}) if(TARGET portable_lib) target_link_libraries(${GEN_LIB_NAME} PRIVATE portable_lib) - else() + elseif(NOT EXECUTORCH_BUILD_SHARED) target_link_libraries(${GEN_LIB_NAME} PRIVATE executorch_core) endif() + executorch_target_link_shared_runtime(${GEN_LIB_NAME}) endfunction() # Generate a runtime lib for registering operators in Executorch diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index 958b425c47c..dea0cccc3d5 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -212,6 +212,35 @@ function(executorch_target_copy_mlx_metallib target) endif() endfunction() +# Make a target resolve the ExecuTorch runtime from libexecutorch.so. +# +# Naming the shared runtime as an ordinary dependency is not enough. CMake +# orders link libraries so that an archive precedes what it depends on, which +# puts libexecutorch_core.a ahead of libexecutorch.so; the archive then +# satisfies the runtime symbols first and the target ends up with a private copy +# of the backend registry. Link options come before the ordered libraries, so +# naming the runtime there leaves the archive with nothing left to resolve. +# +# --no-as-needed is needed around it because a shared library with no +# already-referenced symbol at the point it appears can be dropped, and the +# static archive further along the line would then supply the registry after +# all. +function(executorch_target_link_shared_runtime target_name) + if(NOT EXECUTORCH_BUILD_SHARED) + return() + endif() + # The generator expression alone does not make the runtime get built first, so + # state the build-order dependency explicitly. + add_dependencies(${target_name} executorch_shared) + set_property( + TARGET ${target_name} + APPEND + PROPERTY + LINK_OPTIONS + "SHELL:LINKER:--no-as-needed $ LINKER:--as-needed" + ) +endfunction() + # Create and install a shared library composed from dependency libraries. The # target links the provided dependencies and carries VERSION/SOVERSION. function(executorch_add_shared_library target_name) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 1d6096a2e96..27eb186c56e 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -8,7 +8,8 @@ # for this file and find ExecuTorch package if it is installed. Typical usage # is: # -# find_package(executorch REQUIRED) +# find_package(executorch REQUIRED) target_link_libraries(my_app PRIVATE +# executorch::runtime) # ------- # # Finds the ExecuTorch library @@ -19,10 +20,71 @@ # EXECUTORCH_INCLUDE_DIRS -- The include directories for ExecuTorch # EXECUTORCH_LIBRARIES -- Libraries to link against # +# and, when the prebuilt shared runtime is present, the imported target: +# +# executorch::runtime -- The prebuilt C++ runtime (libexecutorch.so) +# cmake_minimum_required(VERSION 3.19) -# Find prebuilt _portable_lib..so. This file should be installed -# under /executorch/share/cmake +# This file is installed to /executorch/share/cmake, so the +# package root is two levels up. Everything is resolved relative to this file so +# the wheel stays relocatable: no absolute path from the machine that built it +# is baked in here. +get_filename_component( + _executorch_package_root "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE +) + +set(EXECUTORCH_INCLUDE_DIRS + "${_executorch_package_root}/include" + "${_executorch_package_root}/include/executorch/runtime/core/portable_type/c10" +) + +set(EXECUTORCH_LIBRARIES) +set(EXECUTORCH_FOUND OFF) + +# The prebuilt runtime. Match the versioned file rather than a hardcoded major +# so the config keeps working across releases. +file(GLOB _executorch_runtime_candidates + "${_executorch_package_root}/lib/libexecutorch.so" + "${_executorch_package_root}/lib/libexecutorch.so.*" +) +# An unversioned libexecutorch.so sorts before any libexecutorch.so., so +# a development symlink wins over the versioned file when both are present. +list(SORT _executorch_runtime_candidates) +list(LENGTH _executorch_runtime_candidates _executorch_runtime_count) +if(_executorch_runtime_count GREATER 0) + list(GET _executorch_runtime_candidates 0 _executorch_runtime_library) + + set(EXECUTORCH_FOUND ON) + message(STATUS "ExecuTorch runtime found at ${_executorch_runtime_library}") + + add_library(executorch::runtime SHARED IMPORTED) + set_target_properties( + executorch::runtime + PROPERTIES IMPORTED_LOCATION "${_executorch_runtime_library}" + INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" + INTERFACE_COMPILE_FEATURES cxx_std_17 + INTERFACE_COMPILE_DEFINITIONS C10_USING_CUSTOM_GENERATED_MACROS + ) + # Consumers get the wheel's lib/ directory in their RUNPATH automatically, + # because CMake adds the imported library's directory. Also record + # $ORIGIN-relative entries so an application that is deployed next to a copy + # of the runtime keeps working without relinking or LD_LIBRARY_PATH. $ORIGIN + # is a loader token, so it belongs only in RUNPATH, never in + # IMPORTED_LOCATION. + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set_property( + TARGET executorch::runtime + APPEND + PROPERTY INTERFACE_LINK_OPTIONS "LINKER:-rpath,$ORIGIN" + "LINKER:-rpath,$ORIGIN/../lib" + ) + endif() +endif() + +# Find prebuilt _portable_lib..so. This is the legacy contract used +# to build custom-op extensions against the Python module, and is kept working +# independently of the runtime target above. # Find python if(DEFINED ENV{CONDA_DEFAULT_ENV} AND NOT $ENV{CONDA_DEFAULT_ENV} STREQUAL @@ -55,11 +117,9 @@ endif() find_library( _portable_lib_LIBRARY NAMES _portable_lib${EXT_SUFFIX} - PATHS "${CMAKE_CURRENT_LIST_DIR}/../../extension/pybindings/" + PATHS "${_executorch_package_root}/extension/pybindings/" ) -set(EXECUTORCH_LIBRARIES) -set(EXECUTORCH_FOUND OFF) if(_portable_lib_LIBRARY) set(EXECUTORCH_FOUND ON) message( @@ -67,7 +127,6 @@ if(_portable_lib_LIBRARY) ) list(APPEND EXECUTORCH_LIBRARIES _portable_lib) add_library(_portable_lib STATIC IMPORTED) - set(EXECUTORCH_INCLUDE_DIRS ${CMAKE_CURRENT_LIST_DIR}/../../include) # PyTorch requires C++20, so pybindings must be compiled with C++20. set_target_properties( _portable_lib diff --git a/tools/cmake/preset/pybind.cmake b/tools/cmake/preset/pybind.cmake index d292c9ed240..068f80d1e2b 100644 --- a/tools/cmake/preset/pybind.cmake +++ b/tools/cmake/preset/pybind.cmake @@ -104,6 +104,11 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") endif() endif() set_overridable_option(EXECUTORCH_BUILD_OPENVINO OFF) + # Ship one shared runtime that both the pybind extension and standalone C++ + # consumers link, so a process has a single backend registry. Linux only: + # macOS C++ consumers are served by the Swift package distribution, and the + # runtime has no export annotations for a Windows DLL. + set_overridable_option(EXECUTORCH_BUILD_SHARED ON) elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR CMAKE_SYSTEM_NAME STREQUAL "WIN32" ) From 008351d3a7d32a5fcd078c7c81dc8e5c306fb48e Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Thu, 30 Jul 2026 23:51:12 -0700 Subject: [PATCH 02/70] Update [ghstack-poisoned] --- CMakeLists.txt | 7 +------ tools/cmake/Utils.cmake | 40 +++++++++++++++++++++++++++++++++------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 78613f542fb..3a34ca55811 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -968,12 +968,7 @@ if(EXECUTORCH_BUILD_SHARED) endif() endforeach() foreach(_whole_target ${_executorch_shared_whole_archive}) - target_link_options( - executorch_shared - PRIVATE - "SHELL:LINKER:--whole-archive $ LINKER:--no-whole-archive" - ) - add_dependencies(executorch_shared ${_whole_target}) + executorch_target_whole_archive(executorch_shared ${_whole_target}) endforeach() configure_file( tools/cmake/executorch.pc.in ${CMAKE_CURRENT_BINARY_DIR}/executorch.pc diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index dea0cccc3d5..8e200184ace 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -47,6 +47,27 @@ function(executorch_msvc_kernel_link_options target_name) ) endfunction() +# Add a whole-archive reference to a static library on a consumer's link line. +# +# This is deliberately a link option rather than a link library: CMake refuses +# to mix the WHOLE_ARCHIVE link feature with the plain references other targets +# make to the same archive, and link options are also emitted before the ordered +# link libraries, which is what keeps a bundled archive ahead of anything that +# would otherwise satisfy the same symbols. +function(executorch_target_whole_archive target_name archive_target) + if(APPLE) + set(_flags "SHELL:LINKER:-force_load,$") + elseif(MSVC) + set(_flags "SHELL:LINKER:/WHOLEARCHIVE:$") + else() + set(_flags + "SHELL:LINKER:--whole-archive $ LINKER:--no-whole-archive" + ) + endif() + target_link_options(${target_name} PRIVATE "${_flags}") + add_dependencies(${target_name} ${archive_target}) +endfunction() + # Ensure that the load-time constructor functions run. By default, the linker # would remove them since there are no other references to them. function(executorch_target_link_options_shared_lib target_name) @@ -221,23 +242,28 @@ endfunction() # of the backend registry. Link options come before the ordered libraries, so # naming the runtime there leaves the archive with nothing left to resolve. # -# --no-as-needed is needed around it because a shared library with no -# already-referenced symbol at the point it appears can be dropped, and the -# static archive further along the line would then supply the registry after -# all. +# On ELF platforms --no-as-needed is needed around it, because a shared library +# with no already-referenced symbol at the point it appears can be dropped, and +# the static archive further along the line would then supply the registry after +# all. Other linkers keep the reference without it. function(executorch_target_link_shared_runtime target_name) if(NOT EXECUTORCH_BUILD_SHARED) return() endif() + if(APPLE OR MSVC) + set(_runtime_flags "SHELL:$") + else() + set(_runtime_flags + "SHELL:LINKER:--no-as-needed $ LINKER:--as-needed" + ) + endif() # The generator expression alone does not make the runtime get built first, so # state the build-order dependency explicitly. add_dependencies(${target_name} executorch_shared) set_property( TARGET ${target_name} APPEND - PROPERTY - LINK_OPTIONS - "SHELL:LINKER:--no-as-needed $ LINKER:--as-needed" + PROPERTY LINK_OPTIONS "${_runtime_flags}" ) endfunction() From b741e2d1df9647cfaa9e85074dbc31652a4aecfc Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 07:53:04 -0700 Subject: [PATCH 03/70] Update [ghstack-poisoned] --- CMakeLists.txt | 4 ++++ codegen/tools/CMakeLists.txt | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3a34ca55811..7b816f45ff1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1289,7 +1289,11 @@ if(EXECUTORCH_BUILD_PYBIND) strip_python_lib(data_loader) target_include_directories(data_loader PRIVATE ${_common_include_directories}) target_compile_options(data_loader PUBLIC ${_pybind_compile_options}) + # Naming the runtime as a dependency keeps this an ordinary C++ link (standard + # library, include directories, compile definitions); the helper below only + # fixes where the runtime lands on the link line. if(EXECUTORCH_BUILD_SHARED) + target_link_libraries(data_loader PRIVATE executorch_shared) if(NOT APPLE) set_target_properties( data_loader PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" diff --git a/codegen/tools/CMakeLists.txt b/codegen/tools/CMakeLists.txt index 3f3369cbd0d..7cf5e4a9730 100644 --- a/codegen/tools/CMakeLists.txt +++ b/codegen/tools/CMakeLists.txt @@ -47,7 +47,9 @@ if(EXECUTORCH_BUILD_SHARED) # The runtime comes from libexecutorch.so instead of the static core. This # module lands in /executorch/codegen/tools, two levels below # the wheel's lib/ directory. - target_link_libraries(selective_build PRIVATE program_schema) + target_link_libraries( + selective_build PRIVATE executorch_shared program_schema + ) if(NOT APPLE) set_target_properties( selective_build PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" From 58b61c9d9c98347d85bfefa5ad3fb58c2f3c1146 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 10:40:52 -0700 Subject: [PATCH 04/70] Update [ghstack-poisoned] --- CMakeLists.txt | 25 +++++++++++-------------- codegen/tools/CMakeLists.txt | 8 +++----- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b816f45ff1..abf460ea2cb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1289,21 +1289,18 @@ if(EXECUTORCH_BUILD_PYBIND) strip_python_lib(data_loader) target_include_directories(data_loader PRIVATE ${_common_include_directories}) target_compile_options(data_loader PUBLIC ${_pybind_compile_options}) - # Naming the runtime as a dependency keeps this an ordinary C++ link (standard - # library, include directories, compile definitions); the helper below only - # fixes where the runtime lands on the link line. - if(EXECUTORCH_BUILD_SHARED) - target_link_libraries(data_loader PRIVATE executorch_shared) - if(NOT APPLE) - set_target_properties( - data_loader PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" - INSTALL_RPATH "$ORIGIN/../../lib" - ) - endif() - else() - target_link_libraries(data_loader PRIVATE executorch) + # Bisection experiment: link this module the way it was before the shared + # runtime landed, to isolate whether that edit is what breaks the manylinux + # link. Everything else in this change is untouched. If the wheel builds go + # green, this target is the culprit; the shared-runtime wiring is then + # reintroduced here with the real cause addressed. + target_link_libraries(data_loader PRIVATE executorch) + if(EXECUTORCH_BUILD_SHARED AND NOT APPLE) + set_target_properties( + data_loader PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" + INSTALL_RPATH "$ORIGIN/../../lib" + ) endif() - executorch_target_link_shared_runtime(data_loader) install(TARGETS data_loader LIBRARY DESTINATION executorch/extension/pybindings ) diff --git a/codegen/tools/CMakeLists.txt b/codegen/tools/CMakeLists.txt index 7cf5e4a9730..6fcae657526 100644 --- a/codegen/tools/CMakeLists.txt +++ b/codegen/tools/CMakeLists.txt @@ -44,12 +44,11 @@ if(TARGET bundled_program) target_link_libraries(selective_build PRIVATE bundled_program) endif() if(EXECUTORCH_BUILD_SHARED) - # The runtime comes from libexecutorch.so instead of the static core. This + # Bisection experiment: keep the pre-change link shape here too, so the only + # variable under test is how these pybind modules reach the runtime. This # module lands in /executorch/codegen/tools, two levels below # the wheel's lib/ directory. - target_link_libraries( - selective_build PRIVATE executorch_shared program_schema - ) + target_link_libraries(selective_build PRIVATE executorch_core program_schema) if(NOT APPLE) set_target_properties( selective_build PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" @@ -59,7 +58,6 @@ if(EXECUTORCH_BUILD_SHARED) else() target_link_libraries(selective_build PRIVATE executorch_core program_schema) endif() -executorch_target_link_shared_runtime(selective_build) # Install the module install(TARGETS selective_build LIBRARY DESTINATION executorch/codegen/tools) From 2e51b619f4ec3d154e81f7ae123cddbaf6138335 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 11:46:01 -0700 Subject: [PATCH 05/70] Update [ghstack-poisoned] --- CMakeLists.txt | 9 ++++----- codegen/tools/CMakeLists.txt | 12 +++++++----- tools/cmake/Utils.cmake | 8 +++++++- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index abf460ea2cb..33c466f7283 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1289,11 +1289,10 @@ if(EXECUTORCH_BUILD_PYBIND) strip_python_lib(data_loader) target_include_directories(data_loader PRIVATE ${_common_include_directories}) target_compile_options(data_loader PUBLIC ${_pybind_compile_options}) - # Bisection experiment: link this module the way it was before the shared - # runtime landed, to isolate whether that edit is what breaks the manylinux - # link. Everything else in this change is untouched. If the wheel builds go - # green, this target is the culprit; the shared-runtime wiring is then - # reintroduced here with the real cause addressed. + # This module only exposes a pybind type; it calls into no runtime symbols, so + # it links the static core as before and does not need the shared runtime. The + # RPATH entry is still useful because sibling extensions in this directory do + # resolve libexecutorch.so from ../../lib. target_link_libraries(data_loader PRIVATE executorch) if(EXECUTORCH_BUILD_SHARED AND NOT APPLE) set_target_properties( diff --git a/codegen/tools/CMakeLists.txt b/codegen/tools/CMakeLists.txt index 6fcae657526..60a20a7745a 100644 --- a/codegen/tools/CMakeLists.txt +++ b/codegen/tools/CMakeLists.txt @@ -44,11 +44,13 @@ if(TARGET bundled_program) target_link_libraries(selective_build PRIVATE bundled_program) endif() if(EXECUTORCH_BUILD_SHARED) - # Bisection experiment: keep the pre-change link shape here too, so the only - # variable under test is how these pybind modules reach the runtime. This - # module lands in /executorch/codegen/tools, two levels below - # the wheel's lib/ directory. - target_link_libraries(selective_build PRIVATE executorch_core program_schema) + # This module calls into the runtime, so resolve those symbols from + # libexecutorch.so rather than baking in a second copy of the core. It lands + # in /executorch/codegen/tools, two levels below the wheel's + # lib/. + target_link_libraries( + selective_build PRIVATE executorch_shared program_schema + ) if(NOT APPLE) set_target_properties( selective_build PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index 8e200184ace..8d14ec0770b 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -253,8 +253,14 @@ function(executorch_target_link_shared_runtime target_name) if(APPLE OR MSVC) set(_runtime_flags "SHELL:$") else() + # --no-as-needed keeps the runtime in DT_NEEDED even though no symbol has + # been referenced yet at this point on the link line. It is wrapped in + # push-state/pop-state rather than closed with an explicit --as-needed so + # that whatever policy was in effect before is restored: closing with + # --as-needed would leave that in force for everything that follows, and + # would drop shared backends whose only purpose is static-init registration. set(_runtime_flags - "SHELL:LINKER:--no-as-needed $ LINKER:--as-needed" + "SHELL:LINKER:--push-state,--no-as-needed $ LINKER:--pop-state" ) endif() # The generator expression alone does not make the runtime get built first, so From 058a1f651d9bd69f86ee7c44483111ecc589d36d Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 12:45:10 -0700 Subject: [PATCH 06/70] Update [ghstack-poisoned] --- backends/qualcomm/CMakeLists.txt | 33 ++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/backends/qualcomm/CMakeLists.txt b/backends/qualcomm/CMakeLists.txt index 3f4aefa2b76..2c5802016b3 100644 --- a/backends/qualcomm/CMakeLists.txt +++ b/backends/qualcomm/CMakeLists.txt @@ -255,8 +255,23 @@ target_link_libraries( ) target_link_libraries( qnn_executorch_backend PRIVATE qnn_executorch_header qnn_schema qnn_manager - executorch_core qnn_backend_options + qnn_backend_options ) +# Resolve the runtime from the shared library when one is built, so this +# delegate does not carry its own copy of the backend registry. +if(EXECUTORCH_BUILD_SHARED) + target_link_libraries(qnn_executorch_backend PRIVATE executorch_shared) + executorch_target_link_shared_runtime(qnn_executorch_backend) + if(NOT APPLE) + # Ships in executorch/backends/qualcomm, two levels below the wheel's lib/. + set_target_properties( + qnn_executorch_backend PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" + INSTALL_RPATH "$ORIGIN/../../lib" + ) + endif() +else() + target_link_libraries(qnn_executorch_backend PRIVATE executorch_core) +endif() if(${CMAKE_SYSTEM_PROCESSOR} MATCHES Hexagon) # Add macro here so we can dlopen the correct .so library. @@ -359,12 +374,26 @@ if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "x86_64|AMD64") qnn_schema qnn_manager qnn_executorch_header - executorch extension_tensor qnn_backend_options wrappers qnn_executorch_logging ) + # Same reasoning as the delegate above: take the runtime from the shared + # library when there is one, rather than embedding a second registry. + if(EXECUTORCH_BUILD_SHARED) + target_link_libraries(PyQnnManagerAdaptor PRIVATE executorch_shared) + executorch_target_link_shared_runtime(PyQnnManagerAdaptor) + if(NOT APPLE) + # Ships in executorch/backends/qualcomm/python, three levels below lib/. + set_target_properties( + PyQnnManagerAdaptor PROPERTIES BUILD_RPATH "$ORIGIN/../../../lib" + INSTALL_RPATH "$ORIGIN/../../../lib" + ) + endif() + else() + target_link_libraries(PyQnnManagerAdaptor PRIVATE executorch) + endif() pybind11_extension(PyQnnManagerAdaptor) if(NOT MSVC AND NOT ${CMAKE_BUILD_TYPE} MATCHES RelWithDebInfo) From 79688cbb428db1e71f0bf1ea50cde13706a9200f Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 16:52:13 -0700 Subject: [PATCH 07/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 45 +++++++++++++++++++++++++++++++ CMakeLists.txt | 13 +++------ kernels/quantized/CMakeLists.txt | 10 +++++++ tools/cmake/Utils.cmake | 4 ++- 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index c2e729eb1f2..6e9a7a88508 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -154,6 +154,8 @@ def test_cpp_consumer(work_dir: Path) -> None: subprocess.run([str(consumer)], check=True, env=environment) print("✓ C++ consumer builds and runs against the installed wheel") + _assert_runs_relocated(consumer, package_dir, work_dir, environment) + assert shutil.which("readelf") is not None, "readelf is required to check the ELF" dynamic = subprocess.run( @@ -170,6 +172,49 @@ def test_cpp_consumer(work_dir: Path) -> None: print("✓ consumer depends on the shipped runtime with a relocatable RUNPATH") +def _assert_runs_relocated(consumer, package_dir, work_dir, environment) -> None: + """The app still runs after being moved away from the wheel. + + Building in place leaves an absolute path to the wheel's lib directory in the + binary's RUNPATH, which resolves the runtime no matter what `$ORIGIN` says. + Copying the app next to a copy of the runtime, with that absolute entry + removed, is what actually proves the package is relocatable. + + The layout mirrors what the package config supports: the app in `bin/` with + the libraries in a sibling `lib/`, which is what `$ORIGIN/../lib` resolves. + """ + if shutil.which("patchelf") is None: + print("- patchelf not available, skipping the relocated run") + return + + deploy = work_dir / "deployed" + (deploy / "bin").mkdir(parents=True, exist_ok=True) + (deploy / "lib").mkdir(parents=True, exist_ok=True) + moved = deploy / "bin" / consumer.name + shutil.copy2(consumer, moved) + for library in (package_dir / "lib").glob("*.so*"): + shutil.copy2(library, deploy / "lib" / library.name) + + # Keep only the $ORIGIN-relative entries, so nothing absolute can help. + current = subprocess.run( + ["patchelf", "--print-rpath", str(moved)], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + relative = [entry for entry in current.split(":") if entry.startswith("$ORIGIN")] + assert relative, ( + "the consumer has no $ORIGIN-relative RUNPATH entry, so it cannot be " + f"relocated; RUNPATH was: {current}" + ) + subprocess.run( + ["patchelf", "--set-rpath", ":".join(relative), str(moved)], check=True + ) + + subprocess.run([str(moved)], check=True, env=environment, cwd=str(deploy)) + print("✓ consumer still runs when deployed beside a copy of the runtime") + + def run_tests(work_dir: Path) -> None: test_single_backend_registry() test_cpp_consumer(work_dir) diff --git a/CMakeLists.txt b/CMakeLists.txt index 33c466f7283..fcf9210fdb5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1289,17 +1289,10 @@ if(EXECUTORCH_BUILD_PYBIND) strip_python_lib(data_loader) target_include_directories(data_loader PRIVATE ${_common_include_directories}) target_compile_options(data_loader PUBLIC ${_pybind_compile_options}) - # This module only exposes a pybind type; it calls into no runtime symbols, so - # it links the static core as before and does not need the shared runtime. The - # RPATH entry is still useful because sibling extensions in this directory do - # resolve libexecutorch.so from ../../lib. + # This module only exposes a pybind type and calls into no runtime symbols, so + # it links the static core as before and needs nothing from the shared + # runtime. target_link_libraries(data_loader PRIVATE executorch) - if(EXECUTORCH_BUILD_SHARED AND NOT APPLE) - set_target_properties( - data_loader PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" - INSTALL_RPATH "$ORIGIN/../../lib" - ) - endif() install(TARGETS data_loader LIBRARY DESTINATION executorch/extension/pybindings ) diff --git a/kernels/quantized/CMakeLists.txt b/kernels/quantized/CMakeLists.txt index 48385641b20..2ab225b8223 100644 --- a/kernels/quantized/CMakeLists.txt +++ b/kernels/quantized/CMakeLists.txt @@ -86,6 +86,16 @@ if(NOT CMAKE_GENERATOR STREQUAL "Xcode" gen_custom_ops_aot_lib( LIB_NAME "quantized_ops_aot_lib" KERNEL_SOURCES "${_quantized_sources}" ) + if(EXECUTORCH_BUILD_SHARED AND NOT APPLE) + # The generated library resolves the runtime from libexecutorch.so, so it + # needs the path to it whether or not pybindings are also being built. + # This library lands in /executorch/kernels/quantized, two + # levels below the wheel's lib/ directory. + set_target_properties( + quantized_ops_aot_lib PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" + INSTALL_RPATH "$ORIGIN/../../lib" + ) + endif() # Register quantized ops to portable_lib, so that they're available via # pybindings. diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index 8d14ec0770b..cb939a22d07 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -251,7 +251,9 @@ function(executorch_target_link_shared_runtime target_name) return() endif() if(APPLE OR MSVC) - set(_runtime_flags "SHELL:$") + # TARGET_LINKER_FILE rather than TARGET_FILE: on Windows the linker needs + # the import library, not the DLL itself. + set(_runtime_flags "SHELL:$") else() # --no-as-needed keeps the runtime in DT_NEEDED even though no symbol has # been referenced yet at this point on the link line. It is wrapped in From 077bb9df3d543b979778f262e10f2751df4ce38c Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 18:10:18 -0700 Subject: [PATCH 08/70] Update [ghstack-poisoned] --- tools/cmake/Utils.cmake | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index cb939a22d07..ab884772da6 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -247,31 +247,38 @@ endfunction() # the static archive further along the line would then supply the registry after # all. Other linkers keep the reference without it. function(executorch_target_link_shared_runtime target_name) + executorch_target_retain_shared_library(${target_name} executorch_shared) +endfunction() + +# Put a shared library on a consumer's link line and keep it there. +# +# A library whose only purpose is to run a static initializer, such as a backend +# or an operator registration library, has no symbol the consumer references +# directly, so the linker is free to drop it from DT_NEEDED. Some linkers do +# exactly that and the initializer never runs, which shows up at runtime as a +# backend or kernel that is missing rather than as a link error. +function(executorch_target_retain_shared_library target_name library_target) if(NOT EXECUTORCH_BUILD_SHARED) return() endif() if(APPLE OR MSVC) # TARGET_LINKER_FILE rather than TARGET_FILE: on Windows the linker needs # the import library, not the DLL itself. - set(_runtime_flags "SHELL:$") + set(_retain_flags "SHELL:$") else() - # --no-as-needed keeps the runtime in DT_NEEDED even though no symbol has - # been referenced yet at this point on the link line. It is wrapped in - # push-state/pop-state rather than closed with an explicit --as-needed so - # that whatever policy was in effect before is restored: closing with - # --as-needed would leave that in force for everything that follows, and - # would drop shared backends whose only purpose is static-init registration. - set(_runtime_flags - "SHELL:LINKER:--push-state,--no-as-needed $ LINKER:--pop-state" + # push-state/pop-state rather than closing with an explicit --as-needed: + # that would leave --as-needed in force for everything after it on the line + # and drop the next library that only exists for static-init registration. + set(_retain_flags + "SHELL:LINKER:--push-state,--no-as-needed $ LINKER:--pop-state" ) endif() - # The generator expression alone does not make the runtime get built first, so - # state the build-order dependency explicitly. - add_dependencies(${target_name} executorch_shared) + # The generator expression alone does not order the build, so say it outright. + add_dependencies(${target_name} ${library_target}) set_property( TARGET ${target_name} APPEND - PROPERTY LINK_OPTIONS "${_runtime_flags}" + PROPERTY LINK_OPTIONS "${_retain_flags}" ) endfunction() From ab9ad3828785b1896a59a4c234f8331b706e9708 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 21:15:41 -0700 Subject: [PATCH 09/70] Update [ghstack-poisoned] --- kernels/quantized/CMakeLists.txt | 4 ++-- tools/cmake/executorch-wheel-config.cmake | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/kernels/quantized/CMakeLists.txt b/kernels/quantized/CMakeLists.txt index 2ab225b8223..442eaf3a582 100644 --- a/kernels/quantized/CMakeLists.txt +++ b/kernels/quantized/CMakeLists.txt @@ -138,9 +138,9 @@ if(NOT CMAKE_GENERATOR STREQUAL "Xcode" # installed location of our _portable_lib.so file. To see these LC_* # values, run `otool -l libquantized_ops_lib.dylib`. if(APPLE) - set(RPATH "@loader_path/../../extensions/pybindings") + set(RPATH "@loader_path/../../extension/pybindings") else() - set(RPATH "$ORIGIN/../../extensions/pybindings") + set(RPATH "$ORIGIN/../../extension/pybindings") if(EXECUTORCH_BUILD_SHARED) # This library lands in /executorch/kernels/quantized, # two levels below the wheel's lib/ directory. diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 27eb186c56e..8990b93ea70 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -8,8 +8,10 @@ # for this file and find ExecuTorch package if it is installed. Typical usage # is: # -# find_package(executorch REQUIRED) target_link_libraries(my_app PRIVATE -# executorch::runtime) +# ~~~ +# find_package(executorch REQUIRED) +# target_link_libraries(my_app PRIVATE executorch::runtime) +# ~~~ # ------- # # Finds the ExecuTorch library From 060cc4b2ed547696186814be88e0d04ce235a20a Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 21:23:58 -0700 Subject: [PATCH 10/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 37 ++++++++++++++++++----- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 8990b93ea70..f2c8120b1f4 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -60,7 +60,13 @@ if(_executorch_runtime_count GREATER 0) set(EXECUTORCH_FOUND ON) message(STATUS "ExecuTorch runtime found at ${_executorch_runtime_library}") - add_library(executorch::runtime SHARED IMPORTED) + # This file can be processed more than once in a single configure, for example + # when several subprojects each call find_package(executorch). Creating the + # target twice is an error, so only define it once and set the properties + # either way. + if(NOT TARGET executorch::runtime) + add_library(executorch::runtime SHARED IMPORTED) + endif() set_target_properties( executorch::runtime PROPERTIES IMPORTED_LOCATION "${_executorch_runtime_library}" @@ -109,6 +115,16 @@ execute_process( if(SYSCONFIG_RESULT EQUAL 0) message(STATUS "Sysconfig extension suffix: ${EXT_SUFFIX}") +elseif(TARGET executorch::runtime) + # A C++ application linking only the shared runtime does not need Python at + # all, so a missing interpreter must not fail its configure. Skip locating the + # Python extension instead; the legacy _portable_lib target is simply not + # offered in that case. + message( + STATUS + "Python not usable, skipping the Python extension: ${SYSCONFIG_ERROR}" + ) + set(EXT_SUFFIX "") else() message( FATAL_ERROR @@ -116,11 +132,16 @@ else() ) endif() -find_library( - _portable_lib_LIBRARY - NAMES _portable_lib${EXT_SUFFIX} - PATHS "${_executorch_package_root}/extension/pybindings/" -) +if(EXT_SUFFIX) + find_library( + _portable_lib_LIBRARY + NAMES _portable_lib${EXT_SUFFIX} + PATHS "${_executorch_package_root}/extension/pybindings/" + # This config binds to the wheel it ships in, so a same-named library + # elsewhere on the system must not be picked up instead. + NO_DEFAULT_PATH + ) +endif() if(_portable_lib_LIBRARY) set(EXECUTORCH_FOUND ON) @@ -128,7 +149,9 @@ if(_portable_lib_LIBRARY) STATUS "ExecuTorch portable library is found at ${_portable_lib_LIBRARY}" ) list(APPEND EXECUTORCH_LIBRARIES _portable_lib) - add_library(_portable_lib STATIC IMPORTED) + if(NOT TARGET _portable_lib) + add_library(_portable_lib STATIC IMPORTED) + endif() # PyTorch requires C++20, so pybindings must be compiled with C++20. set_target_properties( _portable_lib From 8ca7e77f5ec2246fdea7427fe3911a8951d7424c Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 21:44:46 -0700 Subject: [PATCH 11/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 7 +++++++ tools/cmake/executorch-wheel-config.cmake | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 6e9a7a88508..6769ca40143 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -86,6 +86,13 @@ def _defines_symbol(library: Path, symbol: str) -> bool: result = subprocess.run( ["nm", "-DC", str(library)], capture_output=True, text=True, check=False ) + # A library nm cannot read would otherwise look like one that simply defines + # nothing, letting the single-definer checks pass without having actually + # inspected every shipped library. + assert result.returncode == 0, ( + f"nm could not read {library}, so the symbol checks cannot be trusted: " + f"{result.stderr.strip()}" + ) for line in result.stdout.splitlines(): if symbol not in line: continue diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index f2c8120b1f4..1e8618db794 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -60,6 +60,11 @@ if(_executorch_runtime_count GREATER 0) set(EXECUTORCH_FOUND ON) message(STATUS "ExecuTorch runtime found at ${_executorch_runtime_library}") + # The documented contract is that a consumer can link ${EXECUTORCH_LIBRARIES}. + # Leaving it empty here would make find_package succeed while offering nothing + # linkable to anyone who has not moved to the imported target. + list(APPEND EXECUTORCH_LIBRARIES executorch::runtime) + # This file can be processed more than once in a single configure, for example # when several subprojects each call find_package(executorch). Creating the # target twice is an error, so only define it once and set the properties From 23d62d49defc74e84f9df1c6654ebbb2862bb456 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 23:40:04 -0700 Subject: [PATCH 12/70] Update [ghstack-poisoned] --- CMakeLists.txt | 6 ++++-- backends/qualcomm/CMakeLists.txt | 11 +++++++---- codegen/tools/CMakeLists.txt | 4 +++- extension/llm/custom_ops/CMakeLists.txt | 7 ++++--- extension/llm/runner/CMakeLists.txt | 8 +++++--- extension/training/CMakeLists.txt | 10 +++++++--- kernels/quantized/CMakeLists.txt | 18 ++++++++++-------- 7 files changed, 40 insertions(+), 24 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index fcf9210fdb5..7cb88f4d60c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1136,9 +1136,11 @@ if(EXECUTORCH_BUILD_PYBIND) # RPATH for _portable_lib.so. It sits in # /executorch/extension/pybindings, so torch is three levels up - # and the wheel's own lib/ directory is two. + # and the wheel's own lib/ directory is two. The second entry is + # wheel-specific: a normal install puts the runtime in CMAKE_INSTALL_LIBDIR + # instead, where this relative path would not reach it. set(_portable_lib_rpath "$ORIGIN/../../../torch/lib") - if(EXECUTORCH_BUILD_SHARED) + if(EXECUTORCH_BUILD_SHARED AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) string(APPEND _portable_lib_rpath ":$ORIGIN/../../lib") endif() diff --git a/backends/qualcomm/CMakeLists.txt b/backends/qualcomm/CMakeLists.txt index 2c5802016b3..a1a7e5fa0a5 100644 --- a/backends/qualcomm/CMakeLists.txt +++ b/backends/qualcomm/CMakeLists.txt @@ -262,8 +262,10 @@ target_link_libraries( if(EXECUTORCH_BUILD_SHARED) target_link_libraries(qnn_executorch_backend PRIVATE executorch_shared) executorch_target_link_shared_runtime(qnn_executorch_backend) - if(NOT APPLE) - # Ships in executorch/backends/qualcomm, two levels below the wheel's lib/. + if(NOT APPLE AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: ships in executorch/backends/qualcomm, two levels below + # the wheel's lib/. A normal install puts the runtime in + # CMAKE_INSTALL_LIBDIR, which this relative path would not reach. set_target_properties( qnn_executorch_backend PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" INSTALL_RPATH "$ORIGIN/../../lib" @@ -384,8 +386,9 @@ if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "x86_64|AMD64") if(EXECUTORCH_BUILD_SHARED) target_link_libraries(PyQnnManagerAdaptor PRIVATE executorch_shared) executorch_target_link_shared_runtime(PyQnnManagerAdaptor) - if(NOT APPLE) - # Ships in executorch/backends/qualcomm/python, three levels below lib/. + if(NOT APPLE AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: ships in executorch/backends/qualcomm/python, three + # levels below the wheel's lib/. set_target_properties( PyQnnManagerAdaptor PROPERTIES BUILD_RPATH "$ORIGIN/../../../lib" INSTALL_RPATH "$ORIGIN/../../../lib" diff --git a/codegen/tools/CMakeLists.txt b/codegen/tools/CMakeLists.txt index 60a20a7745a..c5097eed537 100644 --- a/codegen/tools/CMakeLists.txt +++ b/codegen/tools/CMakeLists.txt @@ -51,7 +51,9 @@ if(EXECUTORCH_BUILD_SHARED) target_link_libraries( selective_build PRIVATE executorch_shared program_schema ) - if(NOT APPLE) + if(NOT APPLE AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: the runtime ships two levels up under lib/. A normal + # install puts it in CMAKE_INSTALL_LIBDIR, which this would not reach. set_target_properties( selective_build PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" INSTALL_RPATH "$ORIGIN/../../lib" diff --git a/extension/llm/custom_ops/CMakeLists.txt b/extension/llm/custom_ops/CMakeLists.txt index 992c0223ea3..c99fdb7cf2e 100644 --- a/extension/llm/custom_ops/CMakeLists.txt +++ b/extension/llm/custom_ops/CMakeLists.txt @@ -116,10 +116,11 @@ if(EXECUTORCH_BUILD_KERNELS_LLM_AOT) set(RPATH "@loader_path/../../pybindings") else() set(RPATH "$ORIGIN/../../pybindings") - if(EXECUTORCH_BUILD_SHARED) - # This library lands in + if(EXECUTORCH_BUILD_SHARED AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: this library lands in # /executorch/extension/llm/custom_ops, three levels below - # the wheel's lib/ directory. + # the wheel's lib/. A normal install puts the runtime in + # CMAKE_INSTALL_LIBDIR, which this relative path would not reach. string(APPEND RPATH ":$ORIGIN/../../../lib") endif() endif() diff --git a/extension/llm/runner/CMakeLists.txt b/extension/llm/runner/CMakeLists.txt index 6dbeaf6907e..d8bdcbce441 100644 --- a/extension/llm/runner/CMakeLists.txt +++ b/extension/llm/runner/CMakeLists.txt @@ -138,9 +138,11 @@ if(EXECUTORCH_BUILD_PYBIND) ) else() set(RPATH "$ORIGIN/../../pybindings:$ORIGIN/../../../../torch/lib") - if(EXECUTORCH_BUILD_SHARED) - # This module lands in /executorch/extension/llm/runner, - # three levels below the wheel's lib/ directory. + if(EXECUTORCH_BUILD_SHARED AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: this module lands in + # /executorch/extension/llm/runner, three levels below the + # wheel's lib/. A normal install puts the runtime in CMAKE_INSTALL_LIBDIR, + # which this relative path would not reach. string(APPEND RPATH ":$ORIGIN/../../../lib") endif() endif() diff --git a/extension/training/CMakeLists.txt b/extension/training/CMakeLists.txt index 2574d343462..978d1420e6c 100644 --- a/extension/training/CMakeLists.txt +++ b/extension/training/CMakeLists.txt @@ -91,10 +91,14 @@ if(EXECUTORCH_BUILD_PYBIND) target_link_libraries(_training_lib PRIVATE ${_pybind_training_dep_libs}) executorch_target_link_shared_runtime(_training_lib) - if(EXECUTORCH_BUILD_SHARED AND NOT APPLE) - # This module lands in + if(EXECUTORCH_BUILD_SHARED + AND NOT APPLE + AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE + ) + # Wheel-specific: this module lands in # /executorch/extension/training/pybindings, three levels - # below the wheel's lib/ directory. + # below the wheel's lib/. A normal install puts the runtime in + # CMAKE_INSTALL_LIBDIR, which this relative path would not reach. set_target_properties( _training_lib PROPERTIES BUILD_RPATH "$ORIGIN/../../../lib" INSTALL_RPATH "$ORIGIN/../../../lib" diff --git a/kernels/quantized/CMakeLists.txt b/kernels/quantized/CMakeLists.txt index 442eaf3a582..2878befec82 100644 --- a/kernels/quantized/CMakeLists.txt +++ b/kernels/quantized/CMakeLists.txt @@ -86,11 +86,14 @@ if(NOT CMAKE_GENERATOR STREQUAL "Xcode" gen_custom_ops_aot_lib( LIB_NAME "quantized_ops_aot_lib" KERNEL_SOURCES "${_quantized_sources}" ) - if(EXECUTORCH_BUILD_SHARED AND NOT APPLE) - # The generated library resolves the runtime from libexecutorch.so, so it - # needs the path to it whether or not pybindings are also being built. - # This library lands in /executorch/kernels/quantized, two - # levels below the wheel's lib/ directory. + if(EXECUTORCH_BUILD_SHARED + AND NOT APPLE + AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE + ) + # Wheel-specific: the generated library lands in + # /executorch/kernels/quantized, two levels below the + # wheel's lib/. A normal install puts the runtime in CMAKE_INSTALL_LIBDIR, + # which this relative path would not reach. set_target_properties( quantized_ops_aot_lib PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" INSTALL_RPATH "$ORIGIN/../../lib" @@ -141,9 +144,8 @@ if(NOT CMAKE_GENERATOR STREQUAL "Xcode" set(RPATH "@loader_path/../../extension/pybindings") else() set(RPATH "$ORIGIN/../../extension/pybindings") - if(EXECUTORCH_BUILD_SHARED) - # This library lands in /executorch/kernels/quantized, - # two levels below the wheel's lib/ directory. + if(EXECUTORCH_BUILD_SHARED AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: two levels below the wheel's lib/ directory. string(APPEND RPATH ":$ORIGIN/../../lib") endif() endif() From c076daaa2c7585906174467c7e0b0f1cb664b50c Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 23:56:57 -0700 Subject: [PATCH 13/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 1e8618db794..38097cd9f68 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -130,6 +130,10 @@ elseif(TARGET executorch::runtime) "Python not usable, skipping the Python extension: ${SYSCONFIG_ERROR}" ) set(EXT_SUFFIX "") + # find_library caches its result, so a value left by an earlier configure + # would survive and the extension would still be offered despite being skipped + # here. + unset(_portable_lib_LIBRARY CACHE) else() message( FATAL_ERROR @@ -162,6 +166,11 @@ if(_portable_lib_LIBRARY) _portable_lib PROPERTIES IMPORTED_LOCATION "${_portable_lib_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" - CXX_STANDARD 20 + # An interface requirement rather than CXX_STANDARD: an imported + # target compiles nothing itself, and CXX_STANDARD does not reach + # consumers, so a custom-op build linking this could still + # compile + # as C++17 and fail against headers that need C++20. + INTERFACE_COMPILE_FEATURES cxx_std_20 ) endif() From e18448dc6d398bbb66770cb88e4f995bc56948e3 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 08:19:24 -0700 Subject: [PATCH 14/70] Update [ghstack-poisoned] --- CMakeLists.txt | 14 +++++++++----- tools/cmake/executorch-wheel-config.cmake | 12 ++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7cb88f4d60c..a3cedecbdb3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -778,11 +778,6 @@ if(EXECUTORCH_BUILD_OPENVINO) list(APPEND _executorch_backends openvino_backend) endif() -if(EXECUTORCH_BUILD_QNN) - add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/qualcomm) - list(APPEND _executorch_backends qnn_executorch_backend) -endif() - if(EXECUTORCH_BUILD_ENN) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/samsung) list(APPEND _executorch_backends enn_backend) @@ -979,6 +974,15 @@ if(EXECUTORCH_BUILD_SHARED) ) endif() +# Added after the shared runtime rather than with the other backends: this +# backend resolves the runtime from executorch_shared, and naming a target that +# does not exist yet makes the link fail because the library has not been built +# at that point on the line. +if(EXECUTORCH_BUILD_QNN) + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/qualcomm) + list(APPEND _executorch_backends qnn_executorch_backend) +endif() + if(EXECUTORCH_BUILD_KERNELS_TORCHAO) if(NOT TARGET cpuinfo) message( diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 38097cd9f68..f8d5601d50b 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -174,3 +174,15 @@ if(_portable_lib_LIBRARY) INTERFACE_COMPILE_FEATURES cxx_std_20 ) endif() + +# find_package checks _FOUND, which is case-sensitive and does not +# match the EXECUTORCH_FOUND spelling this file documents. Without this, a +# REQUIRED find_package would succeed even when nothing usable was located. +set(executorch_FOUND ${EXECUTORCH_FOUND}) +if(NOT executorch_FOUND AND executorch_FIND_REQUIRED) + message( + FATAL_ERROR + "Found the ExecuTorch package but neither the shared runtime nor the Python " + "extension could be located inside it." + ) +endif() From 596690f8d933dd4ca4012fe098ee793270425ad2 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 08:44:16 -0700 Subject: [PATCH 15/70] Update [ghstack-poisoned] --- kernels/quantized/CMakeLists.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/kernels/quantized/CMakeLists.txt b/kernels/quantized/CMakeLists.txt index 2878befec82..9269a8c00f4 100644 --- a/kernels/quantized/CMakeLists.txt +++ b/kernels/quantized/CMakeLists.txt @@ -86,9 +86,14 @@ if(NOT CMAKE_GENERATOR STREQUAL "Xcode" gen_custom_ops_aot_lib( LIB_NAME "quantized_ops_aot_lib" KERNEL_SOURCES "${_quantized_sources}" ) + # Only when the pybind extension is absent. When it is present, the block + # below sets the full runtime path for this target in one place, and setting + # it here as well would leave two independent copies of the same logic where + # the later one wins. if(EXECUTORCH_BUILD_SHARED AND NOT APPLE AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE + AND NOT TARGET portable_lib ) # Wheel-specific: the generated library lands in # /executorch/kernels/quantized, two levels below the From e33f3c11d8a56f4174c6af1528281305cf965487 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 16:46:18 -0700 Subject: [PATCH 16/70] Update [ghstack-poisoned] --- CMakeLists.txt | 13 +++++++++---- tools/cmake/Utils.cmake | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a3cedecbdb3..eab1d5e91c0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1295,10 +1295,15 @@ if(EXECUTORCH_BUILD_PYBIND) strip_python_lib(data_loader) target_include_directories(data_loader PRIVATE ${_common_include_directories}) target_compile_options(data_loader PUBLIC ${_pybind_compile_options}) - # This module only exposes a pybind type and calls into no runtime symbols, so - # it links the static core as before and needs nothing from the shared - # runtime. - target_link_libraries(data_loader PRIVATE executorch) + # This module only exposes a pybind type and calls into no runtime symbols. + # The static target force-links every registration object, which would give + # this module its own operator registry alongside the one in the shared + # runtime, so resolve against the shared runtime instead when there is one. + if(TARGET executorch_shared) + target_link_libraries(data_loader PRIVATE executorch_shared) + else() + target_link_libraries(data_loader PRIVATE executorch) + endif() install(TARGETS data_loader LIBRARY DESTINATION executorch/extension/pybindings ) diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index ab884772da6..fdbc482f37a 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -71,6 +71,20 @@ endfunction() # Ensure that the load-time constructor functions run. By default, the linker # would remove them since there are no other references to them. function(executorch_target_link_options_shared_lib target_name) + # A shared library cannot be retained with --whole-archive: that flag only + # governs how an archive's members are pulled in, so the library is still + # subject to --as-needed and gets dropped along with its registration + # constructor. Export scoped --no-as-needed retention instead, which is what + # actually keeps a registration-only shared library on the link line. + get_target_property(_target_type ${target_name} TYPE) + if(_target_type STREQUAL "SHARED_LIBRARY" AND NOT (APPLE OR MSVC)) + target_link_options( + ${target_name} + INTERFACE + "SHELL:LINKER:--push-state,--no-as-needed $ LINKER:--pop-state" + ) + return() + endif() if(APPLE) executorch_macos_kernel_link_options(${target_name}) elseif(MSVC) From 2749cc7978e3a4accf02c600a2674eef2fc39fc3 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 17:16:08 -0700 Subject: [PATCH 17/70] Update [ghstack-poisoned] --- CMakeLists.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index eab1d5e91c0..a0ad07866bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1301,6 +1301,15 @@ if(EXECUTORCH_BUILD_PYBIND) # runtime, so resolve against the shared runtime instead when there is one. if(TARGET executorch_shared) target_link_libraries(data_loader PRIVATE executorch_shared) + if(NOT APPLE AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: this installs beside the other pybind extensions, two + # levels below the wheel's lib/ directory, so it needs the same relative + # path they use to reach the shared runtime. + set_target_properties( + data_loader PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" + INSTALL_RPATH "$ORIGIN/../../lib" + ) + endif() else() target_link_libraries(data_loader PRIVATE executorch) endif() From 74f27c3132f9491be929a1f882b37775eab83ad9 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 18:30:22 -0700 Subject: [PATCH 18/70] Update [ghstack-poisoned] --- tools/cmake/Codegen.cmake | 8 +++++++- tools/cmake/executorch-wheel-config.cmake | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tools/cmake/Codegen.cmake b/tools/cmake/Codegen.cmake index 66d72914e67..e338707dd2c 100644 --- a/tools/cmake/Codegen.cmake +++ b/tools/cmake/Codegen.cmake @@ -261,7 +261,13 @@ function(gen_custom_ops_aot_lib) executorch_target_link_options_shared_lib(${GEN_LIB_NAME}) if(TARGET portable_lib) target_link_libraries(${GEN_LIB_NAME} PRIVATE portable_lib) - elseif(NOT EXECUTORCH_BUILD_SHARED) + elseif(TARGET executorch_shared) + # Linked as a library rather than only retained, so this target also picks + # up the runtime's include directories and compile definitions. The + # retention helper below adds link options alone, which would leave a shared + # build without the pybind extension compiling against no runtime headers. + target_link_libraries(${GEN_LIB_NAME} PRIVATE executorch_shared) + else() target_link_libraries(${GEN_LIB_NAME} PRIVATE executorch_core) endif() executorch_target_link_shared_runtime(${GEN_LIB_NAME}) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index f8d5601d50b..3c62b6935f7 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -159,7 +159,11 @@ if(_portable_lib_LIBRARY) ) list(APPEND EXECUTORCH_LIBRARIES _portable_lib) if(NOT TARGET _portable_lib) - add_library(_portable_lib STATIC IMPORTED) + # SHARED, not STATIC: this resolves to the Python extension module, which is + # a shared object. Declaring it static makes CMake treat it as an archive, + # which changes how it is placed on a link line and how runtime paths are + # handled. + add_library(_portable_lib SHARED IMPORTED) endif() # PyTorch requires C++20, so pybindings must be compiled with C++20. set_target_properties( From 24ee4f91cb0f7962d2e3a4087857bffe1c98d732 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 18:54:47 -0700 Subject: [PATCH 19/70] Update [ghstack-poisoned] --- setup.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/setup.py b/setup.py index f33e5478476..f8a9f237600 100644 --- a/setup.py +++ b/setup.py @@ -313,6 +313,17 @@ def get_build_type(is_debug=None) -> str: return "Debug" if debug else "Release" +# Headers whose implementations are built as separate targets and are not part of +# the runtime the wheel ships. Copying them would let an application compile +# against an API it then cannot link. +_UNSUPPORTED_WHEEL_HEADERS = frozenset( + { + "file_descriptor_data_loader.h", + "serialize.h", + } +) + + def get_dynamic_lib_name(name: str) -> str: if _is_windows(): return f"{name}.dll" @@ -780,6 +791,8 @@ def run(self): ]: src_list = Path(include_dir).rglob("*.h") for src in src_list: + if src.name in _UNSUPPORTED_WHEEL_HEADERS: + continue src_to_dst.append( (str(src), os.path.join("include/executorch", str(src))) ) From b1075f5b80050add4cca3d105130c34ac4969def Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 19:48:21 -0700 Subject: [PATCH 20/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 43 +++++++++++++++++++++++++++++++ extension/training/CMakeLists.txt | 12 ++++++--- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 6769ca40143..a3371d71f8f 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -26,6 +26,7 @@ import re import shutil import subprocess +import sys from pathlib import Path # Registry entry points. A second definer of any of these means a second @@ -222,6 +223,48 @@ def _assert_runs_relocated(consumer, package_dir, work_dir, environment) -> None print("✓ consumer still runs when deployed beside a copy of the runtime") +def test_python_extensions_import() -> None: + """Every shipped Python extension must import from a clean environment. + + The symbol and dependency checks work on the files. This covers the other + half: an extension can be packaged correctly and still fail to load because a + runtime path does not reach one of its dependencies. Run in a subprocess with + `LD_LIBRARY_PATH` removed so a value from the build environment cannot supply + a path the shipped library is missing. + """ + modules = [ + "executorch.extension.pybindings.portable_lib", + "executorch.extension.training", + ] + environment = { + key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" + } + for module in modules: + result = subprocess.run( + [sys.executable, "-c", f"import {module}"], + capture_output=True, + text=True, + check=False, + env=environment, + ) + if result.returncode == 0: + print(f"✓ {module} imports from a clean environment") + continue + # A Python dependency that is simply not installed here, including torch, + # says nothing about how the wheel was built. Only a failure to load a + # native library does. + if "ModuleNotFoundError" in result.stderr: + print(f"- {module} needs a package this environment lacks, skipping") + continue + assert "cannot open shared object file" not in result.stderr, ( + f"{module} ships in the wheel but cannot load a native dependency, " + f"which usually means a runtime path does not reach it: " + f"{result.stderr.strip()[-400:]}" + ) + print(f"- {module} did not import for an unrelated reason, skipping") + + def run_tests(work_dir: Path) -> None: test_single_backend_registry() + test_python_extensions_import() test_cpp_consumer(work_dir) diff --git a/extension/training/CMakeLists.txt b/extension/training/CMakeLists.txt index 978d1420e6c..20d4b1ae53f 100644 --- a/extension/training/CMakeLists.txt +++ b/extension/training/CMakeLists.txt @@ -98,10 +98,16 @@ if(EXECUTORCH_BUILD_PYBIND) # Wheel-specific: this module lands in # /executorch/extension/training/pybindings, three levels # below the wheel's lib/. A normal install puts the runtime in - # CMAKE_INSTALL_LIBDIR, which this relative path would not reach. + # CMAKE_INSTALL_LIBDIR, which this relative path would not reach. The Torch + # path is listed too: this module links Torch directly, and the only other + # entry reaching it is the absolute build directory CMake adds, which does + # not exist anywhere else. + set(_training_lib_rpath + "$ORIGIN/../../../lib:$ORIGIN/../../../../torch/lib" + ) set_target_properties( - _training_lib PROPERTIES BUILD_RPATH "$ORIGIN/../../../lib" - INSTALL_RPATH "$ORIGIN/../../../lib" + _training_lib PROPERTIES BUILD_RPATH "${_training_lib_rpath}" + INSTALL_RPATH "${_training_lib_rpath}" ) endif() From b5d2c6be2241765c22fed7b7a92ecdad2784383e Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 19:51:20 -0700 Subject: [PATCH 21/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 3c62b6935f7..6689024c7a8 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -134,6 +134,10 @@ elseif(TARGET executorch::runtime) # would survive and the extension would still be offered despite being skipped # here. unset(_portable_lib_LIBRARY CACHE) + # Also clear any normal-scope value, since find_library writes the cache entry + # while a plain variable of the same name can shadow it and still look like a + # successful discovery. + unset(_portable_lib_LIBRARY) else() message( FATAL_ERROR @@ -176,6 +180,10 @@ if(_portable_lib_LIBRARY) # compile # as C++17 and fail against headers that need C++20. INTERFACE_COMPILE_FEATURES cxx_std_20 + # The same definition the runtime target carries. A custom-op + # build that links only this target still compiles against the + # same headers and needs it too. + INTERFACE_COMPILE_DEFINITIONS C10_USING_CUSTOM_GENERATED_MACROS ) endif() From 3447ef541704f61ad4dc309c9e73e44e9a2b7fcf Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 20:18:28 -0700 Subject: [PATCH 22/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index a3371d71f8f..9db5da1fdfa 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -253,15 +253,21 @@ def test_python_extensions_import() -> None: # A Python dependency that is simply not installed here, including torch, # says nothing about how the wheel was built. Only a failure to load a # native library does. - if "ModuleNotFoundError" in result.stderr: + # A Python package this environment simply does not have says nothing + # about how the wheel was built. Match only that shape, so a native load + # failure reported as ModuleNotFoundError is still caught below. + missing_python_package = re.search( + r"ModuleNotFoundError: No module named '(?!executorch)", result.stderr + ) + if missing_python_package: print(f"- {module} needs a package this environment lacks, skipping") continue - assert "cannot open shared object file" not in result.stderr, ( - f"{module} ships in the wheel but cannot load a native dependency, " - f"which usually means a runtime path does not reach it: " - f"{result.stderr.strip()[-400:]}" + # Anything else is a real failure to load what the wheel ships: a missing + # native library, an unresolved symbol, or an ABI mismatch. + raise AssertionError( + f"{module} ships in the wheel but does not import: " + f"{result.stderr.strip()[-500:]}" ) - print(f"- {module} did not import for an unrelated reason, skipping") def run_tests(work_dir: Path) -> None: From 642fa5e6a7715bc665b0bd40ba324331128ab607 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 22:15:30 -0700 Subject: [PATCH 23/70] Update [ghstack-poisoned] --- setup.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f8a9f237600..73dae582fae 100644 --- a/setup.py +++ b/setup.py @@ -315,9 +315,14 @@ def get_build_type(is_debug=None) -> str: # Headers whose implementations are built as separate targets and are not part of # the runtime the wheel ships. Copying them would let an application compile -# against an API it then cannot link. +# against an API it then cannot link. This is a list rather than a rule because +# the wheel copies header trees by directory while the runtime's sources are +# chosen per target, so there is nothing to derive the answer from. A supported +# header set expressed alongside the runtime's own source list would remove the +# need for it. _UNSUPPORTED_WHEEL_HEADERS = frozenset( { + "bundled_module.h", "file_descriptor_data_loader.h", "serialize.h", } From aebfb4d2403ea7a6adb2083479121b01df7955c6 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 23:01:16 -0700 Subject: [PATCH 24/70] Update [ghstack-poisoned] --- backends/qualcomm/CMakeLists.txt | 6 +++++- tools/cmake/Utils.cmake | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/backends/qualcomm/CMakeLists.txt b/backends/qualcomm/CMakeLists.txt index a1a7e5fa0a5..93d82f933ad 100644 --- a/backends/qualcomm/CMakeLists.txt +++ b/backends/qualcomm/CMakeLists.txt @@ -376,11 +376,15 @@ if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "x86_64|AMD64") qnn_schema qnn_manager qnn_executorch_header - extension_tensor qnn_backend_options wrappers qnn_executorch_logging ) + # extension_tensor is bundled into the shared runtime, so naming it again here + # would give this module a second copy of what that library already provides. + if(NOT EXECUTORCH_BUILD_SHARED) + target_link_libraries(PyQnnManagerAdaptor PRIVATE extension_tensor) + endif() # Same reasoning as the delegate above: take the runtime from the shared # library when there is one, rather than embedding a second registry. if(EXECUTORCH_BUILD_SHARED) diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index fdbc482f37a..58ddcb4c629 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -277,8 +277,10 @@ function(executorch_target_retain_shared_library target_name library_target) endif() if(APPLE OR MSVC) # TARGET_LINKER_FILE rather than TARGET_FILE: on Windows the linker needs - # the import library, not the DLL itself. - set(_retain_flags "SHELL:$") + # the import library, not the DLL itself. Plain rather than SHELL: this is a + # single path, and SHELL splits on spaces, so a path containing one would + # reach the linker as two broken arguments. + set(_retain_flags "$") else() # push-state/pop-state rather than closing with an explicit --as-needed: # that would leave --as-needed in force for everything after it on the line From 607ef9375db5c68973b554afb3e403e2e3d529ea Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 08:04:16 -0700 Subject: [PATCH 25/70] Update [ghstack-poisoned] --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index 73dae582fae..7ee07396498 100644 --- a/setup.py +++ b/setup.py @@ -323,6 +323,10 @@ def get_build_type(is_debug=None) -> str: _UNSUPPORTED_WHEEL_HEADERS = frozenset( { "bundled_module.h", + # These two include a third-party header the wheel does not ship, so they + # cannot compile from an installed package no matter what is linked. + "cpuinfo_utils.h", + "threadpool.h", "file_descriptor_data_loader.h", "serialize.h", } From 0670cb3b6c2d841ad2b42dfe3ed8d5336c5e6f4d Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 10:33:35 -0700 Subject: [PATCH 26/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 76 +++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 6689024c7a8..da7f3fabcc3 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -26,6 +26,14 @@ # # executorch::runtime -- The prebuilt C++ runtime (libexecutorch.so) # +# Component targets are defined only when the wheel ships that component. Each +# one already carries the runtime dependency and, for a registration-only +# library, the link options that keep it from being dropped: +# +# executorch::threadpool -- The shared thread pool executorch::kernels -- +# The CPU operator kernels executorch::xnnpack_backend -- The XNNPACK delegate +# executorch::cuda_backend -- The CUDA delegate, CUDA wheels only +# cmake_minimum_required(VERSION 3.19) # This file is installed to /executorch/share/cmake, so the @@ -95,6 +103,74 @@ if(_executorch_runtime_count GREATER 0) endif() endif() +# Define an imported target for one shipped component library. +# +# A component is a prebuilt shared library next to the runtime, such as the CPU +# kernels or a delegate backend. Without a target for each one, a consumer has +# to find the file itself and decide how to keep it on the link line, which +# means depending on the wheel's private layout. The retention part matters +# most: a registration-only library has no symbol the application references, so +# a normal link drops it and its registration never runs. +# +# Call as: executorch_define_component( ) +function(executorch_define_component _suffix _library_name) + file(GLOB _candidates + "${_executorch_package_root}/lib/lib${_library_name}.so" + "${_executorch_package_root}/lib/lib${_library_name}.so.*" + ) + if(NOT _candidates) + return() + endif() + # An unversioned name sorts first, so a development symlink wins over the + # versioned file when both are present. + list(SORT _candidates) + list(GET _candidates 0 _library) + + set(_target "executorch::${_suffix}") + if(NOT TARGET ${_target}) + add_library(${_target} SHARED IMPORTED) + endif() + set_target_properties( + ${_target} + PROPERTIES IMPORTED_LOCATION "${_library}" + INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" + INTERFACE_COMPILE_FEATURES cxx_std_17 + INTERFACE_COMPILE_DEFINITIONS C10_USING_CUSTOM_GENERATED_MACROS + ) + # Every component resolves the runtime from the same shared library, so record + # that rather than leaving a consumer to link both by hand. + if(TARGET executorch::runtime) + set_property( + TARGET ${_target} + APPEND + PROPERTY INTERFACE_LINK_LIBRARIES executorch::runtime + ) + endif() + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set_property( + TARGET ${_target} + APPEND + PROPERTY + INTERFACE_LINK_OPTIONS + "LINKER:-rpath,$ORIGIN" + "LINKER:-rpath,$ORIGIN/../lib" + # Scoped rather than a bare --no-as-needed: leaving it in force would + # also retain everything later on the line. + "SHELL:LINKER:--push-state,--no-as-needed ${_library} LINKER:--pop-state" + ) + elseif(APPLE) + set_property( + TARGET ${_target} + APPEND + PROPERTY INTERFACE_LINK_OPTIONS "SHELL:-force_load ${_library}" + ) + endif() + set(EXECUTORCH_LIBRARIES + ${EXECUTORCH_LIBRARIES} ${_target} + PARENT_SCOPE + ) +endfunction() + # Find prebuilt _portable_lib..so. This is the legacy contract used # to build custom-op extensions against the Python module, and is kept working # independently of the runtime target above. From abb743af3990f2e3737eeb27801ce4478eeda54e Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 10:49:40 -0700 Subject: [PATCH 27/70] Update [ghstack-poisoned] --- CMakeLists.txt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a0ad07866bb..0a3b469320a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -974,10 +974,9 @@ if(EXECUTORCH_BUILD_SHARED) ) endif() -# Added after the shared runtime rather than with the other backends: this -# backend resolves the runtime from executorch_shared, and naming a target that -# does not exist yet makes the link fail because the library has not been built -# at that point on the line. +# Added after the shared runtime for readability, so the dependency reads in the +# order it exists. CMake resolves target names at generate time, so a reference +# from an earlier subdirectory would work too, as several other backends here do. if(EXECUTORCH_BUILD_QNN) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/qualcomm) list(APPEND _executorch_backends qnn_executorch_backend) From 7e257ad729b9658068f4665e389c8ca9f31113c5 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 11:02:19 -0700 Subject: [PATCH 28/70] Update [ghstack-poisoned] --- tools/cmake/Utils.cmake | 12 ++++++--- tools/cmake/executorch-wheel-config.cmake | 32 ++++++++++++++--------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index 58ddcb4c629..0cd9cd2f523 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -81,7 +81,11 @@ function(executorch_target_link_options_shared_lib target_name) target_link_options( ${target_name} INTERFACE - "SHELL:LINKER:--push-state,--no-as-needed $ LINKER:--pop-state" + # Separate options rather than one SHELL: string, which splits on spaces + # and would break a library path containing one. + "LINKER:--push-state,--no-as-needed" + "$" + "LINKER:--pop-state" ) return() endif() @@ -285,8 +289,10 @@ function(executorch_target_retain_shared_library target_name library_target) # push-state/pop-state rather than closing with an explicit --as-needed: # that would leave --as-needed in force for everything after it on the line # and drop the next library that only exists for static-init registration. - set(_retain_flags - "SHELL:LINKER:--push-state,--no-as-needed $ LINKER:--pop-state" + # Separate options rather than one SHELL: string, which splits on spaces and + # would break a library path containing one. + set(_retain_flags "LINKER:--push-state,--no-as-needed" + "$" "LINKER:--pop-state" ) endif() # The generator expression alone does not order the build, so say it outright. diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index da7f3fabcc3..268959abe69 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -26,13 +26,15 @@ # # executorch::runtime -- The prebuilt C++ runtime (libexecutorch.so) # -# Component targets are defined only when the wheel ships that component. Each -# one already carries the runtime dependency and, for a registration-only -# library, the link options that keep it from being dropped: +# Component targets are defined only when the wheel ships that component, so the +# set depends on which wheel is installed. Each one carries the runtime +# dependency and, for a registration-only library, the link options that keep it +# from being dropped. The names, when present, are: # -# executorch::threadpool -- The shared thread pool executorch::kernels -- -# The CPU operator kernels executorch::xnnpack_backend -- The XNNPACK delegate -# executorch::cuda_backend -- The CUDA delegate, CUDA wheels only +# executorch::threadpool executorch::kernels executorch::xnnpack_backend +# executorch::cuda_backend +# +# Check with if(TARGET executorch::) rather than assuming one exists. # cmake_minimum_required(VERSION 3.19) @@ -150,13 +152,17 @@ function(executorch_define_component _suffix _library_name) set_property( TARGET ${_target} APPEND - PROPERTY - INTERFACE_LINK_OPTIONS - "LINKER:-rpath,$ORIGIN" - "LINKER:-rpath,$ORIGIN/../lib" - # Scoped rather than a bare --no-as-needed: leaving it in force would - # also retain everything later on the line. - "SHELL:LINKER:--push-state,--no-as-needed ${_library} LINKER:--pop-state" + PROPERTY INTERFACE_LINK_OPTIONS + "LINKER:-rpath,$ORIGIN" + "LINKER:-rpath,$ORIGIN/../lib" + # Three separate options rather than one SHELL: string: SHELL + # splits on spaces, so a library path containing one would reach + # the linker as two broken arguments. Kept scoped because leaving + # --no-as-needed in force would also retain everything later on + # the line. + "LINKER:--push-state,--no-as-needed" + "${_library}" + "LINKER:--pop-state" ) elseif(APPLE) set_property( From 546ca1c8588a9e6c6fc194666b21641b270727d0 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 11:44:11 -0700 Subject: [PATCH 29/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 268959abe69..079fa8b65fd 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -155,14 +155,15 @@ function(executorch_define_component _suffix _library_name) PROPERTY INTERFACE_LINK_OPTIONS "LINKER:-rpath,$ORIGIN" "LINKER:-rpath,$ORIGIN/../lib" - # Three separate options rather than one SHELL: string: SHELL - # splits on spaces, so a library path containing one would reach - # the linker as two broken arguments. Kept scoped because leaving - # --no-as-needed in force would also retain everything later on - # the line. - "LINKER:--push-state,--no-as-needed" - "${_library}" - "LINKER:--pop-state" + # One option per component rather than a shared push-state pair: + # CMake removes duplicate link options, so repeating the same + # push-state text for a second component silently drops its + # scoping and the library goes back to being --as-needed. Naming + # the library inside the same option keeps each one distinct. + # + # --no-as-needed applies only to what follows within the pushed + # state, so the pop restores whatever the consumer had. + "LINKER:--push-state,--no-as-needed,${_library},--pop-state" ) elseif(APPLE) set_property( From 16fcceff2e925914eeb0bc548fbb0c331cc24e1d Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 13:13:20 -0700 Subject: [PATCH 30/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 2 +- tools/cmake/executorch-wheel-config.cmake | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 9db5da1fdfa..d14101e9a1a 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -60,7 +60,7 @@ """ _CONSUMER_CMAKE = """\ -cmake_minimum_required(VERSION 3.24) +cmake_minimum_required(VERSION 3.28) project(executorch_wheel_consumer CXX) find_package(executorch REQUIRED) add_executable(consumer consumer.cpp) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 079fa8b65fd..f4f8cda0be1 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -36,7 +36,13 @@ # # Check with if(TARGET executorch::) rather than assuming one exists. # -cmake_minimum_required(VERSION 3.19) +# 3.28 rather than something older: the imported targets below export +# "$ORIGIN"-relative runtime paths as link options, and CMake writes that token +# incorrectly before 3.28. Versions 3.24 through 3.27 emit a doubled dollar with +# the Makefile generator and a bare dollar with Ninja, so a consumer builds and +# runs in place, because the absolute package directory is also recorded, then +# fails once it is deployed somewhere else. +cmake_minimum_required(VERSION 3.28) # This file is installed to /executorch/share/cmake, so the # package root is two levels up. Everything is resolved relative to this file so From 8fbcb0d520143bfce5ae628537f82c75977351a2 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 14:10:01 -0700 Subject: [PATCH 31/70] Update [ghstack-poisoned] --- docs/source/using-executorch-cpp.md | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/source/using-executorch-cpp.md b/docs/source/using-executorch-cpp.md index 5505ade9573..a4f45be9c00 100644 --- a/docs/source/using-executorch-cpp.md +++ b/docs/source/using-executorch-cpp.md @@ -40,6 +40,55 @@ Running a model using the low-level runtime APIs allows for a high-degree of con ## Building with CMake +There are two ways to get the C++ runtime. Linking the prebuilt libraries from the +pip package needs no source checkout and is the quicker option. Building from source +gives you every option the project has, and is what you need for a platform the wheel +does not cover. + +### Using the prebuilt libraries from the pip package + +On Linux, `pip install executorch` includes prebuilt shared libraries, the public +headers, and a CMake package, so a C++ application can link the runtime without +building ExecuTorch itself: + +``` +# CMakeLists.txt +cmake_minimum_required(VERSION 3.28) +project(my_app CXX) + +find_package(executorch REQUIRED) + +add_executable(my_app main.cpp) +target_link_libraries(my_app PRIVATE executorch::runtime) +``` + +Point CMake at the installed package when you configure: + +``` +cmake -S . -B build \ + -DCMAKE_PREFIX_PATH="$(python -c 'import executorch, pathlib; print(pathlib.Path(executorch.__path__[0]) / "share" / "cmake")')" +cmake --build build +``` + +`executorch::runtime` gives you the core runtime, which can load and run a `.pte` +file. Anything a model needs beyond that comes from a separate component target, and +each one is defined only when the installed wheel actually ships it: + +| Target | What it provides | +| --- | --- | +| `executorch::runtime` | The core runtime. Always present. | + +Each target already carries what it needs: the runtime dependency, the include +directories, the runtime search paths, and the linker options that keep a +registration-only library from being dropped. You do not need to name library files +or add link flags yourself. + +CMake 3.28 or newer is required. Older versions write the `$ORIGIN` token in the +runtime search path incorrectly, which makes an application fail to find the +libraries once it is copied somewhere else. + +### Building from source + ExecuTorch uses CMake as the primary build system. Inclusion of the module and tensor APIs are controlled by the `EXECUTORCH_BUILD_EXTENSION_MODULE` and `EXECUTORCH_BUILD_EXTENSION_TENSOR` CMake options. As these APIs may not be supported on embedded systems, they are disabled by default when building from source. The low-level API surface is always included. To link, add the `executorch` target as a CMake dependency, along with `executorch_backends`, `executorch_extensions`, and `extension_kernels`, to link all configured backends, extensions, and kernels. ``` From 1555421975d7f10b828fafddec25149ee0e27032 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 14:24:34 -0700 Subject: [PATCH 32/70] Update [ghstack-poisoned] --- docs/source/using-executorch-cpp.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/source/using-executorch-cpp.md b/docs/source/using-executorch-cpp.md index a4f45be9c00..277c729bcc4 100644 --- a/docs/source/using-executorch-cpp.md +++ b/docs/source/using-executorch-cpp.md @@ -70,6 +70,34 @@ cmake -S . -B build \ cmake --build build ``` +The application uses the same `Module` and `TensorPtr` APIs described above, and +nothing else changes because the runtime came from a package rather than a source +build: + +```cpp +// main.cpp +#include +#include + +using executorch::extension::make_tensor_ptr; +using executorch::extension::module::Module; + +int main() { + Module module("model.pte"); + + std::vector data(2 * 8, 1.0f); + auto input = make_tensor_ptr({2, 8}, data.data()); + + const auto result = module.forward(input); + if (!result.ok()) { + return 1; + } + const auto output = result->at(0).toTensor(); + std::printf("produced %zu values\n", (size_t)output.numel()); + return 0; +} +``` + `executorch::runtime` gives you the core runtime, which can load and run a `.pte` file. Anything a model needs beyond that comes from a separate component target, and each one is defined only when the installed wheel actually ships it: From 59cf98515073d05364eaa1b592e1946a677c8026 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 14:55:32 -0700 Subject: [PATCH 33/70] Update [ghstack-poisoned] --- tools/cmake/Utils.cmake | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index 0cd9cd2f523..848452902f5 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -81,11 +81,11 @@ function(executorch_target_link_options_shared_lib target_name) target_link_options( ${target_name} INTERFACE - # Separate options rather than one SHELL: string, which splits on spaces - # and would break a library path containing one. - "LINKER:--push-state,--no-as-needed" - "$" - "LINKER:--pop-state" + # One option with the library inside it, for two reasons. A SHELL: string + # would split on spaces and break a path containing one, and separate + # options repeat identical text that CMake de-duplicates, which silently + # leaves every library after the first outside any --no-as-needed scope. + "LINKER:--push-state,--no-as-needed,$,--pop-state" ) return() endif() @@ -289,10 +289,11 @@ function(executorch_target_retain_shared_library target_name library_target) # push-state/pop-state rather than closing with an explicit --as-needed: # that would leave --as-needed in force for everything after it on the line # and drop the next library that only exists for static-init registration. - # Separate options rather than one SHELL: string, which splits on spaces and - # would break a library path containing one. - set(_retain_flags "LINKER:--push-state,--no-as-needed" - "$" "LINKER:--pop-state" + # The library goes inside the single option: a SHELL: string would split on + # spaces, and separate options repeat identical text that CMake + # de-duplicates, which would leave every library after the first unscoped. + set(_retain_flags + "LINKER:--push-state,--no-as-needed,$,--pop-state" ) endif() # The generator expression alone does not order the build, so say it outright. From b2ff575458a8fbd56a9dbbeccdd3b757a2548b9e Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 15:08:30 -0700 Subject: [PATCH 34/70] Update [ghstack-poisoned] --- setup.py | 9 +++++++++ tools/cmake/executorch-wheel-config.cmake | 17 +++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index 7ee07396498..2fb798e7bb9 100644 --- a/setup.py +++ b/setup.py @@ -777,6 +777,15 @@ def run(self): "tools/cmake/executorch-wheel-config.cmake", "share/cmake/executorch-config.cmake", ), + # Also at the standard location, so a consumer can point + # CMAKE_PREFIX_PATH at the installed package root. CMake only + # searches lib/cmake/ and a few similar directories + # for a named package, not a bare share/cmake, so without this a + # consumer has to know the exact leaf holding the file. + ( + "tools/cmake/executorch-wheel-config.cmake", + "lib/cmake/executorch/executorch-config.cmake", + ), ] # Copy all the necessary headers into include/executorch/ so that they can # be found in the pip package. This is the subset of headers that are diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index f4f8cda0be1..ec075e7f4f1 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -44,12 +44,17 @@ # fails once it is deployed somewhere else. cmake_minimum_required(VERSION 3.28) -# This file is installed to /executorch/share/cmake, so the -# package root is two levels up. Everything is resolved relative to this file so -# the wheel stays relocatable: no absolute path from the machine that built it -# is baked in here. -get_filename_component( - _executorch_package_root "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE +# Everything is resolved relative to this file so the wheel stays relocatable: +# no absolute path from the machine that built it is baked in here. The file is +# installed both under share/cmake, which the historical contract uses, and +# under lib/cmake/executorch, which a plain CMAKE_PREFIX_PATH pointed at the +# package root can discover, so the root is located by a marker rather than a +# fixed depth. +find_path( + _executorch_package_root include/executorch + PATHS "${CMAKE_CURRENT_LIST_DIR}/.." "${CMAKE_CURRENT_LIST_DIR}/../.." + "${CMAKE_CURRENT_LIST_DIR}/../../.." + NO_DEFAULT_PATH ) set(EXECUTORCH_INCLUDE_DIRS From d41a53df337e3607e036ea9ca55aac5de3737684 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 17:49:48 -0700 Subject: [PATCH 35/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index ec075e7f4f1..ca157166872 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -55,6 +55,10 @@ find_path( PATHS "${CMAKE_CURRENT_LIST_DIR}/.." "${CMAKE_CURRENT_LIST_DIR}/../.." "${CMAKE_CURRENT_LIST_DIR}/../../.." NO_DEFAULT_PATH + # NO_CACHE so the search runs on every configure. A cached result would survive + # the package being upgraded or relocated in place and keep naming a directory + # that has moved, which is worse than reporting it as not found. + NO_CACHE ) set(EXECUTORCH_INCLUDE_DIRS From e9abdd5e473e2c386f9a09a4f20a2ddb3ad71ec8 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 18:02:29 -0700 Subject: [PATCH 36/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 327 ++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index d14101e9a1a..cef5a425907 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -22,10 +22,12 @@ on the shipped runtime with a relocatable RUNPATH. """ +import importlib.util import os import re import shutil import subprocess +import tempfile import sys from pathlib import Path @@ -270,7 +272,332 @@ def test_python_extensions_import() -> None: ) +_CUSTOM_OP_SOURCE = """\ +// A custom operator, built the way an out-of-tree project builds one: against the +// shipped Python extension rather than an ExecuTorch source tree. +#include +#include + +namespace { + +executorch::aten::Tensor& custom_double_out( + executorch::runtime::KernelRuntimeContext& context, + const executorch::aten::Tensor& input, + executorch::aten::Tensor& out) { + (void)context; + const float* in = input.const_data_ptr(); + float* dst = out.mutable_data_ptr(); + for (ssize_t i = 0; i < input.numel(); ++i) { + dst[i] = in[i] * 2.0f; + } + return out; +} + +} // namespace + +// The registration macro is the point of the check: it has to compile and resolve +// against the registry the shipped extension provides. +EXECUTORCH_LIBRARY(wheel_check, "custom_double.out", custom_double_out); +""" + + +_CUSTOM_OP_CMAKE = """\ +cmake_minimum_required(VERSION 3.28) +project(custom_op_check CXX) + +find_package(executorch REQUIRED) + +add_library(custom_op_check SHARED custom_op.cpp) +# The legacy contract: a custom-op library links the shipped Python extension, +# which owns the operator registry it registers into. +target_link_libraries(custom_op_check PRIVATE _portable_lib) +""" + + +def test_shipped_libraries_load() -> None: + """Every shipped library must depend only on things that exist. + + The symbol checks prove each component is defined exactly once, but a library + can still be unloadable if it needs something nothing provides, which is a + packaging bug rather than a duplication bug. + + A dependency the wheel ships elsewhere is fine even when `ldd` cannot resolve + it: some extensions are loaded after `import torch` has already brought their + dependencies into the process, so they intentionally carry no path to them. + Only a name nothing in the wheel provides is a real problem. + """ + if shutil.which("ldd") is None: + print("- ldd not available, skipping the load check") + return + + package_dir = _installed_package_dir() + libraries = _shipped_shared_objects(package_dir) + shipped = {library.name for library in libraries} + + # A dependency is only excusable when the wheel ships it AND the loader can + # actually reach it from the library that needs it. Loaded-later extensions + # such as the Torch libraries are the real exception: they resolve once the + # Python package that owns them is imported. Anything the wheel itself ships + # must resolve here, because a RUNPATH applies to the library carrying it and + # is not inherited on behalf of a dependency's own dependencies. + broken = {} + unreachable = {} + unresolved = {} + for library in libraries: + resolved = subprocess.run( + # -r resolves data and function symbols too, not just the NEEDED + # entries. A SHARED link does not error on undefined symbols, so + # without this an under-linked library passes here and fails at first + # use instead. + ["ldd", "-r", str(library)], + capture_output=True, + text=True, + check=False, + # Any LD_LIBRARY_PATH in the build environment would paper over a + # RUNPATH the shipped library is actually missing. + env={ + key: value + for key, value in os.environ.items() + if key != "LD_LIBRARY_PATH" + }, + ) + # ldd reports missing libraries on stdout but undefined symbols on stderr, + # so both streams matter. + combined = resolved.stdout + resolved.stderr + missing = [ + line.split("=>")[0].strip() + for line in combined.splitlines() + if "not found" in line + ] + # Interpreter symbols are excluded rather than whole files. A library that + # is loaded by Python, whether a extension module or an ahead-of-time + # plugin, resolves those only once an interpreter is running, so ldd can + # never resolve them and their absence says nothing about packaging. + # Filtering the symbols rather than guessing from the file name keeps the + # check active for everything else those libraries need. + undefined = [ + line.strip() + for line in combined.splitlines() + if "undefined symbol" in line + and not re.search(r"undefined symbol:\s+_?Py", line) + ] + if undefined: + unresolved[str(library.relative_to(package_dir))] = undefined[:5] + absent = [name for name in missing if name not in shipped] + present_but_unreachable = [name for name in missing if name in shipped] + if absent: + broken[str(library.relative_to(package_dir))] = absent + if present_but_unreachable: + unreachable[str(library.relative_to(package_dir))] = present_but_unreachable + + assert not broken, ( + "shipped libraries need dependencies that nothing provides, so they will " + f"fail to load: {broken}" + ) + assert not unreachable, ( + "shipped libraries need dependencies the wheel ships but the loader " + "cannot reach from them, which usually means a missing RUNPATH entry: " + f"{unreachable}" + ) + assert not unresolved, ( + "shipped libraries reference symbols nothing provides, so they will fail " + f"at first use rather than at load: {unresolved}" + ) + print("✓ every shipped library resolves in an environment with torch present") + + +def test_shipped_libraries_resolve_without_build_tree() -> None: + """A shipped library must resolve using only its relative runtime paths. + + Packaging copies binaries out of the build directory, so they still carry the + absolute paths they were linked with. On the machine that produced the wheel + those paths exist, which means a library whose relative path is wrong can still + resolve and look correct. Anywhere else it would fail. + + Copy each library and its wheel-provided dependencies into a fresh tree that + mirrors the wheel layout, drop every absolute runtime path, and check what is + left is enough. + """ + if shutil.which("ldd") is None or shutil.which("patchelf") is None: + print("- ldd or patchelf unavailable, skipping the relocated load check") + return + + package_dir = _installed_package_dir() + libraries = _shipped_shared_objects(package_dir) + environment = { + key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" + } + + with tempfile.TemporaryDirectory() as work_dir: + root = Path(work_dir) / package_dir.name + # Mirror the layout so a relative path such as $ORIGIN/../../lib still + # points where it would in a real install. + for library in libraries: + target = root / library.relative_to(package_dir) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(library, target) + + broken = {} + for library in libraries: + target = root / library.relative_to(package_dir) + current = subprocess.run( + ["patchelf", "--print-rpath", str(target)], + capture_output=True, + text=True, + check=False, + ).stdout.strip() + relative = [ + entry for entry in current.split(":") if entry.startswith("$ORIGIN") + ] + subprocess.run( + ["patchelf", "--set-rpath", ":".join(relative), str(target)], + # A failure here would leave the original absolute build paths in + # place, and the check below would then pass by resolving through + # them, which is exactly what this test exists to rule out. + check=True, + ) + resolved = subprocess.run( + ["ldd", str(target)], + capture_output=True, + text=True, + check=False, + env=environment, + ).stdout + shipped = {item.name for item in libraries} + all_missing = [ + line.split("=>")[0].strip() + for line in resolved.splitlines() + if "not found" in line + ] + # Only wheel-provided dependencies are asserted on, because an external + # one is expected to come from the environment. They are still reported, + # since silently dropping them would hide a library that resolves only + # through an absolute build path. + missing = [name for name in all_missing if name in shipped] + external = [name for name in all_missing if name not in shipped] + if external: + print( + f"- {library.relative_to(package_dir)} also needs " + f"{external} from the environment" + ) + if missing: + broken[str(library.relative_to(package_dir))] = missing + + assert not broken, ( + "shipped libraries only resolve their wheel-provided dependencies " + "through absolute build paths, so they would fail on any other " + f"machine: {broken}" + ) + print("✓ every shipped library resolves without the build tree") + + +def test_custom_op_compiles(work_dir: Path) -> None: + """A custom operator compiles and links against the shipped extension. + + This is how an out-of-tree project adds its own kernels, and it points at the + Python extension rather than the runtime, so it is not covered by the consumer + check above. + """ + assert shutil.which("cmake") is not None, "cmake is required to build a consumer" + + package_dir = _installed_package_dir() + if not list(package_dir.glob("extension/pybindings/_portable_lib*")): + print("- the wheel ships no Python extension, skipping the custom op check") + return + + source_dir = work_dir / "custom-op" + build_dir = work_dir / "custom-op-build" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "custom_op.cpp").write_text(_CUSTOM_OP_SOURCE) + (source_dir / "CMakeLists.txt").write_text(_CUSTOM_OP_CMAKE) + + configure = subprocess.run( + [ + "cmake", + "-S", + str(source_dir), + "-B", + str(build_dir), + f"-DCMAKE_PREFIX_PATH={package_dir}", + ], + capture_output=True, + text=True, + check=False, + ) + assert configure.returncode == 0, ( + "a custom operator project cannot configure against the wheel: " + f"{(configure.stderr or configure.stdout).strip()[-600:]}" + ) + + compiled = subprocess.run( + ["cmake", "--build", str(build_dir)], + capture_output=True, + text=True, + check=False, + ) + assert compiled.returncode == 0, ( + "a custom operator does not compile or link against the shipped extension: " + f"{(compiled.stderr or compiled.stdout).strip()[-800:]}" + ) + assert list(build_dir.rglob("libcustom_op_check.so")) or list( + build_dir.rglob("custom_op_check.dll") + ), "the custom operator library was not produced" + print("✓ a custom operator compiles against the shipped Python extension") + + +def test_wheel_platform_tag() -> None: + """The wheel's declared platform tag must match what its libraries need. + + A library that quietly picks up a newer dependency, or a newer minimum glibc, + makes the wheel unusable on machines the tag says it supports. auditwheel is + the tool that decides this, so ask it rather than guessing. + + Only a contradiction between the tag and the contents fails here. Reports about + instruction set extensions are left to the caller, because a prebuilt tool that + ships in the wheel can legitimately require a newer baseline than the tag + implies. + """ + if importlib.util.find_spec("auditwheel") is None: + print("- auditwheel unavailable, skipping the platform tag check") + return + + wheels = sorted(Path(os.environ.get("WHEEL_DIR", ".")).glob("executorch-*.whl")) + if not wheels: + print("- no wheel file to inspect, skipping the platform tag check") + return + + result = subprocess.run( + [sys.executable, "-m", "auditwheel", "show", str(wheels[-1])], + capture_output=True, + text=True, + check=False, + ) + # auditwheel wraps its verdict across lines, so compare on collapsed + # whitespace rather than the literal output. + combined = " ".join((result.stdout + result.stderr).split()) + match = re.search( + r'consistent with the following platform tag: "([^"]+)"', combined + ) + assert match, ( + "auditwheel reported no platform tag for the wheel, so its contents could " + f"not be checked against what it claims: {combined[-400:]}" + ) + # The tag auditwheel derives from the contents has to be the one the file name + # claims. A wheel that names a stricter tag than its libraries support installs + # on machines it cannot actually run on. + claimed = wheels[-1].name.split("-")[-1].removesuffix(".whl") + assert match.group(1) in claimed, ( + f"the wheel claims platform tag {claimed} but its contents only support " + f"{match.group(1)}" + ) + print(f"✓ the wheel contents match its declared platform tag {match.group(1)}") + + def run_tests(work_dir: Path) -> None: test_single_backend_registry() test_python_extensions_import() + test_shipped_libraries_load() + test_shipped_libraries_resolve_without_build_tree() + test_wheel_platform_tag() + test_custom_op_compiles(work_dir) test_cpp_consumer(work_dir) From d3ee68a69576a59fb8beac4bbc3ae8b8c923de9d Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 18:57:49 -0700 Subject: [PATCH 37/70] Update [ghstack-poisoned] --- tools/cmake/Utils.cmake | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index 848452902f5..fc1568f868b 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -55,13 +55,16 @@ endfunction() # link libraries, which is what keeps a bundled archive ahead of anything that # would otherwise satisfy the same symbols. function(executorch_target_whole_archive target_name archive_target) + # Comma-separated inside one LINKER: option rather than a SHELL: string with + # spaces. SHELL: splits on spaces, so an archive path containing one reaches + # the linker as two broken arguments and the link fails. if(APPLE) - set(_flags "SHELL:LINKER:-force_load,$") + set(_flags "LINKER:-force_load,$") elseif(MSVC) - set(_flags "SHELL:LINKER:/WHOLEARCHIVE:$") + set(_flags "LINKER:/WHOLEARCHIVE:$") else() set(_flags - "SHELL:LINKER:--whole-archive $ LINKER:--no-whole-archive" + "LINKER:--whole-archive,$,--no-whole-archive" ) endif() target_link_options(${target_name} PRIVATE "${_flags}") From 8bfba132b2c6de0ec20f3d6e04620c57a83139c7 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 19:44:50 -0700 Subject: [PATCH 38/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 43 +++++++++++++++++++++++++++++++ tools/cmake/preset/pybind.cmake | 7 +++++ 2 files changed, 50 insertions(+) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index cef5a425907..dc3d89d0b38 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -593,6 +593,48 @@ def test_wheel_platform_tag() -> None: print(f"✓ the wheel contents match its declared platform tag {match.group(1)}") +def test_no_absolute_runtime_paths() -> None: + """No shipped library may carry an absolute runtime search path. + + Packaging copies libraries out of the build tree rather than installing them, + so anything CMake recorded at build time ships as-is. An absolute entry both + names the build machine and points somewhere that will not exist for a user. + + The check reads the shipped file directly, with nothing stripped, which is what + a user actually receives. + """ + if shutil.which("patchelf") is None: + print("- patchelf unavailable, skipping the runtime path check") + return + + package_dir = _installed_package_dir() + offenders = {} + for library in sorted(package_dir.rglob("*.so*")): + if not library.is_file() or library.is_symlink(): + continue + result = subprocess.run( + ["patchelf", "--print-rpath", str(library)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + continue + absolute = [ + entry + for entry in result.stdout.strip().split(":") + if entry.startswith("/") + ] + if absolute: + offenders[str(library.relative_to(package_dir))] = absolute + + assert not offenders, ( + "shipped libraries carry absolute runtime search paths, so they are not " + f"relocatable and name the build machine: {offenders}" + ) + print("✓ no shipped library carries an absolute runtime search path") + + def run_tests(work_dir: Path) -> None: test_single_backend_registry() test_python_extensions_import() @@ -600,4 +642,5 @@ def run_tests(work_dir: Path) -> None: test_shipped_libraries_resolve_without_build_tree() test_wheel_platform_tag() test_custom_op_compiles(work_dir) + test_no_absolute_runtime_paths() test_cpp_consumer(work_dir) diff --git a/tools/cmake/preset/pybind.cmake b/tools/cmake/preset/pybind.cmake index 068f80d1e2b..e4b619b7094 100644 --- a/tools/cmake/preset/pybind.cmake +++ b/tools/cmake/preset/pybind.cmake @@ -27,6 +27,13 @@ set_overridable_option(EXECUTORCH_BUILD_EXTENSION_MODULE ON) set_overridable_option(EXECUTORCH_BUILD_EXTENSION_NAMED_DATA_MAP ON) set_overridable_option(EXECUTORCH_BUILD_WHEEL_DO_NOT_USE ON) +# Use the install runtime paths at build time. Packaging copies libraries out of +# the build tree rather than running an install step, so without this the build +# paths ship: every library keeps the absolute directories of whatever it linked +# against, which names the machine that built it and stops the wheel being +# relocatable. +set_overridable_option(CMAKE_BUILD_WITH_INSTALL_RPATH ON) + # Optional VGF enable for the default pybind/install flow. This is intentionally # scoped to this preset rather than acting as a general environment-to-CMake # override mechanism. From c0ebd906f35fa88ff2ca308189447c7f630543d4 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 21:53:51 -0700 Subject: [PATCH 39/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index ca157166872..19d8a1c24d6 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -296,3 +296,28 @@ if(NOT executorch_FOUND AND executorch_FIND_REQUIRED) "extension could be located inside it." ) endif() + +# Component requests are answered from the targets that were actually defined above, +# so a consumer asking for a component this wheel does not ship gets told at +# configure time rather than at link or load time. Without this a REQUIRED request +# for a missing component, or for a name that does not exist at all, would configure +# and then fail much later. +# +# The check is written out rather than using check_required_components, which comes +# from a module a package config cannot assume is already included. +foreach(_component ${executorch_FIND_COMPONENTS}) + if(TARGET executorch::${_component}) + set(executorch_${_component}_FOUND TRUE) + else() + set(executorch_${_component}_FOUND FALSE) + if(executorch_FIND_REQUIRED_${_component}) + set(executorch_FOUND FALSE) + set(executorch_NOT_FOUND_MESSAGE + "this ExecuTorch package does not provide the required component '${_component}'" + ) + endif() + endif() +endforeach() +if(NOT executorch_FOUND AND executorch_FIND_REQUIRED) + message(FATAL_ERROR "${executorch_NOT_FOUND_MESSAGE}") +endif() From 7e6e924521c73abf27ae1ebaea8002640f5ca155 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 22:06:42 -0700 Subject: [PATCH 40/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index dc3d89d0b38..f816cae7787 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -238,6 +238,12 @@ def test_python_extensions_import() -> None: "executorch.extension.pybindings.portable_lib", "executorch.extension.training", ] + # Torch has to be installed, the same as for the dependency check: these + # extensions link it, so without it they cannot import for a reason that says + # nothing about packaging. + if importlib.util.find_spec("torch") is None: + print("- torch is not installed, skipping the extension import check") + return environment = { key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" } @@ -329,6 +335,12 @@ def test_shipped_libraries_load() -> None: if shutil.which("ldd") is None: print("- ldd not available, skipping the load check") return + # Torch has to be installed for this to mean anything: several shipped libraries + # depend on it and resolve once it is imported. Without it every one of them looks + # broken, which would report a packaging fault that does not exist. + if importlib.util.find_spec("torch") is None: + print("- torch is not installed, skipping the load check") + return package_dir = _installed_package_dir() libraries = _shipped_shared_objects(package_dir) From d363b856d593d32e550cd027b01fb0ae76c0f377 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 22:11:52 -0700 Subject: [PATCH 41/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 19d8a1c24d6..48973cbf378 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -51,7 +51,11 @@ cmake_minimum_required(VERSION 3.28) # package root can discover, so the root is located by a marker rather than a # fixed depth. find_path( - _executorch_package_root include/executorch + _executorch_package_root + # share/cmake identifies the package root and only exists there. A generic marker + # such as include/executorch can also appear one level down, in which case the + # search from lib/cmake/executorch would stop at lib/ and resolve the wrong root. + NAMES share/cmake/executorch-config.cmake PATHS "${CMAKE_CURRENT_LIST_DIR}/.." "${CMAKE_CURRENT_LIST_DIR}/../.." "${CMAKE_CURRENT_LIST_DIR}/../../.." NO_DEFAULT_PATH @@ -251,6 +255,10 @@ if(EXT_SUFFIX) # This config binds to the wheel it ships in, so a same-named library # elsewhere on the system must not be picked up instead. NO_DEFAULT_PATH + # NO_CACHE for the same reason as the package root above: a cached result would + # survive the package being upgraded or relocated in the same build directory and + # keep naming the previous copy, or a file that no longer exists. + NO_CACHE ) endif() From 2c3b44103178f4257e39fdaa5d6fd90743a2782d Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 22:15:27 -0700 Subject: [PATCH 42/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 48973cbf378..dc0de3777f7 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -188,7 +188,10 @@ function(executorch_define_component _suffix _library_name) set_property( TARGET ${_target} APPEND - PROPERTY INTERFACE_LINK_OPTIONS "SHELL:-force_load ${_library}" + # One LINKER: option with a comma rather than a SHELL: string with a space: + # SHELL splits on spaces, so a library path containing one would reach the + # linker as two broken arguments. + PROPERTY INTERFACE_LINK_OPTIONS "LINKER:-force_load,${_library}" ) endif() set(EXECUTORCH_LIBRARIES From 395f1bdf3c4af448ee65ff0b80b13d07eb4cc431 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 22:29:32 -0700 Subject: [PATCH 43/70] Update [ghstack-poisoned] --- setup.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/setup.py b/setup.py index 2fb798e7bb9..30989e80066 100644 --- a/setup.py +++ b/setup.py @@ -342,6 +342,46 @@ def get_dynamic_lib_name(name: str) -> str: return f"lib{name}.so" +def _write_cmake_version_file(destination: str) -> None: + """Generate the CMake package version file next to the package config. + + Read from version.txt so the version CMake reports is the same one the wheel and + the runtime SONAME use. Written by hand rather than with + write_basic_package_version_file because that helper needs a CMake run, and this + file is produced while assembling the wheel. + """ + root = os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(root, "version.txt")) as handle: + version = handle.read().strip() + # A pre-release suffix is not a CMake version component, so keep the numeric + # prefix and let compatibility be decided on the major. + numeric = re.match(r"\d+(?:\.\d+){0,2}", version) + numeric = numeric.group(0) if numeric else "0.0.0" + major = numeric.split(".")[0] + + contents = f"""\ +set(PACKAGE_VERSION "{numeric}") + +if(NOT PACKAGE_FIND_VERSION) + # No version requested, so any version satisfies it. + set(PACKAGE_VERSION_COMPATIBLE TRUE) +elseif(PACKAGE_FIND_VERSION_MAJOR STREQUAL "{major}") + # SameMajorVersion, matching the runtime's SONAME: a consumer asking for {major}.x + # gets any {major}.y, and a request for a different major is refused because the + # shared runtime it would link is not the one it asked for. + set(PACKAGE_VERSION_COMPATIBLE TRUE) + if(PACKAGE_FIND_VERSION STREQUAL PACKAGE_VERSION) + set(PACKAGE_VERSION_EXACT TRUE) + endif() +else() + set(PACKAGE_VERSION_UNSUITABLE TRUE) +endif() +""" + os.makedirs(os.path.dirname(destination), exist_ok=True) + with open(destination, "w") as handle: + handle.write(contents) + + def get_runtime_soname_major() -> str: """The major version in the shared runtime's SONAME. @@ -734,6 +774,18 @@ def analyze_manifest(self): if os.path.isfile(os.path.join(_root, _f)) ] + @staticmethod + def _write_cmake_version_files(dst_root: str) -> None: + """Put a CMake version file beside each copy of the package config. + + Without one, CMake rejects any versioned find_package because it cannot tell + what version the package is, even when the package is usable. + """ + for config_dir in ("share/cmake", "lib/cmake/executorch"): + _write_cmake_version_file( + os.path.join(dst_root, config_dir, "executorch-config-version.cmake") + ) + def run(self): # Copy python files to the output directory. This set of files is # defined by the py_module list and package_data patterns. @@ -828,6 +880,8 @@ def run(self): # the input file is read-only. self.copy_file(src, dst, preserve_mode=False) + self._write_cmake_version_files(dst_root) + # Copy CMake-generated Python directories that setuptools missed. # Setuptools discovers packages at configuration time, before CMake # runs. Directories created by CMake during the build (e.g. by From 217b18d33f1b0bb979d7627c33d53c33e3703f43 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 22:38:22 -0700 Subject: [PATCH 44/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 99 +++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index f816cae7787..c19c1d208b5 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -647,6 +647,104 @@ def test_no_absolute_runtime_paths() -> None: print("✓ no shipped library carries an absolute runtime search path") +_COMPONENT_CONSUMER_CMAKE = """\ +cmake_minimum_required(VERSION 3.28) +project(component_consumer CXX) + +find_package(executorch REQUIRED) + +add_executable(component_consumer consumer.cpp) +target_link_libraries(component_consumer PRIVATE executorch::runtime) + +# Link every component this wheel offers, and report which ones those are so the test +# can check the result. Guarded individually because the set depends on the wheel. +foreach(_component threadpool kernels xnnpack_backend cuda_backend) + if(TARGET executorch::${_component}) + target_link_libraries(component_consumer PRIVATE executorch::${_component}) + # Report the library file, not just the target name: the two differ, and the test + # needs the file name to look for in the built binary. + get_target_property(_location executorch::${_component} IMPORTED_LOCATION) + get_filename_component(_file "${_location}" NAME) + message(STATUS "LINKED_COMPONENT=${_component}:${_file}") + endif() +endforeach() +""" + + +def test_component_targets_link(work_dir: Path) -> None: + """Every component target the wheel offers must link and be retained. + + A component library exists to register something, so nothing in the application + references a symbol from it. That is exactly the case a normal link drops, which is + why the targets carry retention options. This checks the options do their job + instead of trusting them. + """ + assert shutil.which("cmake") is not None, "cmake is required to build a consumer" + if shutil.which("readelf") is None: + print("- readelf unavailable, skipping the component link check") + return + + package_dir = _installed_package_dir() + source_dir = work_dir / "components" + build_dir = work_dir / "components-build" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "consumer.cpp").write_text(_CONSUMER_SOURCE) + (source_dir / "CMakeLists.txt").write_text(_COMPONENT_CONSUMER_CMAKE) + + configure = subprocess.run( + [ + "cmake", + "-S", + str(source_dir), + "-B", + str(build_dir), + f"-DCMAKE_PREFIX_PATH={package_dir}", + ], + capture_output=True, + text=True, + check=False, + ) + assert configure.returncode == 0, ( + "a consumer linking the component targets cannot configure: " + f"{(configure.stderr or configure.stdout).strip()[-500:]}" + ) + linked = dict( + match.split(":", 1) + for match in re.findall(r"LINKED_COMPONENT=(\S+)", configure.stdout) + ) + # A wheel that ships only the runtime has nothing to check here, which is a valid + # configuration rather than a fault. + if not linked: + print("- this wheel offers no component targets, skipping the component check") + return + + built = subprocess.run( + ["cmake", "--build", str(build_dir)], + capture_output=True, + text=True, + check=False, + ) + assert built.returncode == 0, ( + "a consumer linking the component targets does not build: " + f"{(built.stderr or built.stdout).strip()[-700:]}" + ) + + consumer = build_dir / "component_consumer" + needed = subprocess.run( + ["readelf", "-d", str(consumer)], capture_output=True, text=True, check=True + ).stdout + # Each component has to appear in DT_NEEDED. Absent means the retention options did + # not hold and whatever the library registers would never happen at runtime. + dropped = [ + component for component, library in linked.items() if library not in needed + ] + assert not dropped, ( + f"components {dropped} were linked but do not appear in the consumer's " + "DT_NEEDED, so their registration would never run" + ) + print(f"✓ every offered component links and is retained: {sorted(linked)}") + + def run_tests(work_dir: Path) -> None: test_single_backend_registry() test_python_extensions_import() @@ -656,3 +754,4 @@ def run_tests(work_dir: Path) -> None: test_custom_op_compiles(work_dir) test_no_absolute_runtime_paths() test_cpp_consumer(work_dir) + test_component_targets_link(work_dir) From b9eade5fd3b15d7bd306d2842760b0b0fbe7570c Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 23:45:52 -0700 Subject: [PATCH 45/70] Update [ghstack-poisoned] --- setup.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 30989e80066..e8e26e90677 100644 --- a/setup.py +++ b/setup.py @@ -332,6 +332,25 @@ def get_build_type(is_debug=None) -> str: } ) +# Headers excluded by path rather than by name, because the same file name is also used +# by a header that does compile. Each of these includes something the wheel does not +# ship, so it cannot build from an installed package. +_UNSUPPORTED_WHEEL_HEADER_PATHS = ( + # Includes a generated schema header that is not part of the shipped tree. + "runtime/executor/tensor_parser.h", + # GoogleTest and GoogleMock helpers, which a runtime package has no reason to ship. + "runtime/core/exec_aten/testing_util/tensor_util.h", + "runtime/core/testing_util/error_matchers.h", +) + + +def _is_unsupported_wheel_header(source: Path) -> bool: + """Whether a header cannot compile from an installed package.""" + if source.name in _UNSUPPORTED_WHEEL_HEADERS: + return True + posix = source.as_posix() + return any(posix.endswith(suffix) for suffix in _UNSUPPORTED_WHEEL_HEADER_PATHS) + def get_dynamic_lib_name(name: str) -> str: if _is_windows(): @@ -861,7 +880,7 @@ def run(self): ]: src_list = Path(include_dir).rglob("*.h") for src in src_list: - if src.name in _UNSUPPORTED_WHEEL_HEADERS: + if _is_unsupported_wheel_header(src): continue src_to_dst.append( (str(src), os.path.join("include/executorch", str(src))) From f5a9b975b7a52f5cf31b57eebca69ee03480254f Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 23:58:25 -0700 Subject: [PATCH 46/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index dc0de3777f7..c42f38b6df4 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -65,10 +65,18 @@ find_path( NO_CACHE ) -set(EXECUTORCH_INCLUDE_DIRS - "${_executorch_package_root}/include" - "${_executorch_package_root}/include/executorch/runtime/core/portable_type/c10" +set(EXECUTORCH_INCLUDE_DIRS "${_executorch_package_root}/include") +# Added only when present. The C10 compatibility headers ship in every wheel that has +# the runtime, but listing a directory that does not exist makes CMake fail at generate +# time with a message about a non-existent path, which hides whatever the real problem +# was. +if(EXISTS + "${_executorch_package_root}/include/executorch/runtime/core/portable_type/c10" ) + list(APPEND EXECUTORCH_INCLUDE_DIRS + "${_executorch_package_root}/include/executorch/runtime/core/portable_type/c10" + ) +endif() set(EXECUTORCH_LIBRARIES) set(EXECUTORCH_FOUND OFF) From 0799a55f8e8c0ba0166fd5659ef7bcfcf3f8331c Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 07:54:20 -0700 Subject: [PATCH 47/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index c42f38b6df4..37934f96bb9 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -65,11 +65,22 @@ find_path( NO_CACHE ) +# A package with no include directory cannot be used, so this is reported here rather +# than left to fail later as a confusing "non-existent path" at generate time or a +# missing header at compile time. +if(NOT EXISTS "${_executorch_package_root}/include") + message( + FATAL_ERROR + "The ExecuTorch package at ${_executorch_package_root} has no include " + "directory, so nothing can compile against it." + ) +endif() set(EXECUTORCH_INCLUDE_DIRS "${_executorch_package_root}/include") # Added only when present. The C10 compatibility headers ship in every wheel that has # the runtime, but listing a directory that does not exist makes CMake fail at generate # time with a message about a non-existent path, which hides whatever the real problem -# was. +# was. A wheel without them can still compile anything that does not reach for a C10 +# header, so this is a missing directory rather than an unusable package. if(EXISTS "${_executorch_package_root}/include/executorch/runtime/core/portable_type/c10" ) From a10d1a5e87087fe1cac4086b895393f077ef8ed0 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 09:19:56 -0700 Subject: [PATCH 48/70] Update [ghstack-poisoned] --- docs/source/using-executorch-cpp.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/source/using-executorch-cpp.md b/docs/source/using-executorch-cpp.md index 277c729bcc4..d5cda087b72 100644 --- a/docs/source/using-executorch-cpp.md +++ b/docs/source/using-executorch-cpp.md @@ -79,8 +79,7 @@ build: #include #include -using executorch::extension::make_tensor_ptr; -using executorch::extension::module::Module; +using namespace executorch::extension; int main() { Module module("model.pte"); From 23d417d7df7d97aeff1ded6ba77e7f67a834b942 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 09:32:35 -0700 Subject: [PATCH 49/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 89 ++++++++++++----------- 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 37934f96bb9..8ffdea54144 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -52,42 +52,48 @@ cmake_minimum_required(VERSION 3.28) # fixed depth. find_path( _executorch_package_root - # share/cmake identifies the package root and only exists there. A generic marker - # such as include/executorch can also appear one level down, in which case the - # search from lib/cmake/executorch would stop at lib/ and resolve the wrong root. + # share/cmake identifies the package root and only exists there. A generic + # marker such as include/executorch can also appear one level down, in which + # case the search from lib/cmake/executorch would stop at lib/ and resolve the + # wrong root. NAMES share/cmake/executorch-config.cmake PATHS "${CMAKE_CURRENT_LIST_DIR}/.." "${CMAKE_CURRENT_LIST_DIR}/../.." "${CMAKE_CURRENT_LIST_DIR}/../../.." NO_DEFAULT_PATH - # NO_CACHE so the search runs on every configure. A cached result would survive - # the package being upgraded or relocated in place and keep naming a directory - # that has moved, which is worse than reporting it as not found. + # NO_CACHE so the search runs on every configure. A cached result would + # survive the package being upgraded or relocated in place and keep naming a + # directory that has moved, which is worse than reporting it as not found. NO_CACHE ) -# A package with no include directory cannot be used, so this is reported here rather -# than left to fail later as a confusing "non-existent path" at generate time or a -# missing header at compile time. -if(NOT EXISTS "${_executorch_package_root}/include") - message( - FATAL_ERROR - "The ExecuTorch package at ${_executorch_package_root} has no include " - "directory, so nothing can compile against it." - ) -endif() -set(EXECUTORCH_INCLUDE_DIRS "${_executorch_package_root}/include") -# Added only when present. The C10 compatibility headers ship in every wheel that has -# the runtime, but listing a directory that does not exist makes CMake fail at generate -# time with a message about a non-existent path, which hides whatever the real problem -# was. A wheel without them can still compile anything that does not reach for a C10 -# header, so this is a missing directory rather than an unusable package. -if(EXISTS - "${_executorch_package_root}/include/executorch/runtime/core/portable_type/c10" +# Both directories are needed for a usable package. The C10 compatibility +# headers are not optional: core headers such as runtime/core/array_ref.h +# include c10 unconditionally, so a package missing them cannot compile anything +# that touches the runtime API. +# +# A missing directory is reported as not-found rather than raised here, so an +# optional find_package gets a FALSE answer instead of a dead build. The +# REQUIRED handling at the bottom of this file turns it into an error when the +# caller asked for one. +set(_executorch_c10_include + "${_executorch_package_root}/include/executorch/runtime/core/portable_type/c10" ) - list(APPEND EXECUTORCH_INCLUDE_DIRS - "${_executorch_package_root}/include/executorch/runtime/core/portable_type/c10" - ) -endif() +set(EXECUTORCH_INCLUDE_DIRS "${_executorch_package_root}/include" + "${_executorch_c10_include}" +) +foreach(_required_include ${EXECUTORCH_INCLUDE_DIRS}) + if(NOT EXISTS "${_required_include}") + message( + STATUS "ExecuTorch package at ${_executorch_package_root} is missing " + "${_required_include}, so nothing can compile against it." + ) + set(EXECUTORCH_INCLUDE_DIRS) + set(EXECUTORCH_LIBRARIES) + set(EXECUTORCH_FOUND OFF) + set(executorch_FOUND FALSE) + return() + endif() +endforeach() set(EXECUTORCH_LIBRARIES) set(EXECUTORCH_FOUND OFF) @@ -207,9 +213,9 @@ function(executorch_define_component _suffix _library_name) set_property( TARGET ${_target} APPEND - # One LINKER: option with a comma rather than a SHELL: string with a space: - # SHELL splits on spaces, so a library path containing one would reach the - # linker as two broken arguments. + # One LINKER: option with a comma rather than a SHELL: string with a + # space: SHELL splits on spaces, so a library path containing one would + # reach the linker as two broken arguments. PROPERTY INTERFACE_LINK_OPTIONS "LINKER:-force_load,${_library}" ) endif() @@ -277,9 +283,10 @@ if(EXT_SUFFIX) # This config binds to the wheel it ships in, so a same-named library # elsewhere on the system must not be picked up instead. NO_DEFAULT_PATH - # NO_CACHE for the same reason as the package root above: a cached result would - # survive the package being upgraded or relocated in the same build directory and - # keep naming the previous copy, or a file that no longer exists. + # NO_CACHE for the same reason as the package root above: a cached result + # would survive the package being upgraded or relocated in the same build + # directory and keep naming the previous copy, or a file that no longer + # exists. NO_CACHE ) endif() @@ -327,14 +334,14 @@ if(NOT executorch_FOUND AND executorch_FIND_REQUIRED) ) endif() -# Component requests are answered from the targets that were actually defined above, -# so a consumer asking for a component this wheel does not ship gets told at -# configure time rather than at link or load time. Without this a REQUIRED request -# for a missing component, or for a name that does not exist at all, would configure -# and then fail much later. +# Component requests are answered from the targets that were actually defined +# above, so a consumer asking for a component this wheel does not ship gets told +# at configure time rather than at link or load time. Without this a REQUIRED +# request for a missing component, or for a name that does not exist at all, +# would configure and then fail much later. # -# The check is written out rather than using check_required_components, which comes -# from a module a package config cannot assume is already included. +# The check is written out rather than using check_required_components, which +# comes from a module a package config cannot assume is already included. foreach(_component ${executorch_FIND_COMPONENTS}) if(TARGET executorch::${_component}) set(executorch_${_component}_FOUND TRUE) From deb58fda6f1ce891e1d590e6ba6a595d53c31ed5 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 09:43:24 -0700 Subject: [PATCH 50/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 57 ++++++++++++++--------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 8ffdea54144..d4e8fa0179e 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -98,19 +98,41 @@ endforeach() set(EXECUTORCH_LIBRARIES) set(EXECUTORCH_FOUND OFF) -# The prebuilt runtime. Match the versioned file rather than a hardcoded major -# so the config keeps working across releases. -file(GLOB _executorch_runtime_candidates - "${_executorch_package_root}/lib/libexecutorch.so" - "${_executorch_package_root}/lib/libexecutorch.so.*" -) -# An unversioned libexecutorch.so sorts before any libexecutorch.so., so -# a development symlink wins over the versioned file when both are present. -list(SORT _executorch_runtime_candidates) -list(LENGTH _executorch_runtime_candidates _executorch_runtime_count) -if(_executorch_runtime_count GREATER 0) - list(GET _executorch_runtime_candidates 0 _executorch_runtime_library) +# Locate one shipped library by base name. +# +# A wheel ships a single file per library, named for its SONAME, so the major is +# read from the shipped names rather than hardcoded here. Sets to the +# full path, or to an empty string when the wheel does not carry that library. +function(_executorch_find_library _output _base_name) + set(${_output} + "" + PARENT_SCOPE + ) + file(GLOB _matches "${_executorch_package_root}/lib/${_base_name}.so" + "${_executorch_package_root}/lib/${_base_name}.so.[0-9]" + "${_executorch_package_root}/lib/${_base_name}.so.[0-9][0-9]" + ) + list(LENGTH _matches _count) + if(_count EQUAL 0) + return() + endif() + # Highest major wins, so a package that somehow carries two does not silently + # select by string order. Natural ordering keeps .2 below .10. + list( + SORT _matches + COMPARE NATURAL + ORDER DESCENDING + ) + list(GET _matches 0 _selected) + set(${_output} + "${_selected}" + PARENT_SCOPE + ) +endfunction() +# The prebuilt runtime. +_executorch_find_library(_executorch_runtime_library libexecutorch) +if(_executorch_runtime_library) set(EXECUTORCH_FOUND ON) message(STATUS "ExecuTorch runtime found at ${_executorch_runtime_library}") @@ -160,17 +182,10 @@ endif() # # Call as: executorch_define_component( ) function(executorch_define_component _suffix _library_name) - file(GLOB _candidates - "${_executorch_package_root}/lib/lib${_library_name}.so" - "${_executorch_package_root}/lib/lib${_library_name}.so.*" - ) - if(NOT _candidates) + _executorch_find_library(_library "lib${_library_name}") + if(NOT _library) return() endif() - # An unversioned name sorts first, so a development symlink wins over the - # versioned file when both are present. - list(SORT _candidates) - list(GET _candidates 0 _library) set(_target "executorch::${_suffix}") if(NOT TARGET ${_target}) From 012df509ee41c049571d7545722173a39369a1ee Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 10:04:42 -0700 Subject: [PATCH 51/70] Update [ghstack-poisoned] --- setup.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index e8e26e90677..b27b16be665 100644 --- a/setup.py +++ b/setup.py @@ -388,9 +388,17 @@ def _write_cmake_version_file(destination: str) -> None: # SameMajorVersion, matching the runtime's SONAME: a consumer asking for {major}.x # gets any {major}.y, and a request for a different major is refused because the # shared runtime it would link is not the one it asked for. - set(PACKAGE_VERSION_COMPATIBLE TRUE) - if(PACKAGE_FIND_VERSION STREQUAL PACKAGE_VERSION) - set(PACKAGE_VERSION_EXACT TRUE) + # + # A request above this version is refused too. Matching only the major would let a + # package satisfy a request for a release it predates, so a consumer needing something + # added later would link this runtime instead of being told it is not here. + if(PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION) + set(PACKAGE_VERSION_UNSUITABLE TRUE) + else() + set(PACKAGE_VERSION_COMPATIBLE TRUE) + if(PACKAGE_FIND_VERSION STREQUAL PACKAGE_VERSION) + set(PACKAGE_VERSION_EXACT TRUE) + endif() endif() else() set(PACKAGE_VERSION_UNSUITABLE TRUE) From 581fbf3d0971b2e937ed8c0ad146670eebf2a686 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 13:01:19 -0700 Subject: [PATCH 52/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 75 +++++++++++++++++++++++++++++ docs/source/using-executorch-cpp.md | 3 ++ 2 files changed, 78 insertions(+) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index c19c1d208b5..36c95ffdb01 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -47,16 +47,34 @@ _OWNING_KINDS = frozenset("TtBbDdGgSsRrWV") _CONSUMER_SOURCE = """\ +#include +#include #include #include #include +#include + +using namespace executorch::extension; int main() { executorch::runtime::runtime_init(); std::printf( "registered backends: %zu\\n", (size_t)executorch::runtime::get_num_registered_backends()); + + // The documented entry points, not just the lower-level runtime. Constructing these + // needs real definitions at link time, so it checks that the shipped headers and the + // shipped library agree rather than only that the headers parse. + module::Module module("nonexistent.pte"); + std::vector data(4, 1.0f); + auto input = make_tensor_ptr({2, 2}, data.data()); + std::printf("tensor holds %zu values\\n", (size_t)input->numel()); + + // A missing file is the expected outcome here. What matters is that the call resolves + // and returns an error rather than failing to link. + const auto error = module.load(); + std::printf("module load returned 0x%x as expected\\n", (unsigned)error); return 0; } """ @@ -745,6 +763,62 @@ def test_component_targets_link(work_dir: Path) -> None: print(f"✓ every offered component links and is retained: {sorted(linked)}") +def test_documented_example_compiles(work_dir: Path) -> None: + """The C++ example in the documentation must compile against the installed wheel. + + Extracted from the documentation rather than copied here, so the two cannot drift. A + reader who follows the documentation gets code that builds, and a dangling include or a + renamed entry point fails this check instead of shipping. + """ + # Guarded the same way the wheel lookup is: a copy of this file can live outside the + # repository layout, where indexing past the available parents raises. + here = Path(__file__).resolve() + root = here.parents[3] if len(here.parents) > 3 else here.parent + documentation = root / "docs" / "source" / "using-executorch-cpp.md" + if not documentation.is_file(): + print("- the documentation file is not present, skipping the example check") + return + + match = re.search( + r"```cpp\n// main\.cpp\n(.*?)```", documentation.read_text(), re.S + ) + assert match, ( + f"could not find the C++ example in {documentation.name}; the check needs it to " + "verify what the documentation tells a reader to write" + ) + + source_dir = work_dir / "documented" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "main.cpp").write_text("// main.cpp\n" + match.group(1)) + (source_dir / "CMakeLists.txt").write_text( + "cmake_minimum_required(VERSION 3.28)\n" + "project(documented_example CXX)\n" + "find_package(executorch REQUIRED)\n" + "add_executable(documented_example main.cpp)\n" + "target_link_libraries(documented_example PRIVATE executorch::runtime)\n" + ) + + build_dir = work_dir / "documented-build" + configure = subprocess.run( + ["cmake", "-S", str(source_dir), "-B", str(build_dir), + f"-DCMAKE_PREFIX_PATH={_installed_package_dir()}"], + capture_output=True, text=True, check=False, + ) + assert configure.returncode == 0, ( + "the documented example does not configure against the installed wheel: " + f"{(configure.stderr or configure.stdout).strip()[-500:]}" + ) + build = subprocess.run( + ["cmake", "--build", str(build_dir)], + capture_output=True, text=True, check=False, + ) + assert build.returncode == 0, ( + "the documented example does not compile against the installed wheel: " + f"{(build.stderr or build.stdout).strip()[-500:]}" + ) + print("\u2713 the C++ example in the documentation compiles and links") + + def run_tests(work_dir: Path) -> None: test_single_backend_registry() test_python_extensions_import() @@ -754,4 +828,5 @@ def run_tests(work_dir: Path) -> None: test_custom_op_compiles(work_dir) test_no_absolute_runtime_paths() test_cpp_consumer(work_dir) + test_documented_example_compiles(work_dir) test_component_targets_link(work_dir) diff --git a/docs/source/using-executorch-cpp.md b/docs/source/using-executorch-cpp.md index d5cda087b72..34f76c63e64 100644 --- a/docs/source/using-executorch-cpp.md +++ b/docs/source/using-executorch-cpp.md @@ -79,6 +79,9 @@ build: #include #include +#include +#include + using namespace executorch::extension; int main() { From 7719af1b1d10b65af674b7f521a7c3633de97717 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 14:45:05 -0700 Subject: [PATCH 53/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 38 ++++++++++++++++++++++++++++++- setup.py | 18 +++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 36c95ffdb01..59cfa30f5fd 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -575,6 +575,42 @@ def test_custom_op_compiles(work_dir: Path) -> None: print("✓ a custom operator compiles against the shipped Python extension") +def _find_wheel_files() -> list: + """The built wheel files, searched where a build actually leaves them. + + WHEEL_DIR is honoured when set, but it is not set in the wheel-build job, so the + usual output directories are searched too. Without this the check has nothing to + inspect and skips. + """ + candidates = [] + configured = os.environ.get("WHEEL_DIR") + if configured: + candidates.append(Path(configured)) + # The build leaves the wheel in dist/ at the repository root, and this file sits at a + # fixed depth below that root, so the location follows from __file__ rather than from + # the current directory. The release job runs the smoke test from the workspace above + # the repository, where a cwd-relative guess finds nothing. + # + # Guarded because a copy of this file can live outside that layout, where indexing + # past the available parents would raise instead of falling through to the other + # candidates. + here = Path(__file__).resolve() + repository_root = here.parents[3] if len(here.parents) > 3 else here.parent + candidates += [ + repository_root / "dist", + Path.cwd() / "dist", + Path.cwd(), + repository_root / "wheelhouse", + ] + for directory in candidates: + try: + found = sorted(directory.glob("executorch-*.whl")) + except OSError: + continue + if found: + return found + return [] + def test_wheel_platform_tag() -> None: """The wheel's declared platform tag must match what its libraries need. @@ -591,7 +627,7 @@ def test_wheel_platform_tag() -> None: print("- auditwheel unavailable, skipping the platform tag check") return - wheels = sorted(Path(os.environ.get("WHEEL_DIR", ".")).glob("executorch-*.whl")) + wheels = _find_wheel_files() if not wheels: print("- no wheel file to inspect, skipping the platform tag check") return diff --git a/setup.py b/setup.py index b27b16be665..49b14a2d030 100644 --- a/setup.py +++ b/setup.py @@ -384,6 +384,24 @@ def _write_cmake_version_file(destination: str) -> None: if(NOT PACKAGE_FIND_VERSION) # No version requested, so any version satisfies it. set(PACKAGE_VERSION_COMPATIBLE TRUE) +elseif(PACKAGE_FIND_VERSION_RANGE) + # A range request such as 1.0...1.2 sets its own variables, and checking only + # PACKAGE_FIND_VERSION would compare against the lower bound alone and accept a package + # the range excludes. + if(PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION_MIN) + set(PACKAGE_VERSION_UNSUITABLE TRUE) + elseif(PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "INCLUDE" + AND PACKAGE_VERSION VERSION_GREATER PACKAGE_FIND_VERSION_MAX) + set(PACKAGE_VERSION_UNSUITABLE TRUE) + elseif(PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "EXCLUDE" + AND NOT PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION_MAX) + set(PACKAGE_VERSION_UNSUITABLE TRUE) + elseif(NOT PACKAGE_FIND_VERSION_MIN_MAJOR STREQUAL "{major}") + # Same major rule as below: a different major means a different shared runtime. + set(PACKAGE_VERSION_UNSUITABLE TRUE) + else() + set(PACKAGE_VERSION_COMPATIBLE TRUE) + endif() elseif(PACKAGE_FIND_VERSION_MAJOR STREQUAL "{major}") # SameMajorVersion, matching the runtime's SONAME: a consumer asking for {major}.x # gets any {major}.y, and a request for a different major is refused because the From cf57e583fe470042a432526ab23fc7eb7c9b8d48 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 15:05:49 -0700 Subject: [PATCH 54/70] Update [ghstack-poisoned] --- setup.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 49b14a2d030..4b087710e8c 100644 --- a/setup.py +++ b/setup.py @@ -322,11 +322,13 @@ def get_build_type(is_debug=None) -> str: # need for it. _UNSUPPORTED_WHEEL_HEADERS = frozenset( { - "bundled_module.h", - # These two include a third-party header the wheel does not ship, so they - # cannot compile from an installed package no matter what is linked. + # These two include a third-party header the wheel does not ship, so they cannot + # compile from an installed package no matter what is linked. "cpuinfo_utils.h", "threadpool.h", + # These three compile, but declare entry points whose definitions are not in any + # shipped library, so a consumer that includes them fails at link time. + "bundled_module.h", "file_descriptor_data_loader.h", "serialize.h", } From 3ad2b2c05c7ec04ed0e1b9f64a4825a4521ce26f Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 08:32:53 -0700 Subject: [PATCH 55/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index d4e8fa0179e..808ff4afa59 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -145,7 +145,10 @@ if(_executorch_runtime_library) # when several subprojects each call find_package(executorch). Creating the # target twice is an error, so only define it once and set the properties # either way. - if(NOT TARGET executorch::runtime) + if(TARGET executorch::runtime) + # Already defined by an in-tree build, so keep it rather than redefining it. + message(STATUS "executorch: executorch::runtime is already defined, leaving it as is") + else() add_library(executorch::runtime SHARED IMPORTED) endif() set_target_properties( @@ -188,9 +191,14 @@ function(executorch_define_component _suffix _library_name) endif() set(_target "executorch::${_suffix}") - if(NOT TARGET ${_target}) - add_library(${_target} SHARED IMPORTED) + if(TARGET ${_target}) + # An in-tree build defines these names, sometimes as an ALIAS whose properties + # cannot be set. A consumer that both adds this project as a subdirectory and + # calls find_package should keep the target it is already building. + message(STATUS "executorch: ${_target} is already defined, leaving it as is") + return() endif() + add_library(${_target} SHARED IMPORTED) set_target_properties( ${_target} PROPERTIES IMPORTED_LOCATION "${_library}" @@ -312,7 +320,10 @@ if(_portable_lib_LIBRARY) STATUS "ExecuTorch portable library is found at ${_portable_lib_LIBRARY}" ) list(APPEND EXECUTORCH_LIBRARIES _portable_lib) - if(NOT TARGET _portable_lib) + if(TARGET _portable_lib) + # Already defined by an in-tree build, so keep it rather than redefining it. + message(STATUS "executorch: _portable_lib is already defined, leaving it as is") + else() # SHARED, not STATIC: this resolves to the Python extension module, which is # a shared object. Declaring it static makes CMake treat it as an archive, # which changes how it is placed on a link line and how runtime paths are From bae80e5a0ecf050c266517bd6458027b4f3d6a44 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 08:44:22 -0700 Subject: [PATCH 56/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 52 +++++++++++++---------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 808ff4afa59..99eb4bb9446 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -145,32 +145,40 @@ if(_executorch_runtime_library) # when several subprojects each call find_package(executorch). Creating the # target twice is an error, so only define it once and set the properties # either way. - if(TARGET executorch::runtime) - # Already defined by an in-tree build, so keep it rather than redefining it. +if(TARGET executorch::runtime) + # An in-tree build defines this name, sometimes as an ALIAS whose properties cannot be + # set. A consumer that both adds this project as a subdirectory and calls find_package + # should keep the target it is already building, so skip the whole definition. message(STATUS "executorch: executorch::runtime is already defined, leaving it as is") else() add_library(executorch::runtime SHARED IMPORTED) - endif() - set_target_properties( - executorch::runtime - PROPERTIES IMPORTED_LOCATION "${_executorch_runtime_library}" - INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" - INTERFACE_COMPILE_FEATURES cxx_std_17 - INTERFACE_COMPILE_DEFINITIONS C10_USING_CUSTOM_GENERATED_MACROS - ) - # Consumers get the wheel's lib/ directory in their RUNPATH automatically, - # because CMake adds the imported library's directory. Also record - # $ORIGIN-relative entries so an application that is deployed next to a copy - # of the runtime keeps working without relinking or LD_LIBRARY_PATH. $ORIGIN - # is a loader token, so it belongs only in RUNPATH, never in - # IMPORTED_LOCATION. - if(CMAKE_SYSTEM_NAME STREQUAL "Linux") - set_property( - TARGET executorch::runtime - APPEND - PROPERTY INTERFACE_LINK_OPTIONS "LINKER:-rpath,$ORIGIN" - "LINKER:-rpath,$ORIGIN/../lib" + set_target_properties( + executorch::runtime + PROPERTIES IMPORTED_LOCATION "${_executorch_runtime_library}" + INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" + INTERFACE_COMPILE_FEATURES cxx_std_17 + INTERFACE_COMPILE_DEFINITIONS C10_USING_CUSTOM_GENERATED_MACROS ) + # Consumers get the wheel's lib/ directory in their RUNPATH automatically, because + # CMake adds the imported library's directory. Also record $ORIGIN-relative entries so + # an application deployed next to a copy of the runtime keeps working without relinking + # or LD_LIBRARY_PATH. $ORIGIN is a loader token, so it belongs only in RUNPATH, never + # in IMPORTED_LOCATION. + # + # The wheel's own directory is named first so it wins over an unrelated library that + # happens to sit beside the application; otherwise a stray copy in the application's + # own $ORIGIN/../lib would be found first. + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + get_filename_component( + _executorch_runtime_dir "${_executorch_runtime_library}" DIRECTORY + ) + set_property( + TARGET executorch::runtime + APPEND + PROPERTY INTERFACE_LINK_OPTIONS "LINKER:-rpath,${_executorch_runtime_dir}" + "LINKER:-rpath,$ORIGIN" "LINKER:-rpath,$ORIGIN/../lib" + ) + endif() endif() endif() From 9e3f0ac2485d59b5cb7566d729d6c22ab69bf2ad Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 09:05:23 -0700 Subject: [PATCH 57/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 26 +++++++++++++---------- tools/cmake/executorch-wheel-config.cmake | 3 +-- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 59cfa30f5fd..1cdbf277a09 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -107,13 +107,11 @@ def _defines_symbol(library: Path, symbol: str) -> bool: result = subprocess.run( ["nm", "-DC", str(library)], capture_output=True, text=True, check=False ) - # A library nm cannot read would otherwise look like one that simply defines - # nothing, letting the single-definer checks pass without having actually - # inspected every shipped library. - assert result.returncode == 0, ( - f"nm could not read {library}, so the symbol checks cannot be trusted: " - f"{result.stderr.strip()}" - ) + if result.returncode != 0: + # The symbol reader exits non-zero on anything that is not an object file. A + # stray file whose name merely ends in .so must not abort the symbol checks, so + # treat it as defining nothing rather than as a failure. + return False for line in result.stdout.splitlines(): if symbol not in line: continue @@ -815,17 +813,23 @@ def test_documented_example_compiles(work_dir: Path) -> None: print("- the documentation file is not present, skipping the example check") return - match = re.search( - r"```cpp\n// main\.cpp\n(.*?)```", documentation.read_text(), re.S + # Normalise line endings so a checkout with CRLF still matches, and refuse an + # ambiguous document: a second block labelled the same way would silently change + # which example this compiles. + contents = documentation.read_text().replace("\r\n", "\n") + blocks = re.findall(r"```cpp\n// main\.cpp\n(.*?)```", contents, re.S) + assert len(blocks) <= 1, ( + f"{documentation.name} has {len(blocks)} blocks labelled main.cpp, so which\n" + f"one this check compiles is ambiguous" ) - assert match, ( + assert blocks, ( f"could not find the C++ example in {documentation.name}; the check needs it to " "verify what the documentation tells a reader to write" ) source_dir = work_dir / "documented" source_dir.mkdir(parents=True, exist_ok=True) - (source_dir / "main.cpp").write_text("// main.cpp\n" + match.group(1)) + (source_dir / "main.cpp").write_text("// main.cpp\n" + blocks[0]) (source_dir / "CMakeLists.txt").write_text( "cmake_minimum_required(VERSION 3.28)\n" "project(documented_example CXX)\n" diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 99eb4bb9446..543ef6537b6 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -109,8 +109,7 @@ function(_executorch_find_library _output _base_name) PARENT_SCOPE ) file(GLOB _matches "${_executorch_package_root}/lib/${_base_name}.so" - "${_executorch_package_root}/lib/${_base_name}.so.[0-9]" - "${_executorch_package_root}/lib/${_base_name}.so.[0-9][0-9]" + "${_executorch_package_root}/lib/${_base_name}.so.*" ) list(LENGTH _matches _count) if(_count EQUAL 0) From b1cb34fb9bf9487de5bd8145b91bc13beb4c6317 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 09:19:25 -0700 Subject: [PATCH 58/70] Update [ghstack-poisoned] --- setup.py | 10 +++++++--- tools/cmake/executorch-wheel-config.cmake | 9 +++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 4b087710e8c..5b358a4d40e 100644 --- a/setup.py +++ b/setup.py @@ -367,9 +367,13 @@ def _write_cmake_version_file(destination: str) -> None: """Generate the CMake package version file next to the package config. Read from version.txt so the version CMake reports is the same one the wheel and - the runtime SONAME use. Written by hand rather than with - write_basic_package_version_file because that helper needs a CMake run, and this - file is produced while assembling the wheel. + the runtime SONAME use. + + Written by hand rather than from CMake's own template because the rule here is + deliberately stricter than any stock one: a request above the package version is + refused, not just a different major. A delegate built against one release cannot be + assumed to work with a later one, so accepting a higher request would let a consumer + match a package that does not satisfy it. """ root = os.path.dirname(os.path.abspath(__file__)) with open(os.path.join(root, "version.txt")) as handle: diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 543ef6537b6..3ac7c73e4e1 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -12,6 +12,15 @@ # find_package(executorch REQUIRED) # target_link_libraries(my_app PRIVATE executorch::runtime) # ~~~ +# +# This file describes the same contract as the in-tree package config, but is written by hand +# rather than generated, because the wheel copies build products out of the build tree instead +# of running an install step. That leaves two descriptions of one contract, which is why a +# target already defined by an in-tree build has to be detected and left alone below. +# +# The end state that removes the duplication is a staged install whose generated targets file +# the wheel ships, so the source and wheel contracts become the same object rather than two +# things that must agree. # ------- # # Finds the ExecuTorch library From fb295cb44e16616bd053b2c90e135c57719809de Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 09:30:37 -0700 Subject: [PATCH 59/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 3ac7c73e4e1..77e35abf8cc 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -112,6 +112,11 @@ set(EXECUTORCH_FOUND OFF) # A wheel ships a single file per library, named for its SONAME, so the major is # read from the shipped names rather than hardcoded here. Sets to the # full path, or to an empty string when the wheel does not carry that library. +# +# This depends on an invariant on the build side: every library the wheel ships carries a +# VERSION and SOVERSION, so its file name ends in a major. A library built without them ships +# as a bare .so, and while the glob below still finds it, nothing then pins the major a +# consumer linked against, which is the guarantee the SONAME exists to provide. function(_executorch_find_library _output _base_name) set(${_output} "" From 45f4588d87aadbdc8853da24ae270d61ce3bf9a6 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 13:20:51 -0700 Subject: [PATCH 60/70] Update [ghstack-poisoned] --- setup.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/setup.py b/setup.py index 5b358a4d40e..e2fb704cd1d 100644 --- a/setup.py +++ b/setup.py @@ -405,6 +405,12 @@ def _write_cmake_version_file(destination: str) -> None: elseif(NOT PACKAGE_FIND_VERSION_MIN_MAJOR STREQUAL "{major}") # Same major rule as below: a different major means a different shared runtime. set(PACKAGE_VERSION_UNSUITABLE TRUE) + elseif(PACKAGE_FIND_VERSION_MAX_MAJOR + AND NOT PACKAGE_FIND_VERSION_MAX_MAJOR STREQUAL "{major}") + # Both endpoints have to share the major, the way CMake's own template requires. + # Checking only the lower one accepts a range such as 1.0...3.0 against a 1.x + # runtime, which tells a consumer that majors 2 and 3 are satisfied too. + set(PACKAGE_VERSION_UNSUITABLE TRUE) else() set(PACKAGE_VERSION_COMPATIBLE TRUE) endif() From 379613ea6bfc0d1bfc0fec67955e7a3f949afbc4 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 13:40:05 -0700 Subject: [PATCH 61/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 1cdbf277a09..bf3d7c8231d 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -108,9 +108,17 @@ def _defines_symbol(library: Path, symbol: str) -> bool: ["nm", "-DC", str(library)], capture_output=True, text=True, check=False ) if result.returncode != 0: - # The symbol reader exits non-zero on anything that is not an object file. A - # stray file whose name merely ends in .so must not abort the symbol checks, so - # treat it as defining nothing rather than as a failure. + # A file that is not an object file at all is not this check's concern: something whose + # name merely ends in .so must not abort the run. A shipped library the reader cannot + # parse is different, because a real definition could be hiding inside it, and reporting + # "defines nothing" would let a duplicate pass. The ELF magic bytes tell them apart + # without depending on the reader's wording. + with library.open("rb") as handle: + is_object_file = handle.read(4) == b"\x7fELF" + assert not is_object_file, ( + f"nm could not read {library.name}, which is a shipped object file, so the symbol " + f"checks cannot be trusted: {result.stderr.strip()[:200]}" + ) return False for line in result.stdout.splitlines(): if symbol not in line: From 373d87b393aa8ab9574a503d77f42cc0d8bf029c Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 15:05:38 -0700 Subject: [PATCH 62/70] Update [ghstack-poisoned] --- setup.py | 6 ++++++ tools/cmake/executorch-wheel-config.cmake | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/setup.py b/setup.py index e2fb704cd1d..25c57199122 100644 --- a/setup.py +++ b/setup.py @@ -387,6 +387,12 @@ def _write_cmake_version_file(destination: str) -> None: contents = f"""\ set(PACKAGE_VERSION "{numeric}") +# The full version this package was built from. PACKAGE_VERSION drops a prerelease suffix +# and a local version label because neither is a CMake version component, so two different +# prereleases of one release compare equal. A consumer that must pair with one exact build +# compares this instead. +set(EXECUTORCH_BUILD_VERSION "{version}") + if(NOT PACKAGE_FIND_VERSION) # No version requested, so any version satisfies it. set(PACKAGE_VERSION_COMPATIBLE TRUE) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 77e35abf8cc..858fb4d3f2e 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -30,6 +30,14 @@ # EXECUTORCH_FOUND -- True if the system has the ExecuTorch library # EXECUTORCH_INCLUDE_DIRS -- The include directories for ExecuTorch # EXECUTORCH_LIBRARIES -- Libraries to link against +# EXECUTORCH_BUILD_VERSION -- The full version this package was built from, including +# any prerelease suffix and local version label. Compare this +# when an exact build pairing is required, since the CMake +# package version keeps only the numeric part. +# EXECUTORCH_BUILD_VERSION -- The full version this package was built from, including +# any prerelease suffix and local version label. Compare this +# when an exact build pairing is required, since the CMake +# package version keeps only the numeric part. # # and, when the prebuilt shared runtime is present, the imported target: # From 46fb7ec20d6c2a23b3b985c2eb7fe110d3f768da Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 16:23:15 -0700 Subject: [PATCH 63/70] Update [ghstack-poisoned] --- tools/cmake/preset/pybind.cmake | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/cmake/preset/pybind.cmake b/tools/cmake/preset/pybind.cmake index e4b619b7094..27b841d4df9 100644 --- a/tools/cmake/preset/pybind.cmake +++ b/tools/cmake/preset/pybind.cmake @@ -32,7 +32,14 @@ set_overridable_option(EXECUTORCH_BUILD_WHEEL_DO_NOT_USE ON) # paths ship: every library keeps the absolute directories of whatever it linked # against, which names the machine that built it and stops the wheel being # relocatable. -set_overridable_option(CMAKE_BUILD_WITH_INSTALL_RPATH ON) +# +# Linux only. On Apple the pybind target deliberately keeps no install runtime path, +# because adding one duplicates an entry the linker rejects, so switching the build +# over to that empty value would leave the extension unable to find the libraries it +# links against. +if(NOT APPLE) + set_overridable_option(CMAKE_BUILD_WITH_INSTALL_RPATH ON) +endif() # Optional VGF enable for the default pybind/install flow. This is intentionally # scoped to this preset rather than acting as a general environment-to-CMake From 68fcf62be303c28ef41bfd68a99b288ff4db9c22 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 16:51:57 -0700 Subject: [PATCH 64/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 858fb4d3f2e..dc964af0dd0 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -186,9 +186,11 @@ if(TARGET executorch::runtime) # or LD_LIBRARY_PATH. $ORIGIN is a loader token, so it belongs only in RUNPATH, never # in IMPORTED_LOCATION. # - # The wheel's own directory is named first so it wins over an unrelated library that - # happens to sit beside the application; otherwise a stray copy in the application's - # own $ORIGIN/../lib would be found first. + # $ORIGIN is named before the wheel's own directory. An application deployed beside a copy + # of the runtime has to find that copy, and the loader takes the first match, so putting + # the install directory first would keep sending a relocated application back to the + # original wheel for as long as it remains installed. That also makes a relocation test + # that deletes the original pass for the wrong reason. if(CMAKE_SYSTEM_NAME STREQUAL "Linux") get_filename_component( _executorch_runtime_dir "${_executorch_runtime_library}" DIRECTORY @@ -196,8 +198,9 @@ if(TARGET executorch::runtime) set_property( TARGET executorch::runtime APPEND - PROPERTY INTERFACE_LINK_OPTIONS "LINKER:-rpath,${_executorch_runtime_dir}" - "LINKER:-rpath,$ORIGIN" "LINKER:-rpath,$ORIGIN/../lib" + PROPERTY INTERFACE_LINK_OPTIONS "LINKER:-rpath,$ORIGIN" + "LINKER:-rpath,$ORIGIN/../lib" + "LINKER:-rpath,${_executorch_runtime_dir}" ) endif() endif() From 004fe861d49f02b9a834fa901d316eafb79343c8 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 17:49:07 -0700 Subject: [PATCH 65/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index dc964af0dd0..6c34ea7162d 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -191,6 +191,16 @@ if(TARGET executorch::runtime) # the install directory first would keep sending a relocated application back to the # original wheel for as long as it remains installed. That also makes a relocation test # that deletes the original pass for the wrong reason. + # + # The cost, measured rather than assumed: a library that merely shares this SONAME and sits + # in the application's own directory will win. That is what $ORIGIN means in every package + # that uses it, and a package cannot offer relocation while also refusing to honour what the + # user placed beside their binary. The consequence worth worrying about, a delegate pairing + # with a different registry, is caught directly by the single-registry checks, which inspect + # what the shipped libraries define instead of trusting the loader's choice. + # + # The absolute entry cannot simply be dropped to avoid the question: an application built + # against the installed package fails to start without it. if(CMAKE_SYSTEM_NAME STREQUAL "Linux") get_filename_component( _executorch_runtime_dir "${_executorch_runtime_library}" DIRECTORY From c9714fc9b372a2671768d3c68223f6f2b62082a3 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 18:04:03 -0700 Subject: [PATCH 66/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 6c34ea7162d..345d492f427 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -34,10 +34,6 @@ # any prerelease suffix and local version label. Compare this # when an exact build pairing is required, since the CMake # package version keeps only the numeric part. -# EXECUTORCH_BUILD_VERSION -- The full version this package was built from, including -# any prerelease suffix and local version label. Compare this -# when an exact build pairing is required, since the CMake -# package version keeps only the numeric part. # # and, when the prebuilt shared runtime is present, the imported target: # @@ -95,6 +91,23 @@ find_path( set(_executorch_c10_include "${_executorch_package_root}/include/executorch/runtime/core/portable_type/c10" ) +# The full version this package was built from. It lives in the generated version file, which +# CMake includes in a throwaway scope while deciding whether the package is acceptable, so +# nothing assigned there reaches a consumer. Reading that file here, from the config, is what +# makes the value visible. Both files are installed side by side, so the path is fixed +# relative to this one. +set(_executorch_version_file "${CMAKE_CURRENT_LIST_DIR}/executorch-config-version.cmake") +if(EXISTS "${_executorch_version_file}") + file(STRINGS "${_executorch_version_file}" _executorch_version_lines + REGEX "^set\\(EXECUTORCH_BUILD_VERSION") + foreach(_line IN LISTS _executorch_version_lines) + if(_line MATCHES "\"([^\"]+)\"") + set(EXECUTORCH_BUILD_VERSION "${CMAKE_MATCH_1}") + endif() + endforeach() +endif() +unset(_executorch_version_file) + set(EXECUTORCH_INCLUDE_DIRS "${_executorch_package_root}/include" "${_executorch_c10_include}" ) From d9929c906222267cf52cad7454e3c6c655024d12 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 18:32:27 -0700 Subject: [PATCH 67/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 345d492f427..38027b55144 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -47,7 +47,10 @@ # executorch::threadpool executorch::kernels executorch::xnnpack_backend # executorch::cuda_backend # -# Check with if(TARGET executorch::) rather than assuming one exists. +# Check with if(TARGET executorch::) rather than assuming one exists. A name that was +# never defined is not an error at configure time: CMake passes it through to the linker as a +# literal flag, so the build fails much later with "cannot find -lexecutorch::" instead +# of anything that names the missing component. # # 3.28 rather than something older: the imported targets below export # "$ORIGIN"-relative runtime paths as link options, and CMake writes that token From 335c2afb5bd5616b673fd50fb2ec9d256eae9fb3 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 19:58:27 -0700 Subject: [PATCH 68/70] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index bf3d7c8231d..2f8dc6574f2 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -344,6 +344,15 @@ def test_python_extensions_import() -> None: """ +# Libraries that belong to torch rather than to this wheel. A library here resolves when the +# Python package that owns it is imported, so it is not something this wheel can or should ship. +_TORCH_LIBRARY_PREFIXES = ("libtorch", "libc10", "libshm", "libgomp", "libcudnn", "libcublas") + + +def _is_torch_library(name: str) -> bool: + return name.startswith(_TORCH_LIBRARY_PREFIXES) + + def test_shipped_libraries_load() -> None: """Every shipped library must depend only on things that exist. @@ -419,7 +428,16 @@ def test_shipped_libraries_load() -> None: ] if undefined: unresolved[str(library.relative_to(package_dir))] = undefined[:5] - absent = [name for name in missing if name not in shipped] + # Torch's own libraries are the documented exception. They are not in this wheel, and a + # library that needs them resolves once the Python package owning them is imported, which + # is how every accelerator and AOT library in this package is used. Treating them as + # missing fails a wheel that works, and it fires only where torch installs its libraries + # somewhere the plain loader search does not reach. + absent = [ + name + for name in missing + if name not in shipped and not _is_torch_library(name) + ] present_but_unreachable = [name for name in missing if name in shipped] if absent: broken[str(library.relative_to(package_dir))] = absent From f7216d286d910a6110f6374fa2e779802a7a7acd Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 20:13:58 -0700 Subject: [PATCH 69/70] Update [ghstack-poisoned] --- setup.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 25c57199122..f52c36b4458 100644 --- a/setup.py +++ b/setup.py @@ -375,14 +375,25 @@ def _write_cmake_version_file(destination: str) -> None: assumed to work with a later one, so accepting a higher request would let a consumer match a package that does not satisfy it. """ - root = os.path.dirname(os.path.abspath(__file__)) - with open(os.path.join(root, "version.txt")) as handle: - version = handle.read().strip() + # The same version the wheel publishes, including any BUILD_VERSION override, so one + # artifact cannot report one identity to pip and a different one to CMake. + version = Version.string() + # CMake executes this file to decide whether the package is acceptable, so a quote or a + # backslash in the version would be a parse error rather than a bad comparison, and + # find_package would hard-fail for every consumer. The normal pipeline cannot produce one, + # but BUILD_VERSION is an unvalidated environment override. + assert not set(version) & set('"\\'), ( + f"version {version!r} contains a character that cannot appear in the generated CMake " + "version file" + ) # A pre-release suffix is not a CMake version component, so keep the numeric # prefix and let compatibility be decided on the major. numeric = re.match(r"\d+(?:\.\d+){0,2}", version) numeric = numeric.group(0) if numeric else "0.0.0" major = numeric.split(".")[0] + # Only an exclusive bound at the immediately following major means "any {major}.x", + # which is the idiomatic way to ask for this release series. + next_major = str(int(major) + 1) contents = f"""\ set(PACKAGE_VERSION "{numeric}") @@ -412,7 +423,11 @@ def _write_cmake_version_file(destination: str) -> None: # Same major rule as below: a different major means a different shared runtime. set(PACKAGE_VERSION_UNSUITABLE TRUE) elseif(PACKAGE_FIND_VERSION_MAX_MAJOR - AND NOT PACKAGE_FIND_VERSION_MAX_MAJOR STREQUAL "{major}") + AND NOT PACKAGE_FIND_VERSION_MAX_MAJOR STREQUAL "{major}" + AND NOT (PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "EXCLUDE" + AND PACKAGE_FIND_VERSION_MAX_MAJOR EQUAL {next_major} + AND PACKAGE_FIND_VERSION_MAX_MINOR EQUAL 0 + AND PACKAGE_FIND_VERSION_MAX_PATCH EQUAL 0)) # Both endpoints have to share the major, the way CMake's own template requires. # Checking only the lower one accepts a range such as 1.0...3.0 against a 1.x # runtime, which tells a consumer that majors 2 and 3 are satisfied too. From 973f6c93c656a1fb440bd13150bc9bc45ca83916 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 21:06:52 -0700 Subject: [PATCH 70/70] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 55 ++++++++++++++++++----- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 38027b55144..7b71e45b61c 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -52,13 +52,22 @@ # literal flag, so the build fails much later with "cannot find -lexecutorch::" instead # of anything that names the missing component. # -# 3.28 rather than something older: the imported targets below export -# "$ORIGIN"-relative runtime paths as link options, and CMake writes that token -# incorrectly before 3.28. Versions 3.24 through 3.27 emit a doubled dollar with -# the Makefile generator and a bare dollar with Ninja, so a consumer builds and -# runs in place, because the absolute package directory is also recorded, then -# fails once it is deployed somewhere else. -cmake_minimum_required(VERSION 3.28) +# The floor stays where it was, so a consumer that only wants the long-standing variables and the +# prebuilt Python extension keeps working on the CMake it already has. The shared-runtime targets +# below need more than this and check for it themselves. +cmake_minimum_required(VERSION 3.19) + +# The imported targets below export "$ORIGIN"-relative runtime paths as link options, and CMake +# writes that token incorrectly before 3.28. Versions 3.24 through 3.27 emit a doubled dollar with +# the Makefile generator and a bare dollar with Ninja, so a consumer builds and runs in place, +# because the absolute package directory is also recorded, then fails once it is deployed somewhere +# else. Silently defining a target that behaves that way is worse than not defining it, so the +# targets are skipped and a consumer that asked for one gets a message naming the reason. +if(CMAKE_VERSION VERSION_LESS 3.28) + set(_executorch_targets_supported FALSE) +else() + set(_executorch_targets_supported TRUE) +endif() # Everything is resolved relative to this file so the wheel stays relocatable: # no absolute path from the machine that built it is baked in here. The file is @@ -169,7 +178,15 @@ endfunction() # The prebuilt runtime. _executorch_find_library(_executorch_runtime_library libexecutorch) -if(_executorch_runtime_library) +if(_executorch_runtime_library AND NOT _executorch_targets_supported) + message( + STATUS + "executorch: the prebuilt runtime is present but its imported targets need CMake 3.28 or " + "newer, because older versions write the \$ORIGIN token in a runtime search path " + "incorrectly. The long-standing EXECUTORCH_LIBRARIES and the prebuilt Python extension are " + "unaffected." + ) +elseif(_executorch_runtime_library) set(EXECUTORCH_FOUND ON) message(STATUS "ExecuTorch runtime found at ${_executorch_runtime_library}") @@ -243,6 +260,12 @@ endif() # # Call as: executorch_define_component( ) function(executorch_define_component _suffix _library_name) + # Same reason the runtime target is skipped on older CMake: a component target exports an + # $ORIGIN-relative search path, and a version that writes it wrong produces a target that works + # in place and fails once deployed. + if(NOT _executorch_targets_supported) + return() + endif() _executorch_find_library(_library "lib${_library_name}") if(NOT _library) return() @@ -433,9 +456,19 @@ foreach(_component ${executorch_FIND_COMPONENTS}) set(executorch_${_component}_FOUND FALSE) if(executorch_FIND_REQUIRED_${_component}) set(executorch_FOUND FALSE) - set(executorch_NOT_FOUND_MESSAGE - "this ExecuTorch package does not provide the required component '${_component}'" - ) + # Naming the CMake version when that is the cause saves a consumer from concluding the + # component is missing from the package, which is the wrong thing to go looking for. + if(NOT _executorch_targets_supported) + set(executorch_NOT_FOUND_MESSAGE + "the required component '${_component}' needs CMake 3.28 or newer, because older " + "versions write the \$ORIGIN token in a runtime search path incorrectly; this " + "package is otherwise usable through EXECUTORCH_LIBRARIES" + ) + else() + set(executorch_NOT_FOUND_MESSAGE + "this ExecuTorch package does not provide the required component '${_component}'" + ) + endif() endif() endif() endforeach()