From 1ac428161cb52599c3043120043cb36b1465f2a1 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 15:22:01 -0700 Subject: [PATCH 01/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 21 +++++++++++-- backends/cuda/CMakeLists.txt | 52 ++++++++++++++++++++++++------- setup.py | 17 ++++++++++ 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 0553777ad87..e5e44db44af 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -50,6 +50,10 @@ "executorch::backends::xnnpack::XnnpackBackendOptions::workspace_manager", ) +# A representative symbol from the CUDA delegate's shim layer. The delegate's own +# methods are weak symbols, so this checks a strong one instead. +_CUDA_SYMBOLS = ("executorch::backends::cuda::clearCurrentCUDAStream",) + # `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.+)$") @@ -113,8 +117,12 @@ def _defines_symbol(library: Path, symbol: str) -> bool: return False -def _assert_single_definer(symbols, what: str) -> None: - """Exactly one shipped library may define each of `symbols`.""" +def _assert_single_definer(symbols, what: str, optional: bool = False) -> None: + """Exactly one shipped library may define each of `symbols`. + + `optional` allows a component that is only present in some wheel flavors, + such as an accelerator delegate, to be absent without failing. + """ assert shutil.which("nm") is not None, "nm is required to inspect the wheel" package_dir = _installed_package_dir() @@ -124,6 +132,9 @@ def _assert_single_definer(symbols, what: str) -> None: for symbol in symbols: definers = [lib for lib in libraries if _defines_symbol(lib, symbol)] pretty = [str(lib.relative_to(package_dir)) for lib in definers] + if optional and not definers: + print(f"- no {what} in this wheel, skipping") + return assert len(definers) == 1, ( f"expected exactly one library to define {symbol}, found " f"{len(definers)}: {pretty}. More than one definition means the " @@ -152,6 +163,11 @@ def test_single_xnnpack_delegate() -> None: _assert_single_definer(_XNNPACK_SYMBOLS, "XNNPACK delegate") +def test_single_cuda_delegate() -> None: + """Exactly one shipped library may define the CUDA delegate, if present.""" + _assert_single_definer(_CUDA_SYMBOLS, "CUDA delegate", optional=True) + + 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" @@ -209,4 +225,5 @@ def run_tests(work_dir: Path) -> None: test_single_threadpool() test_single_kernel_registration() test_single_xnnpack_delegate() + test_single_cuda_delegate() test_cpp_consumer(work_dir) diff --git a/backends/cuda/CMakeLists.txt b/backends/cuda/CMakeLists.txt index 06990692428..2d599ed659f 100644 --- a/backends/cuda/CMakeLists.txt +++ b/backends/cuda/CMakeLists.txt @@ -93,8 +93,17 @@ target_compile_options( PUBLIC "$<$:${_cuda_cxx_compile_options}>" ) -# Link against ExecuTorch core libraries -target_link_libraries(cuda_platform PRIVATE executorch_core ${CMAKE_DL_LIBS}) +# Link against ExecuTorch core libraries. Resolve them from the shared runtime +# when there is one, so this does not carry a second copy of the backend +# registry. +if(EXECUTORCH_BUILD_SHARED) + target_link_libraries( + cuda_platform PRIVATE executorch_shared ${CMAKE_DL_LIBS} + ) + executorch_target_link_shared_runtime(cuda_platform) +else() + target_link_libraries(cuda_platform PRIVATE executorch_core ${CMAKE_DL_LIBS}) +endif() install( TARGETS cuda_platform @@ -169,14 +178,9 @@ if(_cuda_is_msvc_toolchain) else() target_link_libraries( aoti_cuda_shims - PRIVATE cuda_platform - PUBLIC -Wl,--whole-archive - aoti_common_shims_slim - -Wl,--no-whole-archive - CUDA::cudart - CUDA::curand - extension_cuda - ${CMAKE_DL_LIBS} + PRIVATE cuda_platform -Wl,--whole-archive aoti_common_shims_slim + -Wl,--no-whole-archive + PUBLIC CUDA::cudart CUDA::curand extension_cuda ${CMAKE_DL_LIBS} ) endif() @@ -200,7 +204,33 @@ if(_cuda_is_msvc_toolchain) list(APPEND _aoti_cuda_backend_sources runtime/cuda_allocator.cpp) endif() -add_library(aoti_cuda_backend STATIC ${_aoti_cuda_backend_sources}) +# Build the delegate as a shared library for the wheel so a process has one copy +# of it, and keep it static everywhere else so no other build changes. +if(EXECUTORCH_BUILD_SHARED) + set(_aoti_cuda_backend_library_type SHARED) +else() + set(_aoti_cuda_backend_library_type STATIC) +endif() +add_library( + aoti_cuda_backend ${_aoti_cuda_backend_library_type} + ${_aoti_cuda_backend_sources} +) +if(EXECUTORCH_BUILD_SHARED) + set_target_properties( + aoti_cuda_backend + PROPERTIES OUTPUT_NAME executorch_cuda_backend + VERSION "${PROJECT_VERSION}" + SOVERSION "${PROJECT_VERSION_MAJOR}" + ) + if(NOT APPLE) + # Ships beside the runtime in the wheel's lib/ directory. libcudart and + # friends come from the environment, so they are not bundled here. + set_target_properties( + aoti_cuda_backend PROPERTIES BUILD_RPATH "$ORIGIN" INSTALL_RPATH + "$ORIGIN" + ) + endif() +endif() target_include_directories( aoti_cuda_backend diff --git a/setup.py b/setup.py index f55b37c38ed..a8e412cdc66 100644 --- a/setup.py +++ b/setup.py @@ -1160,6 +1160,23 @@ def run(self): # noqa C901 "EXECUTORCH_BUILD_XNNPACK", ], ), + # Install the CUDA delegate beside them when it is built. The CUDA + # runtime itself is not bundled; it comes from the environment. + BuiltFile( + src_dir="%CMAKE_CACHE_DIR%/backends/cuda/", + src_name=( + "libexecutorch_cuda_backend.so." + f"{get_runtime_soname_major()}.*" + ), + dst=( + "executorch/lib/libexecutorch_cuda_backend.so." + f"{get_runtime_soname_major()}" + ), + dependent_cmake_flags=[ + "EXECUTORCH_BUILD_SHARED", + "EXECUTORCH_BUILD_CUDA", + ], + ), # 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. From 448aec89c7e53f3cb8cde34c23855db7110fca2c Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 17:27:46 -0700 Subject: [PATCH 02/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 78 +++++++++++++++++++++++++++++++ backends/cuda/CMakeLists.txt | 10 ++-- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index e988994217a..abc096d3116 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -117,6 +117,82 @@ def _defines_symbol(library: Path, symbol: str) -> bool: return False +def report_wheel_composition() -> None: + """Print what the wheel ships and what each library needs. + + Not an assertion. A size jump or an unexpected external dependency is the + first visible sign that a component got statically duplicated again, so the + numbers are worth having in the log of every run. + """ + package_dir = _installed_package_dir() + libraries = _shipped_shared_objects(package_dir) + + print("shipped libraries:") + total = 0 + for library in sorted(libraries, key=lambda path: path.name): + size = library.stat().st_size + total += size + print(f" {size / 1024:9.1f} KiB {library.relative_to(package_dir)}") + print(f" {total / 1024:9.1f} KiB total") + + if shutil.which("readelf") is None: + return + # Anything the libraries need that the wheel does not itself ship has to be + # present on the user's machine, so it belongs in the report. Compare against + # the shipped file names rather than guessing from name prefixes. + shipped = {library.name for library in libraries} + external = set() + for library in libraries: + dynamic = subprocess.run( + ["readelf", "-d", str(library)], + capture_output=True, + text=True, + check=False, + ).stdout + for line in dynamic.splitlines(): + if "(NEEDED)" not in line or "[" not in line: + continue + name = line.split("[", 1)[1].rstrip("]").strip() + if name not in shipped: + external.add(name) + if external: + print("external dependencies expected from the environment:") + for name in sorted(external): + print(f" {name}") + + +def test_shipped_libraries_load() -> None: + """Every shipped library must be able to resolve its dependencies. + + The symbol checks prove each component is defined exactly once, but a library + can still be unloadable if the loader cannot find something it needs, which is + a packaging bug rather than a duplication bug. + """ + if shutil.which("ldd") is None: + print("- ldd not available, skipping the load check") + return + + package_dir = _installed_package_dir() + broken = {} + for library in _shipped_shared_objects(package_dir): + resolved = subprocess.run( + ["ldd", str(library)], capture_output=True, text=True, check=False + ).stdout + missing = [ + line.split("=>")[0].strip() + for line in resolved.splitlines() + if "not found" in line + ] + if missing: + broken[str(library.relative_to(package_dir))] = missing + + assert not broken, ( + "shipped libraries cannot resolve their dependencies, so they will fail " + f"to load: {broken}" + ) + print("✓ every shipped library resolves its dependencies") + + def _assert_single_definer(symbols, what: str, optional: bool = False) -> None: """Exactly one shipped library may define each of `symbols`. @@ -266,6 +342,8 @@ def _assert_runs_relocated(consumer, package_dir, work_dir, environment) -> None def run_tests(work_dir: Path) -> None: + report_wheel_composition() + test_shipped_libraries_load() test_single_backend_registry() test_single_threadpool() test_single_kernel_registration() diff --git a/backends/cuda/CMakeLists.txt b/backends/cuda/CMakeLists.txt index 2d599ed659f..644c8ec4aa5 100644 --- a/backends/cuda/CMakeLists.txt +++ b/backends/cuda/CMakeLists.txt @@ -223,11 +223,13 @@ if(EXECUTORCH_BUILD_SHARED) SOVERSION "${PROJECT_VERSION_MAJOR}" ) if(NOT APPLE) - # Ships beside the runtime in the wheel's lib/ directory. libcudart and - # friends come from the environment, so they are not bundled here. + # Ships in the wheel's lib/ directory, but the CUDA shim library it links + # lives under backends/cuda, so both locations have to be searchable. The + # CUDA runtime itself comes from the environment and is not bundled. + set(_cuda_backend_rpath "$ORIGIN:$ORIGIN/../backends/cuda") set_target_properties( - aoti_cuda_backend PROPERTIES BUILD_RPATH "$ORIGIN" INSTALL_RPATH - "$ORIGIN" + aoti_cuda_backend PROPERTIES BUILD_RPATH "${_cuda_backend_rpath}" + INSTALL_RPATH "${_cuda_backend_rpath}" ) endif() endif() From d843d61449380f30397dc07e24e7e3f3b612b59a Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 19:52:37 -0700 Subject: [PATCH 03/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 32 +++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index abc096d3116..d79983ac710 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -162,35 +162,47 @@ def report_wheel_composition() -> None: def test_shipped_libraries_load() -> None: - """Every shipped library must be able to resolve its dependencies. + """Every shipped library must depend only on things that exist. The symbol checks prove each component is defined exactly once, but a library - can still be unloadable if the loader cannot find something it needs, which is - a packaging bug rather than a duplication bug. + can still be unloadable if it needs something nothing provides, which is a + packaging bug rather than a duplication bug. + + A dependency the wheel ships elsewhere is fine even when `ldd` cannot resolve + it: some extensions are loaded after `import torch` has already brought their + dependencies into the process, so they intentionally carry no path to them. + Only a name nothing in the wheel provides is a real problem. """ if shutil.which("ldd") is None: print("- ldd not available, skipping the load check") return package_dir = _installed_package_dir() + libraries = _shipped_shared_objects(package_dir) + shipped = {library.name for library in libraries} + broken = {} - for library in _shipped_shared_objects(package_dir): + for library in libraries: resolved = subprocess.run( ["ldd", str(library)], capture_output=True, text=True, check=False ).stdout missing = [ - line.split("=>")[0].strip() - for line in resolved.splitlines() - if "not found" in line + name + for name in ( + line.split("=>")[0].strip() + for line in resolved.splitlines() + if "not found" in line + ) + if name not in shipped ] if missing: broken[str(library.relative_to(package_dir))] = missing assert not broken, ( - "shipped libraries cannot resolve their dependencies, so they will fail " - f"to load: {broken}" + "shipped libraries need dependencies that nothing provides, so they will " + f"fail to load: {broken}" ) - print("✓ every shipped library resolves its dependencies") + print("✓ every shipped library depends only on things that exist") def _assert_single_definer(symbols, what: str, optional: bool = False) -> None: From 773d4ac15af1451ceadfe683b2f1b420b1662cc1 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 21:22:00 -0700 Subject: [PATCH 04/26] 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 5c03937e33945146346434cd6a80973123a3bf47 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 02:08:05 -0700 Subject: [PATCH 05/26] Update [ghstack-poisoned] --- setup.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f0102f75cee..a2cdf9cbae9 100644 --- a/setup.py +++ b/setup.py @@ -684,8 +684,16 @@ def build_extension(self, ext: _BaseExtension) -> None: name = dst_file.name if ".so." in name: unversioned = dst_file.with_name(name.split(".so.")[0] + ".so") - if not unversioned.exists(): + # exists() follows symlinks, so a stale link left by an earlier build + # looks absent and then symlink() fails. Replace it outright. A + # failure here must not break packaging, since the real library is + # already in place and only the convenience alias would be missing. + try: + if unversioned.is_symlink() or unversioned.exists(): + unversioned.unlink() os.symlink(name, unversioned) + except OSError: + pass # 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, From 7b89a940b0eb08f161240304669dfa231e3da9a9 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 02:34:44 -0700 Subject: [PATCH 06/26] Update [ghstack-poisoned] --- setup.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/setup.py b/setup.py index a2cdf9cbae9..a96aad6bf2d 100644 --- a/setup.py +++ b/setup.py @@ -677,24 +677,6 @@ def build_extension(self, ext: _BaseExtension) -> None: # Copy the file. self.copy_file(os.fspath(src_file), os.fspath(dst_file)) - # A versioned library ships as libfoo.so. with no plain libfoo.so. - # CMake's find_library only matches the unversioned name, so a C++ - # application looking for a shipped component would not find it. Add the - # usual development symlink next to the real file. - name = dst_file.name - if ".so." in name: - unversioned = dst_file.with_name(name.split(".so.")[0] + ".so") - # exists() follows symlinks, so a stale link left by an earlier build - # looks absent and then symlink() fails. Replace it outright. A - # failure here must not break packaging, since the real library is - # already in place and only the convenience alias would be missing. - try: - if unversioned.is_symlink() or unversioned.exists(): - unversioned.unlink() - os.symlink(name, unversioned) - except OSError: - pass - # 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 From 4444cdf2482bd52039dfde2bc4347cb757e1145d Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 05:31:12 -0700 Subject: [PATCH 07/26] Update [ghstack-poisoned] --- .ci/scripts/test-cuda-build.sh | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/.ci/scripts/test-cuda-build.sh b/.ci/scripts/test-cuda-build.sh index e717718be66..4326aba8f33 100755 --- a/.ci/scripts/test-cuda-build.sh +++ b/.ci/scripts/test-cuda-build.sh @@ -80,6 +80,53 @@ except Exception as e: exit(1) " + # The CUDA delegate ships as its own shared library. Nothing else here would + # notice if it were built into more than one place, and a process with two + # copies of the delegate has two copies of its state, so check that the + # installed tree defines it exactly once. + python -c " +import shutil +import subprocess +import sys +from pathlib import Path + +if shutil.which('nm') is None: + print('INFO: nm unavailable, skipping the delegate duplication check') + sys.exit(0) + +import executorch + +# A namespace package has no __file__, so derive the directory from the loader's +# search path instead. +locations = list(getattr(executorch, '__path__', []) or []) +if not locations: + print('INFO: cannot locate the installed package, skipping the check') + sys.exit(0) +package = Path(locations[0]) +symbol = 'executorch::backends::cuda::clearCurrentCUDAStream' +libraries = [p for p in package.rglob('*.so*') if p.is_file() and not p.is_symlink()] +definers = [] +for library in libraries: + result = subprocess.run( + ['nm', '-DC', str(library)], capture_output=True, text=True, check=False + ) + if result.returncode != 0: + continue + for line in result.stdout.splitlines(): + parts = line.split(maxsplit=2) + if len(parts) == 3 and parts[1] in 'TtWVu' and parts[2].startswith(symbol): + definers.append(str(library.relative_to(package))) + break + +if not definers: + print('INFO: no CUDA delegate in this install, nothing to check') + sys.exit(0) +if len(definers) != 1: + print(f'ERROR: expected one library to define the CUDA delegate, found {definers}') + sys.exit(1) +print(f'SUCCESS: exactly one CUDA delegate across {len(libraries)} shipped libraries') +" || exit $? + echo "SUCCESS: ExecuTorch CUDA ${cuda_version} build and verification completed successfully" } From f7d50659566ca01b028b7fa9cdedfeec30b86b88 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 11:56:12 -0700 Subject: [PATCH 08/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 5032d9e6030..6cbe0e3a179 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -26,6 +26,7 @@ import re import shutil import subprocess +import tempfile from pathlib import Path # Registry entry points. A second definer of any of these means a second @@ -263,6 +264,77 @@ def test_shipped_libraries_load() -> None: print("✓ every shipped library resolves every dependency it needs") +def test_shipped_libraries_resolve_without_build_tree() -> None: + """A shipped library must resolve using only its relative runtime paths. + + Packaging copies binaries out of the build directory, so they still carry the + absolute paths they were linked with. On the machine that produced the wheel + those paths exist, which means a library whose relative path is wrong can still + resolve and look correct. Anywhere else it would fail. + + Copy each library and its wheel-provided dependencies into a fresh tree that + mirrors the wheel layout, drop every absolute runtime path, and check what is + left is enough. + """ + if shutil.which("ldd") is None or shutil.which("patchelf") is None: + print("- ldd or patchelf unavailable, skipping the relocated load check") + return + + package_dir = _installed_package_dir() + libraries = _shipped_shared_objects(package_dir) + environment = { + key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" + } + + with tempfile.TemporaryDirectory() as work_dir: + root = Path(work_dir) / package_dir.name + # Mirror the layout so a relative path such as $ORIGIN/../../lib still + # points where it would in a real install. + for library in libraries: + target = root / library.relative_to(package_dir) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(library, target) + + broken = {} + for library in libraries: + target = root / library.relative_to(package_dir) + current = subprocess.run( + ["patchelf", "--print-rpath", str(target)], + capture_output=True, + text=True, + check=False, + ).stdout.strip() + relative = [ + entry for entry in current.split(":") if entry.startswith("$ORIGIN") + ] + subprocess.run( + ["patchelf", "--set-rpath", ":".join(relative), str(target)], + check=False, + ) + resolved = subprocess.run( + ["ldd", str(target)], + capture_output=True, + text=True, + check=False, + env=environment, + ).stdout + shipped = {item.name for item in libraries} + missing = [ + line.split("=>")[0].strip() + for line in resolved.splitlines() + if "not found" in line and line.split("=>")[0].strip() in shipped + ] + if missing: + broken[str(library.relative_to(package_dir))] = missing + + assert not broken, ( + "shipped libraries only resolve their wheel-provided dependencies " + "through absolute build paths, so they would fail on any other " + f"machine: {broken}" + ) + print("✓ every shipped library resolves without the build tree") + + def _assert_single_definer(symbols, what: str, optional: bool = False) -> None: """Exactly one shipped library may define each of `symbols`. From 92c5102b02ef8c7d0c150dc2c4c3558a930ecc76 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 12:32:24 -0700 Subject: [PATCH 09/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 1 + 1 file changed, 1 insertion(+) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 6cbe0e3a179..8004bb0dccc 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -498,6 +498,7 @@ def _assert_runs_relocated(consumer, package_dir, work_dir, environment) -> None def run_tests(work_dir: Path) -> None: report_wheel_composition() test_shipped_libraries_load() + test_shipped_libraries_resolve_without_build_tree() test_single_backend_registry() test_single_threadpool() test_single_kernel_registration() From 2fc369947e26ccc72b550229fb40378c7d326df3 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 14:31:48 -0700 Subject: [PATCH 10/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 5e6fe7cf51f..91b87579546 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -268,9 +268,7 @@ def test_shipped_libraries_load() -> None: if "not found" in line ] undefined = [ - line.strip() - for line in combined.splitlines() - if "undefined symbol" in line + line.strip() for line in combined.splitlines() if "undefined symbol" in line ] if undefined: unresolved[str(library.relative_to(package_dir))] = undefined[:5] From f8f4e0fa54b96dcac87fb2a172e5ddca9e04dbc6 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 17:41:28 -0700 Subject: [PATCH 11/26] Update [ghstack-poisoned] --- backends/cuda/CMakeLists.txt | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/backends/cuda/CMakeLists.txt b/backends/cuda/CMakeLists.txt index b46c7b34ef1..8bec5ca548d 100644 --- a/backends/cuda/CMakeLists.txt +++ b/backends/cuda/CMakeLists.txt @@ -231,21 +231,26 @@ if(EXECUTORCH_BUILD_SHARED) aoti_cuda_backend PROPERTIES BUILD_RPATH "${_cuda_backend_rpath}" INSTALL_RPATH "${_cuda_backend_rpath}" ) - # The shim needs its own entry rather than relying on the backend's. A - # RUNPATH applies to the library that carries it, not to what its own - # dependencies need, so loading the shim first, or on its own, would fail to - # find the extension library it links. The shim ships under backends/cuda - # while that library ships in the wheel's lib/ directory. - if(TARGET aoti_cuda_shims) - set(_cuda_shims_rpath "$ORIGIN:$ORIGIN/../../lib") - set_target_properties( - aoti_cuda_shims PROPERTIES BUILD_RPATH "${_cuda_shims_rpath}" - INSTALL_RPATH "${_cuda_shims_rpath}" - ) - endif() endif() endif() +# Outside the shared-runtime guard on purpose: the shim and the extension +# library it links are packaged for any CUDA build, so the path that lets the +# shim find that library has to be set whenever both exist, not only alongside a +# shared runtime. +if(NOT APPLE AND TARGET aoti_cuda_shims) + # The shim needs its own entry rather than relying on the backend's. A RUNPATH + # applies to the library that carries it, not to what its own dependencies + # need, so loading the shim first, or on its own, would fail to find the + # extension library it links. The shim ships under backends/cuda while that + # library ships in the wheel's lib/ directory. + set(_cuda_shims_rpath "$ORIGIN:$ORIGIN/../../lib") + set_target_properties( + aoti_cuda_shims PROPERTIES BUILD_RPATH "${_cuda_shims_rpath}" + INSTALL_RPATH "${_cuda_shims_rpath}" + ) +endif() + target_include_directories( aoti_cuda_backend PUBLIC ${CUDAToolkit_INCLUDE_DIRS} $ From 30117eb2891cceab1f3fe854fc7394c02ea93914 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 17:56:37 -0700 Subject: [PATCH 12/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index b437a78d78c..2885f056241 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -241,8 +241,14 @@ def test_shipped_libraries_load() -> None: for line in combined.splitlines() if "not found" in line ] + # A Python extension module deliberately leaves the interpreter's own + # symbols undefined, because the interpreter provides them once it loads + # the module. Those are expected and must not be reported. undefined = [ - line.strip() for line in combined.splitlines() if "undefined symbol" in line + line.strip() + for line in combined.splitlines() + if "undefined symbol" in line + and not re.search(r"undefined symbol:\s+_?Py", line) ] if undefined: unresolved[str(library.relative_to(package_dir))] = undefined[:5] From f0cc8d1df5654bffb1f95bf9f19d2e6c045b199c Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sat, 1 Aug 2026 18:05:14 -0700 Subject: [PATCH 13/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 30 ++++++++++++++++++++---------- setup.py | 2 +- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 2885f056241..4fca1c8ad8f 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -241,15 +241,22 @@ def test_shipped_libraries_load() -> None: for line in combined.splitlines() if "not found" in line ] - # A Python extension module deliberately leaves the interpreter's own - # symbols undefined, because the interpreter provides them once it loads - # the module. Those are expected and must not be reported. - undefined = [ - line.strip() - for line in combined.splitlines() - if "undefined symbol" in line - and not re.search(r"undefined symbol:\s+_?Py", line) - ] + # A Python extension module resolves the interpreter's symbols only once + # the interpreter loads it, so unresolved symbols are normal there and say + # nothing about packaging. Whether those modules import at all is covered + # separately. The missing-library checks below still apply to them. + is_python_extension = ".cpython-" in library.name or library.name.endswith( + (".pyd", ".abi3.so") + ) + undefined = ( + [] + if is_python_extension + else [ + line.strip() + for line in combined.splitlines() + if "undefined symbol" in line + ] + ) if undefined: unresolved[str(library.relative_to(package_dir))] = undefined[:5] absent = [name for name in missing if name not in shipped] @@ -320,7 +327,10 @@ def test_shipped_libraries_resolve_without_build_tree() -> None: ] subprocess.run( ["patchelf", "--set-rpath", ":".join(relative), str(target)], - check=False, + # A failure here would leave the original absolute build paths in + # place, and the check below would then pass by resolving through + # them, which is exactly what this test exists to rule out. + check=True, ) resolved = subprocess.run( ["ldd", str(target)], diff --git a/setup.py b/setup.py index 7a68d8aa755..7af0aeb9892 100644 --- a/setup.py +++ b/setup.py @@ -1198,7 +1198,7 @@ def run(self): # noqa C901 # whenever CUDA is on, so gating on the shared runtime as well # would drop it from a CUDA wheel built with a static runtime. BuiltFile( - src_dir="%CMAKE_CACHE_DIR%/extension/cuda/", + src_dir="%CMAKE_CACHE_DIR%/extension/cuda/%BUILD_TYPE%/", src_name="extension_cuda", dst="executorch/lib/", is_dynamic_lib=True, From 9fa91a336acff3753f07b1d78e0b107db91c548c Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 08:53:25 -0700 Subject: [PATCH 14/26] Update [ghstack-poisoned] --- .ci/scripts/test-cuda-build.sh | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.ci/scripts/test-cuda-build.sh b/.ci/scripts/test-cuda-build.sh index 5e9f008beb1..66b794d1911 100755 --- a/.ci/scripts/test-cuda-build.sh +++ b/.ci/scripts/test-cuda-build.sh @@ -140,6 +140,39 @@ if len(definers) != 1: print(f'ERROR: expected one library to define the CUDA delegate, found {definers}') sys.exit(1) print(f'SUCCESS: exactly one CUDA delegate across {len(libraries)} shipped libraries') +" || exit $? + + # Loading it is what the symbol scan above cannot prove. A broken runtime + # path, an undefined symbol, or a mismatched CUDA dependency all pass a name + # check and fail here. + ${CONDA_RUN} python -c " +import ctypes, os, sys +from pathlib import Path + +import executorch + +package = Path(getattr(executorch, '__path__', [None])[0]) +delegates = [ + p for p in package.rglob('libexecutorch_cuda_backend.so*') + if p.is_file() and not p.is_symlink() +] +if len(delegates) != 1: + print(f'ERROR: expected one shipped CUDA delegate, found {delegates}') + sys.exit(1) + +# Strip LD_LIBRARY_PATH so the library has to resolve through its own runtime +# path, the way it would on a user's machine. +os.environ.pop('LD_LIBRARY_PATH', None) +for library in [delegates[0]] + sorted(package.rglob('libaoti_cuda_shims.so*')): + if not library.is_file() or library.is_symlink(): + continue + try: + ctypes.CDLL(str(library), mode=ctypes.RTLD_GLOBAL) + except OSError as error: + print(f'ERROR: {library.relative_to(package)} does not load: {error}') + sys.exit(1) + print(f'loaded {library.relative_to(package)}') +print('SUCCESS: the CUDA delegate and its shim load from the installed package') " || exit $? echo "SUCCESS: ExecuTorch CUDA ${cuda_version} build and verification completed successfully" From 0ba529a5fe0e8452205b8006655066effb4c97ac Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 08:56:02 -0700 Subject: [PATCH 15/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index f20ce9ae59b..db6f119bf29 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -280,7 +280,7 @@ def test_shipped_libraries_load() -> None: "shipped libraries reference symbols nothing provides, so they will fail " f"at first use rather than at load: {unresolved}" ) - print("✓ every shipped library resolves every dependency it needs") + print("✓ every shipped library resolves in an environment with torch present") def test_shipped_libraries_resolve_without_build_tree() -> None: @@ -341,11 +341,22 @@ def test_shipped_libraries_resolve_without_build_tree() -> None: env=environment, ).stdout shipped = {item.name for item in libraries} - missing = [ + all_missing = [ line.split("=>")[0].strip() for line in resolved.splitlines() - if "not found" in line and line.split("=>")[0].strip() in shipped + if "not found" in line ] + # Only wheel-provided dependencies are asserted on, because an external + # one is expected to come from the environment. They are still reported, + # since silently dropping them would hide a library that resolves only + # through an absolute build path. + missing = [name for name in all_missing if name in shipped] + external = [name for name in all_missing if name not in shipped] + if external: + print( + f"- {library.relative_to(package_dir)} also needs " + f"{external} from the environment" + ) if missing: broken[str(library.relative_to(package_dir))] = missing From d036cf87b69dc8b8692ff664a331d45948b518e3 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 12:47:38 -0700 Subject: [PATCH 16/26] Update [ghstack-poisoned] --- .ci/scripts/test-cuda-build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/scripts/test-cuda-build.sh b/.ci/scripts/test-cuda-build.sh index d01a81133c9..44ce87da8d8 100755 --- a/.ci/scripts/test-cuda-build.sh +++ b/.ci/scripts/test-cuda-build.sh @@ -150,7 +150,7 @@ print(f'SUCCESS: exactly one CUDA delegate across {len(libraries)} shipped libra # loader reads that variable once at process start, so clearing it later would # not change what the library is allowed to find. Without this the check could # pass on a machine whose environment happens to cover the dependencies. - env -u LD_LIBRARY_PATH ${CONDA_RUN} python -c " + env -u LD_LIBRARY_PATH python -c " import ctypes, os, sys from pathlib import Path From 14dfdee33baa26a3a28d97321d9b7a6130543cc1 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 13:23:01 -0700 Subject: [PATCH 17/26] Update [ghstack-poisoned] --- tools/cmake/Utils.cmake | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index 0cd9cd2f523..848452902f5 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -81,11 +81,11 @@ function(executorch_target_link_options_shared_lib target_name) target_link_options( ${target_name} INTERFACE - # Separate options rather than one SHELL: string, which splits on spaces - # and would break a library path containing one. - "LINKER:--push-state,--no-as-needed" - "$" - "LINKER:--pop-state" + # One option with the library inside it, for two reasons. A SHELL: string + # would split on spaces and break a path containing one, and separate + # options repeat identical text that CMake de-duplicates, which silently + # leaves every library after the first outside any --no-as-needed scope. + "LINKER:--push-state,--no-as-needed,$,--pop-state" ) return() endif() @@ -289,10 +289,11 @@ function(executorch_target_retain_shared_library target_name library_target) # push-state/pop-state rather than closing with an explicit --as-needed: # that would leave --as-needed in force for everything after it on the line # and drop the next library that only exists for static-init registration. - # Separate options rather than one SHELL: string, which splits on spaces and - # would break a library path containing one. - set(_retain_flags "LINKER:--push-state,--no-as-needed" - "$" "LINKER:--pop-state" + # The library goes inside the single option: a SHELL: string would split on + # spaces, and separate options repeat identical text that CMake + # de-duplicates, which would leave every library after the first unscoped. + set(_retain_flags + "LINKER:--push-state,--no-as-needed,$,--pop-state" ) endif() # The generator expression alone does not order the build, so say it outright. From c67cf7db8a635c87e44d00f3b33afd72bc5e3052 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 14:26:24 -0700 Subject: [PATCH 18/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 53f2ee1a46b..9ffe4727c9d 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -77,10 +77,12 @@ _CONSUMER_SOURCE = """\ #include +#include #include #include #include +#include int main() { executorch::runtime::runtime_init(); @@ -91,9 +93,21 @@ std::printf( "registered backends: %zu\\n", (size_t)executorch::runtime::get_num_registered_backends()); - // Compile against the Module header too. It is shipped and advertised as the - // way to load a program, so a consumer must be able to include it. - (void)sizeof(executorch::extension::Module); + + // Use the Module and tensor APIs, which are how an application is expected to + // load and run a program. Constructing them proves the shipped headers and the + // shipped library agree, which taking sizeof alone would not: a declaration is + // enough for that, while these need real definitions at link time. + executorch::extension::module::Module module("nonexistent.pte"); + std::vector data(4, 1.0f); + auto input = executorch::extension::make_tensor_ptr({2, 2}, data.data()); + std::printf("tensor holds %zu values\\n", (size_t)input->numel()); + + // A load failure is expected here, since no program is shipped for this check. + // What matters is that the call links and returns an error rather than failing + // to resolve a symbol. + const auto error = module.load(); + std::printf("module load returned 0x%x as expected\\n", (unsigned)error); return 0; } """ From 994016608a892fdedac0a9e1a99e86c6b986407f Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 14:33:07 -0700 Subject: [PATCH 19/26] Update [ghstack-poisoned] --- docs/source/using-executorch-cpp.md | 2 +- setup.py | 9 +++++++ tools/cmake/executorch-wheel-config.cmake | 30 +++++++++++++++++------ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/docs/source/using-executorch-cpp.md b/docs/source/using-executorch-cpp.md index 6f4882b891f..97e14879152 100644 --- a/docs/source/using-executorch-cpp.md +++ b/docs/source/using-executorch-cpp.md @@ -66,7 +66,7 @@ Point CMake at the installed package when you configure: ``` cmake -S . -B build \ - -DCMAKE_PREFIX_PATH="$(python -c 'import executorch, pathlib; print(pathlib.Path(executorch.__path__[0]) / "share" / "cmake")')" + -DCMAKE_PREFIX_PATH="$(python -c 'import executorch, pathlib; print(pathlib.Path(executorch.__path__[0]))')" cmake --build build ``` diff --git a/setup.py b/setup.py index 9fc4ce35b15..4bad6d56c94 100644 --- a/setup.py +++ b/setup.py @@ -777,6 +777,15 @@ def run(self): "tools/cmake/executorch-wheel-config.cmake", "share/cmake/executorch-config.cmake", ), + # Also at the standard location, so a consumer can point + # CMAKE_PREFIX_PATH at the installed package root. CMake only + # searches lib/cmake/ and a few similar directories + # for a named package, not a bare share/cmake, so without this a + # consumer has to know the exact leaf holding the file. + ( + "tools/cmake/executorch-wheel-config.cmake", + "lib/cmake/executorch/executorch-config.cmake", + ), ] # Copy all the necessary headers into include/executorch/ so that they can # be found in the pip package. This is the subset of headers that are diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 2e5fadc908e..10cd1610f5d 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -44,13 +44,29 @@ # fails once it is deployed somewhere else. cmake_minimum_required(VERSION 3.28) -# This file is installed to /executorch/share/cmake, so the -# package root is two levels up. Everything is resolved relative to this file so -# the wheel stays relocatable: no absolute path from the machine that built it -# is baked in here. -get_filename_component( - _executorch_package_root "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE -) +# Everything is resolved relative to this file so the wheel stays relocatable: +# no absolute path from the machine that built it is baked in here. +# +# The package root is found by walking up until the shipped layout appears, +# rather than by a fixed number of levels. The file is installed both under +# share/cmake, which the historical contract uses, and under the standard +# lib/cmake/ directory that a plain CMAKE_PREFIX_PATH pointed at +# the package root can discover. Those sit at different depths. +set(_executorch_package_root "") +foreach(_up "/../.." "/../../.." "/..") + get_filename_component( + _executorch_candidate_root "${CMAKE_CURRENT_LIST_DIR}${_up}" ABSOLUTE + ) + if(EXISTS "${_executorch_candidate_root}/include/executorch") + set(_executorch_package_root "${_executorch_candidate_root}") + break() + endif() +endforeach() +if(NOT _executorch_package_root) + get_filename_component( + _executorch_package_root "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE + ) +endif() set(EXECUTORCH_INCLUDE_DIRS "${_executorch_package_root}/include" From 9e115944ebcb5b77476a8fbc72cee017be68fbe4 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 15:23:54 -0700 Subject: [PATCH 20/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 9ffe4727c9d..ee4dea3b8e5 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -260,22 +260,18 @@ def test_shipped_libraries_load() -> None: for line in combined.splitlines() if "not found" in line ] - # A Python extension module resolves the interpreter's symbols only once - # the interpreter loads it, so unresolved symbols are normal there and say - # nothing about packaging. Whether those modules import at all is covered - # separately. The missing-library checks below still apply to them. - is_python_extension = ".cpython-" in library.name or library.name.endswith( - (".pyd", ".abi3.so") - ) - undefined = ( - [] - if is_python_extension - else [ - line.strip() - for line in combined.splitlines() - if "undefined symbol" in line - ] - ) + # Interpreter symbols are excluded rather than whole files. A library that + # is loaded by Python, whether a extension module or an ahead-of-time + # plugin, resolves those only once an interpreter is running, so ldd can + # never resolve them and their absence says nothing about packaging. + # Filtering the symbols rather than guessing from the file name keeps the + # check active for everything else those libraries need. + undefined = [ + line.strip() + for line in combined.splitlines() + if "undefined symbol" in line + and not re.search(r"undefined symbol:\s+_?Py", line) + ] if undefined: unresolved[str(library.relative_to(package_dir))] = undefined[:5] absent = [name for name in missing if name not in shipped] From daec777b8c283c200f7e05a8a2e219ce9e8bc074 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 15:31:08 -0700 Subject: [PATCH 21/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 45 ------------------------------- 1 file changed, 45 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index ee4dea3b8e5..d03a1843b60 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -161,50 +161,6 @@ def _defines_symbol(library: Path, symbol: str) -> bool: return False -def report_wheel_composition() -> None: - """Print what the wheel ships and what each library needs. - - Not an assertion. A size jump or an unexpected external dependency is the - first visible sign that a component got statically duplicated again, so the - numbers are worth having in the log of every run. - """ - package_dir = _installed_package_dir() - libraries = _shipped_shared_objects(package_dir) - - print("shipped libraries:") - total = 0 - for library in sorted(libraries, key=lambda path: path.name): - size = library.stat().st_size - total += size - print(f" {size / 1024:9.1f} KiB {library.relative_to(package_dir)}") - print(f" {total / 1024:9.1f} KiB total") - - if shutil.which("readelf") is None: - return - # Anything the libraries need that the wheel does not itself ship has to be - # present on the user's machine, so it belongs in the report. Compare against - # the shipped file names rather than guessing from name prefixes. - shipped = {library.name for library in libraries} - external = set() - for library in libraries: - dynamic = subprocess.run( - ["readelf", "-d", str(library)], - capture_output=True, - text=True, - check=False, - ).stdout - for line in dynamic.splitlines(): - if "(NEEDED)" not in line or "[" not in line: - continue - name = line.split("[", 1)[1].rstrip("]").strip() - if name not in shipped: - external.add(name) - if external: - print("external dependencies expected from the environment:") - for name in sorted(external): - print(f" {name}") - - def test_shipped_libraries_load() -> None: """Every shipped library must depend only on things that exist. @@ -598,7 +554,6 @@ def test_python_extensions_import() -> None: def run_tests(work_dir: Path) -> None: - report_wheel_composition() test_shipped_libraries_load() test_shipped_libraries_resolve_without_build_tree() test_single_backend_registry() From 7631bab4641252bf885da8367c4990ccac61baf3 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 17:26:30 -0700 Subject: [PATCH 22/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 48 +++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index d03a1843b60..fd3179f28d2 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -22,6 +22,7 @@ on the shipped runtime with a relocatable RUNPATH. """ +import importlib.util import os import re import shutil @@ -553,6 +554,52 @@ def test_python_extensions_import() -> None: ) +def test_wheel_platform_tag() -> None: + """The wheel's declared platform tag must match what its libraries need. + + A library that quietly picks up a newer dependency, or a newer minimum glibc, + makes the wheel unusable on machines the tag says it supports. auditwheel is + the tool that decides this, so ask it rather than guessing. + + Only a contradiction between the tag and the contents fails here. Reports about + instruction set extensions are left to the caller, because a prebuilt tool that + ships in the wheel can legitimately require a newer baseline than the tag + implies. + """ + if importlib.util.find_spec("auditwheel") is None: + print("- auditwheel unavailable, skipping the platform tag check") + return + + wheels = sorted(Path(os.environ.get("WHEEL_DIR", ".")).glob("executorch-*.whl")) + if not wheels: + print("- no wheel file to inspect, skipping the platform tag check") + return + + result = subprocess.run( + [sys.executable, "-m", "auditwheel", "show", str(wheels[-1])], + capture_output=True, + text=True, + check=False, + ) + # auditwheel wraps its verdict across lines, so compare on collapsed + # whitespace rather than the literal output. + combined = " ".join((result.stdout + result.stderr).split()) + match = re.search(r'consistent with the following platform tag: "([^"]+)"', combined) + assert match, ( + "auditwheel reported no platform tag for the wheel, so its contents could " + f"not be checked against what it claims: {combined[-400:]}" + ) + # The tag auditwheel derives from the contents has to be the one the file name + # claims. A wheel that names a stricter tag than its libraries support installs + # on machines it cannot actually run on. + claimed = wheels[-1].name.split("-")[-1].removesuffix(".whl") + assert match.group(1) in claimed, ( + f"the wheel claims platform tag {claimed} but its contents only support " + f"{match.group(1)}" + ) + print(f"✓ the wheel contents match its declared platform tag {match.group(1)}") + + def run_tests(work_dir: Path) -> None: test_shipped_libraries_load() test_shipped_libraries_resolve_without_build_tree() @@ -562,4 +609,5 @@ def run_tests(work_dir: Path) -> None: test_single_kernel_registration() test_single_xnnpack_delegate() test_single_cuda_delegate() + test_wheel_platform_tag() test_cpp_consumer(work_dir) From 235ec6a80c01434cf183df8d7d6687c6c55fcaa9 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 17:53:36 -0700 Subject: [PATCH 23/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 100 +++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index fd3179f28d2..2b613671822 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -554,6 +554,101 @@ def test_python_extensions_import() -> None: ) +_CUSTOM_OP_SOURCE = """\ +// A custom operator, built the way an out-of-tree project builds one: against the +// shipped Python extension rather than an ExecuTorch source tree. +#include +#include + +namespace { + +executorch::aten::Tensor& custom_double_out( + executorch::runtime::KernelRuntimeContext& context, + const executorch::aten::Tensor& input, + executorch::aten::Tensor& out) { + (void)context; + const float* in = input.const_data_ptr(); + float* dst = out.mutable_data_ptr(); + for (ssize_t i = 0; i < input.numel(); ++i) { + dst[i] = in[i] * 2.0f; + } + return out; +} + +} // namespace + +// The registration macro is the point of the check: it has to compile and resolve +// against the registry the shipped extension provides. +EXECUTORCH_LIBRARY(wheel_check, "custom_double.out", custom_double_out); +""" + +_CUSTOM_OP_CMAKE = """\ +cmake_minimum_required(VERSION 3.28) +project(custom_op_check CXX) + +find_package(executorch REQUIRED) + +add_library(custom_op_check SHARED custom_op.cpp) +# The legacy contract: a custom-op library links the shipped Python extension, +# which owns the operator registry it registers into. +target_link_libraries(custom_op_check PRIVATE _portable_lib) +""" + + +def test_custom_op_compiles(work_dir: Path) -> None: + """A custom operator compiles and links against the shipped extension. + + This is how an out-of-tree project adds its own kernels, and it points at the + Python extension rather than the runtime, so it is not covered by the consumer + check above. + """ + assert shutil.which("cmake") is not None, "cmake is required to build a consumer" + + package_dir = _installed_package_dir() + if not list(package_dir.glob("extension/pybindings/_portable_lib*")): + print("- the wheel ships no Python extension, skipping the custom op check") + return + + source_dir = work_dir / "custom-op" + build_dir = work_dir / "custom-op-build" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "custom_op.cpp").write_text(_CUSTOM_OP_SOURCE) + (source_dir / "CMakeLists.txt").write_text(_CUSTOM_OP_CMAKE) + + configure = subprocess.run( + [ + "cmake", + "-S", + str(source_dir), + "-B", + str(build_dir), + f"-DCMAKE_PREFIX_PATH={package_dir}", + ], + capture_output=True, + text=True, + check=False, + ) + assert configure.returncode == 0, ( + "a custom operator project cannot configure against the wheel: " + f"{(configure.stderr or configure.stdout).strip()[-600:]}" + ) + + compiled = subprocess.run( + ["cmake", "--build", str(build_dir)], + capture_output=True, + text=True, + check=False, + ) + assert compiled.returncode == 0, ( + "a custom operator does not compile or link against the shipped extension: " + f"{(compiled.stderr or compiled.stdout).strip()[-800:]}" + ) + assert list(build_dir.rglob("libcustom_op_check.so")) or list( + build_dir.rglob("custom_op_check.dll") + ), "the custom operator library was not produced" + print("✓ a custom operator compiles against the shipped Python extension") + + def test_wheel_platform_tag() -> None: """The wheel's declared platform tag must match what its libraries need. @@ -584,7 +679,9 @@ def test_wheel_platform_tag() -> None: # auditwheel wraps its verdict across lines, so compare on collapsed # whitespace rather than the literal output. combined = " ".join((result.stdout + result.stderr).split()) - match = re.search(r'consistent with the following platform tag: "([^"]+)"', combined) + match = re.search( + r'consistent with the following platform tag: "([^"]+)"', combined + ) assert match, ( "auditwheel reported no platform tag for the wheel, so its contents could " f"not be checked against what it claims: {combined[-400:]}" @@ -610,4 +707,5 @@ def run_tests(work_dir: Path) -> None: test_single_xnnpack_delegate() test_single_cuda_delegate() test_wheel_platform_tag() + test_custom_op_compiles(work_dir) test_cpp_consumer(work_dir) From 324714e0e516f8c2fbea7cc89b9dbf487ed391c0 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 20:36:21 -0700 Subject: [PATCH 24/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index fa0ab422213..fda29fe995f 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -27,8 +27,8 @@ import re import shutil import subprocess -import tempfile import sys +import tempfile from pathlib import Path # Registry entry points. A second definer of any of these means a second @@ -238,6 +238,7 @@ def test_shipped_libraries_load() -> None: ] if undefined: unresolved[str(library.relative_to(package_dir))] = undefined[:5] + # Torch, the interpreter, and the CUDA runtime are excluded rather than # treated as packaging faults. All three arrive from outside the wheel: torch # libraries resolve once the torch package is imported, libpython comes from @@ -558,14 +559,17 @@ def test_python_extensions_import() -> None: ] package_dir = _installed_package_dir() needs_cuda_runtime = any( - "libcudart" in subprocess.run( + "libcudart" + in subprocess.run( ["readelf", "-d", str(library)], capture_output=True, text=True, check=False ).stdout for library in _shipped_shared_objects(package_dir) ) if needs_cuda_runtime and shutil.which("readelf") is not None: - print("- a CUDA wheel needs the CUDA runtime from the environment, so the " - "clean-environment import check does not apply") + print( + "- a CUDA wheel needs the CUDA runtime from the environment, so the " + "clean-environment import check does not apply" + ) return environment = { key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" @@ -773,9 +777,7 @@ def test_no_absolute_runtime_paths() -> None: if result.returncode != 0: continue absolute = [ - entry - for entry in result.stdout.strip().split(":") - if entry.startswith("/") + entry for entry in result.stdout.strip().split(":") if entry.startswith("/") ] if absolute: offenders[str(library.relative_to(package_dir))] = absolute From 8d4d5adde90ee66ed0ef5326735c0da9555a27e8 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Sun, 2 Aug 2026 23:28:12 -0700 Subject: [PATCH 25/26] Update [ghstack-poisoned] --- .ci/scripts/test-cuda-build.sh | 14 +++++++++++-- .ci/scripts/wheel/test_cpp_sdk.py | 35 ++++++++++++++++--------------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/.ci/scripts/test-cuda-build.sh b/.ci/scripts/test-cuda-build.sh index 9e3c4d2ab10..d0847cbdb71 100755 --- a/.ci/scripts/test-cuda-build.sh +++ b/.ci/scripts/test-cuda-build.sh @@ -155,9 +155,19 @@ print(f'SUCCESS: exactly one CUDA delegate across {len(libraries)} shipped libra cuda_search_path="" IFS=':' read -ra _search_entries <<< "${LD_LIBRARY_PATH:-}" for _entry in "${_search_entries[@]}"; do - if [ -n "${_entry}" ] && compgen -G "${_entry}/libcudart.so*" > /dev/null; then - cuda_search_path="${cuda_search_path:+${cuda_search_path}:}${_entry}" + # Any CUDA library, not just the runtime: the nvidia pip packages put each one in + # its own directory, so matching only libcudart would drop the directory holding + # libcurand and the load would still fail. Patterns are looped over rather than + # brace-expanded, which compgen does not apply. + if [ -z "${_entry}" ]; then + continue fi + for _pattern in libcudart libcurand libcublas libcudnn; do + if compgen -G "${_entry}/${_pattern}*.so*" > /dev/null; then + cuda_search_path="${cuda_search_path:+${cuda_search_path}:}${_entry}" + break + fi + done done LD_LIBRARY_PATH="${cuda_search_path}" python -c " diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index a027b5cce84..12ed96b3dfb 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -151,15 +151,13 @@ def _needs_external_cuda_runtime(package_dir: Path) -> bool: without help. The CUDA runtime is deliberately not bundled, so on a machine where it comes from the separate nvidia packages those checks would report a fault that is by design. + + Decided from the shipped file names rather than by reading each ELF, so the answer + does not depend on a tool being installed. Getting this wrong in the absent-tool + direction would treat a CUDA wheel as a CPU one and fail the checks it should skip. """ - if shutil.which("readelf") is None: - return False return any( - "libcudart" - in subprocess.run( - ["readelf", "-d", str(library)], capture_output=True, text=True, check=False - ).stdout - for library in _shipped_shared_objects(package_dir) + "cuda" in library.name for library in _shipped_shared_objects(package_dir) ) @@ -284,16 +282,19 @@ def test_shipped_libraries_load() -> None: # never resolve them and their absence says nothing about packaging. # Filtering the symbols rather than guessing from the file name keeps the # check active for everything else those libraries need. - undefined = ( - [] - if skip_undefined - else [ - line.strip() - for line in combined.splitlines() - if "undefined symbol" in line - and not re.search(r"undefined symbol:\s+_?Py", line) - ] - ) + undefined = [ + line.strip() + for line in combined.splitlines() + if "undefined symbol" in line + and not re.search(r"undefined symbol:\s+_?Py", line) + # On a CUDA wheel the CUDA entry points are unresolved because the runtime + # comes from the environment, so only those are excused. Blanking the whole + # list instead would hide a genuinely under-linked symbol on the same wheel. + and not ( + skip_undefined + and re.search(r"undefined symbol:\s+(cu|cuda|curand|cublas)", line) + ) + ] if undefined: unresolved[str(library.relative_to(package_dir))] = undefined[:5] From 7196f9ee2415ced962fbac89b82f3c3b2f7284d4 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 00:43:18 -0700 Subject: [PATCH 26/26] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 9e2898868c2..715d7e8dcbb 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -311,9 +311,13 @@ def test_shipped_libraries_load() -> None: # On a CUDA wheel the CUDA entry points are unresolved because the runtime # comes from the environment, so only those are excused. Blanking the whole # list instead would hide a genuinely under-linked symbol on the same wheel. + # The leading-underscore forms matter too: nvcc emits host stubs such as + # __cudaRegisterFatBinary for every compiled .cu file. and not ( skip_undefined - and re.search(r"undefined symbol:\s+(cu|cuda|curand|cublas)", line) + and re.search( + r"undefined symbol:\s+_*(cu|cuda|curand|cublas|cudnn)", line + ) ) ] if undefined: @@ -952,9 +956,21 @@ def test_component_targets_link(work_dir: Path) -> None: # Run it, so the check covers a registration constructor actually firing rather than # only the library being named in DT_NEEDED. - environment = { - key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" - } + # + # On a CUDA wheel the consumer links the CUDA delegate, which needs a CUDA runtime + # the wheel does not bundle. Stripping the search path would then fail for a reason + # that is by design, so the environment is left alone there, matching what the other + # checks do for the same wheel. + if _needs_external_cuda_runtime(package_dir): + environment = dict(os.environ) + print( + "- a CUDA wheel needs the CUDA runtime from the environment, so the " + "consumer runs with the search path left in place" + ) + else: + environment = { + key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" + } run = subprocess.run( [str(consumer)], capture_output=True, text=True, check=False, env=environment )