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..2590c01cb55 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -68,11 +68,73 @@ 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}" + ) + # Also record the runtime's directory and the extension's own directory as + # rpaths, so a consumer that installs its library elsewhere still finds + # libexecutorch.so and _portable_lib at load time. CMake adds a linked + # library's directory to the consumer's build-tree RPATH but strips it on + # install, and this target sets no INSTALL_RPATH, so without this the + # installed consumer library reports libexecutorch.so as not found. + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + get_filename_component( + _executorch_runtime_dir "${EXECUTORCH_RUNTIME_LIBRARY}" DIRECTORY + ) + get_filename_component( + _portable_lib_dir "${_portable_lib_LIBRARY}" DIRECTORY + ) + set_property( + TARGET _portable_lib + APPEND + PROPERTY INTERFACE_LINK_OPTIONS + "LINKER:-rpath,${_executorch_runtime_dir}" + "LINKER:-rpath,${_portable_lib_dir}" + ) + unset(_executorch_runtime_dir) + unset(_portable_lib_dir) + endif() + 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" )