diff --git a/.ci/scripts/test-cuda-build.sh b/.ci/scripts/test-cuda-build.sh index e717718be66..d0847cbdb71 100755 --- a/.ci/scripts/test-cuda-build.sh +++ b/.ci/scripts/test-cuda-build.sh @@ -80,6 +80,123 @@ 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 + +# The symbol above belongs to the shim layer, so it stays resolvable even if the +# delegate library itself stops being packaged. Check for the delegate file too, +# otherwise losing it entirely would go unnoticed here. +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 library, found {delegates}') + sys.exit(1) +print(f'SUCCESS: one CUDA delegate library at {delegates[0].relative_to(package)}') + +if not definers: + # This runs inside a job that just built with CUDA enabled, so a missing + # delegate means the build or the packaging stopped producing it. Treating + # that as nothing to check would let the regression through. + print('ERROR: CUDA was enabled but no shipped library defines the delegate') + sys.exit(1) +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. + # + # Narrowed before the interpreter starts, not inside it: the loader reads this + # variable once at process start, so changing it later would not affect what the + # library is allowed to find. Only the entries holding a CUDA runtime are kept, + # because the wheel does not bundle it. Everything else is dropped so the check + # cannot pass on a machine whose environment happens to cover a dependency the + # wheel should have carried itself. + cuda_search_path="" + IFS=':' read -ra _search_entries <<< "${LD_LIBRARY_PATH:-}" + for _entry in "${_search_entries[@]}"; do + # 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 " +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) + +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" } diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index d034728d517..715d7e8dcbb 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 @@ -62,24 +62,53 @@ "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.+)$") -# Symbol kinds that mean the object owns the code or storage. +# Symbol kinds that mean the object owns the code or storage. Weak and unique +# kinds are included because a definition is still a definition, but every +# symbol probed here is a strong one, which is what makes a second definer a +# real second copy rather than ordinary vague linkage. _OWNING_KINDS = frozenset("TtBbDdGgSsRrWV") _CONSUMER_SOURCE = """\ +#include +#include #include #include #include +#include int main() { executorch::runtime::runtime_init(); + // Printed rather than asserted on purpose. This consumer links only the + // runtime, exactly as the documented two-line example does, and the runtime + // alone registers no backend. Requiring a nonzero count here would be + // asserting that the runtime does something it is not supposed to do. std::printf( "registered backends: %zu\\n", (size_t)executorch::runtime::get_num_registered_backends()); + + // 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; } """ @@ -93,6 +122,66 @@ """ +# Dependencies that come from outside the wheel. Torch libraries resolve once the +# torch package is imported, libpython comes from the running interpreter, and the +# CUDA and TensorRT runtimes are deliberately not bundled, so they come from the +# 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", +) + + +def _provided_externally(name: str) -> bool: + """Whether a shared library is expected to come from outside the wheel.""" + return name.startswith(_EXTERNAL_LIBRARY_PREFIXES) + + +# The component library each target is expected to expose. Keyed by the library base +# name as shipped, so the test can start from what is in the wheel and require a target +# for it, rather than only inspecting targets that happen to exist. +_COMPONENT_LIBRARIES = { + "libexecutorch_threadpool": "threadpool", + "libexecutorch_optimized_native_cpu_ops_lib": "kernels", + "libexecutorch_xnnpack_backend": "xnnpack_backend", + "libexecutorch_cuda_backend": "cuda_backend", +} + + +def _shipped_components(package_dir: Path) -> set: + """Component names the wheel ships a library for.""" + found = set() + for library in _shipped_shared_objects(package_dir): + for base, component in _COMPONENT_LIBRARIES.items(): + if library.name.startswith(base): + found.add(component) + return found + + +def _needs_external_cuda_runtime(package_dir: Path) -> bool: + """Whether the wheel's libraries depend on a CUDA runtime it does not bundle. + + Used to skip checks that assume every dependency is either shipped or reachable + 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. + """ + return any( + "cuda" in library.name for library in _shipped_shared_objects(package_dir) + ) + + def _installed_package_dir() -> Path: """The installed executorch package, never the source checkout.""" import executorch @@ -123,25 +212,252 @@ def _defines_symbol(library: Path, symbol: str) -> bool: if symbol not in line: continue match = _DEFINED.match(line) - if ( - match - and match.group("name").startswith(symbol) - and match.group("kind") in _OWNING_KINDS - ): + if not match or match.group("kind") not in _OWNING_KINDS: + continue + # Exact, or followed by the argument list that nm -C prints. A plain prefix + # test would also match a longer name that merely starts the same way. + name = match.group("name") + if name == symbol or name.startswith(symbol + "("): return True return False -def _assert_single_definer(symbols, what: str) -> None: - """Exactly one shipped library may define each of `symbols`.""" +def test_shipped_libraries_load() -> None: + """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 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 + # Torch has to be installed for this to mean anything: several shipped + # libraries depend on it and resolve once it is imported. Without it every one + # of them looks broken, which would report a packaging fault that does not + # exist. + if importlib.util.find_spec("torch") is None: + print("- torch is not installed, skipping the load check") + return + + package_dir = _installed_package_dir() + libraries = _shipped_shared_objects(package_dir) + shipped = {library.name for library in libraries} + + # For a CUDA wheel the undefined-symbol half of this check cannot be sound. When + # the CUDA runtime is reachable only through LD_LIBRARY_PATH, which is how the + # separate nvidia packages provide it, every CUDA entry point is reported + # unresolved. Matching those by library name is not possible either, because ldd + # names the library that references a symbol, not the one that would provide it. + # The missing-library half below still runs. + skip_undefined = _needs_external_cuda_runtime(package_dir) + if skip_undefined: + print( + "- a CUDA wheel gets its CUDA runtime from the environment, so undefined " + "symbol reporting is skipped for it" + ) + + # A dependency is only excusable when the wheel ships it AND the loader can + # actually reach it from the library that needs it. Loaded-later extensions + # such as the Torch libraries are the real exception: they resolve once the + # Python package that owns them is imported. Anything the wheel itself ships + # must resolve here, because a RUNPATH applies to the library carrying it and + # is not inherited on behalf of a dependency's own dependencies. + broken = {} + unreachable = {} + unresolved = {} + for library in libraries: + resolved = subprocess.run( + # -r resolves data and function symbols too, not just the NEEDED + # entries. A SHARED link does not error on undefined symbols, so + # without this an under-linked library passes here and fails at first + # use instead. + ["ldd", "-r", str(library)], + capture_output=True, + text=True, + check=False, + # Any LD_LIBRARY_PATH in the build environment would paper over a + # RUNPATH the shipped library is actually missing. + env={ + key: value + for key, value in os.environ.items() + if key != "LD_LIBRARY_PATH" + }, + ) + # ldd reports missing libraries on stdout but undefined symbols on stderr, + # so both streams matter. + combined = resolved.stdout + resolved.stderr + missing = [ + line.split("=>")[0].strip() + for line in combined.splitlines() + if "not found" 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) + # 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|cudnn)", line + ) + ) + ] + if undefined: + unresolved[str(library.relative_to(package_dir))] = undefined[:5] + + absent = [ + name + for name in missing + if name not in shipped and not _provided_externally(name) + ] + present_but_unreachable = [name for name in missing if name in shipped] + if absent: + broken[str(library.relative_to(package_dir))] = absent + if present_but_unreachable: + unreachable[str(library.relative_to(package_dir))] = present_but_unreachable + + assert not broken, ( + "shipped libraries need dependencies that nothing provides, so they will " + f"fail to load: {broken}" + ) + assert not unreachable, ( + "shipped libraries need dependencies the wheel ships but the loader " + "cannot reach from them, which usually means a missing RUNPATH entry: " + f"{unreachable}" + ) + assert not unresolved, ( + "shipped libraries reference symbols nothing provides, so they will fail " + f"at first use rather than at load: {unresolved}" + ) + print("✓ every shipped library resolves in an environment with torch present") + + +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)], + # 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)], + capture_output=True, + text=True, + check=False, + env=environment, + ).stdout + shipped = {item.name for item in libraries} + all_missing = [ + line.split("=>")[0].strip() + for line in resolved.splitlines() + 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 + + 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`. + + `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() libraries = _shipped_shared_objects(package_dir) assert libraries, f"no shared libraries found under {package_dir}" - for symbol in symbols: - definers = [lib for lib in libraries if _defines_symbol(lib, symbol)] + # Resolve every symbol first, so a component that is only half present is + # reported rather than being mistaken for one that is absent entirely. + found = { + 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] assert len(definers) == 1, ( f"expected exactly one library to define {symbol}, found " @@ -176,6 +492,20 @@ 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. + + Presence is decided from the shipped library file, not from whether the symbol + resolves. Inferring absence from a missing symbol means a rename on a CUDA + wheel would skip the check instead of failing it. + """ + package_dir = _installed_package_dir() + if not list(package_dir.rglob("libexecutorch_cuda_backend.so*")): + print("- no CUDA delegate in this wheel, skipping") + return + _assert_single_definer(_CUDA_SYMBOLS, "CUDA 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" @@ -281,6 +611,10 @@ def test_python_extensions_import() -> None: 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. + + A CUDA wheel is the documented exception. Its libraries need the CUDA runtime, + which the wheel deliberately does not bundle, so the environment has to provide + it and removing the search path would fail for a reason that is by design. """ modules = [ "executorch.extension.pybindings.portable_lib", @@ -292,6 +626,13 @@ def test_python_extensions_import() -> None: if importlib.util.find_spec("torch") is None: print("- torch is not installed, skipping the extension import check") return + package_dir = _installed_package_dir() + if _needs_external_cuda_runtime(package_dir): + 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" } @@ -368,189 +709,6 @@ def test_python_extensions_import() -> None: """ -def test_shipped_libraries_load() -> None: - """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 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 - # Torch has to be installed for this to mean anything: several shipped libraries - # depend on it and resolve once it is imported. Without it every one of them looks - # broken, which would report a packaging fault that does not exist. - if importlib.util.find_spec("torch") is None: - print("- torch is not installed, skipping the load check") - return - - package_dir = _installed_package_dir() - libraries = _shipped_shared_objects(package_dir) - shipped = {library.name for library in libraries} - - # A dependency is only excusable when the wheel ships it AND the loader can - # actually reach it from the library that needs it. Loaded-later extensions - # such as the Torch libraries are the real exception: they resolve once the - # Python package that owns them is imported. Anything the wheel itself ships - # must resolve here, because a RUNPATH applies to the library carrying it and - # is not inherited on behalf of a dependency's own dependencies. - broken = {} - unreachable = {} - unresolved = {} - for library in libraries: - resolved = subprocess.run( - # -r resolves data and function symbols too, not just the NEEDED - # entries. A SHARED link does not error on undefined symbols, so - # without this an under-linked library passes here and fails at first - # use instead. - ["ldd", "-r", str(library)], - capture_output=True, - text=True, - check=False, - # Any LD_LIBRARY_PATH in the build environment would paper over a - # RUNPATH the shipped library is actually missing. - env={ - key: value - for key, value in os.environ.items() - if key != "LD_LIBRARY_PATH" - }, - ) - # ldd reports missing libraries on stdout but undefined symbols on stderr, - # so both streams matter. - combined = resolved.stdout + resolved.stderr - missing = [ - line.split("=>")[0].strip() - for line in combined.splitlines() - if "not found" 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] - present_but_unreachable = [name for name in missing if name in shipped] - if absent: - broken[str(library.relative_to(package_dir))] = absent - if present_but_unreachable: - unreachable[str(library.relative_to(package_dir))] = present_but_unreachable - - assert not broken, ( - "shipped libraries need dependencies that nothing provides, so they will " - f"fail to load: {broken}" - ) - assert not unreachable, ( - "shipped libraries need dependencies the wheel ships but the loader " - "cannot reach from them, which usually means a missing RUNPATH entry: " - f"{unreachable}" - ) - assert not unresolved, ( - "shipped libraries reference symbols nothing provides, so they will fail " - f"at first use rather than at load: {unresolved}" - ) - print("✓ every shipped library resolves in an environment with torch present") - - -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)], - # 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)], - capture_output=True, - text=True, - check=False, - env=environment, - ).stdout - shipped = {item.name for item in libraries} - all_missing = [ - line.split("=>")[0].strip() - for line in resolved.splitlines() - 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 - - 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 test_custom_op_compiles(work_dir: Path) -> None: """A custom operator compiles and links against the shipped extension. @@ -681,9 +839,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 @@ -760,8 +916,15 @@ def test_component_targets_link(work_dir: Path) -> None: match.split(":", 1) for match in re.findall(r"LINKED_COMPONENT=(\S+)", configure.stdout) ) - # A wheel that ships only the runtime has nothing to check here, which is a valid - # configuration rather than a fault. + # Compare against the libraries the wheel actually ships. Checking only the targets + # that exist would pass a component whose target silently failed to be created, + # which is the failure mode a glob-based definition has. + expected = _shipped_components(package_dir) + absent = sorted(expected - set(linked)) + assert not absent, ( + f"the wheel ships libraries for {absent} but the package config defines no " + "target for them, so a consumer cannot link them" + ) if not linked: print("- this wheel offers no component targets, skipping the component check") return @@ -790,19 +953,45 @@ def test_component_targets_link(work_dir: Path) -> None: f"components {dropped} were linked but do not appear in the consumer's " "DT_NEEDED, so their registration would never run" ) - print(f"✓ every offered component links and is retained: {sorted(linked)}") + + # Run it, so the check covers a registration constructor actually firing rather than + # only the library being named in DT_NEEDED. + # + # 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 + ) + assert run.returncode == 0, ( + "the consumer links every component but does not run: " + f"{(run.stderr or run.stdout).strip()[-400:]}" + ) + print(f"✓ every offered component links, is retained, and runs: {sorted(linked)}") def run_tests(work_dir: Path) -> None: - test_single_backend_registry() - test_python_extensions_import() test_shipped_libraries_load() test_shipped_libraries_resolve_without_build_tree() + test_single_backend_registry() + test_python_extensions_import() test_wheel_platform_tag() test_custom_op_compiles(work_dir) test_no_absolute_runtime_paths() test_single_threadpool() test_single_kernel_registration() test_single_xnnpack_delegate() + test_single_cuda_delegate() test_cpp_consumer(work_dir) test_component_targets_link(work_dir) diff --git a/CMakeLists.txt b/CMakeLists.txt index 36220f5fd52..e66b0eb362a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1272,7 +1272,9 @@ if(EXECUTORCH_BUILD_PYBIND) # initializer, so nothing here references a symbol from them and some linkers # drop them from DT_NEEDED. That surfaces at runtime as a missing kernel or an # unregistered backend rather than as a link error. - foreach(_retained_component optimized_native_cpu_ops_lib xnnpack_backend) + foreach(_retained_component optimized_native_cpu_ops_lib xnnpack_backend + aoti_cuda_backend + ) if(TARGET ${_retained_component}) executorch_target_retain_shared_library( portable_lib ${_retained_component} diff --git a/backends/cuda/CMakeLists.txt b/backends/cuda/CMakeLists.txt index 06990692428..8bec5ca548d 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,52 @@ 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 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 "${_cuda_backend_rpath}" + INSTALL_RPATH "${_cuda_backend_rpath}" + ) + 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 diff --git a/docs/source/using-executorch-cpp.md b/docs/source/using-executorch-cpp.md index e2a4c1d102e..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 ``` @@ -108,6 +108,7 @@ each one is defined only when the installed wheel actually ships it: | `executorch::threadpool` | The shared thread pool the kernels and backends use. | | `executorch::kernels` | CPU operator kernels, for any operator not taken by a backend. | | `executorch::xnnpack_backend` | The XNNPACK backend, for optimized CPU execution. | +| `executorch::cuda_backend` | The CUDA backend. Only in a CUDA wheel. | Each target already carries what it needs: the runtime dependency, the include directories, the runtime search paths, and the linker options that keep a diff --git a/setup.py b/setup.py index 8f18b7dd7c0..6bbc2363477 100644 --- a/setup.py +++ b/setup.py @@ -1278,6 +1278,36 @@ 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", + ], + ), + # 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. + 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"], + ), # Install the prebuilt pybindings extension wrapper for the runtime, # portable kernels, and a selection of backends. This lets users # load and execute .pte files from python. diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 38e60cf73ec..c3332724f06 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -214,6 +214,8 @@ executorch_define_component(kernels executorch_optimized_native_cpu_ops_lib) executorch_define_component(xnnpack_backend executorch_xnnpack_backend) +executorch_define_component(cuda_backend executorch_cuda_backend) + # Find prebuilt _portable_lib..so. This is the legacy contract used # to build custom-op extensions against the Python module, and is kept working # independently of the runtime target above.