From 1ac428161cb52599c3043120043cb36b1465f2a1 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Fri, 31 Jul 2026 15:22:01 -0700 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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" }