From 1ac428161cb52599c3043120043cb36b1465f2a1 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 15:22:01 -0700 Subject: [PATCH 01/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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 ) From 57dc7e0b17707f1e62764f335af6482f8aeecb1e Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 09:21:44 -0700 Subject: [PATCH 27/35] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index ec09b65c908..970ac6e27cd 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -816,7 +816,9 @@ def test_wheel_platform_tag() -> None: ) importlib.invalidate_caches() if installed.returncode != 0 or importlib.util.find_spec("auditwheel") is None: - print("- auditwheel could not be installed, skipping the platform tag check") + print( + "- auditwheel could not be installed, skipping the platform tag check" + ) return wheels = _find_wheel_files() From a19256c602df4f8f9daf81d9a4652741647b5b04 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 12:00:24 -0700 Subject: [PATCH 28/35] Update [ghstack-poisoned] --- .ci/scripts/wheel/pre_build_script.sh | 8 ++++++ .ci/scripts/wheel/test_cpp_sdk.py | 36 +++++++++++++-------------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/.ci/scripts/wheel/pre_build_script.sh b/.ci/scripts/wheel/pre_build_script.sh index 367d398bac8..d73b676a6d1 100755 --- a/.ci/scripts/wheel/pre_build_script.sh +++ b/.ci/scripts/wheel/pre_build_script.sh @@ -127,3 +127,11 @@ else export CMAKE_ARGS="${CMAKE_ARGS:-} -DEXECUTORCH_BUILD_VULKAN=OFF" echo "CMAKE_ARGS=${CMAKE_ARGS}" >> "${GITHUB_ENV}" fi + +# The wheel smoke test compares the wheel's contents against its declared platform tag, +# which needs auditwheel. Installed here rather than by the test, so a release check does +# not depend on the network or change the environment it is verifying. Linux only, since +# auditwheel inspects ELF files. +if [[ "$(uname -s)" == "Linux" ]]; then + pip install auditwheel +fi diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 970ac6e27cd..8544749ee4f 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -776,11 +776,21 @@ def _find_wheel_files() -> list: configured = os.environ.get("WHEEL_DIR") if configured: candidates.append(Path(configured)) + # The build leaves the wheel in dist/ at the repository root, and this file sits at a + # fixed depth below that root, so the location follows from __file__ rather than from + # the current directory. The release job runs the smoke test from the workspace above + # the repository, where a cwd-relative guess finds nothing. + # + # Guarded because a copy of this file can live outside that layout, where indexing + # past the available parents would raise instead of falling through to the other + # candidates. + here = Path(__file__).resolve() + repository_root = here.parents[3] if len(here.parents) > 3 else here.parent candidates += [ - Path.cwd(), + repository_root / "dist", Path.cwd() / "dist", - Path.cwd() / "wheelhouse", - Path("/artifacts"), + Path.cwd(), + repository_root / "wheelhouse", ] for directory in candidates: try: @@ -804,22 +814,12 @@ def test_wheel_platform_tag() -> None: ships in the wheel can legitimately require a newer baseline than the tag implies. """ - # Installed on demand rather than assumed. The wheel-build environment does not - # carry auditwheel, so without this the check skipped there and the skip read as - # coverage. + # Not installed here on purpose. A release check should not need the network or + # change the environment it is verifying, so auditwheel is provisioned by the wheel + # build script alongside the other prerequisites. if importlib.util.find_spec("auditwheel") is None: - installed = subprocess.run( - [sys.executable, "-m", "pip", "install", "--quiet", "auditwheel"], - capture_output=True, - text=True, - check=False, - ) - importlib.invalidate_caches() - if installed.returncode != 0 or importlib.util.find_spec("auditwheel") is None: - print( - "- auditwheel could not be installed, skipping the platform tag check" - ) - return + print("- auditwheel is not installed, skipping the platform tag check") + return wheels = _find_wheel_files() if not wheels: From 9829655ccde93d31250525249fee02c54ddd2fd3 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 12:35:37 -0700 Subject: [PATCH 29/35] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 58 ++++++++++++++++++++++++------- extension/cuda/CMakeLists.txt | 14 ++++++++ setup.py | 18 +++++++--- 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 8544749ee4f..b35dcb92507 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -130,20 +130,56 @@ # environment or the separate nvidia packages. None is reachable from an ldd process, # and a wheel must not carry an absolute path to a build machine's copy just to # satisfy a check. Anything the wheel itself ships still has to resolve. -_EXTERNAL_LIBRARY_PREFIXES = ( - "libpython", - "libtorch", - "libc10", - "libcuda", - "libcurand", - "libcublas", - "libnvinfer", +# Base names of the libraries the wheel expects from outside itself: the interpreter, +# PyTorch, and the CUDA runtime. Matched as whole names rather than as prefixes, because a +# prefix test also excuses unrelated libraries that merely start the same way, such as +# libtorchcodec_core.so or libcudagraph_helper.so. +_EXTERNAL_LIBRARY_NAMES = frozenset( + { + "libpython3", + "libtorch", + "libtorch_cpu", + "libtorch_cuda", + "libtorch_python", + "libtorch_global_deps", + "libc10", + "libc10_cuda", + "libcuda", + "libcudart", + "libcurand", + "libcublas", + "libcublasLt", + "libcudnn", + "libcufft", + "libcusparse", + "libcusolver", + "libnvinfer", + "libnvinfer_plugin", + "libnvrtc", + "libnccl", + } +) + +# The CUDA entry points, spelled the way the CUDA APIs are: a known family followed by an +# uppercase letter. A bare "cu" prefix would also suppress ordinary names such as +# custom_double_out, so a library genuinely missing one would pass unnoticed. +_CUDA_SYMBOL = re.compile( + r"undefined symbol:\s+_*(?:" + r"cuda[A-Z]|cu[A-Z]|curand[A-Z]|cublas[A-Z]|cudnn[A-Z]" + r"|cusparse[A-Z]|cusolver[A-Z]|cufft[A-Z]|nvrtc[A-Z]|nccl[A-Z]" + r")" ) +_SONAME_SUFFIX = re.compile(r"\.so(?:\.\d+)*$") + def _provided_externally(name: str) -> bool: """Whether a shared library is expected to come from outside the wheel.""" - return name.startswith(_EXTERNAL_LIBRARY_PREFIXES) + base = _SONAME_SUFFIX.sub("", name) + if base in _EXTERNAL_LIBRARY_NAMES: + return True + # Version-suffixed interpreter names such as libpython3.12. + return bool(re.fullmatch(r"libpython3(?:\.\d+)?", base)) # The component library each target is expected to expose. Keyed by the library base @@ -317,9 +353,7 @@ def test_shipped_libraries_load() -> None: # __cudaRegisterFatBinary for every compiled .cu file. and not ( skip_undefined - and re.search( - r"undefined symbol:\s+_*(cu|cuda|curand|cublas|cudnn)", line - ) + and re.search(_CUDA_SYMBOL, line) ) ] if undefined: diff --git a/extension/cuda/CMakeLists.txt b/extension/cuda/CMakeLists.txt index 0003691ac8b..f747d95541e 100644 --- a/extension/cuda/CMakeLists.txt +++ b/extension/cuda/CMakeLists.txt @@ -32,6 +32,20 @@ target_compile_definitions( extension_cuda PRIVATE EXECUTORCH_EXTENSION_CUDA_BUILDING ) +if(EXECUTORCH_BUILD_SHARED) + # A namespaced, versioned name, matching the other libraries the wheel ships. The CUDA + # delegate carries a real symbol reference to this library, so its DT_NEEDED entry + # survives even under --as-needed. A generic name like libextension_cuda.so could be + # satisfied by an unrelated library that happens to be loaded first, which would bind a + # different caller-stream implementation into the delegate. + set_target_properties( + extension_cuda + PROPERTIES OUTPUT_NAME executorch_extension_cuda + VERSION "${PROJECT_VERSION}" + SOVERSION "${PROJECT_VERSION_MAJOR}" + ) +endif() + install( TARGETS extension_cuda EXPORT ExecuTorchTargets diff --git a/setup.py b/setup.py index 6f3a851d16d..48b414a4448 100644 --- a/setup.py +++ b/setup.py @@ -1310,11 +1310,19 @@ 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/%BUILD_TYPE%/", - src_name="extension_cuda", - dst="executorch/lib/", - is_dynamic_lib=True, - dependent_cmake_flags=["EXECUTORCH_BUILD_CUDA"], + src_dir="%CMAKE_CACHE_DIR%/extension/cuda/", + src_name=( + "libexecutorch_extension_cuda.so." + f"{get_runtime_soname_major()}.*" + ), + dst=( + "executorch/lib/libexecutorch_extension_cuda.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 From 787bcf7e24347af068d59fa45b2499bddee14cbb Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 13:23:31 -0700 Subject: [PATCH 30/35] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 19 +++++++------------ extension/cuda/CMakeLists.txt | 27 ++++++++++++++------------- setup.py | 16 ++++++---------- 3 files changed, 27 insertions(+), 35 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index e31fc39ac3e..436a538fbd0 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -62,9 +62,11 @@ "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",) +# A symbol the CUDA delegate itself defines. The shim layer's symbols stay resolvable even +# if the delegate stops being packaged, so probing one of those would prove the shim is +# present rather than that delegate code exists exactly once. It is a weak definition, +# which _OWNING_KINDS already counts as owning. +_CUDA_SYMBOLS = ("executorch::backends::cuda::CudaBackend::execute",) # `nm -DC` prints " " for a definition and # " U " for an undefined reference. @@ -469,12 +471,8 @@ def test_shipped_libraries_resolve_without_build_tree() -> None: 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`. - - `optional` allows a component that is only present in some wheel flavors, - such as an accelerator delegate, to be absent without failing. - """ +def _assert_single_definer(symbols, what: str) -> None: + """Exactly one shipped library may define each of `symbols`.""" assert shutil.which("nm") is not None, "nm is required to inspect the wheel" package_dir = _installed_package_dir() @@ -487,9 +485,6 @@ def _assert_single_definer(symbols, what: str, optional: bool = False) -> None: symbol: [lib for lib in libraries if _defines_symbol(lib, symbol)] for symbol in symbols } - if optional and not any(found.values()): - print(f"- no {what} in this wheel, skipping") - return for symbol, definers in found.items(): pretty = [str(lib.relative_to(package_dir)) for lib in definers] diff --git a/extension/cuda/CMakeLists.txt b/extension/cuda/CMakeLists.txt index f747d95541e..ca0751ec07e 100644 --- a/extension/cuda/CMakeLists.txt +++ b/extension/cuda/CMakeLists.txt @@ -32,19 +32,20 @@ target_compile_definitions( extension_cuda PRIVATE EXECUTORCH_EXTENSION_CUDA_BUILDING ) -if(EXECUTORCH_BUILD_SHARED) - # A namespaced, versioned name, matching the other libraries the wheel ships. The CUDA - # delegate carries a real symbol reference to this library, so its DT_NEEDED entry - # survives even under --as-needed. A generic name like libextension_cuda.so could be - # satisfied by an unrelated library that happens to be loaded first, which would bind a - # different caller-stream implementation into the delegate. - set_target_properties( - extension_cuda - PROPERTIES OUTPUT_NAME executorch_extension_cuda - VERSION "${PROJECT_VERSION}" - SOVERSION "${PROJECT_VERSION_MAJOR}" - ) -endif() +# A namespaced, versioned name, matching the other libraries the wheel ships. The CUDA +# delegate carries a real symbol reference to this library, so its DT_NEEDED entry survives +# even under --as-needed. A generic name like libextension_cuda.so could be satisfied by an +# unrelated library that happens to be loaded first, which would bind a different +# caller-stream implementation into the delegate. +# +# Not gated on the shared runtime: the target above is always SHARED, so gating the name +# would produce a different file name in a static-runtime build than packaging looks for. +set_target_properties( + extension_cuda + PROPERTIES OUTPUT_NAME executorch_extension_cuda + VERSION "${PROJECT_VERSION}" + SOVERSION "${PROJECT_VERSION_MAJOR}" +) install( TARGETS extension_cuda diff --git a/setup.py b/setup.py index 48b414a4448..4d611efccb4 100644 --- a/setup.py +++ b/setup.py @@ -1298,17 +1298,13 @@ def run(self): # noqa C901 "executorch/lib/libexecutorch_cuda_backend.so." f"{get_runtime_soname_major()}" ), - dependent_cmake_flags=[ - "EXECUTORCH_BUILD_SHARED", - "EXECUTORCH_BUILD_CUDA", - ], + dependent_cmake_flags=["EXECUTORCH_BUILD_CUDA"], ), - # The CUDA delegate calls into this for stream handling, so an - # application that links the delegate from the wheel cannot - # resolve it unless this ships too. It carries no SONAME version, - # so the name is used as built. The target is always built shared - # whenever CUDA is on, so gating on the shared runtime as well - # would drop it from a CUDA wheel built with a static runtime. + # The CUDA delegate and the AOTI shim both call into this for stream + # handling, so an application that links either from the wheel cannot + # resolve it unless this ships too. Gated on CUDA alone, matching the + # shim: the target is always built shared, so requiring the shared + # runtime here would ship the shim without the library it needs. BuiltFile( src_dir="%CMAKE_CACHE_DIR%/extension/cuda/", src_name=( From 41b1113b09944538598d2b4c4e04c2e2823afe18 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 3 Aug 2026 15:07:52 -0700 Subject: [PATCH 31/35] Update [ghstack-poisoned] --- setup.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 81552813e16..58258c1491d 100644 --- a/setup.py +++ b/setup.py @@ -652,9 +652,9 @@ def __init__( modpath: The dotted path of the python module that maps to the extension. """ - assert ( - "/" not in modpath - ), f"modpath must be a dotted python module path: saw '{modpath}'" + assert "/" not in modpath, ( + f"modpath must be a dotted python module path: saw '{modpath}'" + ) full_src = src if src_dir is None and _is_windows(): src_dir = "%BUILD_TYPE%/" @@ -1311,8 +1311,7 @@ def run(self): # noqa C901 BuiltFile( src_dir="%CMAKE_CACHE_DIR%/backends/cuda/", src_name=( - "libexecutorch_cuda_backend.so." - f"{get_runtime_soname_major()}.*" + f"libexecutorch_cuda_backend.so.{get_runtime_soname_major()}.*" ), dst=( "executorch/lib/libexecutorch_cuda_backend.so." From 400a3d00a99a45ac47599aa71dba68915e251736 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 00:28:41 -0700 Subject: [PATCH 32/35] Update [ghstack-poisoned] --- setup.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index 58258c1491d..4639d72264d 100644 --- a/setup.py +++ b/setup.py @@ -652,9 +652,9 @@ def __init__( modpath: The dotted path of the python module that maps to the extension. """ - assert "/" not in modpath, ( - f"modpath must be a dotted python module path: saw '{modpath}'" - ) + assert ( + "/" not in modpath + ), f"modpath must be a dotted python module path: saw '{modpath}'" full_src = src if src_dir is None and _is_windows(): src_dir = "%BUILD_TYPE%/" @@ -1308,6 +1308,10 @@ def run(self): # noqa C901 ), # Install the CUDA delegate beside them when it is built. The CUDA # runtime itself is not bundled; it comes from the environment. + # Gated on the shared runtime as well, because the versioned file name + # below only exists in a shared build. Without it the target is a static + # archive, the glob matches nothing, and the wheel build fails rather + # than skipping the file. BuiltFile( src_dir="%CMAKE_CACHE_DIR%/backends/cuda/", src_name=( @@ -1317,13 +1321,15 @@ def run(self): # noqa C901 "executorch/lib/libexecutorch_cuda_backend.so." f"{get_runtime_soname_major()}" ), - dependent_cmake_flags=["EXECUTORCH_BUILD_CUDA"], + dependent_cmake_flags=[ + "EXECUTORCH_BUILD_SHARED", + "EXECUTORCH_BUILD_CUDA", + ], ), # The CUDA delegate and the AOTI shim both call into this for stream # handling, so an application that links either from the wheel cannot - # resolve it unless this ships too. Gated on CUDA alone, matching the - # shim: the target is always built shared, so requiring the shared - # runtime here would ship the shim without the library it needs. + # resolve it unless this ships too. Gated the same way as the delegate, + # since both carry a versioned file name only in a shared build. BuiltFile( src_dir="%CMAKE_CACHE_DIR%/extension/cuda/", src_name=( From c8bf06be10d5ec1d004a7b53d899d4320cec44fd Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 13:44:57 -0700 Subject: [PATCH 33/35] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 512c1c134cb..590eddc7213 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -215,9 +215,10 @@ def _needs_external_cuda_runtime(package_dir: Path) -> bool: 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. """ - return any( - "cuda" in library.name for library in _shipped_shared_objects(package_dir) - ) + # The shipped delegate is the signal, not a substring. A library whose name merely + # contains "cuda" would flip a CPU wheel into CUDA mode, which skips the clean + # environment import check and part of the dependency check. + return bool(list(package_dir.rglob("libexecutorch_cuda_backend.so*"))) def _installed_package_dir() -> Path: From d816e568f00e101853e67b9fbf732aae700c9b02 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 20:45:40 -0700 Subject: [PATCH 34/35] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 1980f412039..61265d6435d 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -143,6 +143,10 @@ "libtorch_python", "libtorch_global_deps", "libc10", + # Torch links these itself and installs them beside its own libraries, so a shipped + # library that needs them resolves once torch is imported, the same as libtorch. + "libgomp", + "libshm", "libc10_cuda", "libcuda", "libcudart", From 6e111a83e8f41222933f001ec257c995b6f70b26 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 4 Aug 2026 21:33:52 -0700 Subject: [PATCH 35/35] Update [ghstack-poisoned] --- .ci/scripts/wheel/test_cpp_sdk.py | 199 ------------------------------ 1 file changed, 199 deletions(-) diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index 61265d6435d..a70a927034b 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -271,205 +271,6 @@ 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`.""" - assert shutil.which("nm") is not None, "nm is required to inspect the wheel" - - package_dir = _installed_package_dir() - libraries = _shipped_shared_objects(package_dir) - assert libraries, f"no shared libraries found under {package_dir}" - - # Every symbol is resolved before anything is reported, so a component that is only - # half present is described as such rather than looking like one that is absent. - found = { - symbol: [lib for lib in libraries if _defines_symbol(lib, symbol)] - for symbol in symbols - } - for symbol, definers in found.items(): - pretty = [str(lib.relative_to(package_dir)) for lib in definers] - assert len(definers) == 1, ( - f"expected exactly one library to define {symbol}, found " - f"{len(definers)}: {pretty}. More than one definition means the " - f"process has more than one {what}." - ) - print(f"✓ single {what} across {len(libraries)} shipped libraries") - - -def test_single_backend_registry() -> None: - """Exactly one shipped library may define the backend registry.""" - _assert_single_definer(_REGISTRY_SYMBOLS, "backend registry") - - -def test_single_threadpool() -> None: - """Exactly one shipped library may define the thread pool accessor.""" - _assert_single_definer(_THREADPOOL_SYMBOLS, "thread pool") - - -def test_single_kernel_registration() -> None: - """Exactly one shipped library may define the merged CPU kernels.""" - _assert_single_definer(_KERNEL_SYMBOLS, "set of CPU kernels") - # Ownership of the operator table, not just of a kernel implementation. A - # second copy means a second table, and a static initializer registering into - # a table nothing else reads shows up as an operator that is missing at run - # time rather than as a link error. - _assert_single_definer(_KERNEL_REGISTRY_SYMBOLS, "operator registry") - - -def test_single_xnnpack_delegate() -> None: - """Exactly one shipped library may define the XNNPACK delegate.""" - _assert_single_definer(_XNNPACK_SYMBOLS, "XNNPACK delegate") - - -def test_cpp_consumer(work_dir: Path) -> None: - """A standalone C++ app builds and runs against the installed wheel.""" - assert shutil.which("cmake") is not None, "cmake is required to build a consumer" - - package_dir = _installed_package_dir() - config = package_dir / "share" / "cmake" / "executorch-config.cmake" - assert config.is_file(), f"wheel is missing its CMake package config: {config}" - - source_dir = work_dir / "consumer" - build_dir = work_dir / "consumer-build" - source_dir.mkdir(parents=True, exist_ok=True) - (source_dir / "consumer.cpp").write_text(_CONSUMER_SOURCE) - (source_dir / "CMakeLists.txt").write_text(_CONSUMER_CMAKE) - - subprocess.run( - [ - "cmake", - "-S", - str(source_dir), - "-B", - str(build_dir), - f"-DCMAKE_PREFIX_PATH={config.parent}", - ], - check=True, - ) - subprocess.run(["cmake", "--build", str(build_dir)], check=True) - - consumer = build_dir / "consumer" - # No LD_LIBRARY_PATH: the imported target is responsible for making the - # shipped runtime findable. - environment = { - key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" - } - subprocess.run([str(consumer)], check=True, env=environment) - print("✓ C++ consumer builds and runs against the installed wheel") - - _assert_runs_relocated(consumer, package_dir, work_dir, environment) - - assert shutil.which("readelf") is not None, "readelf is required to check the ELF" - - dynamic = subprocess.run( - ["readelf", "-d", str(consumer)], capture_output=True, text=True, check=True - ).stdout - assert "libexecutorch.so" in dynamic, ( - "the consumer does not depend on the shipped runtime; " - f"dynamic section was:\n{dynamic}" - ) - assert "$ORIGIN" in dynamic, ( - "the consumer has no $ORIGIN-relative RUNPATH, so it is not " - f"relocatable; dynamic section was:\n{dynamic}" - ) - print("✓ consumer depends on the shipped runtime with a relocatable RUNPATH") - - -def _assert_runs_relocated(consumer, package_dir, work_dir, environment) -> None: - """The app still runs after being moved away from the wheel. - - Building in place leaves an absolute path to the wheel's lib directory in the - binary's RUNPATH, which resolves the runtime no matter what `$ORIGIN` says. - Copying the app next to a copy of the runtime, with that absolute entry - removed, is what actually proves the package is relocatable. - - The layout mirrors what the package config supports: the app in `bin/` with - the libraries in a sibling `lib/`, which is what `$ORIGIN/../lib` resolves. - """ - if shutil.which("patchelf") is None: - print("- patchelf not available, skipping the relocated run") - return - - deploy = work_dir / "deployed" - (deploy / "bin").mkdir(parents=True, exist_ok=True) - (deploy / "lib").mkdir(parents=True, exist_ok=True) - moved = deploy / "bin" / consumer.name - shutil.copy2(consumer, moved) - for library in (package_dir / "lib").glob("*.so*"): - shutil.copy2(library, deploy / "lib" / library.name) - - # Keep only the $ORIGIN-relative entries, so nothing absolute can help. - current = subprocess.run( - ["patchelf", "--print-rpath", str(moved)], - capture_output=True, - text=True, - check=True, - ).stdout.strip() - relative = [entry for entry in current.split(":") if entry.startswith("$ORIGIN")] - assert relative, ( - "the consumer has no $ORIGIN-relative RUNPATH entry, so it cannot be " - f"relocated; RUNPATH was: {current}" - ) - subprocess.run( - ["patchelf", "--set-rpath", ":".join(relative), str(moved)], check=True - ) - - subprocess.run([str(moved)], check=True, env=environment, cwd=str(deploy)) - print("✓ consumer still runs when deployed beside a copy of the runtime") - - -def test_python_extensions_import() -> None: - """Every shipped Python extension must import from a clean environment. - - The symbol and dependency checks work on the files. This covers the other - half: an extension can be packaged correctly and still fail to load because a - runtime path does not reach one of its dependencies. Run in a subprocess with - `LD_LIBRARY_PATH` removed so a value from the build environment cannot supply - a path the shipped library is missing. - """ - modules = [ - "executorch.extension.pybindings.portable_lib", - "executorch.extension.training", - ] - # Torch has to be installed, the same as for the dependency check: these - # extensions link it, so without it they cannot import for a reason that says - # nothing about packaging. - if importlib.util.find_spec("torch") is None: - print("- torch is not installed, skipping the extension import check") - return - environment = { - key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" - } - for module in modules: - result = subprocess.run( - [sys.executable, "-c", f"import {module}"], - capture_output=True, - text=True, - check=False, - env=environment, - ) - if result.returncode == 0: - print(f"✓ {module} imports from a clean environment") - continue - # A Python dependency that is simply not installed here, including torch, - # says nothing about how the wheel was built. Only a failure to load a - # native library does. - # A Python package this environment simply does not have says nothing - # about how the wheel was built. Match only that shape, so a native load - # failure reported as ModuleNotFoundError is still caught below. - missing_python_package = re.search( - r"ModuleNotFoundError: No module named '(?!executorch)", result.stderr - ) - if missing_python_package: - print(f"- {module} needs a package this environment lacks, skipping") - continue - # Anything else is a real failure to load what the wheel ships: a missing - # native library, an unresolved symbol, or an ABI mismatch. - raise AssertionError( - f"{module} ships in the wheel but does not import: " - f"{result.stderr.strip()[-500:]}" - ) - - _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.