From bc045bce6c1ae0613429eb5aadfb3d7fbf470d4f Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 29 Jul 2026 14:05:04 -0700 Subject: [PATCH 1/9] Update [ghstack-poisoned] --- .ci/scripts/test_cpp_sdk_wheel.sh | 226 ++++++++++++++++++++++ .github/workflows/pull.yml | 23 +++ setup.py | 153 +++++++++++++++ src/executorch/utils/__init__.py | 23 +++ tools/cmake/executorch-wheel-config.cmake | 139 +++++++++++-- 5 files changed, 543 insertions(+), 21 deletions(-) create mode 100755 .ci/scripts/test_cpp_sdk_wheel.sh create mode 100644 src/executorch/utils/__init__.py diff --git a/.ci/scripts/test_cpp_sdk_wheel.sh b/.ci/scripts/test_cpp_sdk_wheel.sh new file mode 100755 index 00000000000..8e874dab5e9 --- /dev/null +++ b/.ci/scripts/test_cpp_sdk_wheel.sh @@ -0,0 +1,226 @@ +#!/bin/bash +# 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. + +# Builds the Linux wheel with the prebuilt C++ SDK, installs it into a clean +# environment, and builds a standalone C++ program against it via +# find_package(executorch). This is the signal that the shipped +# libexecutorch.so, headers, and CMake package config actually let a C++ +# consumer link the ExecuTorch runtime with no source checkout, and that a +# separately built "coreless" backend shared library can register into the one +# runtime registry (the mechanism coalesced multi-backend execution relies on). + +set -euxo pipefail + +PYTHON_EXECUTABLE="${PYTHON_EXECUTABLE:-python}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BUILD_VENV="${REPO_ROOT}/.venv-sdk-build" +TEST_VENV="${REPO_ROOT}/.venv-sdk-test" +WORK_DIR="${REPO_ROOT}/cpp_sdk_consumer" + +rm -rf "${BUILD_VENV}" "${TEST_VENV}" "${WORK_DIR}" "${REPO_ROOT}/dist" \ + "${REPO_ROOT}/pip-out" + +# --------------------------------------------------------------------------- +# Build the wheel. +# --------------------------------------------------------------------------- +"${PYTHON_EXECUTABLE}" -m venv "${BUILD_VENV}" +# shellcheck source=/dev/null +source "${BUILD_VENV}/bin/activate" +python -m pip install --upgrade pip +python -m pip install \ + "cmake>=3.24,<4.0.0" \ + "numpy>=2.0.0" \ + packaging \ + pyyaml \ + setuptools \ + wheel \ + zstd \ + certifi \ + torch \ + torchvision \ + --index-url https://download.pytorch.org/whl/cpu \ + --extra-index-url https://pypi.org/simple + +( + cd "${REPO_ROOT}" + python setup.py bdist_wheel +) + +WHEEL_FILE="$(find "${REPO_ROOT}/dist" -maxdepth 1 -name 'executorch-*.whl' | head -1)" +test -n "${WHEEL_FILE}" + +# --------------------------------------------------------------------------- +# Verify the SDK payload is present in the wheel. +# --------------------------------------------------------------------------- +python - "${WHEEL_FILE}" <<'PY' +import sys +import zipfile + +wheel_file = sys.argv[1] +with zipfile.ZipFile(wheel_file) as wheel: + names = set(wheel.namelist()) + +required = [ + "executorch/lib/libexecutorch.so", + "executorch/share/cmake/executorch-config.cmake", + "executorch/utils/__init__.py", + "executorch/include/executorch/runtime/executor/program.h", + "executorch/include/executorch/extension/module/module.h", +] +missing = [name for name in required if name not in names] +if missing: + raise AssertionError(f"{wheel_file} is missing SDK files: {missing}") + +# Headers whose implementation is not shipped must not be advertised. +forbidden = [ + "executorch/include/executorch/extension/module/bundled_module.h", + "executorch/include/executorch/extension/flat_tensor/serialize/serialize.h", +] +present = [name for name in forbidden if name in names] +if present: + raise AssertionError(f"{wheel_file} advertises unshipped APIs: {present}") + +print("SDK payload OK") +PY + +deactivate + +# --------------------------------------------------------------------------- +# Install into a clean environment and link a C++ consumer against it. +# --------------------------------------------------------------------------- +"${PYTHON_EXECUTABLE}" -m venv "${TEST_VENV}" +# shellcheck source=/dev/null +source "${TEST_VENV}/bin/activate" +python -m pip install --upgrade pip +python -m pip install "cmake>=3.24,<4.0.0" +# --no-deps: the C++ SDK link test does not need torch, proving the runtime is +# linkable standalone. (A plain install pulls declared deps; not needed here.) +python -m pip install --no-deps "${WHEEL_FILE}" + +CMAKE_PREFIX_PATH="$(python -c 'import executorch.utils as u; print(u.cmake_prefix_path)')" +export CMAKE_PREFIX_PATH + +mkdir -p "${WORK_DIR}" +cd "${WORK_DIR}" + +cat > main.cpp <<'CPP' +#include +#include +#include + +using executorch::runtime::get_backend_class; +using executorch::runtime::get_num_registered_backends; +using executorch::runtime::runtime_init; + +int main() { + runtime_init(); + printf("registered backends: %zu\n", get_num_registered_backends()); + // The delegate-only SDK ships no backends, so this must be null. What matters + // is that the symbol links and resolves from libexecutorch.so. + printf("stock backend lookup resolves: %d\n", + get_backend_class("NonexistentBackend") == nullptr); + printf("PASS: linked executorch::runtime from the wheel\n"); + return 0; +} +CPP + +cat > CMakeLists.txt <<'CMAKE' +cmake_minimum_required(VERSION 3.24) +project(executorch_cpp_sdk_consumer LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +find_package(executorch CONFIG REQUIRED) +if(NOT EXECUTORCH_SDK_FOUND) + message(FATAL_ERROR "EXECUTORCH_SDK_FOUND is false; the wheel C++ SDK is missing") +endif() +add_executable(consumer main.cpp) +target_link_libraries(consumer PRIVATE executorch::runtime) +CMAKE + +cmake -S . -B build +cmake --build build +./build/consumer + +# The runtime and the consumer must not pull in libtorch. +if ldd ./build/consumer | grep -Eiq "libtorch|libc10"; then + echo "ERROR: consumer unexpectedly links libtorch/libc10" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Prove cross-shared-object registration: a "coreless" backend .so (no bundled +# runtime, register_backend undefined) registers into the runtime inside +# libexecutorch.so when loaded. This is the mechanism coalesced multi-backend +# execution depends on. +# --------------------------------------------------------------------------- +cat > mybackend.cpp <<'CPP' +#include +using namespace executorch::runtime; +namespace { +struct MyBackend final : public BackendInterface { + bool is_available() const override { return true; } + Result init(BackendInitContext&, FreeableBuffer*, + ArrayRef) const override { + return nullptr; + } + Error execute(BackendExecutionContext&, DelegateHandle*, + Span) const override { + return Error::Ok; + } + void destroy(DelegateHandle*) const override {} +}; +MyBackend g_backend; +Backend g_id{"MyTestBackend", &g_backend}; +static auto g_registered = register_backend(g_id); +} // namespace +CPP + +cat >> CMakeLists.txt <<'CMAKE' +# Coreless: undefined ExecuTorch symbols resolve from libexecutorch.so at load. +add_library(mybackend SHARED mybackend.cpp) +target_link_options(mybackend PRIVATE "LINKER:--unresolved-symbols=ignore-all") +target_include_directories(mybackend PRIVATE + $) +target_compile_definitions(mybackend PRIVATE C10_USING_CUSTOM_GENERATED_MACROS) + +add_executable(reg_consumer reg_main.cpp) +target_link_libraries(reg_consumer PRIVATE executorch::runtime ${CMAKE_DL_LIBS}) +CMAKE + +cat > reg_main.cpp <<'CPP' +#include +#include +#include +#include + +using executorch::runtime::get_backend_class; +using executorch::runtime::runtime_init; + +int main(int argc, char** argv) { + runtime_init(); + if (get_backend_class("MyTestBackend") != nullptr) { + fprintf(stderr, "backend registered before load\n"); + return 1; + } + void* handle = dlopen(argv[1], RTLD_NOW | RTLD_GLOBAL); + if (handle == nullptr) { + fprintf(stderr, "dlopen failed: %s\n", dlerror()); + return 1; + } + if (get_backend_class("MyTestBackend") == nullptr) { + fprintf(stderr, "backend NOT registered after load\n"); + return 1; + } + printf("PASS: coreless backend .so registered into libexecutorch.so\n"); + return 0; +} +CPP + +cmake -S . -B build +cmake --build build +./build/reg_consumer "$(find build -name 'libmybackend.so' | head -1)" + +echo "ALL C++ SDK WHEEL CHECKS PASSED" diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 3ead9e6a49c..27c2906c9ff 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -116,6 +116,29 @@ jobs: # Build and test ExecuTorch with the add model on portable backend. PYTHON_EXECUTABLE=python bash .ci/scripts/test_model.sh "add" "${BUILD_TOOL}" "portable" + test-cpp-sdk-wheel-linux: + name: test-cpp-sdk-wheel-linux + uses: pytorch/test-infra/.github/workflows/linux_job_v2.yml@main + permissions: + id-token: write + contents: read + strategy: + fail-fast: false + with: + runner: linux.2xlarge + docker-image: ci-image:executorch-ubuntu-22.04-gcc11 + submodules: 'recursive' + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + timeout: 90 + script: | + # The generic Linux job chooses to use base env, not the one setup by the image + CONDA_ENV=$(conda env list --json | jq -r ".envs | .[-1]") + conda activate "${CONDA_ENV}" + + # Build the wheel, install it clean, and link a C++ consumer against the + # shipped libexecutorch.so via find_package(executorch). + PYTHON_EXECUTABLE=python bash .ci/scripts/test_cpp_sdk_wheel.sh + test-models-linux-basic: name: test-models-linux-basic uses: pytorch/test-infra/.github/workflows/linux_job_v2.yml@main diff --git a/setup.py b/setup.py index 85228bd37ae..4dcfcf904d7 100644 --- a/setup.py +++ b/setup.py @@ -320,6 +320,27 @@ def get_dynamic_lib_name(name: str) -> str: return f"lib{name}.so" +def _read_soname(lib_path: Path) -> Optional[str]: + """Return the ELF SONAME of a shared library, or None if unavailable. + + Used to recreate the SONAME symlink for a versioned .so in the wheel. Best + effort: any failure (non-ELF, no readelf, non-Linux) returns None. + """ + try: + out = subprocess.run( + ["readelf", "-d", os.fspath(lib_path)], + capture_output=True, + text=True, + check=True, + ).stdout + except Exception: + return None + for line in out.splitlines(): + if "SONAME" in line and "[" in line and "]" in line: + return line[line.index("[") + 1 : line.index("]")] + return None + + def get_executable_name(name: str) -> str: if _is_windows(): return name + ".exe" @@ -508,6 +529,43 @@ def inplace_dir(self, installer: "InstallerBuildExt") -> Path: return Path(package_dir) +class BuiltSharedLib(BuiltFile): + """Installs a SONAME-versioned shared library plus its symlink chain. + + A normal ``BuiltFile`` copies one file. A shared library like + ``libexecutorch.so.1.4.0`` also needs the loader-visible SONAME symlink + (``libexecutorch.so.1``) and the developer symlink (``libexecutorch.so``), + or a consumer that links ``-lexecutorch`` fails at runtime because the + SONAME recorded in dependents cannot be found. This recreates that chain in + the wheel, matching a standard ``cmake --install`` layout. + """ + + def __init__(self, src_dir: str, src_name: str, dst: str): + # src_name is the base library name (e.g. "executorch"); the real file + # is libexecutorch.so., resolved by glob in src_path(). + super().__init__( + src_dir=src_dir, + src_name=f"lib{src_name}.so.*", + dst=dst, + dependent_cmake_flags=[], + ) + + def src_path(self, installer: "InstallerBuildExt") -> Path: + # The glob matches the versioned real file and any symlinks; pick the + # regular file (the real library), not the symlinks. + build_dir = self._get_build_dir(installer) + pattern = self.src.replace("%CMAKE_CACHE_DIR%/", "") + matches = [ + p for p in build_dir.glob(pattern) if p.is_file() and not p.is_symlink() + ] + if len(matches) != 1: + raise ValueError( + f"Expecting exactly 1 real shared library matching {self.src} " + f"in {build_dir}, found {matches}." + ) + return matches[0] + + class BuiltExtension(_BaseExtension): """An extension that installs a python extension that was built by cmake.""" @@ -663,6 +721,31 @@ def build_extension(self, ext: _BaseExtension) -> None: # Copy the file. self.copy_file(os.fspath(src_file), os.fspath(dst_file)) + # For a SONAME-versioned shared library, also recreate the symlink chain + # (libexecutorch.so -> libexecutorch.so. -> libexecutorch.so.) + # so `-lexecutorch` links and the SONAME resolves at load time. + if isinstance(ext, BuiltSharedLib): + self._create_soname_symlinks(src_file, dst_file) + + def _create_soname_symlinks(self, src_file: Path, dst_file: Path) -> None: + real_name = dst_file.name # e.g. libexecutorch.so.1.4.0 + link_dir = dst_file.parent + # SONAME (e.g. libexecutorch.so.1) read from the built library; fall back + # to none if unreadable. The dev symlink drops all version suffixes. + links = set() + soname = _read_soname(src_file) + if soname and soname != real_name: + links.add(soname) + dev_name = real_name.split(".so")[0] + ".so" + if dev_name != real_name: + links.add(dev_name) + for link_name in links: + link_path = link_dir / link_name + if link_path.exists() or link_path.is_symlink(): + link_path.unlink() + # Relative link so the wheel is relocatable. + os.symlink(real_name, os.fspath(link_path)) + # Ensure that the destination file is writable, even if the source was # not. build_py does this by passing preserve_mode=False to copy_file, # but that would clobber the X bit on any executables. TODO(dbort): This @@ -749,6 +832,38 @@ def run(self): src_to_dst.append( (str(src), os.path.join("include/executorch", str(src))) ) + # Delegate-only C++ SDK headers: the Program/Module/Tensor/DataLoader/ + # .ptd APIs a standalone C++ runner needs, so it can link the prebuilt + # runtime without an ExecuTorch source tree. These are listed + # explicitly (not rglob) so we only advertise APIs whose implementation + # archive is actually shipped in executorch/lib/. Excluded on purpose: + # bundled_module.h (needs the separate bundled_module archive), + # flat_tensor/serialize/serialize.h (serializer .cpp not shipped), + # file_descriptor_data_loader.h (impl not in the shipped archive), and + # cpu_caching_malloc_allocator.h (needs a memory_allocator archive we + # do not ship). flat_tensor_header.h IS shipped: it is the .ptd reader. + # Linux only, matching the SDK archives below; keeps Windows/macOS + # wheels unchanged. + sdk_headers = ( + [ + "extension/module/module.h", + "extension/data_loader/buffer_data_loader.h", + "extension/data_loader/file_data_loader.h", + "extension/data_loader/mmap_data_loader.h", + "extension/data_loader/mman.h", + "extension/data_loader/mman_windows.h", + "extension/data_loader/shared_ptr_data_loader.h", + "extension/flat_tensor/flat_tensor_data_map.h", + "extension/flat_tensor/serialize/flat_tensor_header.h", + "extension/named_data_map/merged_data_map.h", + "extension/memory_allocator/malloc_memory_allocator.h", + "extension/memory_allocator/memory_allocator_utils.h", + ] + if sys.platform == "linux" + else [] + ) + for src in sdk_headers: + src_to_dst.append((src, os.path.join("include/executorch", src))) for src, dst in src_to_dst: dst = os.path.join(dst_root, dst) @@ -900,6 +1015,12 @@ def run(self): # noqa C901 ): cmake_configuration_args += ["-DEXECUTORCH_BUILD_OPENVINO=ON"] + # Build the consolidated shared runtime libexecutorch.so so the wheel can + # ship a linkable C++ SDK (see the executorch_shared build target and the + # packaging step). Linux only; the SDK is not shipped on Windows/macOS. + if not minimal_build and sys.platform == "linux": + cmake_configuration_args += ["-DEXECUTORCH_BUILD_SHARED=ON"] + with Buck2EnvironmentFixer(): # Generate the cmake cache from scratch to ensure that the cache state # is predictable. @@ -954,6 +1075,16 @@ def run(self): # noqa C901 # list explicitly rather than relying on each flag being OFF. cmake_build_args += ["--target", "flatbuffers_ep"] else: + # Delegate-only C++ SDK: ship the consolidated shared runtime + # libexecutorch.so (the executorch_shared target), which bundles the + # runtime core plus the common extensions (module, tensor, + # data_loader, flat_tensor, named_data_map). Shared is required so a + # separately distributed backend/delegate .so can register into the + # one process-global registry inside libexecutorch.so. Build it + # explicitly, Linux only, so packaging below always finds it. + if sys.platform == "linux": + cmake_build_args += ["--target", "executorch_shared"] + if cmake_cache.is_enabled("EXECUTORCH_BUILD_PYBIND"): cmake_build_args += ["--target", "portable_lib"] cmake_build_args += ["--target", "data_loader"] @@ -1125,6 +1256,28 @@ def run(self): # noqa C901 modpath="executorch.backends.qualcomm.python.PyQnnManagerAdaptor", dependent_cmake_flags=["EXECUTORCH_BUILD_QNN"], ), + # Delegate-only C++ SDK: the consolidated shared runtime + # libexecutorch.so (core + module/tensor/data_loader/flat_tensor/ + # named_data_map), plus its SONAME symlink chain. Shipping it + # shared (not static archives) lets a separately distributed + # backend/delegate .so register into the one process-global + # registry inside libexecutorch.so, which is what coalesced + # multi-backend .pte execution requires. Paired with + # executorch-config.cmake (executorch::runtime target) and + # executorch.utils.cmake_prefix_path. Linux only: the .so naming + # and symlink chain are Unix specific, so Windows/macOS wheels are + # unchanged. + *( + [ + BuiltSharedLib( + src_dir="%CMAKE_CACHE_DIR%/", + src_name="executorch", + dst="executorch/lib/", + ) + ] + if sys.platform == "linux" + else [] + ), ] ), ], diff --git a/src/executorch/utils/__init__.py b/src/executorch/utils/__init__.py new file mode 100644 index 00000000000..3d5919dcc5c --- /dev/null +++ b/src/executorch/utils/__init__.py @@ -0,0 +1,23 @@ +# 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. + +"""Utilities for locating ExecuTorch's packaged assets. + +``cmake_prefix_path`` points at the directory that contains the installed +ExecuTorch CMake package config, so a C++ project can discover it with: + + cmake -DCMAKE_PREFIX_PATH="$(python -c 'import executorch.utils as u; print(u.cmake_prefix_path)')" +""" + +import os as _os + +# Mirror torch.utils.cmake_prefix_path: /share/cmake. This file +# lives at /utils/__init__.py, so go up one level. +cmake_prefix_path = _os.path.join( + _os.path.dirname(_os.path.dirname(__file__)), "share", "cmake" +) + +__all__ = ["cmake_prefix_path"] diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 1d6096a2e96..de53a5b12bd 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -19,10 +19,24 @@ # EXECUTORCH_INCLUDE_DIRS -- The include directories for ExecuTorch # EXECUTORCH_LIBRARIES -- Libraries to link against # -cmake_minimum_required(VERSION 3.19) +# In addition to the legacy variables above, this config defines namespaced +# imported targets for the prebuilt delegate-only C++ SDK when the corresponding +# static libraries are shipped in the wheel (see the "C++ SDK targets" section +# below): +# +# executorch::core -- executorch_core (runtime, no ops) +# executorch::runtime -- executorch (adds primitive ops) +# executorch::extension_data_loader executorch::extension_flat_tensor +# executorch::extension_named_data_map executorch::extension_tensor +# executorch::extension_module +# +cmake_minimum_required(VERSION 3.24) -# Find prebuilt _portable_lib..so. This file should be installed -# under /executorch/share/cmake +# --------------------------------------------------------------------------- +# Legacy: discover the CPython _portable_lib extension for custom-op authors. +# This keeps `find_package(executorch)` working for prebuilt custom-op +# extensions that link the Python runtime module, unchanged from before. +# --------------------------------------------------------------------------- # Find python if(DEFINED ENV{CONDA_DEFAULT_ENV} AND NOT $ENV{CONDA_DEFAULT_ENV} STREQUAL @@ -43,36 +57,119 @@ execute_process( OUTPUT_STRIP_TRAILING_WHITESPACE ) +set(EXECUTORCH_INCLUDE_DIRS + "${CMAKE_CURRENT_LIST_DIR}/../../include" + "${CMAKE_CURRENT_LIST_DIR}/../../include/executorch/runtime/core/portable_type/c10" +) +set(EXECUTORCH_LIBRARIES) +set(EXECUTORCH_FOUND OFF) + +# Only discover the portable Python module when we could read EXT_SUFFIX; +# probing with an empty suffix would match a wrong/generic file. A missing +# suffix is not fatal because a pure-C++ consumer (see the C++ SDK section +# below) does not need the Python extension at all. if(SYSCONFIG_RESULT EQUAL 0) message(STATUS "Sysconfig extension suffix: ${EXT_SUFFIX}") + find_library( + _portable_lib_LIBRARY + NAMES _portable_lib${EXT_SUFFIX} + PATHS "${CMAKE_CURRENT_LIST_DIR}/../../extension/pybindings/" + ) else() message( - FATAL_ERROR - "Failed to retrieve sysconfig config var EXT_SUFFIX: ${SYSCONFIG_ERROR}" + WARNING + "Failed to retrieve sysconfig config var EXT_SUFFIX: ${SYSCONFIG_ERROR}. " + "The _portable_lib Python runtime target will not be available; the C++ " + "SDK targets (executorch::*) are unaffected." ) endif() -find_library( - _portable_lib_LIBRARY - NAMES _portable_lib${EXT_SUFFIX} - PATHS "${CMAKE_CURRENT_LIST_DIR}/../../extension/pybindings/" -) - -set(EXECUTORCH_LIBRARIES) -set(EXECUTORCH_FOUND OFF) if(_portable_lib_LIBRARY) set(EXECUTORCH_FOUND ON) message( STATUS "ExecuTorch portable library is found at ${_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 - PROPERTIES IMPORTED_LOCATION "${_portable_lib_LIBRARY}" - INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" - CXX_STANDARD 20 + if(NOT TARGET _portable_lib) + add_library(_portable_lib STATIC IMPORTED) + # PyTorch requires C++20, so pybindings must be compiled with C++20. + set_target_properties( + _portable_lib + PROPERTIES IMPORTED_LOCATION "${_portable_lib_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" + CXX_STANDARD 20 + ) + endif() +endif() + +# --------------------------------------------------------------------------- +# C++ SDK targets (delegate-only). Defined only when the prebuilt static +# archives are present in the wheel (they are shipped alongside this config +# under ../../lib). This lets a C++ application link the ExecuTorch runtime and +# the common runtime extensions without an ExecuTorch source checkout: +# +# find_package(executorch REQUIRED) target_link_libraries(app PRIVATE +# executorch::runtime executorch::extension_module executorch::extension_tensor) +# +# The set is intentionally libtorch-free and excludes CPU operator/kernel +# libraries; delegates (e.g. TensorRT, CUDA) supply their own compute. If your +# model needs portable CPU operators, link a kernel library in addition. +# --------------------------------------------------------------------------- + +get_filename_component( + _executorch_sdk_root "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE +) +set(_executorch_sdk_libdir "${_executorch_sdk_root}/lib") + +# EXECUTORCH_SDK_FOUND is separate from EXECUTORCH_FOUND on purpose: the legacy +# EXECUTORCH_FOUND / EXECUTORCH_LIBRARIES contract describes the _portable_lib +# Python runtime for custom-op authors. Overloading it here would let existing +# `if(EXECUTORCH_FOUND) link(${EXECUTORCH_LIBRARIES})` code enter its branch +# with an empty library list. C++ SDK consumers should check the imported target +# (e.g. `if(TARGET executorch::runtime)`) or EXECUTORCH_SDK_FOUND. +# +# The C++ SDK ships one shared library, libexecutorch.so, which bundles the +# runtime core plus the common runtime extensions (module, tensor, data_loader, +# flat_tensor, named_data_map). Shared (not static archives) is required so that +# a separately distributed backend/delegate shared library can register into the +# one process-global registry that lives in libexecutorch.so. A backend .so is +# built "coreless" (its register_backend reference is undefined and resolves +# against libexecutorch.so at load), then force-loaded so its static-init +# registration runs. +set(EXECUTORCH_SDK_FOUND OFF) +find_library( + _executorch_shared_LIBRARY + NAMES executorch + PATHS "${_executorch_sdk_libdir}" + NO_DEFAULT_PATH +) +if(_executorch_shared_LIBRARY) + set(EXECUTORCH_SDK_FOUND ON) + if(NOT TARGET executorch::runtime) + add_library(executorch::runtime SHARED IMPORTED) + set_target_properties( + executorch::runtime + PROPERTIES IMPORTED_LOCATION "${_executorch_shared_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" + INTERFACE_COMPILE_FEATURES cxx_std_17 + INTERFACE_COMPILE_DEFINITIONS + "C10_USING_CUSTOM_GENERATED_MACROS" + ) + endif() + + # Convenience aliases. libexecutorch.so already contains the core and these + # extensions, so all names resolve to the one shared library. Provided so + # consumer CMake can name what it uses without depending on the bundling + # layout. + foreach(_alias core extension_module extension_tensor extension_data_loader + extension_flat_tensor extension_named_data_map ) + if(NOT TARGET executorch::${_alias}) + add_library(executorch::${_alias} INTERFACE IMPORTED) + set_property( + TARGET executorch::${_alias} PROPERTY INTERFACE_LINK_LIBRARIES + executorch::runtime + ) + endif() + endforeach() endif() From e50d9aba4c3b5d917817c22f48c0501d17c2cfe8 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 29 Jul 2026 14:46:37 -0700 Subject: [PATCH 2/9] Update [ghstack-poisoned] --- setup.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index 4dcfcf904d7..0ce81ae028f 100644 --- a/setup.py +++ b/setup.py @@ -540,14 +540,20 @@ class BuiltSharedLib(BuiltFile): the wheel, matching a standard ``cmake --install`` layout. """ - def __init__(self, src_dir: str, src_name: str, dst: str): + def __init__( + self, + src_dir: str, + src_name: str, + dst: str, + dependent_cmake_flags: Optional[List[str]] = None, + ): # src_name is the base library name (e.g. "executorch"); the real file # is libexecutorch.so., resolved by glob in src_path(). super().__init__( src_dir=src_dir, src_name=f"lib{src_name}.so.*", dst=dst, - dependent_cmake_flags=[], + dependent_cmake_flags=dependent_cmake_flags or [], ) def src_path(self, installer: "InstallerBuildExt") -> Path: @@ -1017,8 +1023,12 @@ def run(self): # noqa C901 # Build the consolidated shared runtime libexecutorch.so so the wheel can # ship a linkable C++ SDK (see the executorch_shared build target and the - # packaging step). Linux only; the SDK is not shipped on Windows/macOS. - if not minimal_build and sys.platform == "linux": + # packaging step). Only for an actual wheel build: EXECUTORCH_BUILD_SHARED + # also forces global PIC and changes link behavior, so it must not leak + # into editable/develop/install builds that other CI (unittest, arm, etc.) + # rely on. Linux only; the SDK is not shipped on Windows/macOS. + building_wheel = "bdist_wheel" in self.distribution.commands + if not minimal_build and sys.platform == "linux" and building_wheel: cmake_configuration_args += ["-DEXECUTORCH_BUILD_SHARED=ON"] with Buck2EnvironmentFixer(): @@ -1080,9 +1090,13 @@ def run(self): # noqa C901 # runtime core plus the common extensions (module, tensor, # data_loader, flat_tensor, named_data_map). Shared is required so a # separately distributed backend/delegate .so can register into the - # one process-global registry inside libexecutorch.so. Build it - # explicitly, Linux only, so packaging below always finds it. - if sys.platform == "linux": + # one process-global registry inside libexecutorch.so. Built only for + # a wheel build (guarded by the EXECUTORCH_BUILD_SHARED cache entry, + # which is set just above only for bdist_wheel), so editable/develop + # builds are unaffected and the target always exists when requested. + if sys.platform == "linux" and cmake_cache.is_enabled( + "EXECUTORCH_BUILD_SHARED" + ): cmake_build_args += ["--target", "executorch_shared"] if cmake_cache.is_enabled("EXECUTORCH_BUILD_PYBIND"): @@ -1273,6 +1287,7 @@ def run(self): # noqa C901 src_dir="%CMAKE_CACHE_DIR%/", src_name="executorch", dst="executorch/lib/", + dependent_cmake_flags=["EXECUTORCH_BUILD_SHARED"], ) ] if sys.platform == "linux" From d2f63597e6792739200d5891a60a5e9465bfdf32 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 29 Jul 2026 16:01:10 -0700 Subject: [PATCH 3/9] Update [ghstack-poisoned] --- .ci/scripts/test_cpp_sdk_wheel.sh | 45 ++++++++++- setup.py | 96 ++++++++++++++--------- tools/cmake/executorch-wheel-config.cmake | 2 +- 3 files changed, 102 insertions(+), 41 deletions(-) diff --git a/.ci/scripts/test_cpp_sdk_wheel.sh b/.ci/scripts/test_cpp_sdk_wheel.sh index 8e874dab5e9..b425d17f1a6 100755 --- a/.ci/scripts/test_cpp_sdk_wheel.sh +++ b/.ci/scripts/test_cpp_sdk_wheel.sh @@ -66,6 +66,7 @@ with zipfile.ZipFile(wheel_file) as wheel: required = [ "executorch/lib/libexecutorch.so", + "executorch/lib/libexecutorch.so.1", "executorch/share/cmake/executorch-config.cmake", "executorch/utils/__init__.py", "executorch/include/executorch/runtime/executor/program.h", @@ -75,6 +76,21 @@ missing = [name for name in required if name not in names] if missing: raise AssertionError(f"{wheel_file} is missing SDK files: {missing}") +# The loader resolves the SONAME (libexecutorch.so.), so a versioned +# real library must be present in addition to the dev symlink. Without it the +# wheel links at build time but fails to load at runtime. +versioned = [ + name + for name in names + if name.startswith("executorch/lib/libexecutorch.so.") + and name.split("libexecutorch.so.")[1][:1].isdigit() +] +if not versioned: + raise AssertionError( + f"{wheel_file} has no versioned libexecutorch.so.; the SONAME " + "symlink chain is broken and the library will fail to load at runtime." + ) + # Headers whose implementation is not shipped must not be advertised. forbidden = [ "executorch/include/executorch/extension/module/bundled_module.h", @@ -109,9 +125,17 @@ cd "${WORK_DIR}" cat > main.cpp <<'CPP' #include +#include + +#include +#include +#include #include #include +using executorch::extension::from_blob; +using executorch::extension::Module; +using executorch::extension::TensorPtr; using executorch::runtime::get_backend_class; using executorch::runtime::get_num_registered_backends; using executorch::runtime::runtime_init; @@ -123,13 +147,30 @@ int main() { // is that the symbol links and resolves from libexecutorch.so. printf("stock backend lookup resolves: %d\n", get_backend_class("NonexistentBackend") == nullptr); - printf("PASS: linked executorch::runtime from the wheel\n"); + + // Exercise the advertised SDK surface so a broken header or missing symbol in + // Module / Tensor / DataLoader is caught at compile+link time, not just the + // runtime/backend headers. + std::vector data = {1.0f, 2.0f, 3.0f, 4.0f}; + TensorPtr tensor = from_blob(data.data(), {2, 2}); + printf("tensor numel: %zu\n", tensor->numel()); + + // Construct a Module from a tiny in-memory buffer via the buffer data loader. + // Loading is expected to fail (not a real .pte); we only need this to + // compile and link against the Module/DataLoader symbols in libexecutorch.so. + const uint8_t fake_pte[8] = {0}; + Module module(std::make_unique( + fake_pte, sizeof(fake_pte))); + (void)module.method_names(); + + printf("PASS: linked executorch runtime + Module/Tensor/DataLoader from the " + "wheel\n"); return 0; } CPP cat > CMakeLists.txt <<'CMAKE' -cmake_minimum_required(VERSION 3.24) +cmake_minimum_required(VERSION 3.19) project(executorch_cpp_sdk_consumer LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) find_package(executorch CONFIG REQUIRED) diff --git a/setup.py b/setup.py index 0ce81ae028f..a8eaaca3430 100644 --- a/setup.py +++ b/setup.py @@ -320,11 +320,14 @@ def get_dynamic_lib_name(name: str) -> str: return f"lib{name}.so" -def _read_soname(lib_path: Path) -> Optional[str]: - """Return the ELF SONAME of a shared library, or None if unavailable. - - Used to recreate the SONAME symlink for a versioned .so in the wheel. Best - effort: any failure (non-ELF, no readelf, non-Linux) returns None. +def _read_soname(lib_path: Path) -> str: + """Return the ELF SONAME of a shared library. + + Used to recreate the loader-visible SONAME symlink for a versioned .so in + the wheel. Raises if the SONAME cannot be read: without it the wheel would + link at build time but fail to load at runtime (the SONAME recorded in + dependents would have no matching file), so this must fail the build loudly + rather than silently ship a broken wheel. """ try: out = subprocess.run( @@ -333,12 +336,19 @@ def _read_soname(lib_path: Path) -> Optional[str]: text=True, check=True, ).stdout - except Exception: - return None + except (OSError, subprocess.CalledProcessError) as e: + raise RuntimeError( + f"Could not read the SONAME of {lib_path} via readelf: {e}. " + "The shared-library SONAME symlink cannot be created, which would " + "produce a wheel that fails to load at runtime." + ) from e for line in out.splitlines(): if "SONAME" in line and "[" in line and "]" in line: return line[line.index("[") + 1 : line.index("]")] - return None + raise RuntimeError( + f"{lib_path} has no ELF SONAME; cannot create the SONAME symlink for " + "the wheel. Expected a versioned shared library (e.g. libfoo.so.1.2.3)." + ) def get_executable_name(name: str) -> str: @@ -727,38 +737,48 @@ def build_extension(self, ext: _BaseExtension) -> None: # Copy the file. self.copy_file(os.fspath(src_file), os.fspath(dst_file)) - # For a SONAME-versioned shared library, also recreate the symlink chain - # (libexecutorch.so -> libexecutorch.so. -> libexecutorch.so.) - # so `-lexecutorch` links and the SONAME resolves at load time. + # For a SONAME-versioned shared library, normalize what ships in the + # wheel. pip does not preserve symlinks (it materializes every wheel + # entry as a regular file), so a libexecutorch.so -> .so.1 -> .so.1.2.3 + # chain would become three identical multi-hundred-KB copies. Instead + # ship exactly two files: the SONAME (libexecutorch.so.1, what the loader + # resolves via DT_NEEDED) as the real library, and the dev name + # (libexecutorch.so, what -lexecutorch / find_library(NAMES executorch) + # need). On a filesystem the dev name is a relative symlink; if the wheel + # packer dereferences it, it becomes at most one extra copy, not two. if isinstance(ext, BuiltSharedLib): - self._create_soname_symlinks(src_file, dst_file) - - def _create_soname_symlinks(self, src_file: Path, dst_file: Path) -> None: - real_name = dst_file.name # e.g. libexecutorch.so.1.4.0 - link_dir = dst_file.parent - # SONAME (e.g. libexecutorch.so.1) read from the built library; fall back - # to none if unreadable. The dev symlink drops all version suffixes. - links = set() + self._normalize_shared_lib(src_file, dst_file) + + def _normalize_shared_lib(self, src_file: Path, dst_file: Path) -> None: + lib_dir = dst_file.parent + # SONAME (e.g. libexecutorch.so.1) read from the built library. Required, + # not best-effort: it is what the loader resolves at runtime. + # _read_soname raises if it cannot be determined. soname = _read_soname(src_file) - if soname and soname != real_name: - links.add(soname) - dev_name = real_name.split(".so")[0] + ".so" - if dev_name != real_name: - links.add(dev_name) - for link_name in links: - link_path = link_dir / link_name - if link_path.exists() or link_path.is_symlink(): - link_path.unlink() - # Relative link so the wheel is relocatable. - os.symlink(real_name, os.fspath(link_path)) - - # Ensure that the destination file is writable, even if the source was - # not. build_py does this by passing preserve_mode=False to copy_file, - # but that would clobber the X bit on any executables. TODO(dbort): This - # probably won't work on Windows. - if not os.access(src_file, os.W_OK): - # Make the file writable. This should respect the umask. - os.chmod(src_file, os.stat(src_file).st_mode | 0o222) + soname_path = lib_dir / soname + # Make the SONAME file the real library. dst_file is the fully versioned + # name (e.g. libexecutorch.so.1.4.0) that copy_file just wrote; rename it + # to the SONAME so we do not keep a third redundant copy. + if dst_file.name != soname: + if soname_path.exists() or soname_path.is_symlink(): + soname_path.unlink() + os.replace(os.fspath(dst_file), os.fspath(soname_path)) + + # Dev name (drop all version suffixes), e.g. libexecutorch.so. + dev_name = soname.split(".so")[0] + ".so" + if dev_name != soname: + dev_path = lib_dir / dev_name + if dev_path.exists() or dev_path.is_symlink(): + dev_path.unlink() + # Relative link so the wheel is relocatable on a real filesystem. + os.symlink(soname, os.fspath(dev_path)) + + # Ensure the shipped library is writable (the CMake output may be + # read-only), so a later rebuild/repack can overwrite it. Mirrors the + # writability fix build_py applies via copy_file(preserve_mode=False), + # but scoped to this file so it does not clobber X bits elsewhere. + if not os.access(soname_path, os.W_OK): + os.chmod(soname_path, os.stat(soname_path).st_mode | 0o222) class CustomBuildPy(build_py): diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index de53a5b12bd..e6248d23048 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -30,7 +30,7 @@ # executorch::extension_named_data_map executorch::extension_tensor # executorch::extension_module # -cmake_minimum_required(VERSION 3.24) +cmake_minimum_required(VERSION 3.19) # --------------------------------------------------------------------------- # Legacy: discover the CPython _portable_lib extension for custom-op authors. From 36ba5c94ecfa2d8797b719db7c0df705578df4e1 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 29 Jul 2026 16:31:06 -0700 Subject: [PATCH 4/9] Update [ghstack-poisoned] --- tools/cmake/executorch-wheel-config.cmake | 25 +++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index e6248d23048..3e4ddf34483 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -20,15 +20,18 @@ # EXECUTORCH_LIBRARIES -- Libraries to link against # # In addition to the legacy variables above, this config defines namespaced -# imported targets for the prebuilt delegate-only C++ SDK when the corresponding -# static libraries are shipped in the wheel (see the "C++ SDK targets" section +# imported targets for the prebuilt delegate-only C++ SDK when the shared +# runtime library is shipped in the wheel (see the "C++ SDK targets" section # below): # -# executorch::core -- executorch_core (runtime, no ops) -# executorch::runtime -- executorch (adds primitive ops) -# executorch::extension_data_loader executorch::extension_flat_tensor -# executorch::extension_named_data_map executorch::extension_tensor -# executorch::extension_module +# executorch::runtime -- libexecutorch.so (runtime core + the +# bundled common extensions below) executorch::core -- alias of +# executorch::runtime executorch::extension_module -- alias of +# executorch::runtime executorch::extension_tensor -- alias of +# executorch::runtime executorch::extension_data_loader -- alias of +# executorch::runtime executorch::extension_flat_tensor -- alias of +# executorch::runtime executorch::extension_named_data_map -- alias of +# executorch::runtime # cmake_minimum_required(VERSION 3.19) @@ -103,10 +106,10 @@ if(_portable_lib_LIBRARY) endif() # --------------------------------------------------------------------------- -# C++ SDK targets (delegate-only). Defined only when the prebuilt static -# archives are present in the wheel (they are shipped alongside this config -# under ../../lib). This lets a C++ application link the ExecuTorch runtime and -# the common runtime extensions without an ExecuTorch source checkout: +# C++ SDK targets (delegate-only). Defined only when the prebuilt shared runtime +# library is present in the wheel (shipped alongside this config under +# ../../lib). This lets a C++ application link the ExecuTorch runtime and the +# common runtime extensions without an ExecuTorch source checkout: # # find_package(executorch REQUIRED) target_link_libraries(app PRIVATE # executorch::runtime executorch::extension_module executorch::extension_tensor) From ec849e0781957623287bd6a4d83aabea2ad7a3c0 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 29 Jul 2026 18:00:44 -0700 Subject: [PATCH 5/9] Update [ghstack-poisoned] --- .ci/scripts/test_cpp_sdk_wheel.sh | 16 ++++++++++++++++ setup.py | 8 +++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.ci/scripts/test_cpp_sdk_wheel.sh b/.ci/scripts/test_cpp_sdk_wheel.sh index b425d17f1a6..f45c6557d6a 100755 --- a/.ci/scripts/test_cpp_sdk_wheel.sh +++ b/.ci/scripts/test_cpp_sdk_wheel.sh @@ -128,12 +128,15 @@ cat > main.cpp <<'CPP' #include #include +#include #include +#include #include #include #include using executorch::extension::from_blob; +using executorch::extension::FlatTensorDataMap; using executorch::extension::Module; using executorch::extension::TensorPtr; using executorch::runtime::get_backend_class; @@ -155,6 +158,19 @@ int main() { TensorPtr tensor = from_blob(data.data(), {2, 2}); printf("tensor numel: %zu\n", tensor->numel()); + // Exercise the .ptd data-map surface (FlatTensorDataMap) so those advertised + // headers/symbols are compiled and linked from libexecutorch.so. Loading a + // bogus buffer is expected to fail; we only need the symbol to resolve. + { + const uint8_t bogus_ptd[8] = {0}; + auto data_map_result = FlatTensorDataMap::load( + std::make_unique( + bogus_ptd, sizeof(bogus_ptd)) + .get()); + printf(".ptd data-map load attempted (ok=%d)\n", + data_map_result.ok()); + } + // Construct a Module from a tiny in-memory buffer via the buffer data loader. // Loading is expected to fail (not a real .pte); we only need this to // compile and link against the Module/DataLoader symbols in libexecutorch.so. diff --git a/setup.py b/setup.py index a8eaaca3430..e93cf7b3083 100644 --- a/setup.py +++ b/setup.py @@ -1043,10 +1043,12 @@ def run(self): # noqa C901 # Build the consolidated shared runtime libexecutorch.so so the wheel can # ship a linkable C++ SDK (see the executorch_shared build target and the - # packaging step). Only for an actual wheel build: EXECUTORCH_BUILD_SHARED + # packaging step). Gate on an actual wheel build: EXECUTORCH_BUILD_SHARED # also forces global PIC and changes link behavior, so it must not leak - # into editable/develop/install builds that other CI (unittest, arm, etc.) - # rely on. Linux only; the SDK is not shipped on Windows/macOS. + # into editable/develop builds (pip install -e ., setup.py develop) that + # other CI (unittest, arm, etc.) rely on. A non-editable `pip install .` + # does go through bdist_wheel (PEP 517) and correctly gets the SDK. Linux + # only; the SDK is not shipped on Windows/macOS. building_wheel = "bdist_wheel" in self.distribution.commands if not minimal_build and sys.platform == "linux" and building_wheel: cmake_configuration_args += ["-DEXECUTORCH_BUILD_SHARED=ON"] From e06e96da058897ee81ba3e69a5dcf74168bb9a7c Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 30 Jul 2026 07:05:45 -0700 Subject: [PATCH 6/9] Update [ghstack-poisoned] --- .ci/scripts/test_cpp_sdk_wheel.sh | 8 +++----- setup.py | 4 +--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.ci/scripts/test_cpp_sdk_wheel.sh b/.ci/scripts/test_cpp_sdk_wheel.sh index 2c943ed2d83..befdbd62bd4 100755 --- a/.ci/scripts/test_cpp_sdk_wheel.sh +++ b/.ci/scripts/test_cpp_sdk_wheel.sh @@ -65,7 +65,6 @@ with zipfile.ZipFile(wheel_file) as wheel: names = set(wheel.namelist()) required = [ - "executorch/lib/libexecutorch.so", "executorch/share/cmake/executorch-config.cmake", "executorch/utils/__init__.py", "executorch/include/executorch/runtime/executor/program.h", @@ -75,9 +74,8 @@ missing = [name for name in required if name not in names] if missing: raise AssertionError(f"{wheel_file} is missing SDK files: {missing}") -# The loader resolves the SONAME (libexecutorch.so.), so a versioned -# real library must be present in addition to the dev symlink. Without it the -# wheel links at build time but fails to load at runtime. +# The loader resolves the SONAME (libexecutorch.so.), which is the only +# runtime library shipped. Without it the wheel has no linkable runtime. versioned = [ name for name in names @@ -87,7 +85,7 @@ versioned = [ if not versioned: raise AssertionError( f"{wheel_file} has no versioned libexecutorch.so.; the SONAME " - "symlink chain is broken and the library will fail to load at runtime." + "library is missing and the runtime cannot load." ) # Headers whose implementation is not shipped must not be advertised. diff --git a/setup.py b/setup.py index c111fe771e1..ac384613e53 100644 --- a/setup.py +++ b/setup.py @@ -1095,9 +1095,7 @@ def run(self): # noqa C901 # Ship optimized CPU kernels (with portable fallback for uncovered # ops) in the standalone kernels .so, so the SDK can run non- or # partially-delegated models. Mirrors iOS kernels_optimized. - cmake_configuration_args += [ - "-DEXECUTORCH_BUILD_KERNELS_OPTIMIZED=ON" - ] + cmake_configuration_args += ["-DEXECUTORCH_BUILD_KERNELS_OPTIMIZED=ON"] with Buck2EnvironmentFixer(): # Generate the cmake cache from scratch to ensure that the cache state From ed0c7ed9db9db08f3a32b2263000e1b145f14190 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 30 Jul 2026 07:33:06 -0700 Subject: [PATCH 7/9] Update [ghstack-poisoned] --- .ci/scripts/test_cpp_sdk_wheel.sh | 1 - CMakeLists.txt | 4 ---- setup.py | 16 +++++++------- tools/cmake/executorch-wheel-config.cmake | 26 ++++++++++++++--------- 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/.ci/scripts/test_cpp_sdk_wheel.sh b/.ci/scripts/test_cpp_sdk_wheel.sh index befdbd62bd4..4205190d598 100755 --- a/.ci/scripts/test_cpp_sdk_wheel.sh +++ b/.ci/scripts/test_cpp_sdk_wheel.sh @@ -128,7 +128,6 @@ cat > main.cpp <<'CPP' #include #include #include -#include #include #include #include diff --git a/CMakeLists.txt b/CMakeLists.txt index cca000ed0e0..30e7e529b32 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1441,10 +1441,6 @@ if(EXECUTORCH_BUILD_SHARED AND _executorch_kernels) target_link_libraries( executorch_kernels_shared PRIVATE executorch_shared ${_executorch_kernels} ) - # No EXPORT set: the .so is shipped by setup.py (BuiltSharedLib file copy) and - # found via find_library in executorch-wheel-config.cmake, so it must not also - # join ExecuTorchTargets (that double-registers the target). - install(TARGETS executorch_kernels_shared DESTINATION ${CMAKE_INSTALL_LIBDIR}) endif() install(TARGETS executorch_backends executorch_extensions executorch_kernels diff --git a/setup.py b/setup.py index ac384613e53..6b6ad3947f0 100644 --- a/setup.py +++ b/setup.py @@ -542,14 +542,14 @@ def inplace_dir(self, installer: "InstallerBuildExt") -> Path: class BuiltSharedLib(BuiltFile): - """Installs a SONAME-versioned shared library plus its symlink chain. - - A normal ``BuiltFile`` copies one file. A shared library like - ``libexecutorch.so.1.4.0`` also needs the loader-visible SONAME symlink - (``libexecutorch.so.1``) and the developer symlink (``libexecutorch.so``), - or a consumer that links ``-lexecutorch`` fails at runtime because the - SONAME recorded in dependents cannot be found. This recreates that chain in - the wheel, matching a standard ``cmake --install`` layout. + """Installs a SONAME-versioned shared library. + + A normal ``BuiltFile`` copies one file. For a shared library like + ``libexecutorch.so.1.4.0`` the wheel ships only the loader-visible SONAME + file (e.g. ``libexecutorch.so.1``), which is what dependents record via + DT_NEEDED. pip does not preserve symlinks, so a dev-name link + (``libexecutorch.so``) would become a second full copy; consumers link + through the CMake package's imported targets (full path), so it is omitted. """ def __init__( diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 90a70446f37..010fffeabbe 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -142,12 +142,16 @@ set(_executorch_sdk_libdir "${_executorch_sdk_root}/lib") # against libexecutorch.so at load), then force-loaded so its static-init # registration runs. set(EXECUTORCH_SDK_FOUND OFF) -find_library( - _executorch_shared_LIBRARY - NAMES executorch libexecutorch.so.1 - PATHS "${_executorch_sdk_libdir}" - NO_DEFAULT_PATH +# Discover the versioned SONAME (libexecutorch.so.) rather than +# hardcoding a major, so this keeps working across ABI bumps. The wheel ships +# only the real SONAME file (no dev-name symlink), so find_library(NAMES +# executorch) alone would miss it. +file(GLOB _executorch_shared_candidates + "${_executorch_sdk_libdir}/libexecutorch.so.*" ) +if(_executorch_shared_candidates) + list(GET _executorch_shared_candidates 0 _executorch_shared_LIBRARY) +endif() if(_executorch_shared_LIBRARY) set(EXECUTORCH_SDK_FOUND ON) if(NOT TARGET executorch::runtime) @@ -184,12 +188,14 @@ if(_executorch_shared_LIBRARY) # links executorch::kernels for a full CPU operator set (optimized + portable # fallback). Defined only when the kernels .so is present. Loaded purely for # its op-registration static initializers, so force it to stay linked. - find_library( - _executorch_kernels_LIBRARY - NAMES executorch_kernels libexecutorch_kernels.so.1 - PATHS "${_executorch_sdk_libdir}" - NO_DEFAULT_PATH + # Discover the versioned SONAME rather than hardcoding a major (see the + # runtime lookup above). + file(GLOB _executorch_kernels_candidates + "${_executorch_sdk_libdir}/libexecutorch_kernels.so.*" ) + if(_executorch_kernels_candidates) + list(GET _executorch_kernels_candidates 0 _executorch_kernels_LIBRARY) + endif() if(_executorch_kernels_LIBRARY AND NOT TARGET executorch::kernels) add_library(executorch::kernels SHARED IMPORTED) set_target_properties( From ff6a33230e70902b745f5c80c567ad1ecf0baedc Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 30 Jul 2026 07:58:47 -0700 Subject: [PATCH 8/9] Update [ghstack-poisoned] --- setup.py | 92 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 55 insertions(+), 37 deletions(-) diff --git a/setup.py b/setup.py index 6b6ad3947f0..3ae6831a94f 100644 --- a/setup.py +++ b/setup.py @@ -800,6 +800,60 @@ def analyze_manifest(self): if os.path.isfile(os.path.join(_root, _f)) ] + def _is_cuda_wheel_build(self) -> bool: + # Read EXECUTORCH_BUILD_CUDA from the CMake cache the build command + # produced (it runs before build_py copies files). Gates CUDA-only + # headers so they ship only in a CUDA wheel. + try: + build_temp = self.get_finalized_command("build").build_temp + cache_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + build_temp, + "cmake-out", + "CMakeCache.txt", + ) + if not os.path.exists(cache_path): + return False + return CMakeCache(cache_path=cache_path).is_enabled("EXECUTORCH_BUILD_CUDA") + except Exception: + return False + + def _sdk_headers(self) -> List[str]: + # Delegate-only C++ SDK headers: the Program/Module/Tensor/DataLoader/ + # .ptd APIs a standalone C++ runner needs to link the prebuilt runtime + # without an ExecuTorch source tree. Listed explicitly (not rglob) so we + # advertise only APIs whose implementation archive is shipped in + # executorch/lib/. Excluded on purpose: bundled_module.h, + # flat_tensor/serialize/serialize.h, file_descriptor_data_loader.h, and + # cpu_caching_malloc_allocator.h (their impls are not shipped). + # flat_tensor_header.h IS shipped: it is the .ptd reader. Linux only. + if sys.platform != "linux": + return [] + headers = [ + "extension/module/module.h", + "extension/data_loader/buffer_data_loader.h", + "extension/data_loader/file_data_loader.h", + "extension/data_loader/mmap_data_loader.h", + "extension/data_loader/mman.h", + "extension/data_loader/mman_windows.h", + "extension/data_loader/shared_ptr_data_loader.h", + "extension/flat_tensor/flat_tensor_data_map.h", + "extension/flat_tensor/serialize/flat_tensor_header.h", + "extension/named_data_map/merged_data_map.h", + "extension/memory_allocator/malloc_memory_allocator.h", + "extension/memory_allocator/memory_allocator_utils.h", + ] + # CUDA caller-stream headers ship only in a CUDA wheel, alongside the + # matching libextension_cuda.so and the executorch::extension_cuda / + # executorch::cuda_backend targets, so a CPU wheel does not advertise + # headers whose library and targets it does not contain. + if self._is_cuda_wheel_build(): + headers += [ + "extension/cuda/caller_stream.h", + "extension/cuda/export.h", + ] + return headers + 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. @@ -865,43 +919,7 @@ def run(self): src_to_dst.append( (str(src), os.path.join("include/executorch", str(src))) ) - # Delegate-only C++ SDK headers: the Program/Module/Tensor/DataLoader/ - # .ptd APIs a standalone C++ runner needs, so it can link the prebuilt - # runtime without an ExecuTorch source tree. These are listed - # explicitly (not rglob) so we only advertise APIs whose implementation - # archive is actually shipped in executorch/lib/. Excluded on purpose: - # bundled_module.h (needs the separate bundled_module archive), - # flat_tensor/serialize/serialize.h (serializer .cpp not shipped), - # file_descriptor_data_loader.h (impl not in the shipped archive), and - # cpu_caching_malloc_allocator.h (needs a memory_allocator archive we - # do not ship). flat_tensor_header.h IS shipped: it is the .ptd reader. - # Linux only, matching the SDK archives below; keeps Windows/macOS - # wheels unchanged. - sdk_headers = ( - [ - "extension/module/module.h", - "extension/data_loader/buffer_data_loader.h", - "extension/data_loader/file_data_loader.h", - "extension/data_loader/mmap_data_loader.h", - "extension/data_loader/mman.h", - "extension/data_loader/mman_windows.h", - "extension/data_loader/shared_ptr_data_loader.h", - "extension/flat_tensor/flat_tensor_data_map.h", - "extension/flat_tensor/serialize/flat_tensor_header.h", - "extension/named_data_map/merged_data_map.h", - "extension/memory_allocator/malloc_memory_allocator.h", - "extension/memory_allocator/memory_allocator_utils.h", - # CUDA caller-stream extension headers. Harmless on a CPU - # wheel (headers only); the matching libextension_cuda.so and - # the executorch::extension_cuda / executorch::cuda_backend - # CMake targets are shipped/defined only in a CUDA wheel. - "extension/cuda/caller_stream.h", - "extension/cuda/export.h", - ] - if sys.platform == "linux" - else [] - ) - for src in sdk_headers: + for src in self._sdk_headers(): src_to_dst.append((src, os.path.join("include/executorch", src))) for src, dst in src_to_dst: dst = os.path.join(dst_root, dst) From f853d64beb8240b95109f6fabe956036137cecc9 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Thu, 30 Jul 2026 10:18:55 -0700 Subject: [PATCH 9/9] Update [ghstack-poisoned] --- .ci/scripts/test_cpp_sdk_wheel.sh | 15 +++++++++++ CMakeLists.txt | 4 +++ extension/threadpool/CMakeLists.txt | 29 +++++++++++++++++--- setup.py | 23 ++++++++++++++++ tools/cmake/executorch-wheel-config.cmake | 32 +++++++++++++++++++++++ 5 files changed, 99 insertions(+), 4 deletions(-) diff --git a/.ci/scripts/test_cpp_sdk_wheel.sh b/.ci/scripts/test_cpp_sdk_wheel.sh index 4205190d598..a17cf16dd86 100755 --- a/.ci/scripts/test_cpp_sdk_wheel.sh +++ b/.ci/scripts/test_cpp_sdk_wheel.sh @@ -88,6 +88,21 @@ if not versioned: "library is missing and the runtime cannot load." ) +# If the kernels lib is shipped, the standalone threadpool lib must be too: +# get_threadpool() is a process-wide singleton, so the kernels lib links it +# dynamically instead of embedding its own pool. +has_kernels = any( + n.startswith("executorch/lib/libexecutorch_kernels.so.") for n in names +) +has_threadpool = any( + n.startswith("executorch/lib/libexecutorch_threadpool.so.") for n in names +) +if has_kernels and not has_threadpool: + raise AssertionError( + f"{wheel_file} ships the kernels lib but not the standalone " + "libexecutorch_threadpool.so; the thread pool would be duplicated." + ) + # Headers whose implementation is not shipped must not be advertised. forbidden = [ "executorch/include/executorch/extension/module/bundled_module.h", diff --git a/CMakeLists.txt b/CMakeLists.txt index 30e7e529b32..7d88dd514e7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1438,6 +1438,10 @@ if(EXECUTORCH_BUILD_SHARED AND _executorch_kernels) target_compile_definitions( executorch_kernels_shared PUBLIC C10_USING_CUSTOM_GENERATED_MACROS ) + # Link the shared threadpool first so its exported get_threadpool()/ + # parallel_for satisfy the kernels' references, and the static + # extension_threadpool archive is not pulled in (which would embed a second + # singleton pool). executorch_shared provides the runtime core symbols. target_link_libraries( executorch_kernels_shared PRIVATE executorch_shared ${_executorch_kernels} ) diff --git a/extension/threadpool/CMakeLists.txt b/extension/threadpool/CMakeLists.txt index 3b9c7c66ddb..5deb57c3df1 100644 --- a/extension/threadpool/CMakeLists.txt +++ b/extension/threadpool/CMakeLists.txt @@ -30,10 +30,31 @@ else() set(_threadpool_size_flag "EXECUTORCH_THREADPOOL_USE_PERFORMANCE_CORES") endif() -add_library( - extension_threadpool threadpool.cpp threadpool_guard.cpp thread_parallel.cpp - cpuinfo_utils.cpp -) +# get_threadpool() is a process-wide singleton, so in a shared (pip C++ SDK) +# build it must live in exactly one shared object that every consumer (the +# kernels lib, XNNPACK and other backends, _portable_lib) links dynamically. +# Otherwise each consumer that statically links extension_threadpool embeds its +# own pool. Build it SHARED for EXECUTORCH_BUILD_SHARED and keep it STATIC +# everywhere else (iOS/Android/embedded are unaffected). Mirrors iOS's +# standalone threadpool framework. cpuinfo and pthreadpool are static and +# bundled in; executorch_core resolves from libexecutorch.so at load. +if(EXECUTORCH_BUILD_SHARED) + add_library( + extension_threadpool SHARED threadpool.cpp threadpool_guard.cpp + thread_parallel.cpp cpuinfo_utils.cpp + ) + set_target_properties( + extension_threadpool + PROPERTIES OUTPUT_NAME executorch_threadpool + VERSION "${PROJECT_VERSION}" + SOVERSION "${PROJECT_VERSION_MAJOR}" + ) +else() + add_library( + extension_threadpool threadpool.cpp threadpool_guard.cpp + thread_parallel.cpp cpuinfo_utils.cpp + ) +endif() target_link_libraries( extension_threadpool PUBLIC executorch_core cpuinfo pthreadpool ) diff --git a/setup.py b/setup.py index 3ae6831a94f..9d4387686a8 100644 --- a/setup.py +++ b/setup.py @@ -1189,6 +1189,14 @@ def run(self): # noqa C901 "--target", "executorch_kernels_shared", ] + # Standalone threadpool library: get_threadpool() is a + # process-wide singleton, so it ships in one .so (extension_ + # threadpool built SHARED) that kernels and future delegates link + # dynamically to share one pool. + cmake_build_args += [ + "--target", + "extension_threadpool", + ] if cmake_cache.is_enabled("EXECUTORCH_BUILD_PYBIND"): cmake_build_args += ["--target", "portable_lib"] @@ -1422,6 +1430,21 @@ def run(self): # noqa C901 if sys.platform == "linux" else [] ), + # Standalone threadpool library: one process-wide get_threadpool + # singleton shared by the kernels lib (and future delegates), so + # they do not each embed a separate pool. + *( + [ + BuiltSharedLib( + src_dir="%CMAKE_CACHE_DIR%/extension/threadpool/", + src_name="executorch_threadpool", + dst="executorch/lib/", + dependent_cmake_flags=["EXECUTORCH_BUILD_SHARED"], + ) + ] + if sys.platform == "linux" + else [] + ), # CUDA delegate binaries for the C++ SDK, shipped only in a # CUDA-enabled wheel (PyTorch-style: the default wheel has the core # runtime, the CUDA-index wheel adds these). Co-located with diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 010fffeabbe..fe216891b04 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -190,6 +190,29 @@ if(_executorch_shared_LIBRARY) # its op-registration static initializers, so force it to stay linked. # Discover the versioned SONAME rather than hardcoding a major (see the # runtime lookup above). + file(GLOB _executorch_threadpool_candidates + "${_executorch_sdk_libdir}/libexecutorch_threadpool.so.*" + ) + if(_executorch_threadpool_candidates) + list(GET _executorch_threadpool_candidates 0 _executorch_threadpool_LIBRARY) + endif() + if(_executorch_threadpool_LIBRARY AND NOT TARGET executorch::threadpool) + # One process-wide get_threadpool() singleton lives here; kernels (and + # future delegates) link it so they share a single pool. + add_library(executorch::threadpool SHARED IMPORTED) + set_target_properties( + executorch::threadpool + PROPERTIES IMPORTED_LOCATION "${_executorch_threadpool_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" + INTERFACE_COMPILE_FEATURES cxx_std_17 + ) + set_property( + TARGET executorch::threadpool + APPEND + PROPERTY INTERFACE_LINK_LIBRARIES executorch::runtime + ) + endif() + file(GLOB _executorch_kernels_candidates "${_executorch_sdk_libdir}/libexecutorch_kernels.so.*" ) @@ -209,6 +232,15 @@ if(_executorch_shared_LIBRARY) APPEND PROPERTY INTERFACE_LINK_LIBRARIES executorch::runtime ) + # The kernels lib has a DT_NEEDED on the shared threadpool; expose it so a + # consumer's rpath/link resolves the one pool. + if(TARGET executorch::threadpool) + set_property( + TARGET executorch::kernels + APPEND + PROPERTY INTERFACE_LINK_LIBRARIES executorch::threadpool + ) + endif() if(NOT APPLE AND NOT WIN32) set_property( TARGET executorch::kernels