From 5416595a0bb0e4c6e0fabd1b57094d78122ae8a5 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Thu, 30 Jul 2026 23:23:23 -0700 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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