From 5fd5b060ebf4b1288e3ee89d7a51d4ce04c7502a Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 10 Aug 2026 19:41:19 -0700 Subject: [PATCH] Split the runtime out of the Python bindings extension ## The problem `pip install executorch` gives you the Python half of ExecuTorch and nothing a C++ program can link. Everything is fused into one large Python extension file, so a C++ developer has to clone the repository, sync submodules, and build from source. There is a correctness problem underneath the packaging one. Because the runtime is fused into the extension, anything else that needs it gets its own private copy, and two copies mean two registries. A backend registered in one is invisible to the other. ## The change Build the runtime and the pieces around it as separate shared libraries, and make the Python extension link them instead of embedding them. A shared library is a file a program loads at run time, so several programs can use one copy instead of each carrying its own. ``` executorch/ lib/libexecutorch.so the runtime lib/libexecutorch_kernels_optimized.so CPU operator kernels lib/libexecutorch_backend_xnnpack.so the XNNPACK delegate lib/libexecutorch_threadpool.so one thread pool per process lib/libexecutorch_etdump.so the profiler extension/pybindings/_portable_lib.so now under a megabyte, links the above ``` The extension is much smaller than before, because it no longer contains what it now links. Linux only, and only when the CUDA backend is off. macOS and Windows keep the fused extension because the split relies on ELF sonames, the `$ORIGIN` search-path token and GNU linker options, none of which apply there, and enabling it elsewhere now fails while configuring rather than much later. A CUDA build also keeps the fused extension for now, because the CUDA libraries are not yet shipped alongside the others. ## Test plan Built the wheel from source, installed it into a clean environment, and checked: - exactly one library defines each component, and it is the library that should own it. Counting owners alone would also pass on the old fused layout, which has exactly one too. - the Python extension defines none of them and resolves all of them from outside. - every shipped library loads with no unresolved dependency, and none of them searches a directory from the machine that built the wheel. The build-directory patterns are matched as whole path components, because a bare substring also matched an unrelated directory a user could really have, such as `/home/user/cmake-outputs/torchlibs`, and stripping that breaks a dependency the library legitimately resolves there. - a read-only build output is no longer archived world-writable. The previous code granted write to the group and to everyone via an absolute chmod mode; the fix only grants owner-write, so a `0555` file lands as `0755` instead of `0777`. - a custom operator library compiles and links against the shipped Python extension, which is the existing contract this must not break. Linking these libraries from a standalone C++ application needs the installed CMake package, which a later change adds. - the ahead-of-time quantized library records its route to the runtime through the same helper the other targets use. It was written by hand in two blocks that between them covered only the wheel layout and only when the wheel flag was set, so a plain `-DEXECUTORCH_BUILD_SHARED=ON` build left it with no route at all. Checked all four combinations of the shared build and the presence of the Python extension. Ran on Linux x86_64 and aarch64, including a Jetson device. Not fixed here: these libraries bundle third-party code that torch also links, and both keep it visible, so a process holds two definitions of symbols like `pthreadpool_create`. A caller reaches whichever the loader found first. Fixing it means hiding or dropping the bundled copies, which is a larger change. ghstack-source-id: 965d8dfd3d20b7ed05044de62dd520c3ed872c78 ghstack-comment-id: 5200527760 Pull-Request: https://github.com/pytorch/executorch/pull/21610 --- .ci/scripts/tests/test_wheel_platform_tag.py | 77 + .ci/scripts/wheel/test_linux.py | 9 + .ci/scripts/wheel/test_linux_aarch64.py | 10 + .ci/scripts/wheel/test_shared_libraries.py | 1489 +++++++++++++++++ .../workflows/build-wheels-aarch64-linux.yml | 2 + .github/workflows/build-wheels-linux.yml | 2 + .github/workflows/build-wheels-macos.yml | 2 + .github/workflows/build-wheels-windows.yml | 2 + CMakeLists.txt | 290 +++- backends/qualcomm/CMakeLists.txt | 33 +- backends/xnnpack/CMakeLists.txt | 38 +- codegen/tools/CMakeLists.txt | 15 +- configurations/CMakeLists.txt | 19 + devtools/bundled_program/CMakeLists.txt | 9 +- devtools/etdump/CMakeLists.txt | 37 +- extension/llm/custom_ops/CMakeLists.txt | 22 +- extension/llm/runner/CMakeLists.txt | 4 + extension/threadpool/CMakeLists.txt | 35 +- extension/training/CMakeLists.txt | 57 +- install_utils.py | 44 +- kernels/quantized/CMakeLists.txt | 30 +- .../exec_aten/util/tensor_dimension_limit.h | 2 + setup.py | 191 ++- tools/cmake/Codegen.cmake | 40 +- tools/cmake/Utils.cmake | 252 ++- tools/cmake/executorch-wheel-config.cmake | 56 +- tools/cmake/preset/default.cmake | 4 +- tools/cmake/preset/pybind.cmake | 13 + 28 files changed, 2653 insertions(+), 131 deletions(-) create mode 100644 .ci/scripts/tests/test_wheel_platform_tag.py create mode 100644 .ci/scripts/wheel/test_shared_libraries.py diff --git a/.ci/scripts/tests/test_wheel_platform_tag.py b/.ci/scripts/tests/test_wheel_platform_tag.py new file mode 100644 index 00000000000..415ef29582c --- /dev/null +++ b/.ci/scripts/tests/test_wheel_platform_tag.py @@ -0,0 +1,77 @@ +# 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. + +"""Unit tests for the wheel platform tag comparison. + +Here rather than in the wheel checks, because the decision under test is a pure +function of two strings. Running it as part of the wheel checks meant it needed eleven +built wheels to exercise one comparison, and it still could not run on a machine that +had not built one. + +The comparison had a real defect that this covers. The release pipeline builds in a +manylinux image and rewrites the wheel's file name, so the tag on the file and the tag +auditwheel reports never agree in spelling, and comparing them as text rejected every +correct wheel. No local build reproduces that rewrite, so nothing short of a unit test +catches it before CI. +""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "wheel")) + +from test_shared_libraries import ( # noqa: E402 + _tag_architectures_match, + _wheel_architecture, +) + + +@pytest.mark.parametrize( + "claimed,supported", + [ + # What the release pipeline actually produces: it builds in a manylinux image + # and rewrites the file name, while auditwheel reports a plain linux tag + # because the wheel depends on torch without vendoring torch's libraries. + ("manylinux_2_28_x86_64", "linux_x86_64"), + ("manylinux_2_28_aarch64", "linux_aarch64"), + # The legacy spelling, which has no underscore before its version. + ("manylinux2014_x86_64", "linux_x86_64"), + ("manylinux2014_aarch64", "linux_aarch64"), + # A local build, where nothing rewrites the name. + ("linux_x86_64", "linux_x86_64"), + ("linux_aarch64", "linux_aarch64"), + ], +) +def test_accepts_tags_the_release_pipeline_produces(claimed, supported): + assert _tag_architectures_match(claimed, supported) is True + + +@pytest.mark.parametrize( + "claimed,supported", + [ + ("manylinux_2_28_aarch64", "linux_x86_64"), + ("manylinux_2_28_x86_64", "linux_aarch64"), + ("linux_aarch64", "linux_x86_64"), + ], +) +def test_rejects_an_architecture_mismatch(claimed, supported): + """A wheel labelled for the wrong architecture installs where it cannot run.""" + assert _tag_architectures_match(claimed, supported) is False + + +@pytest.mark.parametrize("tag", ["win_amd64", "macosx_11_0_arm64", "any", "", "linux"]) +def test_reports_a_tag_it_cannot_read(tag): + """None, not False, so an unreadable tag is not mistaken for a mismatch.""" + assert _wheel_architecture(tag) is None + assert _tag_architectures_match(tag, "linux_x86_64") is None + + +def test_reads_every_architecture_the_project_builds_for(): + for architecture in ("x86_64", "aarch64", "i686", "ppc64le", "s390x", "armv7l"): + assert _wheel_architecture(f"linux_{architecture}") == architecture + assert _wheel_architecture(f"manylinux_2_28_{architecture}") == architecture diff --git a/.ci/scripts/wheel/test_linux.py b/.ci/scripts/wheel/test_linux.py index 812eec89215..d76ed6b2462 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_shared_libraries from examples.models import Backend, Model if __name__ == "__main__": @@ -41,6 +44,12 @@ test_base.test_cmsis_nn_install() + # The wheel ships the runtime, the kernels, the delegate, the thread + # pool and the profiler as separate shared libraries now, so check that + # each has exactly one owner and that all of them are loadable. + with tempfile.TemporaryDirectory() as work_dir: + test_shared_libraries.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..b268c72cea3 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_shared_libraries 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 the runtime, the kernels, the delegate, the thread pool and + # the profiler as separate shared libraries now, so check that each has + # exactly one owner and that all of them are loadable. + with tempfile.TemporaryDirectory() as work_dir: + test_shared_libraries.run_tests(Path(work_dir)) + test_base.run_tests( model_tests=[ test_base.ModelTest( diff --git a/.ci/scripts/wheel/test_shared_libraries.py b/.ci/scripts/wheel/test_shared_libraries.py new file mode 100644 index 00000000000..fa6e8681345 --- /dev/null +++ b/.ci/scripts/wheel/test_shared_libraries.py @@ -0,0 +1,1489 @@ +# 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 wheel ships its runtime as separate shared libraries. + +The Python bindings extension used to contain the runtime, the registries, the +CPU kernels, the XNNPACK delegate and the profiler, all statically linked into +one file. It now links them as shared libraries the wheel ships alongside it. +These checks run against the installed wheel only; they never look at the source +tree's build directory, because a checkout on the module search path makes every +check below pass while inspecting the wrong thing. + +The properties verified here are the ones the split exists to create: + +1. Each component has exactly one definer, and it is the library that is meant + to own it. Counting definers alone is not enough: the monolithic layout also + had exactly one of each, inside the Python extension. +2. The extension contains none of those components and depends on every shipped + library instead. +3. Every shipped library loads with no absolute runtime search path, including + after being moved, so the wheel is relocatable rather than only working on + the machine that built it. +""" + +import importlib.metadata +import importlib.util +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +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", +) + +# The thread pool accessor. A second definer means a second pool, which +# oversubscribes the CPU because each pool sizes itself to all cores. +_THREADPOOL_SYMBOLS = ("executorch::extension::threadpool::get_threadpool",) + +# A representative operator from the merged CPU kernels. A second definer means +# the operators are registered twice, which aborts at startup. +_KERNEL_SYMBOLS = ("torch::executor::native::abs_out",) + +# The registry entry points, kept separate from the kernel implementations above. +# A library that carries its own copy of these has its own registration code, which +# is what this split is meant to prevent: one owner of the operator table. Checking +# only a kernel implementation would miss that entirely. +_KERNEL_REGISTRY_SYMBOLS = ( + "executorch::runtime::register_kernels", + "executorch::runtime::get_registered_kernels", +) + +# A representative symbol from the XNNPACK delegate. A second definer means the +# process carries two copies of the delegate. +_XNNPACK_SYMBOLS = ( + "executorch::backends::xnnpack::XnnpackBackendOptions::workspace_manager", +) + +# Third-party code the shipped libraries bundle rather than depend on. These are C +# symbols with default visibility, so a second copy in the same process is not a +# duplicate of ExecuTorch's own code but it is still two thread pools or two +# XNNPACK runtimes, and which one a caller reaches depends on load order. +# +# Checked separately from the wrapper symbols above because the wrappers can each +# have exactly one owner while the bundled code underneath them does not. That is +# the same failure the split exists to prevent, reached by a different route. +_BUNDLED_THREADPOOL_SYMBOLS = ("pthreadpool_create", "cpuinfo_initialize") +_BUNDLED_XNNPACK_SYMBOLS = ("xnn_create_runtime_v4",) + +# A representative symbol from the profiler. A second definer means two event +# tracers, so a trace records only part of what ran. +_ETDUMP_SYMBOLS = ("executorch::etdump::ETDumpGen::ETDumpGen",) + +# `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") + + +def _declared_requirements() -> set: + """Import names the installed wheel declares a requirement for. + + Used to tell a package this environment simply does not have from one the wheel + said it needed, because only the second is a packaging defect. + + Three sources, because none alone is sufficient. importlib's reverse map is + authoritative where a distribution is INSTALLED, which is what makes + PyYAML -> yaml, ruamel.yaml -> ruamel and hydra-core -> hydra resolve correctly. + But it enumerates installed distributions only, so a declared dependency that is + MISSING can never appear in it, which is precisely the case this function exists + to catch. The transformed distribution name covers most of the rest. + + Some distributions import under a name no transformation produces, so those are + listed. Measured against the wheel's own declared list: py-cpuinfo -> cpuinfo, + PyYAML -> yaml and hydra-core -> hydra are all missed by the transformation, and + each would turn a dependency the wheel failed to install into a quiet skip. + """ + # Distribution name to import name, where the two are unrelated. Keyed on the + # normalised distribution name so a change in case or separator still matches. + unrelated_import_names = { + "py_cpuinfo": ("cpuinfo",), + "pyyaml": ("yaml", "_yaml"), + "hydra_core": ("hydra",), + "scikit_learn": ("sklearn",), + "typing_extensions": ("typing_extensions",), + "pillow": ("PIL",), + "protobuf": ("google",), + "opencv_python": ("cv2",), + } + try: + from importlib.metadata import packages_distributions, requires + except ImportError: # pragma: no cover + return set() + try: + declared = requires("executorch") or [] + except Exception: + return set() + + wanted, names = set(), set() + for requirement in declared: + # Only the distribution name, dropping any version specifier, extra or + # environment marker. + name = re.split(r"[\s;\[<>=!~(]", requirement.strip(), maxsplit=1)[0] + if not name: + continue + normalised = name.lower().replace("-", "_").replace(".", "_") + wanted.add(normalised) + # The likely import name, so a MISSING declared dependency is still + # recognised as declared rather than silently skipped. + names.add(name) + names.add(name.replace("-", "_")) + names.add(name.split(".")[0]) + names.update(unrelated_import_names.get(normalised, ())) + + for import_name, distributions in packages_distributions().items(): + for distribution in distributions: + if distribution.lower().replace("-", "_").replace(".", "_") in wanted: + names.add(import_name) + return names + + +def _installed_package_dir() -> Path: + """The installed executorch package, never the source checkout. + + Enforced rather than assumed. Python puts the working directory on the module + search path, so running from a checkout resolves `executorch` to the source + tree, where there are no shipped libraries and every check below passes while + testing nothing. That is worse than a failure, because it looks like a pass. + """ + import executorch + + paths = [Path(entry).resolve() for entry in executorch.__path__] + # Every entry, not just the first. This is a namespace package, so a checkout on + # the module search path adds a second entry, and a module can then resolve from + # the checkout while the first entry still looks like a clean install. + outside = [ + path + for path in paths + if "site-packages" not in path.parts and "dist-packages" not in path.parts + ] + assert not outside, ( + f"executorch also resolves through {outside}, which is not an installed " + "package. Run this from a directory that contains no executorch checkout, " + "or the checks silently inspect the source tree instead of the wheel." + ) + assert len(paths) == 1, ( + f"executorch resolves through {len(paths)} paths ({paths}). Even when all of " + "them are installs, a module could come from either, so which artifact is " + "under test is ambiguous." + ) + return paths[0] + + +def _tool(name: str): + """Locate a build tool, including one pip installed beside this interpreter. + + `shutil.which` searches PATH only, and a virtual environment's `bin` is on PATH + only when the environment is activated. These tests are normally run by invoking + the interpreter directly, so a tool pip installed into that environment is present + on disk and invisible to a PATH search. + """ + found = shutil.which(name) + if found: + return found + beside = Path(sys.executable).parent / name + return str(beside) if beside.is_file() else None + + +def _shipped_shared_objects(package_dir: Path): + """Every shared object the wheel installed. + + Asserts it found some, because a wheel that installed none would let every check that walks this list + report success having examined nothing. + """ + found = [ + path + for path in sorted(package_dir.rglob("*.so*")) + if path.is_file() and not path.is_symlink() + ] + assert ( + found + ), f"no shared objects found under {package_dir}, so nothing below would be checking them" + return found + + +def _shipped_runtime_libraries(package_dir: Path): + """The libraries the wheel ships under lib/, whatever it names them. + + One place, because three checks previously spelled the pattern themselves as + `*.so.*` and every one of them silently stopped matching when the build moved to + unversioned names. The failure was invisible: a check that finds nothing reports + that the component is absent, which each of those treats as acceptable. + + Matches a versioned name too, so a build that does set SOVERSION is still found. + """ + lib_dir = package_dir / "lib" + if not lib_dir.is_dir(): + return [] + return [ + path + for path in sorted(lib_dir.glob("lib*.so*")) + if path.is_file() and not path.is_symlink() + ] + + +def _defines_symbol(library: Path, symbol: str) -> bool: + """Whether `library` owns a definition of `symbol`, read from the dynamic table. + + Limited to exported definitions on purpose, because that is all the shipped + artifacts carry: every library the wheel ships is stripped, so the static symbol + table `nm -C` would read is gone. Measured on a real wheel, `nm -C` finds zero of + these sentinels while `nm -DC` finds them, so widening the reader would turn this + check off rather than strengthen it. + + A duplicate hidden behind non-default visibility would therefore not be seen here. + That is a real gap and it is covered from the other side instead, by + `test_one_registry_in_the_cpp_process` in `test_cpp_sdk.py`, which counts + registered backends in a running process and so does not depend on any symbol + being visible at all. + """ + result = subprocess.run( + [_tool("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 _assert_single_definer(symbols, what: str, owner: str | None = None) -> None: + """At most one shipped library may define each of `symbols`. + + The owner is named where one is expected, because counting definers alone does + not prove the split happened: the monolithic layout has exactly one definer too, + the Python extension. Requiring the symbol to live in the library that is + supposed to own it is what distinguishes the two. + + A component the wheel does not ship at all is a valid configuration, not a + fault. Delegates and kernel sets are build options, so a wheel built without one + has zero definers and is reported as such. What must never happen is two. + """ + assert _tool("nm") is not None, "nm is required to inspect the wheel" + + package_dir = _installed_package_dir() + libraries = _shipped_shared_objects(package_dir) + assert libraries, f"no shared libraries found under {package_dir}" + + # Every symbol is resolved before anything is reported, so a component that is only + # half present is described as such rather than looking like one that is absent. + found = { + symbol: [lib for lib in libraries if _defines_symbol(lib, symbol)] + for symbol in symbols + } + # A component is either wholly present or wholly absent. Some symbols defined and + # others not means a partial build, which is neither of those and is a fault. + present = {symbol for symbol, definers in found.items() if definers} + if not present: + # When the caller has already established that the owner library ships, finding none of its + # symbols is a fault rather than an absence. Returning success here made this a no-op the moment + # a sentinel symbol was renamed or inlined, which turns the ownership check off without anyone + # noticing, and one of these sentinels is a two-line accessor. + assert owner is None, ( + f"the wheel ships {owner}, which owns the {what}, but none of its symbols " + f"{sorted(found)} are defined anywhere. Either the sentinel symbols were renamed or " + "inlined, in which case this check needs updating, or the library is empty." + ) + print(f"- this wheel ships no {what}, nothing to check") + return + assert len(present) == len(found), ( + f"the wheel defines only part of the {what}: {sorted(present)} are present " + f"and {sorted(set(found) - present)} are not, so it is neither shipped nor " + "absent" + ) + + for symbol, definers in found.items(): + pretty = [str(lib.relative_to(package_dir)) for lib in definers] + assert len(definers) == 1, ( + f"expected at most one library to define {symbol}, found " + f"{len(definers)}: {pretty}. More than one definition means the " + f"process has more than one {what}." + ) + if owner is not None: + assert definers[0].name.startswith(owner), ( + f"{symbol} is defined by {pretty[0]}, but it belongs in {owner}. One " + "definer is not enough on its own: the monolithic layout this change " + "replaces also had exactly one, inside the Python extension." + ) + where = f" owned by {owner}" if owner else "" + print(f"✓ single {what}{where} across {len(libraries)} shipped libraries") + + +# Each component the wheel ships as its own library, the symbols that identify it, +# and the library that must own them. `required` says whether the owner has to be +# present: the optimized kernels are optional, because a wheel built without them +# deliberately links the portable ops into the Python extension instead, which is a +# supported configuration rather than a duplicate. +# +# A table rather than one function per component, because the per-function form let +# one of them drift: it looked up its library with its own glob, which silently +# stopped matching when the libraries were renamed while the others kept working. +_OWNED_COMPONENTS = ( + ("backend registry", _REGISTRY_SYMBOLS, "libexecutorch.so", True), + ("operator registry", _KERNEL_REGISTRY_SYMBOLS, "libexecutorch.so", True), + ("thread pool", _THREADPOOL_SYMBOLS, "libexecutorch_threadpool.so", True), + ("profiler", _ETDUMP_SYMBOLS, "libexecutorch_etdump.so", True), + ( + "XNNPACK delegate", + _XNNPACK_SYMBOLS, + "libexecutorch_backend_xnnpack.so", + True, + ), + ( + "set of CPU kernels", + _KERNEL_SYMBOLS, + "libexecutorch_kernels_optimized.so", + False, + ), + # The third-party code these libraries bundle, checked separately from the + # wrappers above. A wrapper can have a single owner while the implementation + # underneath it is bundled into two of these, which is two real thread pools or + # two XNNPACK runtimes. + # + # One copy among the libraries this wheel ships, which is what this change + # controls. torch links the same projects and exports the same symbols, so the + # process still holds two definitions and which one a caller reaches depends on + # load order. Fixing that needs an explicit export list: hiding them wholesale + # with --exclude-libs,ALL breaks aarch64, where the optimized kernels resolve + # cpuinfo_initialize from the thread pool across a library boundary. + ( + "bundled thread pool implementation", + _BUNDLED_THREADPOOL_SYMBOLS, + "libexecutorch_threadpool.so", + True, + ), + ( + "bundled XNNPACK runtime", + _BUNDLED_XNNPACK_SYMBOLS, + "libexecutorch_backend_xnnpack.so", + True, + ), +) + + +def test_each_component_has_one_owner() -> None: + """No component may be defined by more than one library the wheel ships. + + This is the property the split exists to create. Two copies of a component mean + two registries or two thread pools in one process, and a static initializer that + registers into a table nothing else reads shows up as an operator missing at run + time rather than as a link error. + """ + shipped = { + path.name for path in _shipped_runtime_libraries(_installed_package_dir()) + } + for what, symbols, owner, required in _OWNED_COMPONENTS: + present = any(name.startswith(owner) for name in shipped) + assert present or not required, ( + f"the wheel ships no {owner}, which owns the {what}. Either packaging " + "dropped it or the build did not produce it." + ) + _assert_single_definer(symbols, what, owner if present else None) + + +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. + + The list is discovered from the installed package rather than written here, so + an extension added later is covered without anyone remembering to add it. A + hardcoded list is how the ones this change relinked went untested. + """ + package_dir = _installed_package_dir() + modules = [] + for extension in sorted(package_dir.rglob("*.so")): + # Only Python extensions, which carry the interpreter's suffix. The plain + # shared libraries under lib/ are checked by the load test instead. + if ".cpython-" not in extension.name: + continue + relative = extension.relative_to(package_dir).parent + module = extension.name.split(".", 1)[0] + modules.append(".".join(["executorch", *relative.parts, module])) + assert modules, "the wheel ships no Python extension, which cannot be right" + + # 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, PROVIDED the wheel does not claim to require it. + # 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)([\w.]+)", + result.stderr, + ) + if missing_python_package: + absent = missing_python_package.group(1).split(".")[0] + # A package the extension needs must appear in the wheel's declared + # requirements. Without this the test was silent when a required package + # was omitted from Requires-Dist: the environment did not have it, so + # the import failed, and the previous rule skipped anything not already + # declared. That treats a missing declaration as coverage rather than + # as the bug it is. + assert absent in _declared_requirements(), ( + f"{module} cannot import because {absent} is missing, and the wheel " + "does not declare it. Add it to install_requires, or the extension " + "silently needs a package a user is not asked to install." + ) + print(f"- {module} needs {absent}, which 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.24) +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) +# The runtime headers include c10 headers, which belong to torch rather than to +# this wheel, so an out-of-tree operator project supplies them the same way it +# supplies torch itself. The package config does not and should not ship them. +# +# The include directory is passed in rather than found with find_package(Torch), +# because that enables the CUDA language and fails on a machine with a CUDA +# toolkit it cannot probe, which has nothing to do with compiling an operator. +target_include_directories(custom_op_check PRIVATE ${TORCH_INCLUDE_DIR}) +# Deliberately no target_compile_features here. These headers need C++20, and the +# package config is what has to say so. Setting it here would compile the check +# correctly while leaving a real consumer to fail. +""" + + +# 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 _tool("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. + [_tool("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 + # A non-zero exit with none of the expected text means ldd could not inspect + # the file at all, which a text-only search reads as "nothing wrong". A file + # under lib/ that is not a loadable object is a packaging defect, so treat it + # as one rather than passing it. + if ( + resolved.returncode != 0 + and "not found" not in combined + and "undefined symbol" not in combined + ): + unresolved[str(library.relative_to(package_dir))] = [ + f"ldd could not inspect this file: {combined.strip()[:160]}" + ] + continue + 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 _tool("ldd") is None or _tool("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( + [_tool("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( + [_tool("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( + [_tool("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. + """ + # Skipped rather than asserted, the same as every other tool this suite needs. + # A missing compiler says nothing about the wheel, and aborting here would take + # the whole run down with it rather than reporting the one check it prevents. + if _tool("cmake") is None: + print("- cmake unavailable, skipping the custom op check") + return + + 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) + + # Torch's include directory is handed over directly, because the runtime headers + # include c10 headers that belong to torch. A real out-of-tree project supplies + # them the same way; the package config has no business shipping another + # project's headers. + if importlib.util.find_spec("torch") is None: + print("- torch is not installed, skipping the custom op check") + return + import torch + + torch_include = Path(torch.__path__[0]) / "include" + + configure = subprocess.run( + [ + _tool("cmake"), + "-S", + str(source_dir), + "-B", + str(build_dir), + f"-DCMAKE_PREFIX_PATH={package_dir / 'share' / 'cmake'}", + f"-DTORCH_INCLUDE_DIR={torch_include}", + ], + 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( + [_tool("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:]}" + ) + produced = list(build_dir.rglob("libcustom_op_check.so")) or list( + build_dir.rglob("custom_op_check.dll") + ) + assert produced, "the custom operator library was not produced" + + # Loaded, not just built. A shared library on Linux is allowed to have + # unresolved symbols, so an under-linked custom operator links successfully and + # fails only when something dlopens it and its registration initialiser runs. + # That is exactly the failure this contract exists to prevent. + if importlib.util.find_spec("torch") is None: + print("- torch is not installed, so the custom operator is only built") + return + loaded = subprocess.run( + [ + sys.executable, + "-c", + "import torch\n" + "from executorch.extension.pybindings import portable_lib\n" + f"torch.ops.load_library({str(produced[0])!r})\n" + "print('loaded')", + ], + capture_output=True, + text=True, + check=False, + env={ + key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" + }, + ) + assert loaded.returncode == 0, ( + "a custom operator built against the shipped extension cannot be loaded, so " + "it would fail at first use rather than at link time: " + f"{(loaded.stderr or loaded.stdout).strip()[-800:]}" + ) + 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 [] + + +# The architectures a Linux wheel tag can name. Matched by suffix rather than parsed +# positionally, because the version part differs between spellings (linux_x86_64, +# manylinux_2_28_x86_64, manylinux2014_x86_64) enough that a positional pattern picks +# it up as part of the name. +# +# Linux only, which is what this check reads. `arm64` deliberately absent: it is the +# macOS spelling, Linux uses aarch64, and including it made a macosx_11_0_arm64 tag +# look like something this could compare. A tag naming none of these is reported as +# unreadable rather than as a mismatch. +_WHEEL_ARCHITECTURES = ("x86_64", "aarch64", "i686", "ppc64le", "s390x", "armv7l") + + +def _wheel_architecture(tag: str): + """The architecture a platform tag names, or None if it names none of ours.""" + for architecture in _WHEEL_ARCHITECTURES: + if tag.endswith("_" + architecture): + return architecture + return None + + +def _tag_architectures_match(claimed: str, supported: str): + """Whether two platform tags name the same architecture. + + Returned rather than asserted so the same decision can be unit tested without a + wheel. The previous arrangement duplicated the comparison in the test, which meant + the test could pass while the shipped check was wrong, and that is exactly what + happened: the original defect was in how the caller compared the two tags, and a + test that re-implemented the comparison could not see it. + + None for either side means the tag names no architecture this project builds for, + which is a different failure from a mismatch and is reported separately. + """ + claimed_arch = _wheel_architecture(claimed) + supported_arch = _wheel_architecture(supported) + if claimed_arch is None or supported_arch is None: + return None + return claimed_arch == supported_arch + + +def test_wheel_platform_tag() -> None: + """The wheel's declared platform tag must name the architecture it was built for. + + Only the architecture. auditwheel cannot certify a glibc baseline for this wheel: + it depends on torch without vendoring torch's libraries, so the contents reference + libtorch.so from outside any manylinux policy and auditwheel reports a plain + linux_. That is the expected answer for a torch-dependent wheel rather than + a defect, and the manylinux tag on the file comes from the build image. Asserting + the baseline here failed every correct wheel. + + The architecture is still worth checking, because a wheel labelled with the wrong + one installs on machines it cannot run on at all, and that is a mistake this can + actually catch. + """ + if importlib.util.find_spec("auditwheel") is None: + # Installed here rather than skipped, because auditwheel is not in any CI + # image and a skip is indistinguishable from a pass in the summary. This + # check is the only thing that compares the wheel's declared tag against + # what its libraries actually need, and this change adds five libraries + # under that tag. + print("- auditwheel not present, installing it so this check can run") + installed = subprocess.run( + [sys.executable, "-m", "pip", "install", "--quiet", "auditwheel"], + capture_output=True, + text=True, + check=False, + ) + if installed.returncode != 0 or importlib.util.find_spec("auditwheel") is None: + raise AssertionError( + "auditwheel is required to check the wheel's platform tag and could not " + "be installed. Skipping instead would report a pass, and this is the only " + "check that compares the declared tag against what the shipped libraries " + f"actually need: {installed.stderr.strip()[-200:]}" + ) + importlib.invalidate_caches() + + 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:]}" + ) + claimed = wheels[-1].name.split("-")[-1].removesuffix(".whl") + supported = match.group(1) + + claimed_arch = _wheel_architecture(claimed) + supported_arch = _wheel_architecture(supported) + matches = _tag_architectures_match(claimed, supported) + assert matches is not None, ( + f"could not read an architecture from the declared tag {claimed} or from what " + f"auditwheel reported, {supported}" + ) + assert matches, ( + f"the wheel claims architecture {claimed_arch} but its contents are built for " + f"{supported_arch}, so it would install where it cannot run" + ) + print(f"✓ the wheel is tagged for the architecture it contains ({claimed_arch})") + + +def test_no_absolute_runtime_paths() -> None: + """No shipped library may search a directory a user does not have. + + Packaging copies libraries out of the build tree rather than installing them, so + every directory the linker recorded ships as-is. Two kinds are rejected on every + shipped library, not only the lib/ payload: + + - a directory inside the build, which names the machine that produced the wheel + - an empty entry, which the loader reads as the process working directory + + Torch's own directory is accepted. The extensions link torch and resolve it + through the directory the linker recorded, so that entry is load-bearing rather + than leftover. Narrowing this check to lib/ once hid seven extensions carrying + build-tree paths, so the exclusion is by what an entry POINTS AT, never by which + file carries it. + + The check reads the shipped file directly, with nothing stripped, which is what + a user actually receives. + """ + # Fatal, not a skip. Packaging strips these paths best-effort, because it cannot + # guarantee patchelf on PATH, so this is the only place the guarantee can be + # enforced. If both went quiet on the same missing tool, a wheel carrying the + # build machine's directories would ship looking correct. + if _tool("patchelf") is None: + print("- patchelf not present, installing it so this check can run") + subprocess.run( + [sys.executable, "-m", "pip", "install", "--quiet", "patchelf"], + capture_output=True, + text=True, + check=False, + ) + patchelf = _tool("patchelf") + assert patchelf is not None, ( + "patchelf is required to check the shipped runtime paths and could not be " + "installed. Packaging uses it to strip build-tree directories, and without it " + "here neither side would notice that they were left in place." + ) + + package_dir = _installed_package_dir() + + # Directories that only exist inside a build of this project. Compared as whole + # path components, the same way packaging decides what to strip, so the two do + # not describe the build tree differently. + def names_a_build_directory(entry: str) -> bool: + return any( + part in ("pip-out", "cmake-out") or part.startswith("lib.") + for part in entry.split("/") + ) + + # This project's libraries must not name an absolute directory the wheel has a relative route to. The + # one that shipped was a CUDA toolkit prefix recorded on the build machine: it sat ahead of the relative + # hop, so a user with a toolkit at the same prefix resolved the CUDA runtime from there instead of from + # the declared dependency, and the builder always has one, so nothing exercised the hop. + # + # Stated as a property rather than a list of known-bad directories, because a list only catches what + # someone already thought of and that prefix was not on one. + # + # PyTorch's own directory is allowed: the wheel neither declares nor bundles PyTorch, so an absolute + # path is the only way to reach it. The maths library directories are allowed too, because they come + # from PyTorch's build flags and reach everything that links PyTorch, including this project's own + # extensions, naming a location on whichever machine built PyTorch that nothing here can change. + allowed_absolute = ("/torch/lib", "/lib/intel64", "/lib/win-x64") + # PyTorch's own libraries are vendored into the wheel and also record a CUDA toolkit directory. That is + # the one path this check exists to reject on our libraries, so it is allowed only on theirs. + vendored_prefixes = ( + "libtorch", + "libc10", + "libshm", + "libcaffe2", + "libgomp", + "libiomp", + ) + + offenders = {} + checked = 0 + 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 + # An absent RPATH and one containing a single empty entry both print as an + # empty string, so treat empty output as "no runtime path" rather than as an + # empty entry. A library with nothing to search is fine; the defect is + # searching somewhere unusable. + raw = result.stdout.strip() + if not raw: + continue + checked += 1 + bad = [] + for entry in raw.split(":"): + if not entry: + bad.append("") + elif ( + entry.startswith("/") + and not any(allowed in entry for allowed in allowed_absolute) + and not library.name.startswith(vendored_prefixes) + ): + # Named separately so the message says which kind it is: a build directory and a + # toolkit prefix are the same defect with different causes. + kind = ( + "inside a build of this project" + if names_a_build_directory(entry) + else "an absolute directory the wheel has a relative route to" + ) + bad.append(f"{entry} ({kind})") + if bad: + offenders[str(library.relative_to(package_dir))] = bad + + assert not offenders, ( + "shipped libraries search directories a user does not have, either inside " + "the build tree that produced the wheel or, for an empty entry, the process " + f"working directory: {offenders}" + ) + assert checked, ( + f"no shipped library under {package_dir} carries a runtime search path, so this check examined " + "nothing and would pass on a wheel that shipped no libraries at all" + ) + print( + f"✓ none of the {checked} shipped libraries searches a build-tree or empty " + "runtime path" + ) + + +def test_extension_contains_no_component() -> None: + """The Python extension must link the components, not contain them. + + This is the property the change exists to create, and no count of definers + proves it: the monolithic layout has exactly one definer of every symbol too, + inside the extension. The direct statement is that the extension defines none of + what the shipped libraries own, and records a dependency on each instead. + """ + assert _tool("nm") is not None, "nm is required to inspect the wheel" + if _tool("readelf") is None: + print("- readelf unavailable, skipping the extension composition check") + return + + package_dir = _installed_package_dir() + extensions = sorted( + (package_dir / "extension" / "pybindings").glob("_portable_lib.*.so") + ) + assert len(extensions) == 1, f"expected one _portable_lib, found {extensions}" + extension = extensions[0] + + lib_dir = package_dir / "lib" + if not lib_dir.is_dir(): + print("- this wheel ships no lib directory, nothing to check") + return + + # Every symbol group a shipped library owns. The extension holding any of these + # means it still carries its own copy of that component. + # Derived from the ownership table rather than restated. A hand-written copy drifts: this once + # listed five symbol groups while the table covered eleven, so the components added later, including + # both CUDA ones, were never checked here. + # + # The bundled third-party groups are left out on purpose. That code is also linked by torch, and the + # extension links torch, so seeing those symbols there says nothing about this split. + # Only components whose owning library is actually in this wheel. One of them is optional, so a build + # with it turned off ships no owner, and asserting the extension does not define its symbols would + # reject a configuration the table itself marks as supported. + shipped = { + path.name for path in _shipped_runtime_libraries(_installed_package_dir()) + } + owned = tuple( + symbol + for _, symbols, owner, required in _OWNED_COMPONENTS + if symbols not in (_BUNDLED_THREADPOOL_SYMBOLS, _BUNDLED_XNNPACK_SYMBOLS) + and (required or any(name.startswith(owner) for name in shipped)) + for symbol in symbols + ) + contained = [symbol for symbol in owned if _defines_symbol(extension, symbol)] + assert not contained, ( + f"{extension.name} defines {contained}, which the shipped libraries own. The " + "extension is supposed to link them rather than contain them, so this is the " + "monolithic layout the split removes." + ) + + # And it has to actually depend on each shipped library. Defining nothing while + # depending on nothing would be an extension that cannot work at all. + needed = { + line.split("[", 1)[1].rstrip("]").strip() + for line in subprocess.run( + [_tool("readelf"), "-d", str(extension)], + capture_output=True, + text=True, + check=True, + ).stdout.splitlines() + if "NEEDED" in line + } + shipped = {path.name for path in _shipped_runtime_libraries(package_dir)} + assert shipped, ( + f"the wheel installed no runtime libraries under {package_dir / 'lib'}, so this check would " + "compare the extension against nothing and pass" + ) + unused = sorted(shipped - needed) + assert not unused, ( + f"the wheel ships {unused} but {extension.name} does not depend on them, so " + "either they are dead weight or a retention option did not hold" + ) + + # Positive proof that the extension resolves these from elsewhere, rather than + # only the absence of a visible definition. A hidden or local copy would not + # appear in the dynamic symbol table at all, so "defines nothing" on its own is + # satisfiable by an extension that still carries its own private runtime. An + # UNDEFINED reference cannot be faked that way: it says the definition is not + # here and has to come from a dependency. + undefined = subprocess.run( + [_tool("nm"), "-DC", "--undefined-only", str(extension)], + capture_output=True, + text=True, + check=False, + ).stdout + imported = [ + symbol + for symbol in (*_REGISTRY_SYMBOLS, *_THREADPOOL_SYMBOLS) + if symbol in undefined + ] + assert len(imported) == len(_REGISTRY_SYMBOLS) + len(_THREADPOOL_SYMBOLS), ( + f"{extension.name} does not import every registry and thread pool symbol it " + "uses, so it may carry a hidden copy that the visible symbol table does not " + f"show. Imported: {imported}" + ) + print( + f"✓ {extension.name} ({extension.stat().st_size // 1024} KiB) contains no " + f"component, imports the runtime symbols it uses, and depends on all " + f"{len(shipped)} shipped libraries" + ) + + +def test_shipped_library_names_are_expected() -> None: + """Every library in lib/ must be one this build could have produced. + + Packaging copies binaries out of a staging directory rather than running an + install step, so anything left there from an earlier build ships too. That + really happened: a wheel picked up three libraries from a different revision + and still passed every symbol check, because those checks only ask how many + definers a symbol has, never whether a file belongs in the wheel at all. + + Two properties catch it. A library's recorded soname matches its file name, or a + consumer records a dependency the wheel does not contain. And its name is one + packaging knows how to produce, which is what a leftover from an older layout + fails. A wheel ships unversioned names on purpose, so the name itself carries no + version to check. + """ + package_dir = _installed_package_dir() + lib_dir = package_dir / "lib" + if not lib_dir.is_dir(): + print("- this wheel ships no lib directory, nothing to check") + return + + # Regular files only. A symlink here would be a deliberate alias rather than the + # stale-artifact case this check is about, and a leftover from an earlier build is + # a real file, so it is still caught. + shipped = sorted( + p for p in lib_dir.glob("*.so*") if p.is_file() and not p.is_symlink() + ) + assert shipped, f"the wheel ships a lib directory with no libraries: {lib_dir}" + + # The names packaging can put here. Listed rather than derived because setup.py + # names each one literally, and a file with any other name did not come from + # this build. Which of them are present depends on the build options, so + # absence is fine and an unknown name is not. + # + # Matched in full rather than by taking the part before the first ".so", because + # that prefix is satisfied by a name like libexecutorch.so.old.so.1, which is + # exactly the shape a leftover file takes. + known = ( + "libexecutorch", + "libexecutorch_kernels_optimized", + "libexecutorch_backend_xnnpack", + "libexecutorch_threadpool", + "libexecutorch_etdump", + ) + # A plain .so, because the wheel build does not version these. A trailing + # .so. would also be a name packaging did not produce here. + permitted = re.compile(rf"(?:{'|'.join(known)})\.so") + unknown = sorted(p.name for p in shipped if not permitted.fullmatch(p.name)) + assert not unknown, ( + f"the wheel ships {unknown} under lib/, which packaging does not produce. " + "A file packaging did not put there came from a stale staging directory, " + "and it ships while looking correct to every other check." + ) + + if _tool("readelf") is None: + print(f"✓ {len(shipped)} shipped libraries have expected names") + return + + # The recorded soname has to match the file name, or a consumer records a + # dependency on a name the wheel does not contain. + mismatched = {} + for library in shipped: + dynamic = subprocess.run( + [_tool("readelf"), "-d", str(library)], + capture_output=True, + text=True, + check=False, + ).stdout + soname = next( + ( + line.split("[", 1)[1].rstrip("]").strip() + for line in dynamic.splitlines() + if "SONAME" in line + ), + None, + ) + if soname != library.name: + mismatched[library.name] = soname + assert not mismatched, ( + "shipped libraries record a soname that is not their file name, so a " + f"consumer would look for a file the wheel does not ship: {mismatched}" + ) + print(f"✓ {len(shipped)} shipped libraries have expected names and sonames") + + +_PARITY_MODEL = ''' +import json +import sys + +import torch +from executorch.exir import to_edge_transform_and_lower +from executorch.extension.pybindings.portable_lib import ( + _load_for_executorch_from_buffer, +) + + +class Net(torch.nn.Module): + """Several operator kinds rather than one, so the run exercises the merged CPU + kernels rather than a single add.""" + + def __init__(self): + super().__init__() + self.linear = torch.nn.Linear(8, 16) + self.conv = torch.nn.Conv2d(1, 4, 3, padding=1) + + def forward(self, x, image): + a = torch.relu(self.linear(x)) + b = self.conv(image).flatten(1) + return a.sum(dim=1, keepdim=True) + b.mean(dim=1, keepdim=True) + + +delegate = sys.argv[1] == "delegate" +torch.manual_seed(0) +model = Net().eval() +example = (torch.randn(2, 8), torch.randn(2, 1, 6, 6)) +with torch.no_grad(): + expected = model(*example) + +partitioners = [] +if delegate: + from executorch.backends.xnnpack.partition.xnnpack_partitioner import ( + XnnpackPartitioner, + ) + + partitioners = [XnnpackPartitioner()] + +program = to_edge_transform_and_lower( + torch.export.export(model, example), partitioner=partitioners +).to_executorch() +buffer = program.buffer + +actual = _load_for_executorch_from_buffer(buffer).forward(list(example))[0] +# Compared rather than merely run. The point of the split is that behaviour does +# not change, and only a numeric comparison shows that; a model that returns +# wrong values without erroring passes everything else. +torch.testing.assert_close(actual, expected, rtol=1e-4, atol=1e-4) + +print(json.dumps({"delegated": delegate, "has_xnnpack": b"XnnpackBackend" in bytes(buffer)})) +''' + + +def test_model_matches_eager_pytorch(work_dir: Path) -> None: + """A model exported and run through the bindings must match eager PyTorch. + + Twice: once plain, so the CPU kernels resolve from the shared library, and once + delegated to XNNPACK, so the delegate does. The delegated program is also + checked for the delegate's own identity, because a partitioner that claimed + nothing would silently fall back to the CPU kernels and still match. + + Separate processes, because a fault in one export leaves state that makes the + next look broken when it is not. + """ + if importlib.util.find_spec("torch") is None: + print("- torch is not installed, skipping the eager comparison") + return + + work_dir.mkdir(parents=True, exist_ok=True) + script = work_dir / "parity.py" + script.write_text(_PARITY_MODEL) + + # Both halves run. The delegate is a required component in the ownership table + # above, so a wheel reaching here without it has already failed that check, and + # tolerating its absence here would only hide a second symptom of the same fault. + for mode in ["plain", "delegate"]: + result = subprocess.run( + [sys.executable, str(script), mode], + capture_output=True, + text=True, + check=False, + cwd=str(work_dir), + ) + assert result.returncode == 0, ( + f"the {mode} model does not export, run, and match eager PyTorch: " + f"{(result.stderr or result.stdout).strip()[-1500:]}" + ) + report = json.loads(result.stdout.strip().splitlines()[-1]) + if mode == "delegate": + assert report["has_xnnpack"], ( + "the delegated export produced a program with no XNNPACK partition, " + "so the delegate was never exercised and the comparison only proves " + "the CPU kernels work" + ) + print(f"✓ the {mode} model matches eager PyTorch") + + +def test_declared_dependencies_match_the_wheel_tag() -> None: + """A CPU wheel must not declare the CUDA runtime, and a CUDA wheel must declare it. + + The tag is what a user resolves against, so a mismatch is a promise the wheel cannot keep in + either direction: a CPU wheel that pulls the CUDA packages costs a user hundreds of megabytes it + never loads, and a CUDA wheel that declares nothing leaves the runtime unresolvable. + + This is metadata only, so no library check can see it. A CPU wheel that wrongly declared the CUDA + runtime passed every other check in this file. + """ + requirements = importlib.metadata.requires("executorch") or [] + cuda = sorted(r.split()[0] for r in requirements if r.lower().startswith("nvidia")) + + # The local version segment of the installed version states what the wheel was built for. + version = importlib.metadata.version("executorch") + local = version.partition("+")[2] + is_cuda_wheel = local.startswith("cu") + + if is_cuda_wheel: + assert cuda, ( + f"version {version} says this is a CUDA wheel, but it declares no CUDA runtime " + "packages, so nothing resolves the runtime it links" + ) + print(f"✓ this CUDA wheel declares the runtime ({len(cuda)} packages)") + else: + assert not cuda, ( + f"version {version} is not a CUDA wheel, yet it declares {cuda}. A user installing it " + "would download the CUDA runtime this wheel never loads." + ) + print("✓ this non-CUDA wheel declares no CUDA runtime") + + +def run_tests(work_dir: Path) -> None: + # Ordered by what a failure tells you, because these run in sequence and the + # first failure stops the rest. The checks that prove the split behaves + # correctly come first; packaging metadata comes last, so a weak check cannot + # hide a strong one. + test_each_component_has_one_owner() + test_python_extensions_import() + test_declared_dependencies_match_the_wheel_tag() + test_extension_contains_no_component() + test_shipped_library_names_are_expected() + test_shipped_libraries_load() + test_shipped_libraries_resolve_without_build_tree() + test_custom_op_compiles(work_dir) + test_no_absolute_runtime_paths() + test_model_matches_eager_pytorch(work_dir) + # Last: a wrong answer here says the wheel is labelled wrong, not that the + # split is broken. + test_wheel_platform_tag() 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..5511fab231e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -193,6 +193,19 @@ if(DEFINED EXECUTORCH_BAREMETAL_SKIP_INSTALL endif() if(EXECUTORCH_BUILD_SHARED) + # Linux only, and said here rather than left to fail somewhere downstream. The + # shared build names libraries with an ELF soname, records $ORIGIN runtime + # paths, and uses GNU linker options to keep a registration-only library on a + # link line. None of that applies on Apple, which is served by the Swift + # package distribution, or on Windows, where the runtime carries no export + # annotations for a DLL. Enabling it elsewhere failed much later and less + # clearly, when packaging looked for a .so the build never emitted. + if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + message( + FATAL_ERROR "EXECUTORCH_BUILD_SHARED is supported on Linux only, not " + "${CMAKE_SYSTEM_NAME}." + ) + endif() set(CMAKE_POSITION_INDEPENDENT_CODE ON) endif() @@ -284,18 +297,54 @@ add_subdirectory(third-party) # state crash at import time. This function replaces pybind11::embed with # pybind11::module (which links Python::Module instead of Python::Python — # headers and ABI only, no libpython) and adds -undefined dynamic_lookup for -# symbol resolution. No-op on non-Apple platforms. +# symbol resolution. +# +# Linux needs the same treatment for a different reason. An extension is loaded +# by an interpreter that already has the Python runtime in the process, so it +# must not carry its own dependency on libpython: the interpreter's library +# directory is not on any search path a wheel can predict, and an interpreter +# built with a shared libpython leaves the extension unable to resolve it. The +# undefined symbols are supplied by the loading process, which is how a Python +# extension normally works. No-op on Windows, where extensions link the import +# library by design. function(strip_python_lib target) - if(NOT APPLE) + if(MSVC) return() endif() + # The module helper links the embedding form of Python for anything that is + # not a MODULE library, and that form brings a hard dependency on the + # interpreter's shared library plus an absolute path to wherever it was found + # on the build machine. Neither survives being shipped: the loader cannot + # satisfy the dependency from an installed wheel, and the absolute path names + # the build machine. + # + # These targets cannot simply become MODULE libraries, because other targets + # link them and CMake refuses to link a MODULE. So keep the type and replace + # the Python library instead. An extension does not need it: the interpreter + # already provides those symbols to anything it loads. + # + # The interfaces arrive as PRIVATE links, so they appear in LINK_LIBRARIES + # wrapped rather than as bare target names. Rewrite the property from the + # filtered list instead of relying on a name match against the wrapped form. get_target_property(_libs ${target} LINK_LIBRARIES) if(_libs) - list(REMOVE_ITEM _libs Python::Python pybind11::embed) - list(APPEND _libs pybind11::module) - set_target_properties(${target} PROPERTIES LINK_LIBRARIES "${_libs}") + set(_kept "") + foreach(_lib IN LISTS _libs) + # Match the embedding interfaces however they are spelled, including + # inside a wrapper. + if(NOT _lib MATCHES "(pybind11::embed|Python3?::Python)") + list(APPEND _kept "${_lib}") + endif() + endforeach() + list(APPEND _kept pybind11::module) + set_target_properties(${target} PROPERTIES LINK_LIBRARIES "${_kept}") + endif() + if(APPLE) + # Apple's linker needs telling that the undefined symbols resolve at load + # time. The Linux loader already permits that for a shared object and does + # not accept the flag. + target_link_options(${target} PRIVATE "LINKER:-undefined,dynamic_lookup") endif() - target_link_options(${target} PRIVATE "LINKER:-undefined,dynamic_lookup") endfunction() # Size-optimized builds disable exceptions, RTTI, and unwind tables. @@ -932,6 +981,55 @@ 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 + ) + # Ships in the wheel's lib/ directory beside the libraries that link it. + executorch_target_shipped_runtime_path(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() + if(EXECUTORCH_BUILD_KERNELS_TORCHAO) if(NOT TARGET cpuinfo) message( @@ -992,16 +1090,21 @@ if(EXECUTORCH_BUILD_KERNELS_TORCHAO) endif() +# The shared build ships the profiler as one of its libraries, and the Python +# extension records a hard dependency on it, so the target has to exist whenever +# either of those is being built rather than only when devtools is asked for. +if((EXECUTORCH_BUILD_PYBIND OR EXECUTORCH_BUILD_SHARED) + AND NOT EXECUTORCH_BUILD_DEVTOOLS +) + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/devtools) +endif() + if(EXECUTORCH_BUILD_PYBIND) if(NOT EXECUTORCH_BUILD_EXTENSION_DATA_LOADER) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/extension/data_loader) endif() - if(NOT EXECUTORCH_BUILD_DEVTOOLS) - add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/devtools) - endif() - # Add codegen tools subdirectory for selective_build pybind module add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/codegen/tools) @@ -1016,10 +1119,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 +1150,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 +1190,19 @@ 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. set(_portable_lib_rpath "$ORIGIN/../../../torch/lib") 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) @@ -1115,9 +1253,15 @@ if(EXECUTORCH_BUILD_PYBIND) endif() if(EXECUTORCH_BUILD_XNNPACK) - # need to explicitly specify XNNPACK and xnnpack-microkernels-prod here - # otherwise uses XNNPACK and microkernel-prod symbols from libtorch_cpu - list(APPEND _dep_libs xnnpack_backend XNNPACK xnnpack-microkernels-prod) + if(EXECUTORCH_BUILD_SHARED) + # The delegate bundles XNNPACK and its microkernels, so naming them again + # here would ship a second copy. + list(APPEND _dep_libs xnnpack_backend) + else() + # need to explicitly specify XNNPACK and xnnpack-microkernels-prod here + # otherwise uses XNNPACK and microkernel-prod symbols from libtorch_cpu + list(APPEND _dep_libs xnnpack_backend XNNPACK xnnpack-microkernels-prod) + endif() endif() if(EXECUTORCH_BUILD_VULKAN) @@ -1150,7 +1294,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 +1315,25 @@ 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) + # These libraries register their operators or their backend from a static + # initializer, so nothing here references a symbol from them and some linkers + # drop them from DT_NEEDED. That surfaces at runtime as a missing kernel or an + # unregistered backend rather than as a link error. Every shipped library, not + # only the two that register something. A library reached transitively is + # still subject to --as-needed and gets dropped, so the thread pool and the + # profiler need naming here too even though the extension does reference + # symbols from them. A distro toolchain that defaults to --as-needed would + # otherwise leave them out of DT_NEEDED entirely. + foreach(_retained_component optimized_native_cpu_ops_lib xnnpack_backend + extension_threadpool etdump + ) + if(TARGET ${_retained_component}) + executorch_target_retain_shared_library( + portable_lib ${_retained_component} + ) + endif() + endforeach() # Set RPATH to find PyTorch and backend libraries relative to the installation # location. This goes from executorch/extension/pybindings up to @@ -1184,6 +1351,9 @@ if(EXECUTORCH_BUILD_PYBIND) INSTALL_RPATH "${_portable_lib_rpath}" ) endif() + executorch_target_shared_runtime_path( + portable_lib "extension/pybindings" "executorch/extension/pybindings" + ) install( TARGETS portable_lib @@ -1199,7 +1369,18 @@ 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) + executorch_target_shared_runtime_path( + data_loader "extension/pybindings" "executorch/extension/pybindings" + ) + else() + target_link_libraries(data_loader PRIVATE executorch) + endif() install(TARGETS data_loader LIBRARY DESTINATION executorch/extension/pybindings ) @@ -1246,49 +1427,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..7f82eb06590 100644 --- a/backends/qualcomm/CMakeLists.txt +++ b/backends/qualcomm/CMakeLists.txt @@ -255,8 +255,20 @@ 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) + executorch_target_shared_runtime_path( + qnn_executorch_backend "backends/qualcomm" + "${CMAKE_INSTALL_LIBDIR}/executorch/backends/qualcomm" + ) +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 +371,27 @@ 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) + executorch_target_shared_runtime_path( + PyQnnManagerAdaptor "backends/qualcomm/python" + "${CMAKE_INSTALL_LIBDIR}/executorch/backends/qualcomm/python" + ) + else() + target_link_libraries(PyQnnManagerAdaptor PRIVATE executorch) + endif() pybind11_extension(PyQnnManagerAdaptor) if(NOT MSVC AND NOT ${CMAKE_BUILD_TYPE} MATCHES RelWithDebInfo) diff --git a/backends/xnnpack/CMakeLists.txt b/backends/xnnpack/CMakeLists.txt index cd0d945a84f..abd4bd596fe 100644 --- a/backends/xnnpack/CMakeLists.txt +++ b/backends/xnnpack/CMakeLists.txt @@ -96,16 +96,48 @@ target_include_directories( $ ) -set(xnnpack_third_party pthreadpool extension_threadpool cpuinfo) +if(EXECUTORCH_BUILD_SHARED) + # extension_threadpool is a shared library here and already provides + # pthreadpool and cpuinfo. Naming the static archives as well would give this + # delegate its own second copy of both, so a process would end up with two + # thread pools rather than the one the shared library exists to provide. + set(xnnpack_third_party extension_threadpool) +else() + set(xnnpack_third_party pthreadpool extension_threadpool cpuinfo) +endif() include(cmake/Dependencies.cmake) list(TRANSFORM _xnnpack_backend__srcs PREPEND "${EXECUTORCH_ROOT}/") -add_library(xnnpack_backend ${_xnnpack_backend__srcs}) +# Build the delegate as a shared library for the wheel so a process has one copy +# of it, and keep it static everywhere else so no other build changes. +if(EXECUTORCH_BUILD_SHARED) + set(_xnnpack_backend_library_type SHARED) +else() + set(_xnnpack_backend_library_type STATIC) +endif() +add_library( + xnnpack_backend ${_xnnpack_backend_library_type} ${_xnnpack_backend__srcs} +) target_link_libraries( - xnnpack_backend PUBLIC ${xnnpack_third_party} executorch_core xnnpack_schema + xnnpack_backend PUBLIC ${xnnpack_third_party} xnnpack_schema extension_threadpool ) +if(EXECUTORCH_BUILD_SHARED) + set_target_properties( + xnnpack_backend PROPERTIES OUTPUT_NAME executorch_backend_xnnpack + ) + executorch_target_soname_policy(xnnpack_backend) + # XNNPACK and its microkernels are forced static, so bundle them inside this + # library instead of making every consumer supply them. + executorch_target_whole_archive(xnnpack_backend XNNPACK) + executorch_target_whole_archive(xnnpack_backend xnnpack-microkernels-prod) + target_link_libraries(xnnpack_backend PUBLIC executorch_shared) + # Ships beside the runtime in the wheel's lib/ directory. + executorch_target_shipped_runtime_path(xnnpack_backend) +else() + target_link_libraries(xnnpack_backend PUBLIC executorch_core) +endif() target_include_directories( xnnpack_backend PUBLIC ${_common_include_directories} ) diff --git a/codegen/tools/CMakeLists.txt b/codegen/tools/CMakeLists.txt index b829e83c340..143d2c18c6d 100644 --- a/codegen/tools/CMakeLists.txt +++ b/codegen/tools/CMakeLists.txt @@ -43,7 +43,20 @@ 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 + ) + executorch_target_shared_runtime_path( + selective_build "codegen/tools" "executorch/codegen/tools" + ) +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/configurations/CMakeLists.txt b/configurations/CMakeLists.txt index fb154ff88bc..2379630c00e 100644 --- a/configurations/CMakeLists.txt +++ b/configurations/CMakeLists.txt @@ -50,7 +50,15 @@ if(EXECUTORCH_BUILD_KERNELS_OPTIMIZED) else() set(_optimized_native_cpu_ops_lib_portable_kernels_lib portable_kernels) endif() + # Ship this as a shared library in the wheel so the kernels are registered + # once per process instead of once per component that links them. + if(EXECUTORCH_BUILD_SHARED) + set(_merged_cpu_ops_library_type SHARED) + else() + set(_merged_cpu_ops_library_type "") + endif() gen_operators_lib( + ${_merged_cpu_ops_library_type} LIB_NAME "optimized_native_cpu_ops_lib" KERNEL_LIBS @@ -65,4 +73,15 @@ if(EXECUTORCH_BUILD_KERNELS_OPTIMIZED) EXPORT ExecuTorchTargets DESTINATION ${CMAKE_INSTALL_LIBDIR} ) + if(EXECUTORCH_BUILD_SHARED) + # Named after what the library provides rather than after the code + # generation target that produces it, so the shipped file reads as + # libexecutorch_kernels_optimized.so. The target name stays as it is because + # a source build already refers to it. + set_target_properties( + optimized_native_cpu_ops_lib PROPERTIES OUTPUT_NAME + executorch_kernels_optimized + ) + executorch_target_soname_policy(optimized_native_cpu_ops_lib) + endif() endif() 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..cf331e24416 100644 --- a/devtools/etdump/CMakeLists.txt +++ b/devtools/etdump/CMakeLists.txt @@ -39,8 +39,19 @@ add_custom_command( COMMENT "Generating etdump headers" ) +# The profiler is reachable from both the Python extension and a standalone C++ +# application, and each one linking it statically would keep its own tracing +# state. Build it shared for the wheel, where both are loaded into one process, +# and keep it static everywhere else so no other build changes. +if(EXECUTORCH_BUILD_SHARED) + set(_etdump_library_type SHARED) +else() + set(_etdump_library_type STATIC) +endif() + add_library( etdump + ${_etdump_library_type} ${_schema_outputs} ${CMAKE_CURRENT_SOURCE_DIR}/etdump_flatcc.cpp ${CMAKE_CURRENT_SOURCE_DIR}/emitter.cpp @@ -49,11 +60,27 @@ 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 -) +# 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) + # Private, not public: this library bundles the flatbuffer runtime rather than + # depending on it, and the wheel does not ship flatccrt, so exposing it would + # name something a consumer cannot link. Scoped to the shared build so a build + # that does not opt in keeps the parent's public dependency. + target_link_libraries(etdump PRIVATE flatccrt executorch_shared) +else() + target_link_libraries(etdump PUBLIC flatccrt) + target_link_libraries(etdump PRIVATE executorch) +endif() +if(EXECUTORCH_BUILD_SHARED) + set_target_properties(etdump PROPERTIES OUTPUT_NAME executorch_etdump) + executorch_target_soname_policy(etdump) + # Ships beside libexecutorch.so in the wheel's lib/ directory, and needs it, + # so it has to be able to find it from wherever the package is installed. + executorch_target_shipped_runtime_path(etdump) +endif() + target_include_directories( etdump PUBLIC ${DEVTOOLS_INCLUDE_DIR} diff --git a/extension/llm/custom_ops/CMakeLists.txt b/extension/llm/custom_ops/CMakeLists.txt index 8a43a5ddf5c..b858ffd1396 100644 --- a/extension/llm/custom_ops/CMakeLists.txt +++ b/extension/llm/custom_ops/CMakeLists.txt @@ -145,18 +145,36 @@ if(EXECUTORCH_BUILD_KERNELS_LLM_AOT) else() set(RPATH "$ORIGIN/../../pybindings") endif() - set_target_properties(custom_ops_aot_lib PROPERTIES INSTALL_RPATH ${RPATH}) + if(EXECUTORCH_BUILD_SHARED) + # Also on the built artifact, not only on install. Packaging copies this + # library out of the build tree rather than running an install step, so + # without this the built file carries whatever the linker recorded. Scoped + # to the shared build so a build that does not opt in keeps the parent's + # install-only behaviour. + set_target_properties( + custom_ops_aot_lib PROPERTIES BUILD_RPATH ${RPATH} INSTALL_RPATH ${RPATH} + ) + else() + set_target_properties(custom_ops_aot_lib PROPERTIES INSTALL_RPATH ${RPATH}) + endif() + executorch_target_shared_runtime_path( + custom_ops_aot_lib "extension/llm/custom_ops" + "executorch/extension/llm/custom_ops" + ) 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..20a6e934b35 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 @@ -141,6 +142,9 @@ if(EXECUTORCH_BUILD_PYBIND) set_target_properties( _llm_runner PROPERTIES BUILD_RPATH "${RPATH}" INSTALL_RPATH "${RPATH}" ) + executorch_target_shared_runtime_path( + _llm_runner "extension/llm/runner" "executorch/extension/llm/runner" + ) # Add include directories target_include_directories( _llm_runner PRIVATE ${_common_include_directories} ${TORCH_INCLUDE_DIRS} diff --git a/extension/threadpool/CMakeLists.txt b/extension/threadpool/CMakeLists.txt index 3b9c7c66ddb..ed16cb169ac 100644 --- a/extension/threadpool/CMakeLists.txt +++ b/extension/threadpool/CMakeLists.txt @@ -30,13 +30,38 @@ else() set(_threadpool_size_flag "EXECUTORCH_THREADPOOL_USE_PERFORMANCE_CORES") endif() +# The thread pool is a process-wide singleton held in a function-local static, +# so every library that links it statically gets its own copy. Build it shared +# for the wheel, where several extensions are loaded into one interpreter, and +# keep it static everywhere else so no other build changes. +if(EXECUTORCH_BUILD_SHARED) + set(_threadpool_library_type SHARED) +else() + set(_threadpool_library_type STATIC) +endif() + add_library( - extension_threadpool threadpool.cpp threadpool_guard.cpp thread_parallel.cpp - cpuinfo_utils.cpp -) -target_link_libraries( - extension_threadpool PUBLIC executorch_core cpuinfo pthreadpool + extension_threadpool + ${_threadpool_library_type} threadpool.cpp threadpool_guard.cpp + thread_parallel.cpp cpuinfo_utils.cpp ) +if(EXECUTORCH_BUILD_SHARED) + set_target_properties( + extension_threadpool PROPERTIES OUTPUT_NAME executorch_threadpool + ) + executorch_target_soname_policy(extension_threadpool) + # Ships beside libexecutorch.so in the wheel's lib/ directory. + executorch_target_shipped_runtime_path(extension_threadpool) + # cpuinfo and pthreadpool are forced static, so bundle them inside this + # library instead of making every consumer supply them. + executorch_target_whole_archive(extension_threadpool cpuinfo) + executorch_target_whole_archive(extension_threadpool pthreadpool) + target_link_libraries(extension_threadpool PUBLIC executorch_shared) +else() + target_link_libraries( + extension_threadpool PUBLIC executorch_core cpuinfo pthreadpool + ) +endif() target_include_directories( extension_threadpool PUBLIC ${_common_include_directories} ) diff --git a/extension/training/CMakeLists.txt b/extension/training/CMakeLists.txt index e835ae0e0a3..04a880a4043 100644 --- a/extension/training/CMakeLists.txt +++ b/extension/training/CMakeLists.txt @@ -49,17 +49,31 @@ 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 - # otherwise uses XNNPACK and microkernel-prod symbols from libtorch_cpu - list(APPEND _pybind_training_dep_libs xnnpack_backend XNNPACK - xnnpack-microkernels-prod - ) + if(EXECUTORCH_BUILD_SHARED) + # The delegate bundles XNNPACK and its microkernels, so naming them again + # here would ask for a second copy of what that library already provides. + list(APPEND _pybind_training_dep_libs xnnpack_backend) + else() + # need to explicitly specify XNNPACK and xnnpack-microkernels-prod here + # otherwise uses XNNPACK and microkernel-prod symbols from libtorch_cpu + list(APPEND _pybind_training_dep_libs xnnpack_backend XNNPACK + xnnpack-microkernels-prod + ) + endif() endif() pybind11_add_module( @@ -82,6 +96,31 @@ if(EXECUTORCH_BUILD_PYBIND) ) target_link_libraries(_training_lib PRIVATE ${_pybind_training_dep_libs}) + if(EXECUTORCH_BUILD_SHARED + AND EXECUTORCH_BUILD_XNNPACK + AND TARGET xnnpack_backend + ) + # The delegate registers itself from a static initializer, so nothing in + # this extension references a symbol from it and a normal link can drop it. + # Keeping it named on the link line is what makes an XNNPACK-delegated + # program usable from here. + executorch_target_retain_shared_library(_training_lib xnnpack_backend) + endif() + executorch_target_link_shared_runtime(_training_lib) + + if(EXECUTORCH_BUILD_SHARED AND NOT APPLE) + # 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, so the Torch path is recorded here rather than left implicit. + set_target_properties( + _training_lib PROPERTIES INSTALL_RPATH "$ORIGIN/../../../../torch/lib" + ) + executorch_target_shared_runtime_path( + _training_lib "extension/training/pybindings" + "executorch/extension/training/pybindings" + ) + endif() + install(TARGETS _training_lib LIBRARY DESTINATION executorch/extension/training/pybindings ) diff --git a/install_utils.py b/install_utils.py index ee4a91aa661..2266dadf513 100644 --- a/install_utils.py +++ b/install_utils.py @@ -142,22 +142,50 @@ def _get_cuda_version(): def _extract_cmake_define(args: List[str], name: str) -> Optional[str]: - prefix = f"-D{name}=" - for arg in args: - if arg.startswith(prefix): - return arg[len(prefix) :] - return None + """The value CMake would use for -D, which is the last one given. + + Repeating a definition is how a caller overrides an earlier one, and CMake keeps the last, so returning + the first would let packaging read one value while the build used another. + + All three spellings CMake accepts are matched, because it treats them identically: -D=, + -D:=, and -D followed by = as a separate argument. Matching only the + first meant a caller who switched an option off in either of the other two forms was read as leaving it + on, so a CPU row could ship a wheel carrying CUDA. + """ + # A bare -D takes its definition from the next argument, so both spellings collapse to one form. + definitions = [] + remaining = iter(args) + for arg in remaining: + if arg == "-D": + definitions.append(next(remaining, "")) + elif arg.startswith("-D"): + definitions.append(arg[2:]) + + # The name may carry a CMake type, as in EXECUTORCH_BUILD_CUDA:BOOL. + pattern = re.compile(rf"{re.escape(name)}(?::\w+)?=(.*)", re.DOTALL) + value = None + for definition in definitions: + match = pattern.fullmatch(definition) + if match: + value = match.group(1) + return value def _normalize_cmake_bool(value: Optional[str], default: bool = False) -> bool: if value is None: return default normalized = value.strip().upper() - if normalized in {"ON", "1", "TRUE", "YES"}: + # CMake's own rule, measured rather than assumed: true is ON, a non-zero number, + # TRUE, YES or Y, and everything else is false. The previous subset read N, + # IGNORE, NOTFOUND and an empty value as true, and with a default of on that is + # the difference between a CPU wheel and one carrying a GPU delegate nobody + # asked for. + if normalized in {"ON", "TRUE", "YES", "Y"}: return True - if normalized in {"OFF", "0", "FALSE", "NO"}: + try: + return int(normalized) != 0 + except ValueError: return False - return default def _cuda_version_to_pytorch_suffix(major, minor): diff --git a/kernels/quantized/CMakeLists.txt b/kernels/quantized/CMakeLists.txt index 2dac38205b4..9778220722a 100644 --- a/kernels/quantized/CMakeLists.txt +++ b/kernels/quantized/CMakeLists.txt @@ -86,6 +86,14 @@ if(NOT CMAKE_GENERATOR STREQUAL "Xcode" gen_custom_ops_aot_lib( LIB_NAME "quantized_ops_aot_lib" KERNEL_SOURCES "${_quantized_sources}" ) + # The route to the runtime, through the same helper every other target uses. + # Written by hand here before, in two separate blocks that between them + # recorded only the wheel layout and only when the wheel flag was set, so a + # plain -DEXECUTORCH_BUILD_SHARED=ON build produced a library with no route + # to the runtime it links. + executorch_target_shared_runtime_path( + quantized_ops_aot_lib "kernels/quantized" "executorch/kernels/quantized" + ) # Register quantized ops to portable_lib, so that they're available via # pybindings. @@ -126,11 +134,19 @@ if(NOT CMAKE_GENERATOR STREQUAL "Xcode" # libraries will look like "@rpath/_portable_lib.cpython-310-darwin.so", # so we can add an LC_RPATH entry to look in a directory relative to the # installed location of our _portable_lib.so file. To see these LC_* - # values, run `otool -l libquantized_ops_lib.dylib`. + # values, run `otool -l libquantized_ops_lib.dylib`. "extension", not + # "extensions": the plural directory does not exist, so the parent's path + # reached nothing and this library could not find the extension it needs. 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") + endif() + # Appended rather than assigned. The helper above already recorded the + # route to the runtime, and overwriting the property here would drop it. + get_target_property(_existing quantized_ops_aot_lib INSTALL_RPATH) + if(_existing) + set(RPATH "${_existing}:${RPATH}") endif() set_target_properties( quantized_ops_aot_lib PROPERTIES BUILD_RPATH ${RPATH} INSTALL_RPATH @@ -141,9 +157,17 @@ if(NOT CMAKE_GENERATOR STREQUAL "Xcode" endif() add_library(quantized_kernels ${_quantized_kernels__srcs}) +# The thread pool carries the define that switches parallel_for from a serial +# fallback to the real threaded implementation, so without it quantize, +# dequantize and choose_qparams run on one core. Guarded because a bare metal +# target builds these kernels without a thread pool at all, where the serial +# fallback is the only correct choice. target_link_libraries( quantized_kernels PRIVATE executorch_core kernels_util_all_deps ) +if(TARGET extension_threadpool) + target_link_libraries(quantized_kernels PRIVATE extension_threadpool) +endif() target_compile_options(quantized_kernels PUBLIC ${_common_compile_options}) # Build a library for _quantized_kernels_srcs # diff --git a/runtime/core/exec_aten/util/tensor_dimension_limit.h b/runtime/core/exec_aten/util/tensor_dimension_limit.h index 6e072ab0582..c690d96dc91 100644 --- a/runtime/core/exec_aten/util/tensor_dimension_limit.h +++ b/runtime/core/exec_aten/util/tensor_dimension_limit.h @@ -8,6 +8,8 @@ #pragma once +#include + namespace executorch::runtime { /** * The expected output size may not be the existing size of any inputs and diff --git a/setup.py b/setup.py index e2c053f4d1f..453b0acf8fd 100644 --- a/setup.py +++ b/setup.py @@ -57,6 +57,7 @@ import re import shutil import site +import stat import subprocess import sys from distutils import log # type: ignore[import-not-found] @@ -185,6 +186,13 @@ def _base_dependencies() -> List[str]: "packaging", "pandas>=2.2.2; python_version >= '3.10'", "parameterized", + # backends/qualcomm/__init__.py cannot be imported from a clean install + # without both of these. It reads the CPU vendor to disable an mkldnn path on + # AMD, and the module it imports first does a module-scope `import requests`, + # so declaring only the cpuinfo half leaves the import failing on the line + # before. + "py-cpuinfo", + "requests", "pytorch-tokenizers", "pyyaml", "ruamel.yaml", @@ -671,8 +679,102 @@ def build_extension(self, ext: _BaseExtension) -> None: # but that would clobber the X bit on any executables. TODO(dbort): This # probably won't work on Windows. if not os.access(src_file, os.W_OK): - # Make the file writable. This should respect the umask. - os.chmod(src_file, os.stat(src_file).st_mode | 0o222) + # The owner only. A mode of 0o222 would also grant write to the group + # and to everyone, and chmod takes an absolute mode so no umask + # narrows it, which turned a 0o555 build output into 0o777. + os.chmod(src_file, os.stat(src_file).st_mode | stat.S_IWUSR) + + # The destination too, and before the rewrite below, which opens the file + # for writing. copy_file preserves mode here on purpose, because this path + # also copies flatc and preserve_mode=False would drop its executable bit, + # so a read-only source arrives read-only. + # + # This mode is the one the wheel archives, so widening it here ships a + # world-writable library. + if not os.access(dst_file, os.W_OK): + os.chmod(dst_file, os.stat(dst_file).st_mode | stat.S_IWUSR) + + _strip_absolute_runtime_paths(dst_file) + + +def _strip_absolute_runtime_paths(library: Path) -> None: + """Remove unusable runtime search paths from a library the wheel ships. + + These libraries are copied out of the build tree rather than installed, so they + still carry every directory the linker recorded while resolving their + dependencies. Two kinds of entry are removed: + + - a directory inside this build, which names the machine that produced the + wheel and cannot exist for a user + - an empty entry, which the loader reads as the process working directory + + Other absolute entries are kept. The Python extensions link torch and resolve it + through the directory the linker recorded, so dropping that would stop them + importing in an environment where torch is not beside them. + + Best effort: a build without patchelf still produces a working wheel, with the + paths left in place. The tool cannot be guaranteed on PATH (the pip package does + not reliably provide a binary inside a build venv), so failing the build here + would break building from source on a machine that simply lacks it. + + What must not happen is both this and its check going quiet together, which is how + a wheel carrying build-machine directories could ship unnoticed. So the check in + the release tests treats a missing patchelf as a failure rather than a skip: the + wheel-build environment has it, and that is where the guarantee belongs. + """ + if library.suffix != ".so" and ".so." not in library.name: + return + patchelf = shutil.which("patchelf") + if patchelf is None: + return + result = subprocess.run( + [patchelf, "--print-rpath", os.fspath(library)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return + original = result.stdout.strip() + if not original: + # No runtime search path at all, which is nothing to clean. patchelf prints + # the same empty string for an absent tag and for one holding a single empty + # entry, so there is nothing to distinguish here and nothing to do either way. + return + + def keep(entry: str) -> bool: + if not entry: + # The loader reads an empty entry as the process working directory. + return False + if not entry.startswith("/"): + return True + # Absolute, so decide by what it points at. A directory inside this build + # cannot exist for a user. Anything else absolute is a dependency the + # environment provides, such as torch's own lib directory, which is how + # these extensions resolve torch at all. + # + # Matched as whole path components rather than as substrings. A bare + # "/cmake-out" also matches "/home/user/cmake-outputs/torchlibs", which is + # an unrelated directory a user could really have, and stripping it breaks + # a dependency the library legitimately resolves there. + parts = entry.split("/") + # The setuptools staging directory is spelled build/lib.-, + # for example lib.linux-x86_64-cpython-312. A bare startswith("lib.") also + # stripped a real user path like /opt/acme/lib.v2, so match the whole shape. + return not any( + part == "pip-out" + or part == "cmake-out" + or re.fullmatch(r"lib\.[^/]+-(cpython-\d+|\d+(?:\.\d+)*)", part) + for part in parts + ) + + rewritten = ":".join(entry for entry in original.split(":") if keep(entry)) + if rewritten == original: + return + subprocess.run( + [patchelf, "--set-rpath", rewritten, os.fspath(library)], + check=True, + ) class CustomBuildPy(build_py): @@ -1090,6 +1192,74 @@ def run(self): # noqa C901 [] if _is_minimal_build() else [ + # Install the shared runtime the Python extension links, rather + # than having the extension contain its own copy. Named without a + # version, so a consumer's find_library(executorch) resolves it: that + # matches libexecutorch.so and not libexecutorch.so.1. A version is + # only useful where something upgrades the library independently of + # what links it, which never happens inside a wheel. + BuiltFile( + src_dir="%CMAKE_CACHE_DIR%/", + src_name="libexecutorch.so", + dst="executorch/lib/libexecutorch.so", + dependent_cmake_flags=["EXECUTORCH_BUILD_SHARED"], + ), + # Install the profiler next to it, as its own library rather than + # code fused into the Python extension, so a process has one copy of + # it however many consumers load. + BuiltFile( + src_dir="%CMAKE_CACHE_DIR%/devtools/etdump/", + src_name="libexecutorch_etdump.so", + dst="executorch/lib/libexecutorch_etdump.so", + # Not gated on EXECUTORCH_BUILD_DEVTOOLS. The shared build adds + # the devtools subdirectory itself, so the library exists + # whenever the shared build does. The Python extension carries a + # hard dependency on it, so requiring the option here left a + # wheel whose extension could not load at all. + dependent_cmake_flags=["EXECUTORCH_BUILD_SHARED"], + ), + # Install the shared thread pool next to it. It is a separate + # library so that a process has one pool rather than one per + # component that uses it. + BuiltFile( + src_dir="%CMAKE_CACHE_DIR%/extension/threadpool/", + src_name="libexecutorch_threadpool.so", + dst="executorch/lib/libexecutorch_threadpool.so", + # The target only exists when both of its dependencies are + # enabled, so packaging has to require them too or a shared + # build with either turned off looks for a file that was + # never built. + dependent_cmake_flags=[ + "EXECUTORCH_BUILD_SHARED", + "EXECUTORCH_BUILD_PTHREADPOOL", + "EXECUTORCH_BUILD_CPUINFO", + ], + ), + # Install the merged CPU kernels beside them, so the operators are + # registered once per process rather than once per component. + BuiltFile( + src_dir="%CMAKE_CACHE_DIR%/configurations/", + src_name="libexecutorch_kernels_optimized.so", + dst="executorch/lib/libexecutorch_kernels_optimized.so", + # The target is only created when the optimized kernels are + # enabled, so packaging has to require that too rather than + # looking for a file a shared build may never have produced. + dependent_cmake_flags=[ + "EXECUTORCH_BUILD_SHARED", + "EXECUTORCH_BUILD_KERNELS_OPTIMIZED", + ], + ), + # Install the XNNPACK delegate beside them, so a process has one + # copy of it instead of one per component that uses it. + BuiltFile( + src_dir="%CMAKE_CACHE_DIR%/backends/xnnpack/", + src_name="libexecutorch_backend_xnnpack.so", + dst="executorch/lib/libexecutorch_backend_xnnpack.so", + dependent_cmake_flags=[ + "EXECUTORCH_BUILD_SHARED", + "EXECUTORCH_BUILD_XNNPACK", + ], + ), # 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. @@ -1172,6 +1342,23 @@ def run(self): # noqa C901 is_dynamic_lib=True, dependent_cmake_flags=["EXECUTORCH_BUILD_CUDA"], ), + # The stream helper the library above records as a dependency. It was + # never shipped, and resolved only because the copied library still + # carried the absolute directory it was linked in, which exists on a + # build machine and nowhere else. Stripping that path is what made the + # omission visible as a failed import. + # + # Shipped beside its consumer rather than in lib/, because that + # directory only exists in the shared build and this has to work + # without it. The glob covers both names the target can have: the + # shared build renames it to advertise it as a wheel component. + BuiltFile( + src_dir="%CMAKE_CACHE_DIR%/extension/cuda/%BUILD_TYPE%/", + src_name="*extension_cuda", + dst="executorch/backends/cuda/", + is_dynamic_lib=True, + dependent_cmake_flags=["EXECUTORCH_BUILD_CUDA"], + ), BuiltFile( src_dir="%CMAKE_CACHE_DIR%/backends/qualcomm/%BUILD_TYPE%/", src_name="qnn_executorch_backend", diff --git a/tools/cmake/Codegen.cmake b/tools/cmake/Codegen.cmake index 4253fa44dc5..aed21c3a495 100644 --- a/tools/cmake/Codegen.cmake +++ b/tools/cmake/Codegen.cmake @@ -261,15 +261,26 @@ 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) + # Named here as well as retained below, because a PRIVATE link does not + # carry the runtime's include directories and compile definitions, and a + # shared build without the pybind extension would then compile 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 +# +# SHARED opts this library into being a shared object. It is opt-in because most +# callers want the default static library, and only the one shipped in the wheel +# needs to be shared so a process has a single copy of the kernels. function(gen_operators_lib) set(multi_arg_names LIB_NAME KERNEL_LIBS DEPS DTYPE_SELECTIVE_BUILD) - cmake_parse_arguments(GEN "" "" "${multi_arg_names}" ${ARGN}) + cmake_parse_arguments(GEN "SHARED" "" "${multi_arg_names}" ${ARGN}) message(STATUS "Generating operator lib:") message(STATUS " LIB_NAME: ${GEN_LIB_NAME}") @@ -282,7 +293,17 @@ function(gen_operators_lib) set(_opvariant_h ${_out_dir}/selected_op_variants.h) endif() - add_library(${GEN_LIB_NAME}) + if(GEN_SHARED) + add_library(${GEN_LIB_NAME} SHARED) + # The caller names the library and sets its version, because the shipped + # name describes what the library provides rather than which generation + # target produced it, and only the caller knows that. + # + # Ships beside the runtime in the wheel's lib/ directory. + executorch_target_shipped_runtime_path(${GEN_LIB_NAME}) + else() + add_library(${GEN_LIB_NAME}) + endif() set(_srcs_list ${_out_dir}/RegisterCodegenUnboxedKernelsEverything.cpp ${_out_dir}/Functions.h ${_out_dir}/NativeFunctions.h @@ -292,6 +313,21 @@ function(gen_operators_lib) endif() target_sources(${GEN_LIB_NAME} PRIVATE ${_srcs_list}) target_link_libraries(${GEN_LIB_NAME} PRIVATE ${GEN_DEPS}) + # Resolve the runtime from the shared library rather than from the static core + # in GEN_DEPS. Linking the static core gives this library its own copy of the + # operator table, so its static initializer registers into a table nothing + # else reads and the operators appear missing at run time. + # + # Only when this target is itself shared. On a static target the retention + # helper cannot work: PRIVATE link options are dropped on a static library, so + # the --no-as-needed scope never reaches whatever links it, and the helper is + # fatal on that rather than pretending. A static operators library is + # extracted whole into its consumer, and the consumer is what retains the + # runtime, so there is nothing to do here. It still needs the runtime's + # headers, which come through GEN_DEPS. + if(GEN_SHARED) + executorch_target_link_shared_runtime(${GEN_LIB_NAME}) + endif() set(portable_kernels_check "portable_kernels") if(GEN_KERNEL_LIBS) diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index 958b425c47c..e5c5af38c71 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -47,9 +47,64 @@ function(executorch_msvc_kernel_link_options target_name) ) endfunction() +# Bundle a static library's whole contents into a target. +# +# Deliberately a link option rather than the WHOLE_ARCHIVE link feature: that +# feature refuses to coexist with the plain references other targets make to the +# same archive, which the archives bundled here all have, until CMake 3.30. Link +# options are also emitted before the ordered link libraries, which keeps a +# bundled archive ahead of anything that would otherwise satisfy the same +# symbols. +function(executorch_target_whole_archive target_name archive_target) + # One self-contained option per archive. The path sits inside the option so + # its text is unique, which matters because CMake removes a duplicate option + # and that would leave every archive after the first outside the scope, + # silently dropping its registration objects. + # + # The cost, measured rather than assumed: CMake splits a comma-joined LINKER: + # list at every comma, so an archive whose path contains one reaches the + # linker as two broken arguments and the link fails. Accepted, because the + # alternative of giving the path its own option reintroduces the + # de-duplication problem above, and because this file's pre-existing + # SHELL:LINKER: helpers already break on a path containing a space, which is + # the more common case. Both fail loudly at link time rather than producing a + # binary whose registrations are quietly missing. + target_link_options( + ${target_name} + PRIVATE + "LINKER:--push-state,--whole-archive,$,--pop-state" + ) + # Also link it the ordinary way. A link option naming a file is not a build + # prerequisite, so on its own it lets the archive be rebuilt while the library + # bundling it keeps the previous contents, which is a stale registration + # rather than a build error. + target_link_libraries(${target_name} PRIVATE ${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" + ) + # Retention is fully handled above, and applying whole-archive to a shared + # library below would do nothing: that flag governs archive member + # extraction, and this target is not an archive. + return() + endif() if(APPLE) executorch_macos_kernel_link_options(${target_name}) elseif(MSVC) @@ -212,8 +267,185 @@ 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() + # A target with no link step of its own cannot carry this. PRIVATE link + # options are dropped on both static and object libraries, because neither + # links, while PRIVATE link libraries still propagate to whatever consumes + # them as $. The consumer then gets the shared runtime with no + # --no-as-needed around it, which is the precise condition this function + # exists to prevent, and it fails silently: the build succeeds and the + # registrations land in a table nothing else reads. + # + # Written as the set of types that CAN link rather than a list of types to + # reject, so a target kind added later does not quietly escape. + get_target_property(_target_type ${target_name} TYPE) + if(NOT ${_target_type} MATCHES "^(SHARED_LIBRARY|MODULE_LIBRARY|EXECUTABLE)$") + message( + FATAL_ERROR + "executorch_target_retain_shared_library(${target_name}) cannot work on a " + "${_target_type}: it has no link step, so PRIVATE link options are dropped and " + "${library_target} would reach a consumer without --no-as-needed, leaving its " + "registrations in a private table. Make ${target_name} SHARED, or retain " + "${library_target} from the target that links it." + ) + endif() + # The library being retained has to be one the loader can drop, or the option + # says nothing. Only checked when it is already defined: CMake resolves link + # libraries lazily, and a caller may legitimately name a library created later + # in the configure, which several call sites here do. + if(TARGET ${library_target}) + get_target_property(_library_type ${library_target} TYPE) + if(NOT ${_library_type} STREQUAL "SHARED_LIBRARY") + message( + FATAL_ERROR + "executorch_target_retain_shared_library(${target_name} ${library_target}): " + "${library_target} is a ${_library_type}, and --no-as-needed only affects a shared " + "library. A static or object library is linked by extraction instead, " + "so use executorch_target_whole_archive." + ) + endif() + endif() + # Scoped per library for the same reason as whole-archive above: unique option + # text, so nothing is de-duplicated out of the retention scope. Without this a + # registration-only library is dropped under the default --as-needed and its + # static initializer never runs. + target_link_options( + ${target_name} + PRIVATE + "LINKER:--push-state,--no-as-needed,$,--pop-state" + ) + target_link_libraries(${target_name} PRIVATE ${library_target}) +endfunction() + +# Mark a library as one the wheel ships, so it finds its siblings wherever it +# ends up. In the wheel all of these land in one directory, so "$ORIGIN" is the +# whole answer. +# +# BUILD_WITH_INSTALL_RPATH is deliberately not used. It REPLACES the build-time +# path with the install one, which drops the dependency directories CMake +# records, and in the build tree these libraries are NOT siblings: the runtime +# sits at the top while the others are in their own subdirectories. Those +# recorded directories are what resolves them there, and packaging strips them +# so nothing absolute ships. +function(executorch_target_shipped_runtime_path target_name) + set_target_properties( + ${target_name} PROPERTIES BUILD_RPATH "$ORIGIN" INSTALL_RPATH "$ORIGIN" + ) +endfunction() + +# Give a target a runtime search path that reaches libexecutorch.so in both +# layouts it can end up in. +# +# The two layouts put the runtime in different places. A wheel keeps it in the +# package's own lib/ directory, a fixed number of levels above wherever the +# target lands. A normal install puts it in the prefix library directory +# instead, which that relative path does not reach, so both routes are recorded. +# +# `wheel_subdir` is where the target lands inside the package, below +# executorch/, and gives the wheel route. `install_destination` is the +# DESTINATION its own install() rule uses, and gives the install route. Both are +# needed because the two are not the same shape everywhere: most targets install +# prefix relative while the Qualcomm ones install under the library directory, +# and deriving one from the other put two libraries' search paths a component +# off. +function(executorch_target_shared_runtime_path target_name wheel_subdir + install_destination +) + if(NOT EXECUTORCH_BUILD_SHARED OR APPLE) + return() + endif() + # Up out of the subdirectory, then into the package's lib/. + string(REGEX REPLACE "[^/]+" ".." _up "${wheel_subdir}") + set(_paths "$ORIGIN/${_up}/lib") + # Made absolute lexically, so a destination that is already absolute, as a + # ${CMAKE_INSTALL_LIBDIR} based one becomes, is handled the same as a prefix + # relative one. + # + # Not file(REAL_PATH): it resolves symlinks on this side only, while the + # library directory on the other side of the subtraction stays unresolved, so + # a symlinked prefix produced a path that climbed out of the install tree and + # named the link itself. Measured with a symlinked prefix, where the answer + # should be three hops up: REAL_PATH gave $ORIGIN/../../../../../prefix/lib64 + # and this gives $ORIGIN/../../../. It also dev-warns once per call site on a + # directory that does not exist until install time, which is every call in a + # clean build. + cmake_path( + ABSOLUTE_PATH + install_destination + BASE_DIRECTORY + "${CMAKE_INSTALL_PREFIX}" + NORMALIZE + OUTPUT_VARIABLE + _installed_dir + ) + file(RELATIVE_PATH _to_libdir "${_installed_dir}" + "${CMAKE_INSTALL_FULL_LIBDIR}" + ) + string(APPEND _paths ":$ORIGIN/${_to_libdir}") + get_target_property(_existing ${target_name} INSTALL_RPATH) + if(_existing) + set(_paths "${_existing}:${_paths}") + endif() + set_target_properties( + ${target_name} PROPERTIES BUILD_RPATH "${_paths}" INSTALL_RPATH "${_paths}" + ) +endfunction() + +# Apply the SONAME policy for a library the project ships. +# +# A distribution package needs a versioned SONAME: it installs +# libexecutorch.so.1 into a system directory where independent packages link it, +# and the version is what lets a later major coexist during an upgrade. That is +# why the shared library support carries VERSION and SOVERSION. +# +# A wheel is the opposite case. The library and the only things that link it +# ship in the same archive and are replaced together, so no version needs +# pinning, and a versioned name actively hurts: `find_library(executorch)` +# matches libexecutorch.so and not libexecutorch.so.1, so a consumer's +# find_package could not locate it. The torch wheel ships plain names with +# unversioned SONAMEs for the same reason. Offering an unversioned symlink +# instead is not equivalent, because a wheel is a zip and the format has no +# portable symlink support. +function(executorch_target_soname_policy target_name) + if(EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) + return() + endif() + set_target_properties( + ${target_name} PROPERTIES VERSION "${PROJECT_VERSION}" + SOVERSION "${PROJECT_VERSION_MAJOR}" + ) +endfunction() + # Create and install a shared library composed from dependency libraries. The -# target links the provided dependencies and carries VERSION/SOVERSION. +# target links the provided dependencies and carries the project's SONAME +# policy. function(executorch_add_shared_library target_name) set(empty_source_name "${target_name}_empty.cpp") file( @@ -224,15 +456,21 @@ function(executorch_add_shared_library target_name) add_library( ${target_name} SHARED "${CMAKE_CURRENT_BINARY_DIR}/${empty_source_name}" ) + # The dependencies are linked plainly, without a retention option, because + # each one already carries its own INTERFACE whole-archive option and so pulls + # its registration objects in when linked. The empty source above exists only + # to give this library a translation unit. + # + # Nothing here can verify that invariant: CMake cannot tell at configure time + # whether every transitive dependency carries the option. It is checked where + # it is observable instead, by the release checks asserting exactly one owner + # per component in the shipped artifact, which is what fails if extraction + # stops working. if(ARGN) target_link_libraries(${target_name} PRIVATE ${ARGN}) endif() - set_target_properties( - ${target_name} - PROPERTIES VERSION "${PROJECT_VERSION}" - SOVERSION "${PROJECT_VERSION_MAJOR}" - LINKER_LANGUAGE CXX - ) + set_target_properties(${target_name} PROPERTIES LINKER_LANGUAGE CXX) + executorch_target_soname_policy(${target_name}) install( TARGETS ${target_name} EXPORT ExecuTorchTargets diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index 1d6096a2e96..fa789771872 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -68,11 +68,63 @@ if(_portable_lib_LIBRARY) list(APPEND EXECUTORCH_LIBRARIES _portable_lib) add_library(_portable_lib STATIC IMPORTED) set(EXECUTORCH_INCLUDE_DIRS ${CMAKE_CURRENT_LIST_DIR}/../../include) - # 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 + # PyTorch requires C++20, so anything linking this must compile + # as + # C++20. An interface requirement rather than CXX_STANDARD, + # because + # an imported target compiles nothing itself and CXX_STANDARD + # does + # not reach consumers, so a custom-op build could still compile + # as + # C++17 and fail against headers that need C++20. + INTERFACE_COMPILE_FEATURES cxx_std_20 + ) + + # The extension links the runtime rather than containing it, so it no longer + # satisfies the runtime symbols a custom-op library references. Put the + # shipped runtime on this target's interface, which is where the definitions + # moved to, so an out-of-tree operator project keeps building and loading + # against the extension exactly as it did before. + find_library( + EXECUTORCH_RUNTIME_LIBRARY executorch + PATHS "${CMAKE_CURRENT_LIST_DIR}/../../lib" + NO_DEFAULT_PATH + ) + if(EXECUTORCH_RUNTIME_LIBRARY) + set_property( + TARGET _portable_lib + APPEND + PROPERTY INTERFACE_LINK_LIBRARIES "${EXECUTORCH_RUNTIME_LIBRARY}" + ) + # CMake adds a linked library's directory to the consumer's build tree + # runtime search path and strips it on install, so an installed consumer + # library reports libexecutorch.so as not found. Publish the directories + # rather than forcing them onto the target: an interface link option reaches + # every consumer and survives install, which would bake this machine's + # package location into a library the consumer ships onward. A consumer that + # installs elsewhere adds these to its own INSTALL_RPATH. + get_filename_component( + EXECUTORCH_RUNTIME_LIBRARY_DIR "${EXECUTORCH_RUNTIME_LIBRARY}" DIRECTORY + ) + get_filename_component( + EXECUTORCH_PYTHON_EXTENSION_DIR "${_portable_lib_LIBRARY}" DIRECTORY + ) + endif() +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 succeeds even when nothing usable was located, and the +# consumer goes on to link nothing. +set(executorch_FOUND ${EXECUTORCH_FOUND}) +if(NOT executorch_FOUND AND executorch_FIND_REQUIRED) + message( + FATAL_ERROR + "Found the ExecuTorch package but could not locate the Python extension " + "inside it, so there is nothing to link." ) endif() diff --git a/tools/cmake/preset/default.cmake b/tools/cmake/preset/default.cmake index ae5437ea443..fd440a3dc80 100644 --- a/tools/cmake/preset/default.cmake +++ b/tools/cmake/preset/default.cmake @@ -229,8 +229,8 @@ define_overridable_option( ${_default_executorch_build_cpuinfo} ) define_overridable_option( - EXECUTORCH_BUILD_SHARED "Build a consolidated ExecuTorch shared library" BOOL - OFF + EXECUTORCH_BUILD_SHARED + "Build a consolidated ExecuTorch shared library (Linux only)" BOOL OFF ) # Threadpool size options. At most one can be specified. Note that the default diff --git a/tools/cmake/preset/pybind.cmake b/tools/cmake/preset/pybind.cmake index d292c9ed240..f72680836b7 100644 --- a/tools/cmake/preset/pybind.cmake +++ b/tools/cmake/preset/pybind.cmake @@ -104,6 +104,19 @@ 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. + # + # Not with the CUDA backend, whose libraries this build does not ship yet. The + # CUDA libraries currently reach the wheel carrying the absolute path of the + # directory they were linked in, which resolves only on the machine that built + # them. The shared build removes those paths, so enabling it here before the + # CUDA libraries ship would leave the extension unable to load at all. + if(NOT EXECUTORCH_BUILD_CUDA) + set_overridable_option(EXECUTORCH_BUILD_SHARED ON) + endif() elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR CMAKE_SYSTEM_NAME STREQUAL "WIN32" )