diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py new file mode 100644 index 00000000000..2f8dc6574f2 --- /dev/null +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -0,0 +1,898 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Checks that the installed wheel is usable as a C++ SDK. + +The wheel ships a prebuilt runtime library plus a CMake package config, so a +standalone application can find_package(executorch) and link +executorch::runtime without building ExecuTorch from source. These checks run +against the installed wheel only; they never look at the source tree's build +directory. + +Two properties are verified: + +1. Exactly one shipped library defines the backend registry. Backends register + into a process-wide table owned by the runtime, so a second definition would + silently give the process two tables and let a backend register into the one + nobody reads. +2. A C++ consumer builds and runs against the wheel, and records a dependency + on the shipped runtime with a relocatable RUNPATH. +""" + +import importlib.util +import os +import re +import shutil +import subprocess +import tempfile +import sys +from pathlib import Path + +# Registry entry points. A second definer of any of these means a second +# process-wide registry. +_REGISTRY_SYMBOLS = ( + "executorch::runtime::register_backend", + "executorch::runtime::get_num_registered_backends", + "executorch::runtime::get_backend_class", +) + +# `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. +_OWNING_KINDS = frozenset("TtBbDdGgSsRrWV") + +_CONSUMER_SOURCE = """\ +#include +#include +#include +#include + +#include +#include + +using namespace executorch::extension; + +int main() { + executorch::runtime::runtime_init(); + std::printf( + "registered backends: %zu\\n", + (size_t)executorch::runtime::get_num_registered_backends()); + + // The documented entry points, not just the lower-level runtime. Constructing these + // needs real definitions at link time, so it checks that the shipped headers and the + // shipped library agree rather than only that the headers parse. + module::Module module("nonexistent.pte"); + std::vector data(4, 1.0f); + auto input = make_tensor_ptr({2, 2}, data.data()); + std::printf("tensor holds %zu values\\n", (size_t)input->numel()); + + // A missing file is the expected outcome here. What matters is that the call resolves + // and returns an error rather than failing to link. + const auto error = module.load(); + std::printf("module load returned 0x%x as expected\\n", (unsigned)error); + return 0; +} +""" + +_CONSUMER_CMAKE = """\ +cmake_minimum_required(VERSION 3.28) +project(executorch_wheel_consumer CXX) +find_package(executorch REQUIRED) +add_executable(consumer consumer.cpp) +target_link_libraries(consumer PRIVATE executorch::runtime) +""" + + +def _installed_package_dir() -> Path: + """The installed executorch package, never the source checkout.""" + import executorch + + return Path(list(executorch.__path__)[0]).resolve() + + +def _shipped_shared_objects(package_dir: Path): + return [ + path + for path in sorted(package_dir.rglob("*.so*")) + if path.is_file() and not path.is_symlink() + ] + + +def _defines_symbol(library: Path, symbol: str) -> bool: + result = subprocess.run( + ["nm", "-DC", str(library)], capture_output=True, text=True, check=False + ) + if result.returncode != 0: + # A file that is not an object file at all is not this check's concern: something whose + # name merely ends in .so must not abort the run. A shipped library the reader cannot + # parse is different, because a real definition could be hiding inside it, and reporting + # "defines nothing" would let a duplicate pass. The ELF magic bytes tell them apart + # without depending on the reader's wording. + with library.open("rb") as handle: + is_object_file = handle.read(4) == b"\x7fELF" + assert not is_object_file, ( + f"nm could not read {library.name}, which is a shipped object file, so the symbol " + f"checks cannot be trusted: {result.stderr.strip()[:200]}" + ) + return False + for line in result.stdout.splitlines(): + 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 + ): + return True + return False + + +def test_single_backend_registry() -> None: + """Exactly one shipped library may define the backend registry.""" + 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 _REGISTRY_SYMBOLS: + definers = [lib for lib in libraries if _defines_symbol(lib, symbol)] + 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 backend registry." + ) + print(f"✓ single backend registry across {len(libraries)} shipped libraries") + + +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. +#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) +""" + + +# Libraries that belong to torch rather than to this wheel. A library here resolves when the +# Python package that owns it is imported, so it is not something this wheel can or should ship. +_TORCH_LIBRARY_PREFIXES = ("libtorch", "libc10", "libshm", "libgomp", "libcudnn", "libcublas") + + +def _is_torch_library(name: str) -> bool: + return name.startswith(_TORCH_LIBRARY_PREFIXES) + + +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] + # Torch's own libraries are the documented exception. They are not in this wheel, and a + # library that needs them resolves once the Python package owning them is imported, which + # is how every accelerator and AOT library in this package is used. Treating them as + # missing fails a wheel that works, and it fires only where torch installs its libraries + # somewhere the plain loader search does not reach. + absent = [ + name + for name in missing + if name not in shipped and not _is_torch_library(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 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 _find_wheel_files() -> list: + """The built wheel files, searched where a build actually leaves them. + + WHEEL_DIR is honoured when set, but it is not set in the wheel-build job, so the + usual output directories are searched too. Without this the check has nothing to + inspect and skips. + """ + candidates = [] + 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 += [ + repository_root / "dist", + Path.cwd() / "dist", + Path.cwd(), + repository_root / "wheelhouse", + ] + for directory in candidates: + try: + found = sorted(directory.glob("executorch-*.whl")) + except OSError: + continue + if found: + return found + return [] + +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 = _find_wheel_files() + 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 test_no_absolute_runtime_paths() -> None: + """No shipped library may carry an absolute runtime search path. + + Packaging copies libraries out of the build tree rather than installing them, + so anything CMake recorded at build time ships as-is. An absolute entry both + names the build machine and points somewhere that will not exist for a user. + + The check reads the shipped file directly, with nothing stripped, which is what + a user actually receives. + """ + if shutil.which("patchelf") is None: + print("- patchelf unavailable, skipping the runtime path check") + return + + package_dir = _installed_package_dir() + offenders = {} + for library in sorted(package_dir.rglob("*.so*")): + if not library.is_file() or library.is_symlink(): + continue + result = subprocess.run( + ["patchelf", "--print-rpath", str(library)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + continue + absolute = [ + entry + for entry in result.stdout.strip().split(":") + if entry.startswith("/") + ] + if absolute: + offenders[str(library.relative_to(package_dir))] = absolute + + assert not offenders, ( + "shipped libraries carry absolute runtime search paths, so they are not " + f"relocatable and name the build machine: {offenders}" + ) + print("✓ no shipped library carries an absolute runtime search path") + + +_COMPONENT_CONSUMER_CMAKE = """\ +cmake_minimum_required(VERSION 3.28) +project(component_consumer CXX) + +find_package(executorch REQUIRED) + +add_executable(component_consumer consumer.cpp) +target_link_libraries(component_consumer PRIVATE executorch::runtime) + +# Link every component this wheel offers, and report which ones those are so the test +# can check the result. Guarded individually because the set depends on the wheel. +foreach(_component threadpool kernels xnnpack_backend cuda_backend) + if(TARGET executorch::${_component}) + target_link_libraries(component_consumer PRIVATE executorch::${_component}) + # Report the library file, not just the target name: the two differ, and the test + # needs the file name to look for in the built binary. + get_target_property(_location executorch::${_component} IMPORTED_LOCATION) + get_filename_component(_file "${_location}" NAME) + message(STATUS "LINKED_COMPONENT=${_component}:${_file}") + endif() +endforeach() +""" + + +def test_component_targets_link(work_dir: Path) -> None: + """Every component target the wheel offers must link and be retained. + + A component library exists to register something, so nothing in the application + references a symbol from it. That is exactly the case a normal link drops, which is + why the targets carry retention options. This checks the options do their job + instead of trusting them. + """ + assert shutil.which("cmake") is not None, "cmake is required to build a consumer" + if shutil.which("readelf") is None: + print("- readelf unavailable, skipping the component link check") + return + + package_dir = _installed_package_dir() + source_dir = work_dir / "components" + build_dir = work_dir / "components-build" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "consumer.cpp").write_text(_CONSUMER_SOURCE) + (source_dir / "CMakeLists.txt").write_text(_COMPONENT_CONSUMER_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 consumer linking the component targets cannot configure: " + f"{(configure.stderr or configure.stdout).strip()[-500:]}" + ) + linked = dict( + 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. + if not linked: + print("- this wheel offers no component targets, skipping the component check") + return + + built = subprocess.run( + ["cmake", "--build", str(build_dir)], + capture_output=True, + text=True, + check=False, + ) + assert built.returncode == 0, ( + "a consumer linking the component targets does not build: " + f"{(built.stderr or built.stdout).strip()[-700:]}" + ) + + consumer = build_dir / "component_consumer" + needed = subprocess.run( + ["readelf", "-d", str(consumer)], capture_output=True, text=True, check=True + ).stdout + # Each component has to appear in DT_NEEDED. Absent means the retention options did + # not hold and whatever the library registers would never happen at runtime. + dropped = [ + component for component, library in linked.items() if library not in needed + ] + assert not dropped, ( + 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)}") + + +def test_documented_example_compiles(work_dir: Path) -> None: + """The C++ example in the documentation must compile against the installed wheel. + + Extracted from the documentation rather than copied here, so the two cannot drift. A + reader who follows the documentation gets code that builds, and a dangling include or a + renamed entry point fails this check instead of shipping. + """ + # Guarded the same way the wheel lookup is: a copy of this file can live outside the + # repository layout, where indexing past the available parents raises. + here = Path(__file__).resolve() + root = here.parents[3] if len(here.parents) > 3 else here.parent + documentation = root / "docs" / "source" / "using-executorch-cpp.md" + if not documentation.is_file(): + print("- the documentation file is not present, skipping the example check") + return + + # Normalise line endings so a checkout with CRLF still matches, and refuse an + # ambiguous document: a second block labelled the same way would silently change + # which example this compiles. + contents = documentation.read_text().replace("\r\n", "\n") + blocks = re.findall(r"```cpp\n// main\.cpp\n(.*?)```", contents, re.S) + assert len(blocks) <= 1, ( + f"{documentation.name} has {len(blocks)} blocks labelled main.cpp, so which\n" + f"one this check compiles is ambiguous" + ) + assert blocks, ( + f"could not find the C++ example in {documentation.name}; the check needs it to " + "verify what the documentation tells a reader to write" + ) + + source_dir = work_dir / "documented" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "main.cpp").write_text("// main.cpp\n" + blocks[0]) + (source_dir / "CMakeLists.txt").write_text( + "cmake_minimum_required(VERSION 3.28)\n" + "project(documented_example CXX)\n" + "find_package(executorch REQUIRED)\n" + "add_executable(documented_example main.cpp)\n" + "target_link_libraries(documented_example PRIVATE executorch::runtime)\n" + ) + + build_dir = work_dir / "documented-build" + configure = subprocess.run( + ["cmake", "-S", str(source_dir), "-B", str(build_dir), + f"-DCMAKE_PREFIX_PATH={_installed_package_dir()}"], + capture_output=True, text=True, check=False, + ) + assert configure.returncode == 0, ( + "the documented example does not configure against the installed wheel: " + f"{(configure.stderr or configure.stdout).strip()[-500:]}" + ) + build = subprocess.run( + ["cmake", "--build", str(build_dir)], + capture_output=True, text=True, check=False, + ) + assert build.returncode == 0, ( + "the documented example does not compile against the installed wheel: " + f"{(build.stderr or build.stdout).strip()[-500:]}" + ) + print("\u2713 the C++ example in the documentation compiles and links") + + +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_wheel_platform_tag() + test_custom_op_compiles(work_dir) + test_no_absolute_runtime_paths() + test_cpp_consumer(work_dir) + test_documented_example_compiles(work_dir) + test_component_targets_link(work_dir) diff --git a/.ci/scripts/wheel/test_linux.py b/.ci/scripts/wheel/test_linux.py index 812eec89215..532eb850d94 100644 --- a/.ci/scripts/wheel/test_linux.py +++ b/.ci/scripts/wheel/test_linux.py @@ -7,8 +7,11 @@ # LICENSE file in the root directory of this source tree. import platform +import tempfile +from pathlib import Path import test_base +import test_cpp_sdk from examples.models import Backend, Model if __name__ == "__main__": @@ -41,6 +44,12 @@ test_base.test_cmsis_nn_install() + # The wheel ships a prebuilt C++ runtime and a CMake package config, so + # check that a standalone application can actually link and run against + # them, and that the process still has a single backend registry. + with tempfile.TemporaryDirectory() as work_dir: + test_cpp_sdk.run_tests(Path(work_dir)) + test_base.run_tests( model_tests=[ test_base.ModelTest( diff --git a/.ci/scripts/wheel/test_linux_aarch64.py b/.ci/scripts/wheel/test_linux_aarch64.py index c0cca95b3fb..db7dd28f81e 100644 --- a/.ci/scripts/wheel/test_linux_aarch64.py +++ b/.ci/scripts/wheel/test_linux_aarch64.py @@ -5,7 +5,11 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +import tempfile +from pathlib import Path + import test_base +import test_cpp_sdk from examples.models import Backend, Model if __name__ == "__main__": @@ -26,6 +30,12 @@ ), f"OpenvinoBackend not found in registered backends: {registered}" print("✓ OpenvinoBackend is registered") + # The wheel ships a prebuilt C++ runtime and a CMake package config, so check + # that a standalone application can actually link and run against them, and + # that the process still has a single backend registry. + with tempfile.TemporaryDirectory() as work_dir: + test_cpp_sdk.run_tests(Path(work_dir)) + test_base.run_tests( model_tests=[ test_base.ModelTest( diff --git a/.github/workflows/build-wheels-aarch64-linux.yml b/.github/workflows/build-wheels-aarch64-linux.yml index b0b9a9c0fee..8adf4268228 100644 --- a/.github/workflows/build-wheels-aarch64-linux.yml +++ b/.github/workflows/build-wheels-aarch64-linux.yml @@ -6,9 +6,11 @@ on: paths: - .ci/**/* - .github/workflows/build-wheels-aarch64-linux.yml + - '**/CMakeLists.txt' - examples/**/* - pyproject.toml - setup.py + - tools/cmake/**/* push: branches: - nightly diff --git a/.github/workflows/build-wheels-linux.yml b/.github/workflows/build-wheels-linux.yml index 1a89079e428..7428b68a773 100644 --- a/.github/workflows/build-wheels-linux.yml +++ b/.github/workflows/build-wheels-linux.yml @@ -6,9 +6,11 @@ on: paths: - .ci/**/* - .github/workflows/build-wheels-linux.yml + - '**/CMakeLists.txt' - examples/**/* - pyproject.toml - setup.py + - tools/cmake/**/* push: branches: - nightly diff --git a/.github/workflows/build-wheels-macos.yml b/.github/workflows/build-wheels-macos.yml index 3fddb8e6d26..6ace109edf7 100644 --- a/.github/workflows/build-wheels-macos.yml +++ b/.github/workflows/build-wheels-macos.yml @@ -6,9 +6,11 @@ on: paths: - .ci/**/* - .github/workflows/build-wheels-macos.yml + - '**/CMakeLists.txt' - examples/**/* - pyproject.toml - setup.py + - tools/cmake/**/* push: branches: - nightly diff --git a/.github/workflows/build-wheels-windows.yml b/.github/workflows/build-wheels-windows.yml index 9b1f8663bd2..60c6520b944 100644 --- a/.github/workflows/build-wheels-windows.yml +++ b/.github/workflows/build-wheels-windows.yml @@ -5,9 +5,11 @@ on: paths: - .ci/**/* - .github/workflows/build-wheels-windows.yml + - '**/CMakeLists.txt' - examples/**/* - pyproject.toml - setup.py + - tools/cmake/**/* push: branches: - nightly diff --git a/CMakeLists.txt b/CMakeLists.txt index ff3b9e86f7e..0a3b469320a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -778,11 +778,6 @@ if(EXECUTORCH_BUILD_OPENVINO) list(APPEND _executorch_backends openvino_backend) endif() -if(EXECUTORCH_BUILD_QNN) - add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/qualcomm) - list(APPEND _executorch_backends qnn_executorch_backend) -endif() - if(EXECUTORCH_BUILD_ENN) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/samsung) list(APPEND _executorch_backends enn_backend) @@ -932,6 +927,61 @@ if(EXECUTORCH_BUILD_PTHREADPOOL AND EXECUTORCH_BUILD_CPUINFO) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/extension/threadpool) endif() +# Consolidated shared library: bundles executorch_core plus commonly used +# extensions into a single libexecutorch.so. Defined before the pybind and +# kernel targets below so they can link this one runtime instead of embedding a +# private copy of the core, which would give the process a second backend +# registry. +if(EXECUTORCH_BUILD_SHARED) + executorch_add_shared_library(executorch_shared) + set_target_properties( + executorch_shared + PROPERTIES OUTPUT_NAME executorch + ARCHIVE_OUTPUT_NAME executorch_shared + EXPORT_NAME executorch-shared + ) + target_include_directories( + executorch_shared PUBLIC ${_common_include_directories} + ) + target_compile_definitions( + executorch_shared PUBLIC C10_USING_CUSTOM_GENERATED_MACROS + ) + # Link executorch without WHOLE_ARCHIVE because its INTERFACE link options + # (from executorch_target_link_options_shared_lib) already force + # whole-archive. Everything else is pulled in through link options rather than + # the WHOLE_ARCHIVE link feature, because these archives also reference each + # other plainly and CMake before 3.29 refuses to mix a feature with a plain + # reference to the same item. + target_link_libraries(executorch_shared PRIVATE executorch) + set(_executorch_shared_whole_archive executorch_core) + foreach(_ext_target + extension_data_loader extension_flat_tensor extension_named_data_map + extension_module_static extension_tensor + ) + if(TARGET ${_ext_target}) + list(APPEND _executorch_shared_whole_archive ${_ext_target}) + endif() + endforeach() + foreach(_whole_target ${_executorch_shared_whole_archive}) + executorch_target_whole_archive(executorch_shared ${_whole_target}) + endforeach() + configure_file( + tools/cmake/executorch.pc.in ${CMAKE_CURRENT_BINARY_DIR}/executorch.pc + @ONLY + ) + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/executorch.pc + DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig + ) +endif() + +# Added after the shared runtime for readability, so the dependency reads in the +# order it exists. CMake resolves target names at generate time, so a reference +# from an earlier subdirectory would work too, as several other backends here do. +if(EXECUTORCH_BUILD_QNN) + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/qualcomm) + list(APPEND _executorch_backends qnn_executorch_backend) +endif() + if(EXECUTORCH_BUILD_KERNELS_TORCHAO) if(NOT TARGET cpuinfo) message( @@ -1016,10 +1066,19 @@ if(EXECUTORCH_BUILD_PYBIND) # Ensure bundled_module waits for bundled_program's generated headers add_dependencies(bundled_module bundled_program) - target_link_libraries(bundled_module PRIVATE extension_data_loader) - target_link_libraries( - bundled_module PUBLIC extension_module_static bundled_program - ) + # extension_module_static and the data loader are bundled into + # libexecutorch.so, so link that instead of pulling private static copies in + # through this target's PUBLIC interface. + if(EXECUTORCH_BUILD_SHARED) + target_link_libraries( + bundled_module PUBLIC executorch_shared bundled_program + ) + else() + target_link_libraries(bundled_module PRIVATE extension_data_loader) + target_link_libraries( + bundled_module PUBLIC extension_module_static bundled_program + ) + endif() target_include_directories( bundled_module PUBLIC ${_common_include_directories} @@ -1038,16 +1097,36 @@ if(EXECUTORCH_BUILD_PYBIND) TORCH_PYTHON_LIBRARY torch_python PATHS "${TORCH_INSTALL_PREFIX}/lib" ) - set(_dep_libs - ${TORCH_PYTHON_LIBRARY} - bundled_program - etdump - flatccrt - executorch - extension_data_loader - util - torch - ) + # When the consolidated shared runtime is built, the pybind extension links it + # instead of whole-archiving the static core, so Python and C++ consumers + # share one backend registry. `executorch` and the extensions bundled into + # libexecutorch.so must stay off this list: their INTERFACE link options force + # whole-archive, which would give this module a private second registry. + # executorch_shared is named here for its include directories and compile + # definitions; executorch_target_link_shared_runtime below is what fixes its + # position on the link line. + if(EXECUTORCH_BUILD_SHARED) + set(_dep_libs + ${TORCH_PYTHON_LIBRARY} + bundled_program + etdump + flatccrt + executorch_shared + util + torch + ) + else() + set(_dep_libs + ${TORCH_PYTHON_LIBRARY} + bundled_program + etdump + flatccrt + executorch + extension_data_loader + util + torch + ) + endif() # Build common AOTI functionality if needed by CUDA or Metal backends if(EXECUTORCH_BUILD_CUDA) @@ -1058,13 +1137,24 @@ if(EXECUTORCH_BUILD_PYBIND) list(APPEND _dep_libs aoti_common) endif() - # RPATH for _portable_lib.so + # RPATH for _portable_lib.so. It sits in + # /executorch/extension/pybindings, so torch is three levels up + # and the wheel's own lib/ directory is two. The second entry is + # wheel-specific: a normal install puts the runtime in CMAKE_INSTALL_LIBDIR + # instead, where this relative path would not reach it. set(_portable_lib_rpath "$ORIGIN/../../../torch/lib") + if(EXECUTORCH_BUILD_SHARED AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + string(APPEND _portable_lib_rpath ":$ORIGIN/../../lib") + endif() if(EXECUTORCH_BUILD_EXTENSION_MODULE) - # Always use static linking for pybindings to avoid runtime symbol - # resolution issues - list(APPEND _dep_libs extension_module_static) + # extension_module_static is already bundled into libexecutorch.so; linking + # it again here would whole-archive a second copy. + if(NOT EXECUTORCH_BUILD_SHARED) + # Always use static linking for pybindings to avoid runtime symbol + # resolution issues + list(APPEND _dep_libs extension_module_static) + endif() # Add bundled_module if available if(TARGET bundled_module) list(APPEND _dep_libs bundled_module) @@ -1150,7 +1240,11 @@ if(EXECUTORCH_BUILD_PYBIND) target_compile_definitions(util PUBLIC C10_USING_CUSTOM_GENERATED_MACROS) target_compile_options(util PUBLIC ${_pybind_compile_options}) - target_link_libraries(util PRIVATE torch c10 executorch extension_tensor) + if(EXECUTORCH_BUILD_SHARED) + target_link_libraries(util PRIVATE torch c10 executorch_shared) + else() + target_link_libraries(util PRIVATE torch c10 executorch extension_tensor) + endif() # pybind portable_lib pybind11_add_module(portable_lib SHARED extension/pybindings/pybindings.cpp) @@ -1167,6 +1261,7 @@ if(EXECUTORCH_BUILD_PYBIND) target_include_directories(portable_lib PRIVATE ${TORCH_INCLUDE_DIRS}) target_compile_options(portable_lib PUBLIC ${_pybind_compile_options}) target_link_libraries(portable_lib PRIVATE ${_dep_libs}) + executorch_target_link_shared_runtime(portable_lib) # Set RPATH to find PyTorch and backend libraries relative to the installation # location. This goes from executorch/extension/pybindings up to @@ -1199,7 +1294,24 @@ if(EXECUTORCH_BUILD_PYBIND) strip_python_lib(data_loader) target_include_directories(data_loader PRIVATE ${_common_include_directories}) target_compile_options(data_loader PUBLIC ${_pybind_compile_options}) - target_link_libraries(data_loader PRIVATE executorch) + # This module only exposes a pybind type and calls into no runtime symbols. + # The static target force-links every registration object, which would give + # this module its own operator registry alongside the one in the shared + # runtime, so resolve against the shared runtime instead when there is one. + if(TARGET executorch_shared) + target_link_libraries(data_loader PRIVATE executorch_shared) + if(NOT APPLE AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: this installs beside the other pybind extensions, two + # levels below the wheel's lib/ directory, so it needs the same relative + # path they use to reach the shared runtime. + set_target_properties( + data_loader PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" + INSTALL_RPATH "$ORIGIN/../../lib" + ) + endif() + else() + target_link_libraries(data_loader PRIVATE executorch) + endif() install(TARGETS data_loader LIBRARY DESTINATION executorch/extension/pybindings ) @@ -1246,49 +1358,6 @@ if(EXECUTORCH_BUILD_KERNELS_LLM) list(APPEND _executorch_kernels custom_ops_aot_lib) endif() -# Consolidated shared library: bundles executorch_core plus commonly used -# extensions into a single libexecutorch.so. -if(EXECUTORCH_BUILD_SHARED) - executorch_add_shared_library(executorch_shared) - set_target_properties( - executorch_shared - PROPERTIES OUTPUT_NAME executorch - ARCHIVE_OUTPUT_NAME executorch_shared - EXPORT_NAME executorch-shared - ) - target_include_directories( - executorch_shared PUBLIC ${_common_include_directories} - ) - target_compile_definitions( - executorch_shared PUBLIC C10_USING_CUSTOM_GENERATED_MACROS - ) - # Link executorch without WHOLE_ARCHIVE because its INTERFACE link options - # (from executorch_target_link_options_shared_lib) already force - # whole-archive. Link executorch_core explicitly since executorch only has a - # PRIVATE dep on it (symbols wouldn't propagate otherwise). - target_link_libraries( - executorch_shared PRIVATE executorch - $ - ) - foreach(_ext_target - extension_data_loader extension_flat_tensor extension_named_data_map - extension_module_static extension_tensor - ) - if(TARGET ${_ext_target}) - target_link_libraries( - executorch_shared PRIVATE $ - ) - endif() - endforeach() - configure_file( - tools/cmake/executorch.pc.in ${CMAKE_CURRENT_BINARY_DIR}/executorch.pc - @ONLY - ) - install(FILES ${CMAKE_CURRENT_BINARY_DIR}/executorch.pc - DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig - ) -endif() - if(EXECUTORCH_BUILD_KERNELS_QUANTIZED) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/kernels/quantized) executorch_target_link_options_shared_lib(quantized_ops_lib) diff --git a/backends/qualcomm/CMakeLists.txt b/backends/qualcomm/CMakeLists.txt index 3f4aefa2b76..93d82f933ad 100644 --- a/backends/qualcomm/CMakeLists.txt +++ b/backends/qualcomm/CMakeLists.txt @@ -255,8 +255,25 @@ target_link_libraries( ) target_link_libraries( qnn_executorch_backend PRIVATE qnn_executorch_header qnn_schema qnn_manager - executorch_core qnn_backend_options + qnn_backend_options ) +# Resolve the runtime from the shared library when one is built, so this +# delegate does not carry its own copy of the backend registry. +if(EXECUTORCH_BUILD_SHARED) + target_link_libraries(qnn_executorch_backend PRIVATE executorch_shared) + executorch_target_link_shared_runtime(qnn_executorch_backend) + if(NOT APPLE AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: ships in executorch/backends/qualcomm, two levels below + # the wheel's lib/. A normal install puts the runtime in + # CMAKE_INSTALL_LIBDIR, which this relative path would not reach. + set_target_properties( + qnn_executorch_backend PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" + INSTALL_RPATH "$ORIGIN/../../lib" + ) + endif() +else() + target_link_libraries(qnn_executorch_backend PRIVATE executorch_core) +endif() if(${CMAKE_SYSTEM_PROCESSOR} MATCHES Hexagon) # Add macro here so we can dlopen the correct .so library. @@ -359,12 +376,31 @@ if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "x86_64|AMD64") qnn_schema qnn_manager qnn_executorch_header - executorch - extension_tensor qnn_backend_options wrappers qnn_executorch_logging ) + # extension_tensor is bundled into the shared runtime, so naming it again here + # would give this module a second copy of what that library already provides. + if(NOT EXECUTORCH_BUILD_SHARED) + target_link_libraries(PyQnnManagerAdaptor PRIVATE extension_tensor) + endif() + # Same reasoning as the delegate above: take the runtime from the shared + # library when there is one, rather than embedding a second registry. + if(EXECUTORCH_BUILD_SHARED) + target_link_libraries(PyQnnManagerAdaptor PRIVATE executorch_shared) + executorch_target_link_shared_runtime(PyQnnManagerAdaptor) + if(NOT APPLE AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: ships in executorch/backends/qualcomm/python, three + # levels below the wheel's lib/. + set_target_properties( + PyQnnManagerAdaptor PROPERTIES BUILD_RPATH "$ORIGIN/../../../lib" + INSTALL_RPATH "$ORIGIN/../../../lib" + ) + endif() + else() + target_link_libraries(PyQnnManagerAdaptor PRIVATE executorch) + endif() pybind11_extension(PyQnnManagerAdaptor) if(NOT MSVC AND NOT ${CMAKE_BUILD_TYPE} MATCHES RelWithDebInfo) diff --git a/codegen/tools/CMakeLists.txt b/codegen/tools/CMakeLists.txt index b829e83c340..c5097eed537 100644 --- a/codegen/tools/CMakeLists.txt +++ b/codegen/tools/CMakeLists.txt @@ -43,7 +43,25 @@ if(TARGET bundled_program) target_compile_definitions(selective_build PRIVATE -DET_BUNDLE_IO) target_link_libraries(selective_build PRIVATE bundled_program) endif() -target_link_libraries(selective_build PRIVATE executorch_core program_schema) +if(EXECUTORCH_BUILD_SHARED) + # This module calls into the runtime, so resolve those symbols from + # libexecutorch.so rather than baking in a second copy of the core. It lands + # in /executorch/codegen/tools, two levels below the wheel's + # lib/. + target_link_libraries( + selective_build PRIVATE executorch_shared program_schema + ) + if(NOT APPLE AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: the runtime ships two levels up under lib/. A normal + # install puts it in CMAKE_INSTALL_LIBDIR, which this would not reach. + set_target_properties( + selective_build PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" + INSTALL_RPATH "$ORIGIN/../../lib" + ) + endif() +else() + target_link_libraries(selective_build PRIVATE executorch_core program_schema) +endif() # Install the module install(TARGETS selective_build LIBRARY DESTINATION executorch/codegen/tools) diff --git a/devtools/bundled_program/CMakeLists.txt b/devtools/bundled_program/CMakeLists.txt index 0c213d9a83c..375f7487f06 100644 --- a/devtools/bundled_program/CMakeLists.txt +++ b/devtools/bundled_program/CMakeLists.txt @@ -40,7 +40,14 @@ add_library( bundled_program ${_schema_outputs} ${CMAKE_CURRENT_SOURCE_DIR}/bundled_program.cpp ) -target_link_libraries(bundled_program PUBLIC executorch) +# The `executorch` target forces whole-archive of itself, which would duplicate +# the primitive operator registrations already inside libexecutorch.so and abort +# at load. Resolve them from the shared runtime instead when it is built. +if(EXECUTORCH_BUILD_SHARED) + target_link_libraries(bundled_program PUBLIC executorch_shared) +else() + target_link_libraries(bundled_program PUBLIC executorch) +endif() target_include_directories( bundled_program PUBLIC diff --git a/devtools/etdump/CMakeLists.txt b/devtools/etdump/CMakeLists.txt index 9ef3c8cd6f7..fe754d8129a 100644 --- a/devtools/etdump/CMakeLists.txt +++ b/devtools/etdump/CMakeLists.txt @@ -49,11 +49,15 @@ add_library( ${CMAKE_CURRENT_SOURCE_DIR}/data_sinks/file_data_sink.cpp ${CMAKE_CURRENT_SOURCE_DIR}/data_sinks/file_data_sink.h ) -target_link_libraries( - etdump - PUBLIC flatccrt - PRIVATE executorch -) +target_link_libraries(etdump PUBLIC flatccrt) +# As with bundled_program, avoid the whole-archive of the `executorch` target so +# the primitive operator registrations are not duplicated alongside the copy +# already inside libexecutorch.so. +if(EXECUTORCH_BUILD_SHARED) + target_link_libraries(etdump PRIVATE executorch_shared) +else() + target_link_libraries(etdump PRIVATE executorch) +endif() target_include_directories( etdump PUBLIC ${DEVTOOLS_INCLUDE_DIR} diff --git a/docs/source/using-executorch-cpp.md b/docs/source/using-executorch-cpp.md index 5505ade9573..34f76c63e64 100644 --- a/docs/source/using-executorch-cpp.md +++ b/docs/source/using-executorch-cpp.md @@ -40,6 +40,85 @@ Running a model using the low-level runtime APIs allows for a high-degree of con ## Building with CMake +There are two ways to get the C++ runtime. Linking the prebuilt libraries from the +pip package needs no source checkout and is the quicker option. Building from source +gives you every option the project has, and is what you need for a platform the wheel +does not cover. + +### Using the prebuilt libraries from the pip package + +On Linux, `pip install executorch` includes prebuilt shared libraries, the public +headers, and a CMake package, so a C++ application can link the runtime without +building ExecuTorch itself: + +``` +# CMakeLists.txt +cmake_minimum_required(VERSION 3.28) +project(my_app CXX) + +find_package(executorch REQUIRED) + +add_executable(my_app main.cpp) +target_link_libraries(my_app PRIVATE executorch::runtime) +``` + +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")')" +cmake --build build +``` + +The application uses the same `Module` and `TensorPtr` APIs described above, and +nothing else changes because the runtime came from a package rather than a source +build: + +```cpp +// main.cpp +#include +#include + +#include +#include + +using namespace executorch::extension; + +int main() { + Module module("model.pte"); + + std::vector data(2 * 8, 1.0f); + auto input = make_tensor_ptr({2, 8}, data.data()); + + const auto result = module.forward(input); + if (!result.ok()) { + return 1; + } + const auto output = result->at(0).toTensor(); + std::printf("produced %zu values\n", (size_t)output.numel()); + return 0; +} +``` + +`executorch::runtime` gives you the core runtime, which can load and run a `.pte` +file. Anything a model needs beyond that comes from a separate component target, and +each one is defined only when the installed wheel actually ships it: + +| Target | What it provides | +| --- | --- | +| `executorch::runtime` | The core runtime. Always present. | + +Each target already carries what it needs: the runtime dependency, the include +directories, the runtime search paths, and the linker options that keep a +registration-only library from being dropped. You do not need to name library files +or add link flags yourself. + +CMake 3.28 or newer is required. Older versions write the `$ORIGIN` token in the +runtime search path incorrectly, which makes an application fail to find the +libraries once it is copied somewhere else. + +### Building from source + ExecuTorch uses CMake as the primary build system. Inclusion of the module and tensor APIs are controlled by the `EXECUTORCH_BUILD_EXTENSION_MODULE` and `EXECUTORCH_BUILD_EXTENSION_TENSOR` CMake options. As these APIs may not be supported on embedded systems, they are disabled by default when building from source. The low-level API surface is always included. To link, add the `executorch` target as a CMake dependency, along with `executorch_backends`, `executorch_extensions`, and `extension_kernels`, to link all configured backends, extensions, and kernels. ``` diff --git a/extension/llm/custom_ops/CMakeLists.txt b/extension/llm/custom_ops/CMakeLists.txt index 8a43a5ddf5c..444d39ab4e0 100644 --- a/extension/llm/custom_ops/CMakeLists.txt +++ b/extension/llm/custom_ops/CMakeLists.txt @@ -144,19 +144,33 @@ if(EXECUTORCH_BUILD_KERNELS_LLM_AOT) set(RPATH "@loader_path/../../pybindings") else() set(RPATH "$ORIGIN/../../pybindings") + if(EXECUTORCH_BUILD_SHARED AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: this library lands in + # /executorch/extension/llm/custom_ops, three levels below + # the wheel's lib/. A normal install puts the runtime in + # CMAKE_INSTALL_LIBDIR, which this relative path would not reach. + string(APPEND RPATH ":$ORIGIN/../../../lib") + endif() endif() - set_target_properties(custom_ops_aot_lib PROPERTIES INSTALL_RPATH ${RPATH}) + # The wheel copies this library straight out of the build tree, so the runtime + # search path has to be set on the built artifact and not only on install. + set_target_properties( + custom_ops_aot_lib PROPERTIES BUILD_RPATH ${RPATH} INSTALL_RPATH ${RPATH} + ) if(TARGET portable_lib) # If we have portable_lib built, custom_ops_aot_lib gives the ability to use # the ops in PyTorch and ExecuTorch through pybind target_link_libraries(custom_ops_aot_lib PUBLIC portable_lib) - else() + elseif(NOT EXECUTORCH_BUILD_SHARED) # If no portable_lib, custom_ops_aot_lib still gives the ability to use the # ops in PyTorch target_link_libraries( custom_ops_aot_lib PUBLIC executorch_core kernels_util_all_deps ) + else() + target_link_libraries(custom_ops_aot_lib PUBLIC kernels_util_all_deps) endif() + executorch_target_link_shared_runtime(custom_ops_aot_lib) target_link_libraries( custom_ops_aot_lib PUBLIC cpublas torch extension_tensor diff --git a/extension/llm/runner/CMakeLists.txt b/extension/llm/runner/CMakeLists.txt index 5247a4ba0a6..d8bdcbce441 100644 --- a/extension/llm/runner/CMakeLists.txt +++ b/extension/llm/runner/CMakeLists.txt @@ -123,6 +123,7 @@ if(EXECUTORCH_BUILD_PYBIND) _llm_runner PRIVATE extension_llm_runner tokenizers::tokenizers portable_lib ${TORCH_PYTHON_LIBRARY} ${TORCH_LIBRARIES} ) + executorch_target_link_shared_runtime(_llm_runner) set_target_properties( _llm_runner @@ -137,6 +138,13 @@ if(EXECUTORCH_BUILD_PYBIND) ) else() set(RPATH "$ORIGIN/../../pybindings:$ORIGIN/../../../../torch/lib") + if(EXECUTORCH_BUILD_SHARED AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: this module lands in + # /executorch/extension/llm/runner, three levels below the + # wheel's lib/. A normal install puts the runtime in CMAKE_INSTALL_LIBDIR, + # which this relative path would not reach. + string(APPEND RPATH ":$ORIGIN/../../../lib") + endif() endif() set_target_properties( _llm_runner PROPERTIES BUILD_RPATH "${RPATH}" INSTALL_RPATH "${RPATH}" diff --git a/extension/training/CMakeLists.txt b/extension/training/CMakeLists.txt index e835ae0e0a3..20d4b1ae53f 100644 --- a/extension/training/CMakeLists.txt +++ b/extension/training/CMakeLists.txt @@ -49,10 +49,18 @@ target_link_libraries( target_compile_options(train_xor PUBLIC ${_common_compile_options}) if(EXECUTORCH_BUILD_PYBIND) - # Pybind library. - set(_pybind_training_dep_libs ${TORCH_PYTHON_LIBRARY} etdump executorch util - torch extension_training - ) + # Pybind library. When the consolidated shared runtime is built, the runtime + # is resolved from it rather than from the whole-archive-forcing static + # `executorch` target, so this module shares the one backend registry. + if(EXECUTORCH_BUILD_SHARED) + set(_pybind_training_dep_libs ${TORCH_PYTHON_LIBRARY} etdump util torch + extension_training + ) + else() + set(_pybind_training_dep_libs ${TORCH_PYTHON_LIBRARY} etdump executorch + util torch extension_training + ) + endif() if(EXECUTORCH_BUILD_XNNPACK) # need to explicitly specify XNNPACK and xnnpack-microkernels-prod here @@ -81,6 +89,27 @@ if(EXECUTORCH_BUILD_PYBIND) -fexceptions> ) target_link_libraries(_training_lib PRIVATE ${_pybind_training_dep_libs}) + executorch_target_link_shared_runtime(_training_lib) + + if(EXECUTORCH_BUILD_SHARED + AND NOT APPLE + AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE + ) + # Wheel-specific: this module lands in + # /executorch/extension/training/pybindings, three levels + # below the wheel's lib/. A normal install puts the runtime in + # CMAKE_INSTALL_LIBDIR, which this relative path would not reach. The Torch + # path is listed too: this module links Torch directly, and the only other + # entry reaching it is the absolute build directory CMake adds, which does + # not exist anywhere else. + set(_training_lib_rpath + "$ORIGIN/../../../lib:$ORIGIN/../../../../torch/lib" + ) + set_target_properties( + _training_lib PROPERTIES BUILD_RPATH "${_training_lib_rpath}" + INSTALL_RPATH "${_training_lib_rpath}" + ) + endif() install(TARGETS _training_lib LIBRARY DESTINATION executorch/extension/training/pybindings diff --git a/kernels/quantized/CMakeLists.txt b/kernels/quantized/CMakeLists.txt index 2dac38205b4..9269a8c00f4 100644 --- a/kernels/quantized/CMakeLists.txt +++ b/kernels/quantized/CMakeLists.txt @@ -86,6 +86,24 @@ if(NOT CMAKE_GENERATOR STREQUAL "Xcode" gen_custom_ops_aot_lib( LIB_NAME "quantized_ops_aot_lib" KERNEL_SOURCES "${_quantized_sources}" ) + # Only when the pybind extension is absent. When it is present, the block + # below sets the full runtime path for this target in one place, and setting + # it here as well would leave two independent copies of the same logic where + # the later one wins. + if(EXECUTORCH_BUILD_SHARED + AND NOT APPLE + AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE + AND NOT TARGET portable_lib + ) + # Wheel-specific: the generated library lands in + # /executorch/kernels/quantized, two levels below the + # wheel's lib/. A normal install puts the runtime in CMAKE_INSTALL_LIBDIR, + # which this relative path would not reach. + set_target_properties( + quantized_ops_aot_lib PROPERTIES BUILD_RPATH "$ORIGIN/../../lib" + INSTALL_RPATH "$ORIGIN/../../lib" + ) + endif() # Register quantized ops to portable_lib, so that they're available via # pybindings. @@ -128,9 +146,13 @@ if(NOT CMAKE_GENERATOR STREQUAL "Xcode" # installed location of our _portable_lib.so file. To see these LC_* # values, run `otool -l libquantized_ops_lib.dylib`. if(APPLE) - set(RPATH "@loader_path/../../extensions/pybindings") + set(RPATH "@loader_path/../../extension/pybindings") else() - set(RPATH "$ORIGIN/../../extensions/pybindings") + set(RPATH "$ORIGIN/../../extension/pybindings") + if(EXECUTORCH_BUILD_SHARED AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + # Wheel-specific: two levels below the wheel's lib/ directory. + string(APPEND RPATH ":$ORIGIN/../../lib") + endif() endif() set_target_properties( quantized_ops_aot_lib PROPERTIES BUILD_RPATH ${RPATH} INSTALL_RPATH diff --git a/setup.py b/setup.py index 8331deec8ad..f52c36b4458 100644 --- a/setup.py +++ b/setup.py @@ -313,6 +313,47 @@ def get_build_type(is_debug=None) -> str: return "Debug" if debug else "Release" +# Headers whose implementations are built as separate targets and are not part of +# the runtime the wheel ships. Copying them would let an application compile +# against an API it then cannot link. This is a list rather than a rule because +# the wheel copies header trees by directory while the runtime's sources are +# chosen per target, so there is nothing to derive the answer from. A supported +# header set expressed alongside the runtime's own source list would remove the +# need for it. +_UNSUPPORTED_WHEEL_HEADERS = frozenset( + { + # These two include a third-party header the wheel does not ship, so they cannot + # compile from an installed package no matter what is linked. + "cpuinfo_utils.h", + "threadpool.h", + # These three compile, but declare entry points whose definitions are not in any + # shipped library, so a consumer that includes them fails at link time. + "bundled_module.h", + "file_descriptor_data_loader.h", + "serialize.h", + } +) + +# Headers excluded by path rather than by name, because the same file name is also used +# by a header that does compile. Each of these includes something the wheel does not +# ship, so it cannot build from an installed package. +_UNSUPPORTED_WHEEL_HEADER_PATHS = ( + # Includes a generated schema header that is not part of the shipped tree. + "runtime/executor/tensor_parser.h", + # GoogleTest and GoogleMock helpers, which a runtime package has no reason to ship. + "runtime/core/exec_aten/testing_util/tensor_util.h", + "runtime/core/testing_util/error_matchers.h", +) + + +def _is_unsupported_wheel_header(source: Path) -> bool: + """Whether a header cannot compile from an installed package.""" + if source.name in _UNSUPPORTED_WHEEL_HEADERS: + return True + posix = source.as_posix() + return any(posix.endswith(suffix) for suffix in _UNSUPPORTED_WHEEL_HEADER_PATHS) + + def get_dynamic_lib_name(name: str) -> str: if _is_windows(): return f"{name}.dll" @@ -322,6 +363,115 @@ def get_dynamic_lib_name(name: str) -> str: return f"lib{name}.so" +def _write_cmake_version_file(destination: str) -> None: + """Generate the CMake package version file next to the package config. + + Read from version.txt so the version CMake reports is the same one the wheel and + the runtime SONAME use. + + Written by hand rather than from CMake's own template because the rule here is + deliberately stricter than any stock one: a request above the package version is + refused, not just a different major. A delegate built against one release cannot be + assumed to work with a later one, so accepting a higher request would let a consumer + match a package that does not satisfy it. + """ + # The same version the wheel publishes, including any BUILD_VERSION override, so one + # artifact cannot report one identity to pip and a different one to CMake. + version = Version.string() + # CMake executes this file to decide whether the package is acceptable, so a quote or a + # backslash in the version would be a parse error rather than a bad comparison, and + # find_package would hard-fail for every consumer. The normal pipeline cannot produce one, + # but BUILD_VERSION is an unvalidated environment override. + assert not set(version) & set('"\\'), ( + f"version {version!r} contains a character that cannot appear in the generated CMake " + "version file" + ) + # A pre-release suffix is not a CMake version component, so keep the numeric + # prefix and let compatibility be decided on the major. + numeric = re.match(r"\d+(?:\.\d+){0,2}", version) + numeric = numeric.group(0) if numeric else "0.0.0" + major = numeric.split(".")[0] + # Only an exclusive bound at the immediately following major means "any {major}.x", + # which is the idiomatic way to ask for this release series. + next_major = str(int(major) + 1) + + contents = f"""\ +set(PACKAGE_VERSION "{numeric}") + +# The full version this package was built from. PACKAGE_VERSION drops a prerelease suffix +# and a local version label because neither is a CMake version component, so two different +# prereleases of one release compare equal. A consumer that must pair with one exact build +# compares this instead. +set(EXECUTORCH_BUILD_VERSION "{version}") + +if(NOT PACKAGE_FIND_VERSION) + # No version requested, so any version satisfies it. + set(PACKAGE_VERSION_COMPATIBLE TRUE) +elseif(PACKAGE_FIND_VERSION_RANGE) + # A range request such as 1.0...1.2 sets its own variables, and checking only + # PACKAGE_FIND_VERSION would compare against the lower bound alone and accept a package + # the range excludes. + if(PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION_MIN) + set(PACKAGE_VERSION_UNSUITABLE TRUE) + elseif(PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "INCLUDE" + AND PACKAGE_VERSION VERSION_GREATER PACKAGE_FIND_VERSION_MAX) + set(PACKAGE_VERSION_UNSUITABLE TRUE) + elseif(PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "EXCLUDE" + AND NOT PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION_MAX) + set(PACKAGE_VERSION_UNSUITABLE TRUE) + elseif(NOT PACKAGE_FIND_VERSION_MIN_MAJOR STREQUAL "{major}") + # Same major rule as below: a different major means a different shared runtime. + set(PACKAGE_VERSION_UNSUITABLE TRUE) + elseif(PACKAGE_FIND_VERSION_MAX_MAJOR + AND NOT PACKAGE_FIND_VERSION_MAX_MAJOR STREQUAL "{major}" + AND NOT (PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "EXCLUDE" + AND PACKAGE_FIND_VERSION_MAX_MAJOR EQUAL {next_major} + AND PACKAGE_FIND_VERSION_MAX_MINOR EQUAL 0 + AND PACKAGE_FIND_VERSION_MAX_PATCH EQUAL 0)) + # Both endpoints have to share the major, the way CMake's own template requires. + # Checking only the lower one accepts a range such as 1.0...3.0 against a 1.x + # runtime, which tells a consumer that majors 2 and 3 are satisfied too. + set(PACKAGE_VERSION_UNSUITABLE TRUE) + else() + set(PACKAGE_VERSION_COMPATIBLE TRUE) + endif() +elseif(PACKAGE_FIND_VERSION_MAJOR STREQUAL "{major}") + # SameMajorVersion, matching the runtime's SONAME: a consumer asking for {major}.x + # gets any {major}.y, and a request for a different major is refused because the + # shared runtime it would link is not the one it asked for. + # + # A request above this version is refused too. Matching only the major would let a + # package satisfy a request for a release it predates, so a consumer needing something + # added later would link this runtime instead of being told it is not here. + if(PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION) + set(PACKAGE_VERSION_UNSUITABLE TRUE) + else() + set(PACKAGE_VERSION_COMPATIBLE TRUE) + if(PACKAGE_FIND_VERSION STREQUAL PACKAGE_VERSION) + set(PACKAGE_VERSION_EXACT TRUE) + endif() + endif() +else() + set(PACKAGE_VERSION_UNSUITABLE TRUE) +endif() +""" + os.makedirs(os.path.dirname(destination), exist_ok=True) + with open(destination, "w") as handle: + handle.write(contents) + + +def get_runtime_soname_major() -> str: + """The major version in the shared runtime's SONAME. + + CMake derives SOVERSION from version.txt, so read the major from the same + place. Version.string() is not usable here because BUILD_VERSION can + override it without changing what the linker recorded. + """ + root = os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(root, "version.txt")) as f: + return f.read().strip().split(".")[0] + + def get_executable_name(name: str) -> str: if _is_windows(): return name + ".exe" @@ -702,6 +852,18 @@ def analyze_manifest(self): if os.path.isfile(os.path.join(_root, _f)) ] + @staticmethod + def _write_cmake_version_files(dst_root: str) -> None: + """Put a CMake version file beside each copy of the package config. + + Without one, CMake rejects any versioned find_package because it cannot tell + what version the package is, even when the package is usable. + """ + for config_dir in ("share/cmake", "lib/cmake/executorch"): + _write_cmake_version_file( + os.path.join(dst_root, config_dir, "executorch-config-version.cmake") + ) + def run(self): # Copy python files to the output directory. This set of files is # defined by the py_module list and package_data patterns. @@ -745,6 +907,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 @@ -758,12 +929,18 @@ def run(self): "runtime/kernel/", "runtime/backend/", "runtime/platform/", + "extension/data_loader/", + "extension/flat_tensor/", "extension/kernel_util/", + "extension/module/", + "extension/named_data_map/", "extension/tensor/", "extension/threadpool/", ]: src_list = Path(include_dir).rglob("*.h") for src in src_list: + if _is_unsupported_wheel_header(src): + continue src_to_dst.append( (str(src), os.path.join("include/executorch", str(src))) ) @@ -781,6 +958,8 @@ def run(self): # the input file is read-only. self.copy_file(src, dst, preserve_mode=False) + self._write_cmake_version_files(dst_root) + # Copy CMake-generated Python directories that setuptools missed. # Setuptools discovers packages at configuration time, before CMake # runs. Directories created by CMake during the build (e.g. by @@ -1089,6 +1268,15 @@ def run(self): # noqa C901 [] if _is_minimal_build() else [ + # Install the shared C++ runtime so a standalone application can + # link executorch::runtime from the wheel. Shipped under its + # SONAME so the DT_NEEDED a consumer records resolves at runtime. + BuiltFile( + src_dir="%CMAKE_CACHE_DIR%/", + src_name=f"libexecutorch.so.{get_runtime_soname_major()}.*", + dst=f"executorch/lib/libexecutorch.so.{get_runtime_soname_major()}", + dependent_cmake_flags=["EXECUTORCH_BUILD_SHARED"], + ), # 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/Codegen.cmake b/tools/cmake/Codegen.cmake index 4253fa44dc5..e338707dd2c 100644 --- a/tools/cmake/Codegen.cmake +++ b/tools/cmake/Codegen.cmake @@ -261,9 +261,16 @@ function(gen_custom_ops_aot_lib) executorch_target_link_options_shared_lib(${GEN_LIB_NAME}) if(TARGET portable_lib) target_link_libraries(${GEN_LIB_NAME} PRIVATE portable_lib) + elseif(TARGET executorch_shared) + # Linked as a library rather than only retained, so this target also picks + # up the runtime's include directories and compile definitions. The + # retention helper below adds link options alone, which would leave a shared + # build without the pybind extension compiling against no runtime headers. + target_link_libraries(${GEN_LIB_NAME} PRIVATE executorch_shared) else() target_link_libraries(${GEN_LIB_NAME} PRIVATE executorch_core) endif() + executorch_target_link_shared_runtime(${GEN_LIB_NAME}) endfunction() # Generate a runtime lib for registering operators in Executorch diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index 958b425c47c..fc1568f868b 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -47,9 +47,51 @@ function(executorch_msvc_kernel_link_options target_name) ) endfunction() +# Add a whole-archive reference to a static library on a consumer's link line. +# +# This is deliberately a link option rather than a link library: CMake refuses +# to mix the WHOLE_ARCHIVE link feature with the plain references other targets +# make to the same archive, and link options are also emitted before the ordered +# link libraries, which is what keeps a bundled archive ahead of anything that +# would otherwise satisfy the same symbols. +function(executorch_target_whole_archive target_name archive_target) + # Comma-separated inside one LINKER: option rather than a SHELL: string with + # spaces. SHELL: splits on spaces, so an archive path containing one reaches + # the linker as two broken arguments and the link fails. + if(APPLE) + set(_flags "LINKER:-force_load,$") + elseif(MSVC) + set(_flags "LINKER:/WHOLEARCHIVE:$") + else() + set(_flags + "LINKER:--whole-archive,$,--no-whole-archive" + ) + endif() + target_link_options(${target_name} PRIVATE "${_flags}") + add_dependencies(${target_name} ${archive_target}) +endfunction() + # Ensure that the load-time constructor functions run. By default, the linker # would remove them since there are no other references to them. function(executorch_target_link_options_shared_lib target_name) + # A shared library cannot be retained with --whole-archive: that flag only + # governs how an archive's members are pulled in, so the library is still + # subject to --as-needed and gets dropped along with its registration + # constructor. Export scoped --no-as-needed retention instead, which is what + # actually keeps a registration-only shared library on the link line. + get_target_property(_target_type ${target_name} TYPE) + if(_target_type STREQUAL "SHARED_LIBRARY" AND NOT (APPLE OR MSVC)) + target_link_options( + ${target_name} + INTERFACE + # 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() if(APPLE) executorch_macos_kernel_link_options(${target_name}) elseif(MSVC) @@ -212,6 +254,60 @@ function(executorch_target_copy_mlx_metallib target) endif() endfunction() +# Make a target resolve the ExecuTorch runtime from libexecutorch.so. +# +# Naming the shared runtime as an ordinary dependency is not enough. CMake +# orders link libraries so that an archive precedes what it depends on, which +# puts libexecutorch_core.a ahead of libexecutorch.so; the archive then +# satisfies the runtime symbols first and the target ends up with a private copy +# of the backend registry. Link options come before the ordered libraries, so +# naming the runtime there leaves the archive with nothing left to resolve. +# +# On ELF platforms --no-as-needed is needed around it, because a shared library +# with no already-referenced symbol at the point it appears can be dropped, and +# the static archive further along the line would then supply the registry after +# all. Other linkers keep the reference without it. +function(executorch_target_link_shared_runtime target_name) + executorch_target_retain_shared_library(${target_name} executorch_shared) +endfunction() + +# Put a shared library on a consumer's link line and keep it there. +# +# A library whose only purpose is to run a static initializer, such as a backend +# or an operator registration library, has no symbol the consumer references +# directly, so the linker is free to drop it from DT_NEEDED. Some linkers do +# exactly that and the initializer never runs, which shows up at runtime as a +# backend or kernel that is missing rather than as a link error. +function(executorch_target_retain_shared_library target_name library_target) + if(NOT EXECUTORCH_BUILD_SHARED) + return() + endif() + if(APPLE OR MSVC) + # TARGET_LINKER_FILE rather than TARGET_FILE: on Windows the linker needs + # the import library, not the DLL itself. Plain rather than SHELL: this is a + # single path, and SHELL splits on spaces, so a path containing one would + # reach the linker as two broken arguments. + set(_retain_flags "$") + else() + # 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. + # 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. + add_dependencies(${target_name} ${library_target}) + set_property( + TARGET ${target_name} + APPEND + PROPERTY LINK_OPTIONS "${_retain_flags}" + ) +endfunction() + # Create and install a shared library composed from dependency libraries. The # target links the provided dependencies and carries VERSION/SOVERSION. function(executorch_add_shared_library target_name) diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 1d6096a2e96..7b71e45b61c 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -8,7 +8,19 @@ # for this file and find ExecuTorch package if it is installed. Typical usage # is: # +# ~~~ # find_package(executorch REQUIRED) +# target_link_libraries(my_app PRIVATE executorch::runtime) +# ~~~ +# +# This file describes the same contract as the in-tree package config, but is written by hand +# rather than generated, because the wheel copies build products out of the build tree instead +# of running an install step. That leaves two descriptions of one contract, which is why a +# target already defined by an in-tree build has to be detected and left alone below. +# +# The end state that removes the duplication is a staged install whose generated targets file +# the wheel ships, so the source and wheel contracts become the same object rather than two +# things that must agree. # ------- # # Finds the ExecuTorch library @@ -18,11 +30,308 @@ # EXECUTORCH_FOUND -- True if the system has the ExecuTorch library # EXECUTORCH_INCLUDE_DIRS -- The include directories for ExecuTorch # EXECUTORCH_LIBRARIES -- Libraries to link against +# EXECUTORCH_BUILD_VERSION -- The full version this package was built from, including +# any prerelease suffix and local version label. Compare this +# when an exact build pairing is required, since the CMake +# package version keeps only the numeric part. +# +# and, when the prebuilt shared runtime is present, the imported target: +# +# executorch::runtime -- The prebuilt C++ runtime (libexecutorch.so) # +# Component targets are defined only when the wheel ships that component, so the +# set depends on which wheel is installed. Each one carries the runtime +# dependency and, for a registration-only library, the link options that keep it +# from being dropped. The names, when present, are: +# +# executorch::threadpool executorch::kernels executorch::xnnpack_backend +# executorch::cuda_backend +# +# Check with if(TARGET executorch::) rather than assuming one exists. A name that was +# never defined is not an error at configure time: CMake passes it through to the linker as a +# literal flag, so the build fails much later with "cannot find -lexecutorch::" instead +# of anything that names the missing component. +# +# The floor stays where it was, so a consumer that only wants the long-standing variables and the +# prebuilt Python extension keeps working on the CMake it already has. The shared-runtime targets +# below need more than this and check for it themselves. cmake_minimum_required(VERSION 3.19) -# Find prebuilt _portable_lib..so. This file should be installed -# under /executorch/share/cmake +# The imported targets below export "$ORIGIN"-relative runtime paths as link options, and CMake +# writes that token incorrectly before 3.28. Versions 3.24 through 3.27 emit a doubled dollar with +# the Makefile generator and a bare dollar with Ninja, so a consumer builds and runs in place, +# because the absolute package directory is also recorded, then fails once it is deployed somewhere +# else. Silently defining a target that behaves that way is worse than not defining it, so the +# targets are skipped and a consumer that asked for one gets a message naming the reason. +if(CMAKE_VERSION VERSION_LESS 3.28) + set(_executorch_targets_supported FALSE) +else() + set(_executorch_targets_supported TRUE) +endif() + +# 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 file is +# installed both under share/cmake, which the historical contract uses, and +# under lib/cmake/executorch, which a plain CMAKE_PREFIX_PATH pointed at the +# package root can discover, so the root is located by a marker rather than a +# fixed depth. +find_path( + _executorch_package_root + # share/cmake identifies the package root and only exists there. A generic + # marker such as include/executorch can also appear one level down, in which + # case the search from lib/cmake/executorch would stop at lib/ and resolve the + # wrong root. + NAMES share/cmake/executorch-config.cmake + PATHS "${CMAKE_CURRENT_LIST_DIR}/.." "${CMAKE_CURRENT_LIST_DIR}/../.." + "${CMAKE_CURRENT_LIST_DIR}/../../.." + NO_DEFAULT_PATH + # NO_CACHE so the search runs on every configure. A cached result would + # survive the package being upgraded or relocated in place and keep naming a + # directory that has moved, which is worse than reporting it as not found. + NO_CACHE +) + +# Both directories are needed for a usable package. The C10 compatibility +# headers are not optional: core headers such as runtime/core/array_ref.h +# include c10 unconditionally, so a package missing them cannot compile anything +# that touches the runtime API. +# +# A missing directory is reported as not-found rather than raised here, so an +# optional find_package gets a FALSE answer instead of a dead build. The +# REQUIRED handling at the bottom of this file turns it into an error when the +# caller asked for one. +set(_executorch_c10_include + "${_executorch_package_root}/include/executorch/runtime/core/portable_type/c10" +) +# The full version this package was built from. It lives in the generated version file, which +# CMake includes in a throwaway scope while deciding whether the package is acceptable, so +# nothing assigned there reaches a consumer. Reading that file here, from the config, is what +# makes the value visible. Both files are installed side by side, so the path is fixed +# relative to this one. +set(_executorch_version_file "${CMAKE_CURRENT_LIST_DIR}/executorch-config-version.cmake") +if(EXISTS "${_executorch_version_file}") + file(STRINGS "${_executorch_version_file}" _executorch_version_lines + REGEX "^set\\(EXECUTORCH_BUILD_VERSION") + foreach(_line IN LISTS _executorch_version_lines) + if(_line MATCHES "\"([^\"]+)\"") + set(EXECUTORCH_BUILD_VERSION "${CMAKE_MATCH_1}") + endif() + endforeach() +endif() +unset(_executorch_version_file) + +set(EXECUTORCH_INCLUDE_DIRS "${_executorch_package_root}/include" + "${_executorch_c10_include}" +) +foreach(_required_include ${EXECUTORCH_INCLUDE_DIRS}) + if(NOT EXISTS "${_required_include}") + message( + STATUS "ExecuTorch package at ${_executorch_package_root} is missing " + "${_required_include}, so nothing can compile against it." + ) + set(EXECUTORCH_INCLUDE_DIRS) + set(EXECUTORCH_LIBRARIES) + set(EXECUTORCH_FOUND OFF) + set(executorch_FOUND FALSE) + return() + endif() +endforeach() + +set(EXECUTORCH_LIBRARIES) +set(EXECUTORCH_FOUND OFF) + +# Locate one shipped library by base name. +# +# A wheel ships a single file per library, named for its SONAME, so the major is +# read from the shipped names rather than hardcoded here. Sets to the +# full path, or to an empty string when the wheel does not carry that library. +# +# This depends on an invariant on the build side: every library the wheel ships carries a +# VERSION and SOVERSION, so its file name ends in a major. A library built without them ships +# as a bare .so, and while the glob below still finds it, nothing then pins the major a +# consumer linked against, which is the guarantee the SONAME exists to provide. +function(_executorch_find_library _output _base_name) + set(${_output} + "" + PARENT_SCOPE + ) + file(GLOB _matches "${_executorch_package_root}/lib/${_base_name}.so" + "${_executorch_package_root}/lib/${_base_name}.so.*" + ) + list(LENGTH _matches _count) + if(_count EQUAL 0) + return() + endif() + # Highest major wins, so a package that somehow carries two does not silently + # select by string order. Natural ordering keeps .2 below .10. + list( + SORT _matches + COMPARE NATURAL + ORDER DESCENDING + ) + list(GET _matches 0 _selected) + set(${_output} + "${_selected}" + PARENT_SCOPE + ) +endfunction() + +# The prebuilt runtime. +_executorch_find_library(_executorch_runtime_library libexecutorch) +if(_executorch_runtime_library AND NOT _executorch_targets_supported) + message( + STATUS + "executorch: the prebuilt runtime is present but its imported targets need CMake 3.28 or " + "newer, because older versions write the \$ORIGIN token in a runtime search path " + "incorrectly. The long-standing EXECUTORCH_LIBRARIES and the prebuilt Python extension are " + "unaffected." + ) +elseif(_executorch_runtime_library) + set(EXECUTORCH_FOUND ON) + message(STATUS "ExecuTorch runtime found at ${_executorch_runtime_library}") + + # The documented contract is that a consumer can link ${EXECUTORCH_LIBRARIES}. + # Leaving it empty here would make find_package succeed while offering nothing + # linkable to anyone who has not moved to the imported target. + list(APPEND EXECUTORCH_LIBRARIES executorch::runtime) + + # 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(TARGET executorch::runtime) + # An in-tree build defines this name, sometimes as an ALIAS whose properties cannot be + # set. A consumer that both adds this project as a subdirectory and calls find_package + # should keep the target it is already building, so skip the whole definition. + message(STATUS "executorch: executorch::runtime is already defined, leaving it as is") + else() + add_library(executorch::runtime SHARED IMPORTED) + set_target_properties( + executorch::runtime + PROPERTIES IMPORTED_LOCATION "${_executorch_runtime_library}" + INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" + INTERFACE_COMPILE_FEATURES cxx_std_17 + INTERFACE_COMPILE_DEFINITIONS C10_USING_CUSTOM_GENERATED_MACROS + ) + # Consumers get the wheel's lib/ directory in their RUNPATH automatically, because + # CMake adds the imported library's directory. Also record $ORIGIN-relative entries so + # an application deployed next to a copy of the runtime keeps working without relinking + # or LD_LIBRARY_PATH. $ORIGIN is a loader token, so it belongs only in RUNPATH, never + # in IMPORTED_LOCATION. + # + # $ORIGIN is named before the wheel's own directory. An application deployed beside a copy + # of the runtime has to find that copy, and the loader takes the first match, so putting + # the install directory first would keep sending a relocated application back to the + # original wheel for as long as it remains installed. That also makes a relocation test + # that deletes the original pass for the wrong reason. + # + # The cost, measured rather than assumed: a library that merely shares this SONAME and sits + # in the application's own directory will win. That is what $ORIGIN means in every package + # that uses it, and a package cannot offer relocation while also refusing to honour what the + # user placed beside their binary. The consequence worth worrying about, a delegate pairing + # with a different registry, is caught directly by the single-registry checks, which inspect + # what the shipped libraries define instead of trusting the loader's choice. + # + # The absolute entry cannot simply be dropped to avoid the question: an application built + # against the installed package fails to start without it. + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + get_filename_component( + _executorch_runtime_dir "${_executorch_runtime_library}" DIRECTORY + ) + set_property( + TARGET executorch::runtime + APPEND + PROPERTY INTERFACE_LINK_OPTIONS "LINKER:-rpath,$ORIGIN" + "LINKER:-rpath,$ORIGIN/../lib" + "LINKER:-rpath,${_executorch_runtime_dir}" + ) + endif() + endif() +endif() + +# Define an imported target for one shipped component library. +# +# A component is a prebuilt shared library next to the runtime, such as the CPU +# kernels or a delegate backend. Without a target for each one, a consumer has +# to find the file itself and decide how to keep it on the link line, which +# means depending on the wheel's private layout. The retention part matters +# most: a registration-only library has no symbol the application references, so +# a normal link drops it and its registration never runs. +# +# Call as: executorch_define_component( ) +function(executorch_define_component _suffix _library_name) + # Same reason the runtime target is skipped on older CMake: a component target exports an + # $ORIGIN-relative search path, and a version that writes it wrong produces a target that works + # in place and fails once deployed. + if(NOT _executorch_targets_supported) + return() + endif() + _executorch_find_library(_library "lib${_library_name}") + if(NOT _library) + return() + endif() + + set(_target "executorch::${_suffix}") + if(TARGET ${_target}) + # An in-tree build defines these names, sometimes as an ALIAS whose properties + # cannot be set. A consumer that both adds this project as a subdirectory and + # calls find_package should keep the target it is already building. + message(STATUS "executorch: ${_target} is already defined, leaving it as is") + return() + endif() + add_library(${_target} SHARED IMPORTED) + set_target_properties( + ${_target} + PROPERTIES IMPORTED_LOCATION "${_library}" + INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" + INTERFACE_COMPILE_FEATURES cxx_std_17 + INTERFACE_COMPILE_DEFINITIONS C10_USING_CUSTOM_GENERATED_MACROS + ) + # Every component resolves the runtime from the same shared library, so record + # that rather than leaving a consumer to link both by hand. + if(TARGET executorch::runtime) + set_property( + TARGET ${_target} + APPEND + PROPERTY INTERFACE_LINK_LIBRARIES executorch::runtime + ) + endif() + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set_property( + TARGET ${_target} + APPEND + PROPERTY INTERFACE_LINK_OPTIONS + "LINKER:-rpath,$ORIGIN" + "LINKER:-rpath,$ORIGIN/../lib" + # One option per component rather than a shared push-state pair: + # CMake removes duplicate link options, so repeating the same + # push-state text for a second component silently drops its + # scoping and the library goes back to being --as-needed. Naming + # the library inside the same option keeps each one distinct. + # + # --no-as-needed applies only to what follows within the pushed + # state, so the pop restores whatever the consumer had. + "LINKER:--push-state,--no-as-needed,${_library},--pop-state" + ) + elseif(APPLE) + set_property( + TARGET ${_target} + APPEND + # One LINKER: option with a comma rather than a SHELL: string with a + # space: SHELL splits on spaces, so a library path containing one would + # reach the linker as two broken arguments. + PROPERTY INTERFACE_LINK_OPTIONS "LINKER:-force_load,${_library}" + ) + endif() + set(EXECUTORCH_LIBRARIES + ${EXECUTORCH_LIBRARIES} ${_target} + PARENT_SCOPE + ) +endfunction() + +# 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. # Find python if(DEFINED ENV{CONDA_DEFAULT_ENV} AND NOT $ENV{CONDA_DEFAULT_ENV} STREQUAL @@ -45,6 +354,24 @@ 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 "") + # find_library caches its result, so a value left by an earlier configure + # would survive and the extension would still be offered despite being skipped + # here. + unset(_portable_lib_LIBRARY CACHE) + # Also clear any normal-scope value, since find_library writes the cache entry + # while a plain variable of the same name can shadow it and still look like a + # successful discovery. + unset(_portable_lib_LIBRARY) else() message( FATAL_ERROR @@ -52,27 +379,99 @@ else() ) endif() -find_library( - _portable_lib_LIBRARY - NAMES _portable_lib${EXT_SUFFIX} - PATHS "${CMAKE_CURRENT_LIST_DIR}/../../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 + # NO_CACHE for the same reason as the package root above: a cached result + # would survive the package being upgraded or relocated in the same build + # directory and keep naming the previous copy, or a file that no longer + # exists. + NO_CACHE + ) +endif() -set(EXECUTORCH_LIBRARIES) -set(EXECUTORCH_FOUND OFF) if(_portable_lib_LIBRARY) set(EXECUTORCH_FOUND ON) message( STATUS "ExecuTorch portable library is found at ${_portable_lib_LIBRARY}" ) list(APPEND EXECUTORCH_LIBRARIES _portable_lib) - add_library(_portable_lib STATIC IMPORTED) - set(EXECUTORCH_INCLUDE_DIRS ${CMAKE_CURRENT_LIST_DIR}/../../include) + if(TARGET _portable_lib) + # Already defined by an in-tree build, so keep it rather than redefining it. + message(STATUS "executorch: _portable_lib is already defined, leaving it as is") + else() + # SHARED, not STATIC: this resolves to the Python extension module, which is + # a shared object. Declaring it static makes CMake treat it as an archive, + # which changes how it is placed on a link line and how runtime paths are + # handled. + add_library(_portable_lib SHARED IMPORTED) + endif() # PyTorch requires C++20, so pybindings must be compiled with C++20. set_target_properties( _portable_lib PROPERTIES IMPORTED_LOCATION "${_portable_lib_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" - CXX_STANDARD 20 + # An interface requirement rather than CXX_STANDARD: an imported + # target compiles nothing itself, and CXX_STANDARD does not reach + # consumers, so a custom-op build linking this could still + # compile + # as C++17 and fail against headers that need C++20. + INTERFACE_COMPILE_FEATURES cxx_std_20 + # The same definition the runtime target carries. A custom-op + # build that links only this target still compiles against the + # same headers and needs it too. + INTERFACE_COMPILE_DEFINITIONS C10_USING_CUSTOM_GENERATED_MACROS + ) +endif() + +# find_package checks _FOUND, which is case-sensitive and does not +# match the EXECUTORCH_FOUND spelling this file documents. Without this, a +# REQUIRED find_package would succeed even when nothing usable was located. +set(executorch_FOUND ${EXECUTORCH_FOUND}) +if(NOT executorch_FOUND AND executorch_FIND_REQUIRED) + message( + FATAL_ERROR + "Found the ExecuTorch package but neither the shared runtime nor the Python " + "extension could be located inside it." ) endif() + +# Component requests are answered from the targets that were actually defined +# above, so a consumer asking for a component this wheel does not ship gets told +# at configure time rather than at link or load time. Without this a REQUIRED +# request for a missing component, or for a name that does not exist at all, +# would configure and then fail much later. +# +# The check is written out rather than using check_required_components, which +# comes from a module a package config cannot assume is already included. +foreach(_component ${executorch_FIND_COMPONENTS}) + if(TARGET executorch::${_component}) + set(executorch_${_component}_FOUND TRUE) + else() + set(executorch_${_component}_FOUND FALSE) + if(executorch_FIND_REQUIRED_${_component}) + set(executorch_FOUND FALSE) + # Naming the CMake version when that is the cause saves a consumer from concluding the + # component is missing from the package, which is the wrong thing to go looking for. + if(NOT _executorch_targets_supported) + set(executorch_NOT_FOUND_MESSAGE + "the required component '${_component}' needs CMake 3.28 or newer, because older " + "versions write the \$ORIGIN token in a runtime search path incorrectly; this " + "package is otherwise usable through EXECUTORCH_LIBRARIES" + ) + else() + set(executorch_NOT_FOUND_MESSAGE + "this ExecuTorch package does not provide the required component '${_component}'" + ) + endif() + endif() + endif() +endforeach() +if(NOT executorch_FOUND AND executorch_FIND_REQUIRED) + message(FATAL_ERROR "${executorch_NOT_FOUND_MESSAGE}") +endif() diff --git a/tools/cmake/preset/pybind.cmake b/tools/cmake/preset/pybind.cmake index d292c9ed240..27b841d4df9 100644 --- a/tools/cmake/preset/pybind.cmake +++ b/tools/cmake/preset/pybind.cmake @@ -27,6 +27,20 @@ set_overridable_option(EXECUTORCH_BUILD_EXTENSION_MODULE ON) set_overridable_option(EXECUTORCH_BUILD_EXTENSION_NAMED_DATA_MAP ON) set_overridable_option(EXECUTORCH_BUILD_WHEEL_DO_NOT_USE ON) +# Use the install runtime paths at build time. Packaging copies libraries out of +# the build tree rather than running an install step, so without this the build +# paths ship: every library keeps the absolute directories of whatever it linked +# against, which names the machine that built it and stops the wheel being +# relocatable. +# +# Linux only. On Apple the pybind target deliberately keeps no install runtime path, +# because adding one duplicates an entry the linker rejects, so switching the build +# over to that empty value would leave the extension unable to find the libraries it +# links against. +if(NOT APPLE) + set_overridable_option(CMAKE_BUILD_WITH_INSTALL_RPATH ON) +endif() + # Optional VGF enable for the default pybind/install flow. This is intentionally # scoped to this preset rather than acting as a general environment-to-CMake # override mechanism. @@ -104,6 +118,11 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") endif() endif() set_overridable_option(EXECUTORCH_BUILD_OPENVINO OFF) + # Ship one shared runtime that both the pybind extension and standalone C++ + # consumers link, so a process has a single backend registry. Linux only: + # macOS C++ consumers are served by the Swift package distribution, and the + # runtime has no export annotations for a Windows DLL. + set_overridable_option(EXECUTORCH_BUILD_SHARED ON) elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR CMAKE_SYSTEM_NAME STREQUAL "WIN32" )