From edf72e7a626ea5e5b97348695e4c2765feedc8eb Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Tue, 11 Aug 2026 23:08:09 -0700 Subject: [PATCH] Ship a C++ SDK in the wheel The wheel ships the runtime, kernels, delegate, thread pool and profiler as separate shared libraries, but nothing outside Python can use them, because the installed CMake package names none of them. A C++ application would have to hard-code paths into the wheel's private layout. The headers have the same gap. The wheel installs only the subset a custom-operator build needs, which leaves out `extension/module`, the entry point the documentation tells C++ callers to use. So the wheel ships the libraries to run a model and no way to call them. Name each shipped library as a CMake component, so `find_package` locates them, and ship the headers a caller needs. A component is just a name a consumer can ask for, and CMake reports a missing one while configuring rather than at link time. ```cmake find_package(executorch 1.5 REQUIRED COMPONENTS kernels_optimized) target_link_libraries(my_app PRIVATE executorch::runtime executorch::kernels_optimized) ``` | component | library it resolves to | | --- | --- | | `executorch::runtime` | `libexecutorch.so` | | `executorch::kernels_optimized` | `libexecutorch_kernels_optimized.so` | | `executorch::backend_xnnpack` | `libexecutorch_backend_xnnpack.so` | | `executorch::threadpool` | `libexecutorch_threadpool.so` | | `executorch::etdump` | `libexecutorch_etdump.so` | Each component records where the wheel keeps its libraries, so an application built against it finds them without the caller setting a library search path. Headers include the module and tensor entry points, the CPU kernel helpers, the allocator and data loader concrete classes Module's constructors take, the profiler entry points, and the FlatTensorDataMap and MergedDataMap types plus the .ptd file header a caller writing a .ptd needs. CMake 3.28 or newer gets these targets. Older versions do not, because they write the `$ORIGIN` marker (the "look next to me" token in a library search path) incorrectly: ``` 3.24.3, 3.27.9 Makefiles double the dollar sign, Ninja drops the name 3.28.4, 3.31.8 both write the token correctly ``` That would produce a target that runs where it was built and fails once the application is copied elsewhere, so no target is defined below 3.28. Those versions get plain variables instead: `EXECUTORCH_LIBRARIES` with the runtime and every shipped library by path, plus `EXECUTORCH_INCLUDE_DIRS`, `EXECUTORCH_COMPILE_DEFINITIONS` and `EXECUTORCH_CXX_STANDARD`. All four are needed, because an imported target carries the definitions and the C++ standard along with the library and a plain path carries neither. Linking the libraries alone stops at `#error "You need C++17 to compile ExecuTorch"`. `ET_USE_THREADPOOL` is added to `EXECUTORCH_COMPILE_DEFINITIONS` on the pre-3.28 route when the thread pool library ships. Without it the runtime header supplies a local inline serial fallback for `parallel_for`, so a consumer following the documented recipe linked the thread pool library and still ran serial code with no diagnostic. Built the wheel, installed it into a clean environment, and built a C++ application against the installed wheel alone: - the application links the runtime, runs a model, and matches eager PyTorch, and still runs after being copied away from the wheel. - asking for a component the wheel does not ship fails while configuring, naming the component. - a version request is honoured, including ranges. - shipped headers can be included on their own, and one entry point per shipped component also links against the shipped libraries. A small number are exempt because they need something outside the package: a Windows shim, a test framework, or a header that says in its own text not to include it directly. The exempt list is compiled too, so an entry that starts working is reported rather than left in place. - the thread pool probe compiles with `ET_USE_THREADPOOL`, on both the modern-CMake route (from the runtime target) and the pre-3.28 route (from `EXECUTORCH_COMPILE_DEFINITIONS`). Without it the header supplies a local inline definition and the probe linked identically whether or not the library was on the link line, so it could not detect the component being dropped. Measured both ways. - an application's runtime search path is recorded as `DT_RUNPATH`, not the older `DT_RPATH`. That matters because `DT_RPATH` is searched ahead of `LD_LIBRARY_PATH` and is inherited by dependencies, so a consumer could not point a locally built or instrumented runtime at their application. Verified by shadowing the runtime through `LD_LIBRARY_PATH` and watching the loader pick it up, which `DT_RPATH` ignores. - on real CMake 3.24 and 3.27, an application configures, builds and runs through the variables. Measured what each one contributes, with the consumer pinned to C++14 so its own standard does not hide the package's requirement: linking `EXECUTORCH_LIBRARIES` alone fails on a missing header, adding the include directories and definitions then fails on the C++ standard, and applying `EXECUTORCH_CXX_STANDARD` builds and loads a model. The kernels also need scoped retention there, because a registration-only library exports nothing the application references and the linker drops it, which showed up as "Missing operator" at run time rather than as a link error. The smoke test now runs the same shape automatically when `EXECUTORCH_PRE_328_CMAKE` points at an older cmake binary, so a future change on the fallback path fails a check rather than only showing up on the first user with older cmake. - `find_package` succeeds when the interpreter on PATH is not the one the wheel was built for. The extension's own file name carries its suffix, so asking a different interpreter for it reported a complete install as not found. Ran on Linux x86_64 and aarch64. The macOS wheel keeps the fused extension and ships no separate libraries, so these checks do not apply there and its smoke test does not run them. ghstack-source-id: 490d0661f05ee374413793788a7688c24c4a4835 ghstack-comment-id: 5215967468 Pull-Request: https://github.com/pytorch/executorch/pull/21639 --- .ci/scripts/wheel/test_cpp_sdk.py | 1238 +++++++++++++++++ .ci/scripts/wheel/test_linux.py | 8 + .ci/scripts/wheel/test_linux_aarch64.py | 6 + README-wheel.md | 4 +- devtools/etdump/etdump_flatcc.h | 1 - docs/source/using-executorch-cpp.md | 145 ++ .../memory_allocator/memory_allocator_utils.h | 6 +- runtime/executor/platform_memory_allocator.h | 1 + setup.py | 175 ++- .../executorch-wheel-config-version.cmake.in | 49 + tools/cmake/executorch-wheel-config.cmake | 691 ++++++++- 11 files changed, 2276 insertions(+), 48 deletions(-) create mode 100644 .ci/scripts/wheel/test_cpp_sdk.py create mode 100644 tools/cmake/executorch-wheel-config-version.cmake.in diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py new file mode 100644 index 00000000000..7b4aaf1a917 --- /dev/null +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -0,0 +1,1238 @@ +# 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 a standalone C++ application can use the wheel as an SDK. + +The wheel ships prebuilt runtime, kernel, delegate, thread pool and profiler +libraries, plus headers and a CMake package config. A Python test can exercise none +of that: the Python extension links those libraries itself, so it passes whether or +not the package config names them correctly, whether or not the headers are complete, +and whether or not an application that links them can find them at run time. + +So these checks build and run a real application from outside the wheel. Nothing here +uses the source tree, because a user has only the installed package. +""" + +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +# Exports the model to a .pte and prints the reference outputs, so the C++ side can +# be compared against eager PyTorch rather than merely checked for not crashing. +# +# The same network as the Python parity check: several operator kinds, so a run +# exercises the merged CPU kernels rather than a single add. +_EXPORT_SCRIPT = """ +import json +import sys + +import torch +from executorch.exir import to_edge_transform_and_lower + + +class Net(torch.nn.Module): + 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) + + +destination, mode = sys.argv[1], sys.argv[2] +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 mode == "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 +with open(destination, "wb") as handle: + handle.write(buffer) + +# The inputs travel with the model so the C++ side feeds identical values. Written as +# plain text rather than a tensor format, because reading one is not what is under +# test here and a dependency on one would be a second thing that can fail. +print( + json.dumps( + { + "inputs": [ + {"shape": list(t.shape), "data": t.flatten().tolist()} + for t in example + ], + "expected": expected.flatten().tolist(), + "delegated": mode == "delegate", + "has_xnnpack": b"XnnpackBackend" in bytes(buffer), + } + ) +) +""" + + +_CONSUMER_SOURCE = r""" +// A standalone application. It includes only what the wheel installs and links only +// the wheel's imported targets, so it fails if the shipped headers are incomplete or +// the package config does not make the libraries findable at run time. +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace executorch::extension; + +namespace { + +// A minimal reader for the numbers the export step printed. Deliberately not a JSON +// library: adding a dependency here would mean a failure could come from the parser +// rather than from the SDK. +std::vector read_floats(const std::string& path) { + std::ifstream file(path); + std::vector values; + float value = 0.0f; + while (file >> value) { + values.push_back(value); + } + return values; +} + +std::vector read_ints(const std::string& path) { + std::ifstream file(path); + std::vector values; + int value = 0; + while (file >> value) { + values.push_back(value); + } + return values; +} + +} // namespace + +int main(int argc, char** argv) { + // Seven, because argv[6] is read below: the program name plus six arguments. A guard of six let a + // caller that passed one too few read past the end of argv. + if (argc < 7) { + std::printf("usage: consumer \n"); + return 2; + } + executorch::runtime::runtime_init(); + + const auto shape_a = read_ints(argv[2]); + auto data_a = read_floats(argv[3]); + const auto shape_b = read_ints(argv[4]); + auto data_b = read_floats(argv[5]); + const auto expected = read_floats(argv[6]); + + std::vector sizes_a(shape_a.begin(), shape_a.end()); + std::vector sizes_b(shape_b.begin(), shape_b.end()); + + // The documented entry points, not the lower-level runtime API. Constructing these + // needs real definitions at link time, so it checks that the shipped headers and + // the shipped libraries agree rather than only that the headers parse. + module::Module module(argv[1]); + const auto load_error = module.load(); + if (load_error != executorch::runtime::Error::Ok) { + std::printf("load failed: 0x%x\n", (unsigned)load_error); + return 1; + } + + auto input_a = make_tensor_ptr(sizes_a, data_a.data()); + auto input_b = make_tensor_ptr(sizes_b, data_b.data()); + + const auto result = module.forward({input_a, input_b}); + if (!result.ok()) { + std::printf("forward failed: 0x%x\n", (unsigned)result.error()); + return 1; + } + + const auto output = result->at(0).toTensor(); + if ((size_t)output.numel() != expected.size()) { + std::printf( + "output has %zu values, expected %zu\n", + (size_t)output.numel(), + expected.size()); + return 1; + } + + // Compared against eager PyTorch, not merely produced. A model that returns wrong + // numbers without erroring would satisfy every other check here. + const float* actual = output.const_data_ptr(); + double worst = 0.0; + for (size_t i = 0; i < expected.size(); ++i) { + const double diff = std::fabs((double)actual[i] - (double)expected[i]); + // Rejected here rather than through the comparison below, because fmax treats a + // NaN as a missing value and returns the other operand, so an all-NaN output + // would leave the running maximum at zero and pass at any tolerance. + if (!std::isfinite(diff)) { + std::printf("output value %zu is not comparable: %g\n", i, (double)actual[i]); + return 1; + } + worst = std::fmax(worst, diff); + } + if (worst > 1e-4) { + std::printf("output differs from eager PyTorch by %g\n", worst); + return 1; + } + + std::printf( + "ok backends=%zu maxdiff=%g\n", + (size_t)executorch::runtime::get_num_registered_backends(), + worst); + return 0; +} +""" + + +def _consumer_cmake(components) -> str: + """A consumer project that links the given components by their public names. + + REQUIRED COMPONENTS rather than a bare find_package, because that is the form the + documentation shows and it has to fail loudly when the wheel does not ship what + it advertises. + """ + requested = " ".join(components) + links = "\n".join( + f"target_link_libraries(consumer PRIVATE executorch::{name})" + for name in components + ) + return f"""cmake_minimum_required(VERSION 3.28) +project(consumer CXX) +find_package(executorch REQUIRED COMPONENTS {requested}) +add_executable(consumer consumer.cpp) +{links} +""" + + +def _tool(name: str) -> 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 checks run by invoking the interpreter + directly, so a tool 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 name + + +def _installed_package_dir() -> Path: + """Where the wheel installed itself, found without importing it. + + Imported from the source tree, `executorch.__file__` points at the checkout rather + than at the installed package, so a check would read the wrong files and pass while + the wheel was broken. + """ + for entry in sys.path: + candidate = Path(entry) / "executorch" + if (candidate / "share" / "cmake").is_dir(): + return candidate + raise AssertionError( + "no installed executorch package with share/cmake on sys.path; these checks " + "must run against an installed wheel, not the source tree" + ) + + +def _write_tensor(directory: Path, stem: str, tensor) -> tuple: + """Write one tensor's shape and values as whitespace-separated text.""" + shape_file = directory / f"{stem}.shape" + data_file = directory / f"{stem}.data" + shape_file.write_text(" ".join(str(n) for n in tensor["shape"])) + data_file.write_text(" ".join(repr(v) for v in tensor["data"])) + return shape_file, data_file + + +def _export(work_dir: Path, mode: str) -> tuple: + """Export the model to a .pte and return it with the reference numbers.""" + script = work_dir / "export.py" + script.write_text(_EXPORT_SCRIPT) + model = work_dir / f"model_{mode}.pte" + result = subprocess.run( + [sys.executable, str(script), str(model), mode], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, ( + f"exporting the {mode} model failed, so the C++ side cannot be checked " + f"against it:\n{result.stdout[-2000:]}\n{result.stderr[-2000:]}" + ) + reference = json.loads(result.stdout.strip().splitlines()[-1]) + assert model.is_file(), f"the export step produced no {model}" + return model, reference + + +def _build_consumer(work_dir: Path, name: str, components) -> Path: + """Configure and build the consumer application against the installed package.""" + package_dir = _installed_package_dir() + config = package_dir / "share" / "cmake" / "executorch-config.cmake" + assert config.is_file(), f"the wheel ships no CMake package config at {config}" + + source_dir = work_dir / name + build_dir = work_dir / f"{name}-build" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "consumer.cpp").write_text(_CONSUMER_SOURCE) + (source_dir / "CMakeLists.txt").write_text(_consumer_cmake(components)) + + configured = subprocess.run( + [ + _tool("cmake"), + "-S", + str(source_dir), + "-B", + str(build_dir), + f"-DCMAKE_PREFIX_PATH={config.parent}", + ], + capture_output=True, + text=True, + check=False, + ) + assert configured.returncode == 0, ( + f"a consumer requesting {list(components)} could not configure against the " + f"installed package:\n{configured.stdout[-2000:]}\n{configured.stderr[-2000:]}" + ) + built = subprocess.run( + [_tool("cmake"), "--build", str(build_dir)], + capture_output=True, + text=True, + check=False, + ) + assert built.returncode == 0, ( + f"a consumer requesting {list(components)} compiled against the shipped " + f"headers but did not link:\n{built.stdout[-3000:]}\n{built.stderr[-3000:]}" + ) + consumer = build_dir / "consumer" + assert consumer.is_file(), f"the build produced no {consumer}" + return consumer + + +def _run_consumer(consumer: Path, model: Path, reference, work_dir: Path) -> str: + """Run the application and require it to match eager PyTorch.""" + inputs = reference["inputs"] + shape_a, data_a = _write_tensor(work_dir, "a", inputs[0]) + shape_b, data_b = _write_tensor(work_dir, "b", inputs[1]) + expected = work_dir / "expected.data" + expected.write_text(" ".join(repr(v) for v in reference["expected"])) + + # No LD_LIBRARY_PATH. Making the shipped libraries findable is the package + # config's job, and inheriting one from the environment would hide a failure to + # do it. + environment = { + key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" + } + result = subprocess.run( + [ + str(consumer), + str(model), + str(shape_a), + str(data_a), + str(shape_b), + str(data_b), + str(expected), + ], + capture_output=True, + text=True, + check=False, + env=environment, + ) + assert result.returncode == 0, ( + "the C++ application built against the installed wheel did not run " + f"correctly:\n{result.stdout[-2000:]}\n{result.stderr[-2000:]}" + ) + return result.stdout.strip() + + +def test_runtime_alone_links_but_cannot_compute(work_dir: Path) -> None: + """Linking only the runtime builds and loads, and cannot execute. + + Measured rather than assumed: libexecutorch.so defines only primitive operators, such + as aten::sym_size.int, and none of the kernels a model computes with, so an + application linking it alone loads a program and then reports the operators it needs + as missing. That is the intended split, and stating it here documents why the kernels + are a separate component instead of leaving a reader to guess. + + The value of the check is the boundary. It fails if the runtime silently starts + carrying model kernels again, which would mean the split had regressed, and it fails + if the runtime cannot even load a program. + """ + model, reference = _export(work_dir, "plain") + consumer = _build_consumer(work_dir, "runtime-only", ["runtime"]) + + inputs = reference["inputs"] + shape_a, data_a = _write_tensor(work_dir, "ra", inputs[0]) + shape_b, data_b = _write_tensor(work_dir, "rb", inputs[1]) + expected = work_dir / "r_expected.data" + expected.write_text(" ".join(repr(v) for v in reference["expected"])) + environment = { + key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" + } + result = subprocess.run( + [ + str(consumer), + str(model), + str(shape_a), + str(data_a), + str(shape_b), + str(data_b), + str(expected), + ], + capture_output=True, + text=True, + check=False, + env=environment, + ) + combined = result.stdout + result.stderr + assert result.returncode != 0, ( + "an application linking only executorch::runtime executed a model, so the " + "runtime is carrying operator kernels that are supposed to live in their own " + "component" + ) + assert "Missing operator" in combined, ( + "an application linking only the runtime failed for some reason other than " + f"absent kernels, which is the documented behaviour:\n{combined[-1500:]}" + ) + print("✓ executorch::runtime alone links and loads, and has no model kernels") + + +def test_kernels_component_runs_a_model(work_dir: Path) -> None: + """Adding the CPU kernels component keeps the model running and correct.""" + model, reference = _export(work_dir, "plain") + consumer = _build_consumer( + work_dir, "with-kernels", ["runtime", "kernels_optimized"] + ) + output = _run_consumer(consumer, model, reference, work_dir) + print(f"✓ a C++ app linking executorch::kernels_optimized runs a model ({output})") + + +def test_delegated_model_needs_the_delegate_component(work_dir: Path) -> None: + """A delegated model runs when the delegate is linked, and fails when it is not. + + Both halves matter. Only running the positive case would pass even if the delegate + target did nothing, because the runtime falls back to portable kernels for + anything a backend does not claim. The negative case is what shows the delegate is + actually doing the work, and that the retention options on the target are what + make its registration reach the registry. + """ + model, reference = _export(work_dir, "delegate") + assert reference["has_xnnpack"], ( + "the exported program contains no XnnpackBackend payload, so this check would " + "prove nothing about the delegate" + ) + + # The kernels come too. A partitioner claims only what its backend supports, so a + # delegated program still has ordinary operators in it, and an application without + # the kernels fails on those rather than on anything to do with the delegate. + # Measured: this model keeps aten::mean.out outside the XNNPACK partition. + consumer = _build_consumer( + work_dir, + "with-delegate", + ["runtime", "kernels_optimized", "backend_xnnpack"], + ) + output = _run_consumer(consumer, model, reference, work_dir) + print( + f"✓ a C++ app linking executorch::backend_xnnpack runs a delegated model " + f"({output})" + ) + + # The same program, run by an application that has the kernels but not the + # delegate. Only the delegate is removed, so a failure can only be about the + # missing backend rather than about absent operators. + without = _build_consumer(work_dir, "no-delegate", ["runtime", "kernels_optimized"]) + environment = { + key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" + } + inputs = reference["inputs"] + shape_a, data_a = _write_tensor(work_dir, "na", inputs[0]) + shape_b, data_b = _write_tensor(work_dir, "nb", inputs[1]) + expected = work_dir / "n_expected.data" + expected.write_text(" ".join(repr(v) for v in reference["expected"])) + result = subprocess.run( + [ + str(without), + str(model), + str(shape_a), + str(data_a), + str(shape_b), + str(data_b), + str(expected), + ], + capture_output=True, + text=True, + check=False, + env=environment, + ) + assert result.returncode != 0, ( + "a delegated program ran in an application that never linked the delegate, so " + "either the delegate is reaching the registry without being asked for or the " + f"program was not delegated at all. Output was:\n{result.stdout[-1000:]}" + ) + # The exit code alone is not enough: this consumer also exits non-zero on a missing + # file and on too few arguments, so a run that failed to start would pass this check + # while proving nothing about the delegate. + combined = result.stdout + result.stderr + assert "not registered" in combined, ( + "the application failed for some reason other than the delegate being absent, " + f"which is the documented behaviour:\n{combined[-1500:]}" + ) + print( + "✓ the same delegated model fails without executorch::backend_xnnpack, " + "so the component is what registers it" + ) + + +def test_consumer_is_relocatable(work_dir: Path) -> None: + """The application still runs after being moved away from the wheel. + + Building in place leaves the wheel's absolute lib directory on the link line, which + resolves the runtime whatever $ORIGIN says. Copying the application next to a copy + of the libraries, with the original package hidden, is what actually shows the + package is relocatable rather than only working where it was built. + """ + model, reference = _export(work_dir, "plain") + consumer = _build_consumer(work_dir, "relocate", ["runtime", "kernels_optimized"]) + + assert shutil.which("readelf") is not None, "readelf is needed to read the RUNPATH" + dynamic = subprocess.run( + [_tool("readelf"), "-d", str(consumer)], + capture_output=True, + text=True, + check=True, + ).stdout + assert "libexecutorch.so" in dynamic, ( + "the application records no dependency on the shipped runtime, so it is not " + f"linking what the wheel ships:\n{dynamic}" + ) + assert "$ORIGIN" in dynamic, ( + "the application has no $ORIGIN-relative runtime search path, so it cannot " + f"work anywhere but where it was built:\n{dynamic}" + ) + # The newer tag specifically, not just any search path. DT_RPATH is searched + # ahead of LD_LIBRARY_PATH and applies to a dependency's own dependencies, so + # a consumer given DT_RPATH cannot point an instrumented or locally built + # runtime at their application. Both tags satisfy the check above, so without + # this the package could silently go back to the older one. + assert "(RUNPATH)" in dynamic, ( + "the application's runtime search path is recorded as DT_RPATH rather than " + "DT_RUNPATH. DT_RPATH outranks LD_LIBRARY_PATH and is inherited by " + "dependencies, so a consumer could not override a packaged library with " + f"their own build:\n{dynamic}" + ) + + package_dir = _installed_package_dir() + deployed = work_dir / "deployed" + deployed.mkdir(parents=True, exist_ok=True) + shutil.copy2(consumer, deployed / "consumer") + # Every directory the wheel ships a library in, not just lib/. The CUDA delegate records a + # dependency on a library under backends/cuda/, so copying lib/ alone produced a deployment + # that cannot start, and this check could not see it. + for source in ("lib", "backends/cuda"): + directory = package_dir / source + if not directory.is_dir(): + continue + for library in sorted(directory.glob("lib*.so*")): + if library.is_file() and not library.is_symlink(): + shutil.copy2(library, deployed / library.name) + + moved = deployed / "consumer" + # Strip the absolute entry the build left behind, so only $ORIGIN can resolve the + # libraries. Without this the application would find the original wheel and the + # check would pass for the wrong reason. + # Fatal, not a skip. Stripping the absolute entry is the whole point: without it the + # relocated application finds the original package and this check passes for the + # wrong reason. A skip here is indistinguishable from a pass in the log, which is + # the shape of failure this suite exists to avoid. + patchelf = _tool("patchelf") + if shutil.which("patchelf") is None and not Path(patchelf).is_file(): + 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 shutil.which("patchelf") or Path(patchelf).is_file(), ( + "patchelf is required to prove the application is relocatable, and could not " + "be installed. Without it the relocated application resolves the original " + "package and the check would pass without testing anything." + ) + current = subprocess.run( + [patchelf, "--print-rpath", str(moved)], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + kept = [ + entry + for entry in current.split(":") + if entry and not entry.startswith(str(package_dir)) + ] + subprocess.run( + [patchelf, "--set-rpath", ":".join(kept) or "$ORIGIN", str(moved)], check=True + ) + + output = _run_consumer(moved, model, reference, work_dir) + print(f"✓ the application still runs deployed away from the wheel ({output})") + + +def test_one_registry_in_the_cpp_process(work_dir: Path) -> None: + """An application linking several components gets one registry, not one each. + + This is the property the split exists to create, checked in a C++ process rather + than only by inspecting symbol tables. Every component resolves the registry from + the one runtime library, so the count a consumer observes grows by exactly what it + links that registers a backend, rather than resetting or doubling. + """ + model, reference = _export(work_dir, "plain") + + def backends_seen(name, components) -> int: + consumer = _build_consumer(work_dir, name, components) + output = _run_consumer(consumer, model, reference, work_dir) + for field in output.split(): + if field.startswith("backends="): + return int(field.split("=", 1)[1]) + raise AssertionError(f"the application printed no backend count: {output}") + + # Both cases have to be able to run the model, so both link the kernels. The + # variable under test is how many further component libraries are linked, not + # whether the program executes. + lean = backends_seen("registry-lean", ["runtime", "kernels_optimized"]) + full = backends_seen( + "registry-full", + ["runtime", "kernels_optimized", "threadpool", "etdump", "backend_xnnpack"], + ) + # The delegate genuinely adds one backend, so the counts differ by exactly that. + # What must not happen is the count resetting or doubling, which is what a second + # registry in the process looks like. + assert full == lean + 1, ( + f"an application linking two components sees {lean} registered backends while " + f"one linking five, of which exactly one registers a backend, sees {full}. A " + "component is carrying its own registry rather than resolving the shared one." + ) + print( + f"✓ one shared registry: {lean} backends with two components, {full} with five" + ) + + +def test_find_package_honours_a_version_request(work_dir: Path) -> None: + """`find_package(executorch )` must accept and reject correctly. + + Without a version file CMake rejects every versioned request, whatever version is + actually installed, so a consumer pinning a minimum cannot configure at all. + The wheel generates the file at packaging time because the version is only known + then: the base comes from version.txt and a nightly overrides it. + """ + package_dir = _installed_package_dir() + version_file = package_dir / "share" / "cmake" / "executorch-config-version.cmake" + assert version_file.is_file(), ( + f"the wheel ships no CMake version file at {version_file}, so find_package " + "rejects every versioned request" + ) + + installed = None + build_version = None + for line in version_file.read_text().splitlines(): + if line.startswith("set(PACKAGE_VERSION"): + installed = line.split('"')[1] + elif line.startswith("set(EXECUTORCH_BUILD_VERSION"): + build_version = line.split('"')[1] + assert installed, f"could not read PACKAGE_VERSION from {version_file}" + assert not installed.startswith("@"), ( + f"the version file still holds an unsubstituted placeholder, {installed}, so " + "packaging copied the template instead of filling it in" + ) + + # The two variables report different things and are filled separately. Only checking the numeric one + # would pass on a file where the full version was truncated to it, or where its placeholder was never + # substituted, and the full version is what a consumer compares to pin an exact build. + assert build_version, f"could not read EXECUTORCH_BUILD_VERSION from {version_file}" + assert not build_version.startswith( + "@" + ), f"the build version still holds an unsubstituted placeholder, {build_version}" + assert build_version.startswith(installed), ( + f"the build version {build_version} does not start with the numeric release {installed}, " + "so they describe different builds" + ) + from executorch.version import __version__ as installed_version + + assert build_version == installed_version, ( + f"the version file says {build_version} but the installed package says {installed_version}, " + "so a consumer pinning an exact build would compare against the wrong one" + ) + + # CMake compares dotted integers only, and find_package rejects a REQUESTED version + # that is not one, so the numeric release part is what a consumer can ask for. The + # wheel's own version can carry more: a dev segment for a nightly and a local part + # such as +cpu or a commit hash. CMake truncates the stored version at the first + # non-numeric segment, which makes those compare equal to the release, so pinning + # the release is the behaviour a consumer actually gets. + release = re.match(r"\d+(?:\.\d+)*", installed) + assert release, f"no numeric release part in the installed version {installed}" + release = release.group(0) + major = release.split(".")[0] + too_new = f"{int(major) + 1}.0" + source_dir = work_dir / "version-probe" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "consumer.cpp").write_text("int main() { return 0; }\n") + + for requested, must_accept in ((release, True), ("0.1", True), (too_new, False)): + # Deliberately the older floor: this probe never links an imported target, so it also checks + # that version acceptance answers correctly below the version those targets need. + (source_dir / "CMakeLists.txt").write_text( + "cmake_minimum_required(VERSION 3.24)\n" + "project(probe CXX)\n" + f"find_package(executorch {requested} REQUIRED)\n" + "add_executable(consumer consumer.cpp)\n" + ) + build_dir = work_dir / f"version-probe-build-{requested}" + result = subprocess.run( + [ + _tool("cmake"), + "-S", + str(source_dir), + "-B", + str(build_dir), + f"-DCMAKE_PREFIX_PATH={version_file.parent}", + ], + capture_output=True, + text=True, + check=False, + ) + accepted = result.returncode == 0 + assert accepted == must_accept, ( + f"find_package(executorch {requested}) against an installed {installed} " + f"{'was rejected' if must_accept else 'was accepted'}, which is wrong:\n" + f"{result.stdout[-800:]}{result.stderr[-800:]}" + ) + print( + f"✓ find_package honours a version request (installed {installed}, " + f"accepts {release}, rejects {too_new})" + ) + + +def test_profiler_component_is_usable(work_dir: Path) -> None: + """A C++ application must be able to construct the profiler the etdump component represents. + + Linking a component proves the library resolves. It does not prove a consumer can call anything in it, + and the profiler shipped for a while with only an internal alignment helper as its public surface, so + the component could be requested and linked but not used. + """ + package_dir = _installed_package_dir() + # Globbed, not an exact name: the library carries a version suffix outside a wheel build, and an exact + # match would silently skip this check there. The profiler is required elsewhere in this suite, so its + # absence is a fault rather than a reason to skip. + shipped = sorted((package_dir / "lib").glob("libexecutorch_etdump.so*")) + assert shipped, ( + f"the wheel ships no profiler library under {package_dir / 'lib'}, so the etdump component it " + "advertises cannot be linked" + ) + + source_dir = work_dir / "with-etdump" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "consumer.cpp").write_text( + "#include \n" + "#include \n" + "int main() {\n" + " auto tracer = std::make_unique();\n" + " return tracer == nullptr ? 1 : 0;\n" + "}\n" + ) + (source_dir / "CMakeLists.txt").write_text(_consumer_cmake(["runtime", "etdump"])) + + build_dir = work_dir / "with-etdump-build" + configured = subprocess.run( + [ + _tool("cmake"), + "-S", + str(source_dir), + "-B", + str(build_dir), + f"-DCMAKE_PREFIX_PATH={package_dir}", + ], + capture_output=True, + text=True, + check=False, + ) + assert configured.returncode == 0, ( + "configuring an application that uses the profiler failed:\n" + f"{configured.stdout[-1500:]}\n{configured.stderr[-1500:]}" + ) + built = subprocess.run( + [_tool("cmake"), "--build", str(build_dir)], + capture_output=True, + text=True, + check=False, + ) + assert built.returncode == 0, ( + "an application that constructs the profiler failed to build against the installed wheel, so the " + f"component cannot be used by a consumer:\n{built.stdout[-2000:]}\n{built.stderr[-2000:]}" + ) + print("✓ a C++ app linking executorch::etdump constructs the profiler") + + +def test_every_shipped_header_compiles(work_dir: Path) -> None: + """Each installed header must compile on its own against the installed wheel. + + A header that cannot be included is worse than one that is absent, because the failure arrives in + someone else's project at compile time. This caught a profiler header that includes a regular + expression library the wheel does not carry, and whose implementation the shipped library does not + define either. + + Compiled one at a time rather than all together, so the message names the header at fault. + """ + package_dir = _installed_package_dir() + include_root = package_dir / "include" + headers = sorted(include_root.rglob("*.h")) + assert ( + headers + ), f"no headers found under {include_root}, so this check would prove nothing" + + # The same include directories the CMake package exports, since that is what a consumer gets. + includes = [ + f"-I{include_root}", + f"-I{include_root / 'executorch' / 'runtime' / 'core' / 'portable_type' / 'c10'}", + # The same definition every imported target carries. Without it the vendored c10 headers reach for + # a header generated inside a PyTorch build, which no wheel can carry, so compiling without it + # tests a configuration no consumer of this package is ever in. + "-DC10_USING_CUSTOM_GENERATED_MACROS", + ] + # A CUDA wheel's headers reference the CUDA runtime, which the wheel does not publish headers for and + # states as a requirement instead. A consumer of that component supplies a toolkit of its own, so this + # check does the same rather than treating the header as unbuildable. Taken from the toolkit itself, so + # it follows whichever toolkit the build used instead of a list of prefixes that goes stale. + nvcc = shutil.which("nvcc") + cuda_root = os.environ.get("CUDA_HOME") or ( + str(Path(nvcc).parent.parent) if nvcc else "" + ) + if cuda_root and (Path(cuda_root) / "include" / "cuda_runtime.h").is_file(): + includes.append(f"-I{Path(cuda_root) / 'include'}") + # Headers a wheel-only consumer cannot compile and is not expected to. Each needs something outside the + # package: a platform that is not the one being built for, or a third-party library the wheel does not + # carry. They ship because a source build includes them, and holding them to this rule would report a + # defect with no available fix. + needs_more_than_the_wheel = ( + # These ship because other shipped headers include them, so they cannot be left out, and they do + # not compile on their own: each needs a third-party library the wheel links but publishes no + # headers for, or a platform other than the one being built for. + "mman_windows.h", # a Windows compatibility shim, needs the MinGW headers + "testing_util/tensor_util.h", # a test helper, needs a test framework + # These say in their own text that they must not be included directly, and name the header to + # include instead. Including one anyway is a use error rather than a packaging defect. + "c10/util/complex_math.h", + "c10/util/complex_utils.h", + ) + + source = work_dir / "header_probe.cpp" + broken = [] + skipped_but_fine = [] + compiled = 0 + skipped_names = [] + for header in headers: + relative = header.relative_to(include_root) + skipped = relative.as_posix().endswith(needs_more_than_the_wheel) + source.write_text( + f"#include <{relative.as_posix()}>\nint main() {{ return 0; }}\n" + ) + result = subprocess.run( + [_tool("c++"), "-std=c++20", *includes, "-fsyntax-only", str(source)], + capture_output=True, + text=True, + check=False, + ) + if skipped: + skipped_names.append(relative.as_posix()) + if result.returncode == 0: + skipped_but_fine.append(str(relative)) + continue + compiled += 1 + if result.returncode != 0: + missing = re.search(r"fatal error: ([^:]+): No such file", result.stderr) + broken.append( + f"{relative}: {missing.group(1) if missing else 'does not compile'}" + ) + + # A skip list quietly loses value as the code changes: an entry that starts compiling stays skipped and + # nobody notices the coverage was given up for nothing. So the skipped ones are compiled too, and an + # entry that now works is reported rather than left in place. + assert not skipped_but_fine, ( + "these headers are on the skip list but compile now, so the list is stale and is giving up " + f"coverage for no reason. Remove them from it: {skipped_but_fine}" + ) + + assert not broken, ( + "the wheel ships headers that cannot be included from the installed package, so a consumer " + "following the documentation would fail to compile:\n " + "\n ".join(broken) + ) + # The number actually compiled, and the exemptions named. Reporting the total + # selected would claim coverage of headers this never compiled. + print( + f"✓ {compiled} of {len(headers)} shipped headers compile against the installed " + f"wheel; {len(skipped_names)} need something the wheel does not carry " + f"({', '.join(sorted(skipped_names))})" + ) + + +def test_shipped_headers_have_implementations(work_dir: Path) -> None: + """A header that compiles but has no implementation in any shipped library is unusable. + + Compiling proves only that the declarations parse. Two headers once shipped whose implementation + lived in a component no shipped library links, so a consumer got an undefined reference at link + time. A syntax check cannot see that, so this links a real program against the shipped libraries. + + A sample, not a sweep: one entry point per header listed below, chosen because there is no way to + guess a callable declaration from a header alone. It catches a component whose library stops being + linked, which is the failure that shipped. It does not catch a newly added declaration that nobody + implements; that needs a symbol scan over every shipped header, which is worth doing separately. + """ + package = _installed_package_dir() + include_root = package / "include" + + # One small program per header, calling the declaration a consumer would call first. Kept as source + # rather than derived, because there is no way to guess a usable call from a header alone. + probes = { + "extension/memory_allocator/malloc_memory_allocator.h": ( + "#include \n" + "using namespace executorch::extension;\n" + "int main() { MallocMemoryAllocator allocator; return allocator.allocate(16) == nullptr; }\n" + ), + "extension/module/module.h": ( + "#include \n" + "using namespace executorch::extension;\n" + 'int main() { Module module("none.pte"); return module.method_names().ok() ? 0 : 1; }\n' + ), + "extension/tensor/tensor.h": ( + "#include \n" + "using namespace executorch::extension;\n" + "int main() { float data[4] = {}; auto tensor = make_tensor_ptr({2, 2}, data); " + "return tensor->numel() == 4 ? 0 : 1; }\n" + ), + # The profiler, which lives in its own library rather than in the runtime. Included because a + # dead declaration shipped on this class for a while and the probes above could not reach it: + # they link the runtime only, so no etdump symbol was ever resolved here. + "devtools/etdump/etdump_flatcc.h": ( + "#include \n" + "using namespace executorch::etdump;\n" + 'int main() { ETDumpGen generator; generator.create_event_block("probe"); ' + "return 0; }\n" + ), + # The thread pool, which is the other component backing declarations in a shipped header. + # Compiled with ET_USE_THREADPOOL, which is what the package puts on the runtime target when + # the thread pool ships, so this probe sees the same declaration a consumer does. Without it + # the header supplies a local inline definition instead and the probe linked identically with + # and without the library, which made it unable to detect the component being dropped. + # Measured both ways: with the definition, linking the runtime alone fails on + # executorch::extension::parallel_for. + "runtime/kernel/thread_parallel_interface.h": ( + "#define ET_USE_THREADPOOL\n" + "#include \n" + "using namespace executorch::extension;\n" + "int main() { return parallel_for(0, 1, 1, [](int64_t, int64_t) {}) ? 0 : 1; }\n" + ), + } + + includes = [ + f"-I{include_root}", + f"-I{include_root / 'executorch' / 'runtime' / 'core' / 'portable_type' / 'c10'}", + "-DC10_USING_CUSTOM_GENERATED_MACROS", + ] + library_dir = package / "lib" + unresolved = [] + for header, program in probes.items(): + assert ( + include_root / "executorch" / header + ).is_file(), f"{header} is not shipped, so this probe is checking nothing" + source = work_dir / "link_probe.cpp" + source.write_text(program) + result = subprocess.run( + [ + _tool("c++"), + "-std=c++17", + *includes, + str(source), + "-o", + str(work_dir / "link_probe"), + f"-L{library_dir}", + "-lexecutorch", + # The component libraries too, not only the runtime. Linking the runtime alone left + # every declaration outside it unreachable, so a probe for one of those headers passed + # without resolving anything. Missing ones are skipped by the loop below. + *[ + f"-l{name}" + for name in ( + "executorch_etdump", + "executorch_kernels_optimized", + "executorch_threadpool", + ) + if (library_dir / f"lib{name}.so").is_file() + ], + f"-Wl,-rpath,{library_dir}", + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + missing = re.findall(r"undefined reference to `([^']+)'", result.stderr) + unresolved.append(f"{header}: {sorted(set(missing))[:3] or 'did not link'}") + + assert not unresolved, ( + "the wheel ships headers whose implementation is in no shipped library, so a consumer compiles " + "and then fails to link:\n " + "\n ".join(unresolved) + ) + print(f"✓ all {len(probes)} probed headers link against the shipped libraries") + + +def test_documented_example_compiles(work_dir: Path) -> None: + """The C++ example in the documentation must compile against the installed wheel. + + Extracted from the documentation rather than copied here, so the two cannot drift. A + reader who follows the documentation gets code that builds, and a dangling include or + a renamed entry point fails this check instead of shipping. + """ + here = Path(__file__).resolve() + root = here.parents[3] if len(here.parents) > 3 else here.parent + documentation = root / "docs" / "source" / "using-executorch-cpp.md" + if not documentation.is_file(): + print("- the documentation is not present, skipping the example check") + return + + # The first fenced cpp block after the prebuilt-package heading. Anchored on the + # heading so an unrelated example elsewhere on the page is not picked up. + text = documentation.read_text() + marker = "### Using the prebuilt libraries from the pip package" + assert marker in text, ( + f"{documentation.name} no longer documents the prebuilt package, so a reader has " + "no instructions for the libraries this wheel ships" + ) + section = text[text.index(marker) :] + blocks = re.findall(r"```cpp\n(.*?)```", section, re.S) + assert blocks, ( + f"{documentation.name} documents the prebuilt package but shows no C++ example, " + "so nothing proves the documented usage compiles" + ) + + source_dir = work_dir / "documented" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "main.cpp").write_text(blocks[0]) + # The components the documentation itself tells a reader to ask for. + (source_dir / "CMakeLists.txt").write_text( + _consumer_cmake(["runtime", "kernels_optimized"]).replace( + "consumer.cpp", "main.cpp" + ) + ) + + package_dir = _installed_package_dir() + config = package_dir / "share" / "cmake" / "executorch-config.cmake" + build_dir = work_dir / "documented-build" + configured = subprocess.run( + [ + _tool("cmake"), + "-S", + str(source_dir), + "-B", + str(build_dir), + f"-DCMAKE_PREFIX_PATH={config.parent}", + ], + capture_output=True, + text=True, + check=False, + ) + assert configured.returncode == 0, ( + "the documented example does not configure against the installed package:\n" + f"{configured.stdout[-1500:]}{configured.stderr[-1500:]}" + ) + built = subprocess.run( + [_tool("cmake"), "--build", str(build_dir)], + capture_output=True, + text=True, + check=False, + ) + assert built.returncode == 0, ( + "the documented example does not build against the installed package, so a " + f"reader following the documentation gets code that fails:\n" + f"{built.stdout[-2500:]}{built.stderr[-2500:]}" + ) + print("✓ the C++ example in the documentation compiles against the wheel") + + +def _provision_pre_328_cmake(work_dir: Path) -> str: + """Return a path to a cmake older than 3.28, or "" if one cannot be had. + + The route this check exercises is only reachable with such a binary, and a + release builder is not obliged to carry one, so fetch it rather than leaving + the check permanently skipped. Failure to fetch is not a test failure: the + caller reports the missing coverage instead. + """ + override = os.environ.get("EXECUTORCH_PRE_328_CMAKE", "") + if override and Path(override).is_file(): + return override + + venv_dir = work_dir / "pre-328-cmake" + binary = venv_dir / "bin" / "cmake" + if not binary.is_file(): + try: + subprocess.run( + [sys.executable, "-m", "venv", str(venv_dir)], + check=True, + capture_output=True, + ) + subprocess.run( + [ + str(venv_dir / "bin" / "pip"), + "install", + "--quiet", + "cmake==3.24.*", + ], + check=True, + capture_output=True, + ) + except (subprocess.CalledProcessError, OSError) as error: + print(f"- could not provision a pre-3.28 cmake: {error}") + return "" + return str(binary) if binary.is_file() else "" + + +def test_pre_3_28_route_builds_a_consumer_through_variables(work_dir: Path) -> None: + """The pre-3.28 route offers only variables, and those variables have to work. + + CMake before 3.28 writes the $ORIGIN token in a runtime search path incorrectly, so + the config file skips its imported targets on those versions and exposes plain + variables instead. The whole modern-CMake test set never enters that branch, because + cmake_minimum_required(3.28) does not lower CMAKE_VERSION and the tests above run + with whatever cmake is on PATH. So a consumer stuck on an older cmake would find at + run time that the wheel produced no usable link. + + Runs only when EXECUTORCH_PRE_328_CMAKE points at a cmake binary older than 3.28, + since a released wheel is not obliged to carry one. Skipped otherwise, with a + message that says so, so a build that has no old cmake still reports the coverage + it lacks rather than reporting green. + """ + old_cmake = os.environ.get("EXECUTORCH_PRE_328_CMAKE", "") + if not old_cmake or not Path(old_cmake).is_file(): + old_cmake = _provision_pre_328_cmake(work_dir) + if not old_cmake: + print( + "- no cmake older than 3.28 is available, skipping the pre-3.28 route " + "check" + ) + return + version = subprocess.run( + [old_cmake, "--version"], capture_output=True, text=True, check=True + ).stdout.splitlines()[0] + match = re.search(r"(\d+)\.(\d+)", version) + assert match, f"could not read a version from {old_cmake}: {version!r}" + major, minor = int(match.group(1)), int(match.group(2)) + assert (major, minor) < (3, 28), ( + f"EXECUTORCH_PRE_328_CMAKE={old_cmake} is version {major}.{minor}, but this " + "check needs a binary older than 3.28 to enter the fallback route" + ) + + package_dir = _installed_package_dir() + config = package_dir / "share" / "cmake" / "executorch-config.cmake" + assert config.is_file(), f"the wheel ships no CMake package config at {config}" + + source_dir = work_dir / "pre-328" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "consumer.cpp").write_text(_CONSUMER_SOURCE) + # No COMPONENTS and no named target, which is the shape the older-CMake route + # forces. The variables have to carry everything a consumer needs: the runtime and + # its components, the include directories, the compile definitions and the C++ + # standard. + (source_dir / "CMakeLists.txt").write_text( + "cmake_minimum_required(VERSION 3.19)\n" + "project(consumer CXX)\n" + "find_package(executorch REQUIRED)\n" + "add_executable(consumer consumer.cpp)\n" + "target_include_directories(consumer PRIVATE ${EXECUTORCH_INCLUDE_DIRS})\n" + "target_compile_definitions(consumer PRIVATE ${EXECUTORCH_COMPILE_DEFINITIONS})\n" + "target_link_libraries(consumer PRIVATE ${EXECUTORCH_LIBRARIES})\n" + "set_target_properties(consumer PROPERTIES CXX_STANDARD ${EXECUTORCH_CXX_STANDARD})\n" + ) + build_dir = work_dir / "pre-328-build" + for command in ( + [ + old_cmake, + "-S", + str(source_dir), + "-B", + str(build_dir), + f"-DCMAKE_PREFIX_PATH={config.parent}", + ], + [old_cmake, "--build", str(build_dir)], + ): + result = subprocess.run(command, capture_output=True, text=True, check=False) + assert result.returncode == 0, ( + "a consumer on CMake older than 3.28 could not build against the wheel " + f"through the documented variables:\n" + f"{result.stdout[-2000:]}\n{result.stderr[-2000:]}" + ) + + consumer = build_dir / "consumer" + assert consumer.is_file(), f"the build produced no {consumer}" + # The runtime and CPU kernels have to be on the link line, since the variables are + # the only thing that carries them on this route. Reading the dynamic section rather + # than running because running needs a model, which the modern-CMake tests above + # cover once and this one only owns the variables path. + dependencies = subprocess.run( + ["readelf", "-d", str(consumer)], capture_output=True, text=True, check=True + ).stdout + assert "libexecutorch.so" in dependencies, ( + "a consumer built through EXECUTORCH_LIBRARIES on pre-3.28 CMake does not " + f"depend on the runtime:\n{dependencies}" + ) + assert "libexecutorch_kernels_optimized" in dependencies, ( + "the pre-3.28 aggregate does not carry the CPU kernels, so a consumer built " + "through it would fail at run time with operators reported missing" + ) + print( + f"✓ a consumer on CMake {major}.{minor} builds through EXECUTORCH_LIBRARIES " + "and links the runtime plus the CPU kernels" + ) + + +def run_tests(work_dir: Path) -> None: + test_find_package_honours_a_version_request(work_dir) + test_profiler_component_is_usable(work_dir) + test_every_shipped_header_compiles(work_dir) + test_shipped_headers_have_implementations(work_dir) + test_documented_example_compiles(work_dir) + test_runtime_alone_links_but_cannot_compute(work_dir) + test_kernels_component_runs_a_model(work_dir) + test_pre_3_28_route_builds_a_consumer_through_variables(work_dir) + test_delegated_model_needs_the_delegate_component(work_dir) + test_consumer_is_relocatable(work_dir) + test_one_registry_in_the_cpp_process(work_dir) + + +if __name__ == "__main__": + with tempfile.TemporaryDirectory() as directory: + run_tests(Path(directory)) diff --git a/.ci/scripts/wheel/test_linux.py b/.ci/scripts/wheel/test_linux.py index d76ed6b2462..fdc11478adc 100644 --- a/.ci/scripts/wheel/test_linux.py +++ b/.ci/scripts/wheel/test_linux.py @@ -11,6 +11,7 @@ from pathlib import Path import test_base +import test_cpp_sdk import test_shared_libraries from examples.models import Backend, Model @@ -50,6 +51,13 @@ with tempfile.TemporaryDirectory() as work_dir: test_shared_libraries.run_tests(Path(work_dir)) + # And that a C++ application outside the wheel can actually use them. + # Nothing above covers this: the Python extension links those libraries + # itself, so it passes whether or not the package config names them or the + # shipped headers are complete. + with tempfile.TemporaryDirectory() as work_dir: + test_cpp_sdk.run_tests(Path(work_dir)) + test_base.run_tests( model_tests=[ test_base.ModelTest( diff --git a/.ci/scripts/wheel/test_linux_aarch64.py b/.ci/scripts/wheel/test_linux_aarch64.py index b268c72cea3..d8e8c25dba2 100644 --- a/.ci/scripts/wheel/test_linux_aarch64.py +++ b/.ci/scripts/wheel/test_linux_aarch64.py @@ -9,6 +9,7 @@ from pathlib import Path import test_base +import test_cpp_sdk import test_shared_libraries from examples.models import Backend, Model @@ -36,6 +37,11 @@ with tempfile.TemporaryDirectory() as work_dir: test_shared_libraries.run_tests(Path(work_dir)) + # And that a C++ application outside the wheel can actually use those + # libraries, which nothing above covers. + with tempfile.TemporaryDirectory() as work_dir: + test_cpp_sdk.run_tests(Path(work_dir)) + test_base.run_tests( model_tests=[ test_base.ModelTest( diff --git a/README-wheel.md b/README-wheel.md index f3a89f342ee..5a38ba24084 100644 --- a/README-wheel.md +++ b/README-wheel.md @@ -22,8 +22,8 @@ The prebuilt `executorch.runtime` module included in this package provides a way to run ExecuTorch `.pte` files, with some restrictions: * Only [core ATen operators](docs/source/ir-ops-set-definition.md) are linked into the prebuilt module * Only the [XNNPACK backend delegate](docs/source/backends/xnnpack/xnnpack-overview.md) is linked into the prebuilt module. -* \[macOS only] [Core ML](docs/source/backends/coreml/coreml-overview.md) and [MPS](docs/source/backends/mps/mps-overview.md) backend - are also linked into the prebuilt module. +* \[macOS only] [Core ML](docs/source/backends/coreml/coreml-overview.md) backend is + also linked into the prebuilt module. * \[Linux x86_64] [QNN](docs/source/backends-qualcomm.md) backend is linked into the prebuilt module. * \[Linux] [OpenVINO](docs/source/build-run-openvino.md) backend is also linked into the prebuilt module. OpenVINO requires the runtime to be installed separately: diff --git a/devtools/etdump/etdump_flatcc.h b/devtools/etdump/etdump_flatcc.h index 8b39b243165..3a14747d096 100644 --- a/devtools/etdump/etdump_flatcc.h +++ b/devtools/etdump/etdump_flatcc.h @@ -74,7 +74,6 @@ class ETDumpGen : public ::executorch::runtime::EventTracer { public: ETDumpGen(::executorch::runtime::Span buffer = {nullptr, (size_t)0}); ~ETDumpGen() override; - void clear_builder(); void create_event_block(const char* name) override; virtual ::executorch::runtime::EventTracerEntry start_profiling( diff --git a/docs/source/using-executorch-cpp.md b/docs/source/using-executorch-cpp.md index 5505ade9573..f825fe0785b 100644 --- a/docs/source/using-executorch-cpp.md +++ b/docs/source/using-executorch-cpp.md @@ -40,6 +40,151 @@ Running a model using the low-level runtime APIs allows for a high-degree of con ## Building with CMake +There are two ways to get the C++ runtime. Linking the prebuilt libraries from the pip +package needs no source checkout and is the quicker option. Building from source gives +you every option the project has, and is what you need for a platform the wheel does not +cover. + +### Using the prebuilt libraries from the pip package + +On Linux, `pip install executorch` includes prebuilt shared libraries, the public +headers, and a CMake package, so a C++ application can link the runtime without building +ExecuTorch itself: + +```cmake +# CMakeLists.txt +cmake_minimum_required(VERSION 3.28) +project(my_app CXX) + +find_package(executorch REQUIRED COMPONENTS kernels_optimized) + +add_executable(my_app main.cpp) +target_link_libraries(my_app PRIVATE executorch::runtime + executorch::kernels_optimized) +``` + +Point CMake at the installed package when you configure: + +``` +cmake -S . -B build \ + -DCMAKE_PREFIX_PATH="$(python -c 'import executorch, pathlib; print(pathlib.Path(executorch.__path__[0]) / "share" / "cmake")')" +cmake --build build +``` + +The application uses the same `Module` and `TensorPtr` APIs described above: + +```cpp +// main.cpp +#include +#include + +#include +#include + +using namespace executorch::extension; + +int main() { + Module module("model.pte"); + + std::vector data(2 * 8, 1.0f); + auto input = make_tensor_ptr({2, 8}, data.data()); + + const auto result = module.forward(input); + if (!result.ok()) { + std::printf("forward failed: 0x%x\n", (unsigned)result.error()); + return 1; + } + std::printf("ok, %zu outputs\n", result->size()); + return 0; +} +``` + +#### What each component provides + +Ask for the components your model needs. A component the wheel was not built with is +reported while CMake configures, rather than failing later at link time. + +| Component | What it provides | +| --- | --- | +| `executorch::runtime` | the program loader and executor. Always present. | +| `executorch::kernels_optimized` | CPU operator kernels. Needed for any operator a delegate does not claim. | +| `executorch::backend_xnnpack` | the XNNPACK delegate. | +| `executorch::threadpool` | the shared thread pool. | +| `executorch::etdump` | the profiler. | + +The runtime on its own loads a program but registers only primitive operators, not the +kernels a model computes with, so a model that is not fully delegated needs a kernel +component too. Linking a delegate is what registers it: a program delegated to XNNPACK +fails to load in an application that did not link `executorch::backend_xnnpack`. + +To require a minimum version, pass it to `find_package`: + +```cmake +find_package(executorch 1.0 REQUIRED) +``` + +#### On CMake older than 3.28 + +The example above needs CMake 3.28. Older versions write the `$ORIGIN` marker (the +"look next to me" token in a library search path) incorrectly, which would leave you with +a target that runs where it was built and fails once the application is copied +elsewhere. Rather than hand you a target that behaves that way, the package defines no +imported targets below 3.28 and exports plain variables instead. + +An imported target carries more than a library path, so on this route you have to apply +the rest yourself. Linking the libraries alone does not compile: + +```cmake +cmake_minimum_required(VERSION 3.19) +project(my_app CXX) + +find_package(executorch REQUIRED) + +add_executable(my_app main.cpp) +target_include_directories(my_app PRIVATE ${EXECUTORCH_INCLUDE_DIRS}) +target_compile_definitions(my_app PRIVATE ${EXECUTORCH_COMPILE_DEFINITIONS}) +target_link_libraries(my_app PRIVATE ${EXECUTORCH_LIBRARIES}) +set_property(TARGET my_app PROPERTY CXX_STANDARD ${EXECUTORCH_CXX_STANDARD}) +set_property(TARGET my_app PROPERTY CXX_STANDARD_REQUIRED ON) +``` + +On this route the application also has to record where the libraries live, or it runs +from its build directory and then fails to start once installed with a message like +`libexecutorch.so: cannot open shared object file`. CMake records the wheel's library +directory while building, because the libraries are named by absolute path, but it removes +that entry on install. Ask for it to be kept: + +```cmake +set_property(TARGET my_app PROPERTY INSTALL_RPATH "${EXECUTORCH_RUNTIME_LIBRARY_DIR}") +target_link_options(my_app PRIVATE "LINKER:--enable-new-dtags") +``` + +The second line matters on Linux. Without it this linker records the older `DT_RPATH` tag, which is +searched before `LD_LIBRARY_PATH` and also applies to your dependencies' own dependencies, so you +could not point the application at a different build of the runtime. With it you get `DT_RUNPATH`, +which only affects your application and stays overridable. + +The imported target route does not need this on Linux: the package sets its search paths as +explicit link options, and those survive installation. + +On macOS it does need one line. CMake removes an entry that points at a directory holding a +library the application linked, so the entry naming the wheel's own directory is deleted from +the installed binary and it stops finding the runtime: + +```cmake +set_property(TARGET my_app PROPERTY INSTALL_RPATH_USE_LINK_PATH TRUE) +``` + +An application deployed beside the libraries is unaffected either way, because the +`@loader_path` and `$ORIGIN` entries are kept. + +`EXECUTORCH_LIBRARIES` names the runtime and every component the wheel shipped, so you +cannot choose components on this route. Upgrade to CMake 3.28 and link the specific +targets you need instead. + +### Building from source + + ExecuTorch uses CMake as the primary build system. Inclusion of the module and tensor APIs are controlled by the `EXECUTORCH_BUILD_EXTENSION_MODULE` and `EXECUTORCH_BUILD_EXTENSION_TENSOR` CMake options. As these APIs may not be supported on embedded systems, they are disabled by default when building from source. The low-level API surface is always included. To link, add the `executorch` target as a CMake dependency, along with `executorch_backends`, `executorch_extensions`, and `extension_kernels`, to link all configured backends, extensions, and kernels. ``` diff --git a/extension/memory_allocator/memory_allocator_utils.h b/extension/memory_allocator/memory_allocator_utils.h index 079537e60cd..369d9c07648 100644 --- a/extension/memory_allocator/memory_allocator_utils.h +++ b/extension/memory_allocator/memory_allocator_utils.h @@ -16,12 +16,10 @@ #include #include -using executorch::runtime::Error; -using executorch::runtime::Result; namespace executorch::extension::utils { // Util to get alighment adjusted allocation size -inline Result get_aligned_size(size_t size, size_t alignment) { +inline runtime::Result get_aligned_size(size_t size, size_t alignment) { // The minimum alignment that malloc() is guaranteed to provide. static constexpr size_t kMallocAlignment = alignof(std::max_align_t); if (alignment > kMallocAlignment) { @@ -31,7 +29,7 @@ inline Result get_aligned_size(size_t size, size_t alignment) { const size_t extra = alignment - 1; if ET_UNLIKELY (extra >= SIZE_MAX - size) { ET_LOG(Error, "Malloc size overflow: size=%zu + extra=%zu", size, extra); - return Result(Error::InvalidArgument); + return runtime::Result(runtime::Error::InvalidArgument); } size += extra; } diff --git a/runtime/executor/platform_memory_allocator.h b/runtime/executor/platform_memory_allocator.h index 601a4c19c85..a103da15501 100644 --- a/runtime/executor/platform_memory_allocator.h +++ b/runtime/executor/platform_memory_allocator.h @@ -13,6 +13,7 @@ #include #include +#include #include #include #include diff --git a/setup.py b/setup.py index 4505cae1bf4..dddac8771a0 100644 --- a/setup.py +++ b/setup.py @@ -85,6 +85,42 @@ format="%(asctime)s [%(levelname)s] %(message)s", ) +# Headers swept in by a directory copy that a consumer of the wheel cannot use, because each needs +# something the wheel does not carry. Publishing one is worse than leaving it out: the failure arrives in +# someone else's project rather than here. +# +# Matched on the path ending, not the bare file name. Two different headers here share the name +# tensor_util.h, one a widely included utility and one a test helper, so a name match either kept the test +# helper or removed the utility everything needs. +# +# Only headers that nothing else the wheel installs includes belong here. A header other shipped headers +# pull in must keep shipping even when it cannot be compiled on its own. +_UNSHIPPABLE_HEADERS = frozenset( + { + # Needs a header generated when the schema is compiled, which in turn needs the FlatBuffers C++ + # headers. Those are a third-party library this wheel does not vendor. + "runtime/executor/tensor_parser.h", + # A test helper, needing a test framework the wheel does not ship. + "runtime/core/testing_util/error_matchers.h", + # Reads processor details through cpuinfo, whose headers the wheel does not publish. + "extension/threadpool/cpuinfo_utils.h", + # Holds a pthreadpool member by value, so it needs that library's header, which the wheel does not + # publish either. The component it belongs to is a link dependency the runtime carries, not + # something a consumer includes. + "extension/threadpool/threadpool.h", + # Declares CPUCachingAllocator, whose implementation is in a component no shipped library links, + # so including it compiles and then fails at link time with an undefined reference. + "extension/memory_allocator/cpu_caching_malloc_allocator.h", + # Declares BundledModule, which is built only for the Python bindings, so its implementation is in + # the Python extension. A C++ application cannot link that, and building the source instead needs + # bundled-program headers the wheel does not publish. + "extension/module/bundled_module.h", + # Declares FileDescriptorDataLoader, whose implementation is in no CMake target at all, so no + # shipped library defines it. Including it compiles and then fails at link time. + "extension/data_loader/file_descriptor_data_loader.h", + } +) + try: from tools.cmake.cmake_cache import CMakeCache except ImportError: @@ -867,10 +903,21 @@ def run(self): "tools/cmake/executorch-wheel-config.cmake", "share/cmake/executorch-config.cmake", ), + # And again where CMake looks when a consumer points CMAKE_PREFIX_PATH at the + # package root, which is the ordinary way to use an installed package. CMake + # searches /lib/cmake/, not /share/cmake directly, so + # without this copy the root is not a usable prefix and a consumer needs a + # path that names this project's layout. The first location stays because the + # existing contract uses it. + ( + "tools/cmake/executorch-wheel-config.cmake", + "lib/cmake/executorch/executorch-config.cmake", + ), ] - # Copy all the necessary headers into include/executorch/ so that they can - # be found in the pip package. This is the subset of headers that are - # essential for building custom ops extensions. + # The headers the package installs. Two audiences now: a custom-operator + # build, which needs the kernel and tensor helpers, and a C++ application + # using the shipped libraries as an SDK, which needs the documented entry + # points as well. # TODO: Use cmake to gather the headers instead of hard-coding them here. # For example: # https://discourse.cmake.org/t/installing-headers-the-modern-way-regurgitated-and-revisited/3238/3 @@ -883,9 +930,40 @@ def run(self): "extension/kernel_util/", "extension/tensor/", "extension/threadpool/", + # Module is how the documentation tells a C++ application to load and + # run a program. Without it the package ships the libraries to do that + # and no way to call them, which the C++ consumer check catches. + "extension/module/", + # Module's constructors take unique_ptr to the runtime's allocator + # and loader bases, whose headers already ship. These supply the + # concrete subclasses a caller has to construct to pass one, such as + # MallocMemoryAllocator and FileDataLoader. + "extension/memory_allocator/", + "extension/data_loader/", + # The MergedDataMap and FlatTensorDataMap entry points, whose + # implementations ship inside libexecutorch.so. The .ptd file header + # is included too, so a caller writing or reading a .ptd by hand has + # its declarations. + "extension/named_data_map/merged_data_map.h", + "extension/flat_tensor/flat_tensor_data_map.h", + "extension/flat_tensor/serialize/flat_tensor_header.h", + # ETDump, whose library the package ships as a component. A profiler + # that cannot be included is a library nobody can call. + # + # The whole directory except the filter, which includes a regular + # expression library the wheel does not carry and whose implementation + # is not in the shipped library either. Publishing a header that cannot + # be included is worse than not publishing it, because the failure + # arrives at compile time in someone else's project. + "devtools/etdump/etdump_flatcc.h", + "devtools/etdump/emitter.h", + "devtools/etdump/utils.h", + "devtools/etdump/data_sinks/", ]: - src_list = Path(include_dir).rglob("*.h") - for src in src_list: + # A directory entry publishes everything under it, and a file entry publishes + # just that file. Some directories hold headers a consumer cannot compile + # against, so those are named individually rather than swept in. + for src in _headers_to_install(Path(include_dir)): src_to_dst.append( (str(src), os.path.join("include/executorch", str(src))) ) @@ -923,6 +1001,93 @@ def run(self): self.mkpath(os.path.dirname(dst_file)) self.copy_file(src_file, dst_file, preserve_mode=False) + if not _is_minimal_build(): + self._write_cmake_version_file(dst_root) + + def _write_cmake_version_file(self, dst_root: str) -> None: + """Write the CMake package version file, so `find_package(executorch 1.2)` works. + + Generated rather than copied, because the version is only known here: + version.txt gives the base and BUILD_VERSION overrides it for a nightly. A + checked-in file would go stale the first time either changed. + """ + template = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "tools", + "cmake", + "executorch-wheel-config-version.cmake.in", + ) + with open(template) as handle: + contents = handle.read() + # Only the numeric release part. A Python version can carry a local segment + # such as "1.5.0+cpu" or a development suffix, and `find_package(executorch + # 1.5.0+cpu)` is rejected by CMake as an invalid argument, so a consumer could + # not name the version this file reports. Strip to the dotted numbers CMake + # can compare, which is what a consumer asks for in practice. + # Two variables with different jobs. CMake compares PACKAGE_VERSION, so it has to be the + # numeric release and nothing else. EXECUTORCH_BUILD_VERSION is documented as the full + # version, which is what a consumer pinning an exact build compares against, so filling it + # from the numeric part would make that comparison pass against a different wheel. + build_version = Version.string() + cmake_version = re.match(r"\d+(?:\.\d+)*", build_version) + if not cmake_version: + # A version file claiming 0 would satisfy every version request, which is worse than + # not building at all. + raise RuntimeError( + f"cannot derive a numeric CMake version from {build_version!r}; the version file " + "would claim 0 and satisfy every version request" + ) + contents = contents.replace("@EXECUTORCH_VERSION@", cmake_version.group(0)) + contents = contents.replace("@EXECUTORCH_BUILD_VERSION@", build_version) + # CMake only reads a version file that sits beside the configuration file it found, so this + # goes to both locations the configuration is installed to. Writing it to one would leave a + # version request silently unchecked when the other location was used. + for destination in ( + os.path.join(dst_root, "share", "cmake", "executorch-config-version.cmake"), + os.path.join( + dst_root, + "lib", + "cmake", + "executorch", + "executorch-config-version.cmake", + ), + ): + self.mkpath(os.path.dirname(destination)) + with open(destination, "w") as handle: + handle.write(contents) + + +def _headers_to_install(entry: Path): + """The headers a copy list entry publishes, skipping any a consumer could not or should not use. + + A directory entry publishes everything under it, and a file entry publishes just that file. A header a + consumer cannot compile is worse than an absent one, because the failure lands in their project rather + than here, and the directory entries sweep in a few of those. + + Test directories are skipped as a whole rather than by name. They hold mocks and stubs for this + project's own tests, nothing the wheel installs includes them, and a consumer linking a mock allocator + or a stub platform would get behaviour no release intends. Matched on any part starting with "test", so + a directory named testing_util counts too, which a plain equality check missed. + """ + candidates = entry.rglob("*.h") if entry.is_dir() else [entry] + return [ + src + for src in candidates + if not _is_unshippable_header(src) + and not any(part.startswith("test") for part in src.parts[:-1]) + ] + + +def _is_unshippable_header(src: Path) -> bool: + """Whether a header is on the list of ones a consumer of the wheel could not compile. + + Compared on the path ending rather than the file name. Two headers here are both called + tensor_util.h, one a utility that many shipped headers include and one a test helper, so matching the + bare name either kept the helper or removed the utility everything needs. + """ + posix = src.as_posix() + return any(posix.endswith(entry) for entry in _UNSHIPPABLE_HEADERS) + class Buck2EnvironmentFixer(contextlib.AbstractContextManager): """Removes HOME from the environment when running as root. diff --git a/tools/cmake/executorch-wheel-config-version.cmake.in b/tools/cmake/executorch-wheel-config-version.cmake.in new file mode 100644 index 00000000000..1c3f8640fa9 --- /dev/null +++ b/tools/cmake/executorch-wheel-config-version.cmake.in @@ -0,0 +1,49 @@ +# 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. + +# Version file for the wheel's CMake package, so `find_package(executorch 1.2)` +# answers correctly instead of matching any version at all. +# +# Written by packaging rather than checked in, because the version is only known +# when the wheel is built: version.txt gives the base, and BUILD_VERSION +# overrides it for a nightly. A checked-in file would go stale the first time +# either changed. +# +# The same shape as the torch wheel's TorchConfigVersion.cmake, which is the +# file a consumer of this ecosystem will already have met. +set(PACKAGE_VERSION "@EXECUTORCH_VERSION@") + +# The same version again, unabridged. find_package compares dotted integers +# only, so the version above is read as its numeric release part and a consumer +# cannot pin a nightly or a specific build through it: passing the full string +# to find_package is a hard argument error. This variable is what a consumer +# compares when an exact build pairing is required. +set(EXECUTORCH_BUILD_VERSION "@EXECUTORCH_BUILD_VERSION@") + +# Any version at least as new as the one requested is compatible. ExecuTorch has +# no stable ABI promise across majors yet, so this is deliberately permissive; +# when it does, this is where the rule changes. +if("${PACKAGE_VERSION}" VERSION_LESS "${PACKAGE_FIND_VERSION}") + set(PACKAGE_VERSION_COMPATIBLE FALSE) +else() + set(PACKAGE_VERSION_COMPATIBLE TRUE) + if("${PACKAGE_VERSION}" VERSION_EQUAL "${PACKAGE_FIND_VERSION}") + set(PACKAGE_VERSION_EXACT TRUE) + endif() +endif() + +# A range request names an upper bound, and without this branch the check above +# sees only the lower one, so find_package(executorch 1.0...<2.0) accepted 3.0. +# CMake sets these variables only for a range, so a plain request is unaffected. +if(PACKAGE_FIND_VERSION_RANGE AND PACKAGE_VERSION_COMPATIBLE) + if(PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "INCLUDE") + if("${PACKAGE_VERSION}" VERSION_GREATER "${PACKAGE_FIND_VERSION_MAX}") + set(PACKAGE_VERSION_COMPATIBLE FALSE) + endif() + elseif(NOT "${PACKAGE_VERSION}" VERSION_LESS "${PACKAGE_FIND_VERSION_MAX}") + set(PACKAGE_VERSION_COMPATIBLE FALSE) + endif() +endif() diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index fa789771872..c337bada296 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -8,7 +8,21 @@ # for this file and find ExecuTorch package if it is installed. Typical usage # is: # +# ~~~ # find_package(executorch REQUIRED) +# target_link_libraries(my_app PRIVATE executorch::runtime) +# ~~~ +# +# This is the wheel's own contract, written by hand rather than generated, +# because the wheel copies build products out of the build tree instead of +# running an install step. +# +# It is NOT identical to the in-tree package config. That one exposes the +# build's own bare target names, such as executorch and xnnpack_backend, while +# this one exposes namespaced imported targets like executorch::runtime, because +# a wheel consumer links prebuilt files rather than participating in the build. +# Consumer code written against one therefore does not configure against the +# other. # ------- # # Finds the ExecuTorch library @@ -16,13 +30,511 @@ # This will define the following variables: # # EXECUTORCH_FOUND -- True if the system has the ExecuTorch library +# # EXECUTORCH_INCLUDE_DIRS -- The include directories for ExecuTorch -# EXECUTORCH_LIBRARIES -- Libraries to link against # +# EXECUTORCH_COMPILE_DEFINITIONS -- Definitions a consumer must compile with. +# The vendored c10 headers otherwise reach for a header generated inside a +# PyTorch build, which no wheel can carry. +# +# EXECUTORCH_CXX_STANDARD -- The minimum C++ standard the shipped headers need. +# A consumer using these variables has to set it, because a compiler defaulting +# to an older standard cannot parse them. +# +# EXECUTORCH_LIBRARIES -- Libraries to link against: the prebuilt runtime and +# the components the wheel shipped, except the ones documented below as opt in. +# Not the Python extension, which carries unresolved interpreter symbols that +# only resolve inside an interpreter, so a standalone application linking it +# fails with a page of PyUnicode_InternFromString errors. A project building a +# custom operator against the extension asks for the _portable_lib target by +# name, which is the long-standing contract for that and also carries the C++20 +# requirement PyTorch's headers need. +# +# EXECUTORCH_BUILD_VERSION -- The full version this package was built from, +# including any prerelease suffix and local version label. Compare this when an +# exact build pairing is required, since the CMake package version keeps only +# the numeric part. +# +# and, when the prebuilt shared runtime is present, the imported target: +# +# executorch::runtime -- The prebuilt C++ runtime (libexecutorch.so). Loads +# and executes a program, and carries only primitive operators rather than the +# kernels a model computes with, so running a model needs a kernel component as +# well. +# +# Component targets are defined only when the wheel ships that component, so the +# set depends on which wheel is installed. Each one carries the runtime +# dependency and, for a registration-only library, the link options that keep it +# from being dropped. The names, when present, are: +# +# executorch::kernels_optimized -- The CPU operator kernels. Needed to run a +# model. executorch::backend_xnnpack -- The XNNPACK delegate. +# executorch::threadpool -- The shared thread pool. executorch::etdump -- +# The profiler. +# +# Check with if(TARGET executorch::) rather than assuming one exists. A +# namespaced name that was never defined is a configure-time error that names +# the component, so a consumer who links one unconditionally gets a clear +# failure rather than a broken build. Guarding is still worth doing, because a +# component's absence is a legitimate state: a CPU-only wheel ships no +# accelerator delegate, and a consumer that guards adapts instead of failing. +# +# The floor stays where it was, so a consumer that only wants the long-standing +# variables and the prebuilt Python extension keeps working on the CMake it +# already has. The shared-runtime targets below need more than this and check +# for it themselves. cmake_minimum_required(VERSION 3.19) -# Find prebuilt _portable_lib..so. This file should be installed -# under /executorch/share/cmake +# The imported targets below export "$ORIGIN"-relative runtime paths as link +# options, and CMake writes that token incorrectly before 3.28. Measured rather +# than inferred, on a consumer linking such a target: +# +# ~~~ +# 3.24.3, 3.27.9 Makefiles double the dollar sign, Ninja drops the name +# 3.28.4, 3.31.8 both write the token correctly +# ~~~ +# +# Either broken form leaves a consumer building and running in place, because +# the absolute package directory is also recorded, then failing once it is +# deployed somewhere else. Silently defining a target that behaves that way is +# worse than not defining it, so the targets are skipped and a consumer that +# asked for one gets a message naming the reason. +if(CMAKE_VERSION VERSION_LESS 3.28) + set(_executorch_targets_supported FALSE) +else() + set(_executorch_targets_supported TRUE) +endif() + +# Everything is resolved relative to this file so the wheel stays relocatable: +# no absolute path from the machine that built it is baked in here. The file is +# installed both under share/cmake, which the historical contract uses, and +# under lib/cmake/executorch, which a plain CMAKE_PREFIX_PATH pointed at the +# package root can discover, so the root is located by a marker rather than a +# fixed depth. +# +# Tested directly rather than through find_path. The root is a known relative +# offset from this file, so a search adds nothing, and find_path applies the +# consumer's find-root rules: under a cross-compiling toolchain that sets +# CMAKE_FIND_ROOT_PATH_MODE_INCLUDE to ONLY it reroots these absolute paths into +# the target sysroot, finds nothing, and reports a complete package as missing. +set(_executorch_package_root "") +foreach(_candidate + "${CMAKE_CURRENT_LIST_DIR}/.." "${CMAKE_CURRENT_LIST_DIR}/../.." + "${CMAKE_CURRENT_LIST_DIR}/../../.." +) + # share/cmake identifies the package root and only exists there. A generic + # marker such as include/executorch can also appear one level down, in which + # case the search from lib/cmake/executorch would stop at lib/ and resolve the + # wrong root. + if(EXISTS "${_candidate}/share/cmake/executorch-config.cmake") + set(_executorch_package_root "${_candidate}") + break() + endif() +endforeach() + +# Normalise the result before it is used to build paths. The search can return a +# directory with a trailing separator, which then appears doubled in every path +# derived from it and in the message reporting where the runtime was found. +if(_executorch_package_root) + string(REGEX REPLACE "/+$" "" _executorch_package_root + "${_executorch_package_root}" + ) +endif() + +# Both directories are needed for a usable package. The C10 compatibility +# headers are not optional: core headers such as runtime/core/array_ref.h +# include c10 unconditionally, so a package missing them cannot compile anything +# that touches the runtime API. +# +# A missing directory is reported as not-found rather than raised here, so an +# optional find_package gets a FALSE answer instead of a dead build. The +# REQUIRED handling at the bottom of this file turns it into an error when the +# caller asked for one. +set(_executorch_c10_include + "${_executorch_package_root}/include/executorch/runtime/core/portable_type/c10" +) +# The full version this package was built from. It lives in the generated +# version file, which CMake includes in a throwaway scope while deciding whether +# the package is acceptable, so nothing assigned there reaches a consumer. +# Reading that file here, from the config, is what makes the value visible. Both +# files are installed side by side, so the path is fixed relative to this one. +set(_executorch_version_file + "${CMAKE_CURRENT_LIST_DIR}/executorch-config-version.cmake" +) +if(EXISTS "${_executorch_version_file}") + file(STRINGS "${_executorch_version_file}" _executorch_version_lines + REGEX "^set\\(EXECUTORCH_BUILD_VERSION" + ) + foreach(_line IN LISTS _executorch_version_lines) + if(_line MATCHES "\"([^\"]+)\"") + set(EXECUTORCH_BUILD_VERSION "${CMAKE_MATCH_1}") + endif() + endforeach() +endif() +unset(_executorch_version_file) + +set(EXECUTORCH_INCLUDE_DIRS "${_executorch_package_root}/include" + "${_executorch_c10_include}" +) +# The same definition the imported targets carry. A consumer on CMake older than +# 3.28 gets no imported targets and links through EXECUTORCH_LIBRARIES instead, +# and without this it could not compile at all: the vendored c10 headers reach +# for a header generated inside a PyTorch build that no wheel can carry. +set(EXECUTORCH_COMPILE_DEFINITIONS C10_USING_CUSTOM_GENERATED_MACROS) +# The standard the imported targets require as a compile feature. Exported as a +# variable too, because a consumer on CMake older than 3.28 gets no imported +# targets and would otherwise compile these headers with whatever its compiler +# defaults to. +set(EXECUTORCH_CXX_STANDARD 17) +foreach(_required_include ${EXECUTORCH_INCLUDE_DIRS}) + if(NOT EXISTS "${_required_include}") + message( + STATUS "ExecuTorch package at ${_executorch_package_root} is missing " + "${_required_include}, so nothing can compile against it." + ) + set(EXECUTORCH_INCLUDE_DIRS) + set(EXECUTORCH_COMPILE_DEFINITIONS) + set(EXECUTORCH_CXX_STANDARD) + set(EXECUTORCH_LIBRARIES) + set(EXECUTORCH_FOUND OFF) + set(executorch_FOUND FALSE) + return() + endif() +endforeach() + +set(EXECUTORCH_LIBRARIES) +set(EXECUTORCH_FOUND OFF) + +# Locate one shipped library by base name. +# +# Sets to the full path, or to an empty string when the wheel does not +# carry that library. +# +# A wheel ships plain, unversioned names. That is deliberate: the library and +# the only things that link it ship in the same archive and are replaced +# together, so there is no upgrade during which two majors must coexist, and a +# versioned name would actively hurt because find_library(executorch) matches +# libexecutorch.so and not libexecutorch.so.1. The torch wheel ships unversioned +# names for the same reason. +# +# The versioned pattern is matched anyway, at no cost, so that a package +# assembled by other means than the wheel build, where the SONAME policy does +# apply and file names end in a major, still resolves. Highest major wins there +# rather than whichever sorts first. +function(_executorch_find_library _output _base_name) + set(${_output} + "" + PARENT_SCOPE + ) + file(GLOB _matches "${_executorch_package_root}/lib/${_base_name}.so" + "${_executorch_package_root}/lib/${_base_name}.so.*" + ) + list(LENGTH _matches _count) + if(_count EQUAL 0) + return() + endif() + # Highest major wins, so a package that somehow carries two does not silently + # select by string order. Natural ordering keeps .2 below .10. + list( + SORT _matches + COMPARE NATURAL + ORDER DESCENDING + ) + list(GET _matches 0 _selected) + set(${_output} + "${_selected}" + PARENT_SCOPE + ) +endfunction() + +# The prebuilt runtime. +_executorch_find_library(_executorch_runtime_library libexecutorch) +if(_executorch_runtime_library AND NOT _executorch_targets_supported) + # The imported targets are skipped, but the libraries themselves are present + # and linkable by path, so the long-standing variables are still honoured. + # Leaving them empty here contradicted the message below and made a REQUIRED + # find_package fail on a package that carries a working runtime. + # + # The kernel libraries are listed too, not only the runtime. The runtime + # carries only primitive operators, not the kernels a model computes with, so + # a consumer given the runtime alone links successfully and then fails at run + # time with "Missing operator: aten::mul.out", which reads as a model problem + # rather than a missing library. + # + # Each one is wrapped in scoped retention, because a registration-only library + # exports nothing the application references and the linker discards it under + # --as-needed. Measured: without this the library reaches the link line and is + # absent from the binary's dependencies, so the registrations never run. The + # imported targets express the same thing through link options, which this + # older-CMake route cannot use. + set(EXECUTORCH_FOUND ON) + list(APPEND EXECUTORCH_LIBRARIES "${_executorch_runtime_library}") + # Every shipped library, not only the kernels. A delegate registers itself + # from a static initializer, so leaving one out gave a clean configure and + # then a load failure saying the backend is not registered, which reads as a + # model problem. Anything the wheel did not ship is simply not found and + # skipped. + foreach(_executorch_component IN + ITEMS libexecutorch_kernels_optimized libexecutorch_backend_xnnpack + libexecutorch_threadpool libexecutorch_etdump + ) + _executorch_find_library( + _executorch_component_library "${_executorch_component}" + ) + if(_executorch_component_library) + # The retention option only on the platform whose linker has it. Elsewhere + # the plain path is still correct, it just leaves a registration-only + # library subject to being dropped, which is the same position a source + # build is in there. + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + list( + APPEND + EXECUTORCH_LIBRARIES + "-Wl,--push-state,--no-as-needed,${_executorch_component_library},--pop-state" + ) + else() + list(APPEND EXECUTORCH_LIBRARIES "${_executorch_component_library}") + endif() + # The switch a source build sets. The runtime header uses it to pick + # between an extern declaration (which resolves in the thread pool + # library) and a local inline serial fallback. On the imported-target + # route below it lives on the runtime target's + # INTERFACE_COMPILE_DEFINITIONS; here it goes on + # EXECUTORCH_COMPILE_DEFINITIONS, which is what the documented pre-3.28 + # recipe tells a consumer to apply. + if(_executorch_component STREQUAL "libexecutorch_threadpool") + list(APPEND EXECUTORCH_COMPILE_DEFINITIONS ET_USE_THREADPOOL) + endif() + endif() + endforeach() + unset(_executorch_component_library) + message( + STATUS + "executorch: the prebuilt runtime is present but its imported targets need CMake 3.28 or " + "newer, because older versions write the \$ORIGIN token in a runtime search path " + "incorrectly. EXECUTORCH_LIBRARIES carries the runtime and every shipped component by path " + "instead. Linking it is not sufficient on its own: an imported target would also carry the " + "include directories, the compile definitions and the C++ standard, so here a consumer has " + "to apply EXECUTORCH_INCLUDE_DIRS, EXECUTORCH_COMPILE_DEFINITIONS and " + "EXECUTORCH_CXX_STANDARD itself. The prebuilt Python extension is unaffected." + ) +elseif(_executorch_runtime_library) + set(EXECUTORCH_FOUND ON) + message(STATUS "ExecuTorch runtime found at ${_executorch_runtime_library}") + + # The documented contract is that a consumer can link ${EXECUTORCH_LIBRARIES}. + # Leaving it empty here would make find_package succeed while offering nothing + # linkable to anyone who has not moved to the imported target. + list(APPEND EXECUTORCH_LIBRARIES executorch::runtime) + + # This file can be processed more than once in a single configure, for example + # when several subprojects each call find_package(executorch). Creating the + # target twice is an error, so only define it once and set the properties + # either way. + if(TARGET executorch::runtime) + # This file ran already in the same configure, because another subproject + # also called find_package. Redefining the target would be an error, so keep + # the one that is already there. + message( + STATUS "executorch: executorch::runtime is already defined, reusing it" + ) + else() + add_library(executorch::runtime SHARED IMPORTED) + set_target_properties( + executorch::runtime + PROPERTIES IMPORTED_LOCATION "${_executorch_runtime_library}" + INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" + INTERFACE_COMPILE_FEATURES cxx_std_17 + INTERFACE_COMPILE_DEFINITIONS + C10_USING_CUSTOM_GENERATED_MACROS + ) + # Consumers get the wheel's lib/ directory in their RUNPATH automatically, + # because CMake adds the imported library's directory. Also record + # $ORIGIN-relative entries so an application deployed next to a copy of the + # runtime keeps working without relinking or LD_LIBRARY_PATH. $ORIGIN is a + # loader token, so it belongs only in RUNPATH, never in IMPORTED_LOCATION. + # + # $ORIGIN is named before the wheel's own directory. An application deployed + # beside a copy of the runtime has to find that copy, and the loader takes + # the first match, so putting the install directory first would keep sending + # a relocated application back to the original wheel for as long as it + # remains installed. That also makes a relocation test that deletes the + # original pass for the wrong reason. + # + # The cost, measured rather than assumed: a library that merely shares this + # SONAME and sits in the application's own directory will win. That is what + # $ORIGIN means in every package that uses it, and a package cannot offer + # relocation while also refusing to honour what the user placed beside their + # binary. The consequence worth worrying about, a delegate pairing with a + # different registry, is caught directly by the single-registry checks, + # which inspect what the shipped libraries define instead of trusting the + # loader's choice. + # + # The package's own library directory has to be added explicitly, as an + # option rather than by relying on CMake. CMake does put it in the + # consumer's search path because the imported library is named by absolute + # path, but it records that as a link-path rpath, and install(TARGETS) + # strips exactly those. Measured: a consumer runs from its build tree and + # then fails with exit 127 and two libraries not found once installed, while + # the $ORIGIN entries survive because they are set here as explicit options. + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + # $ORIGIN-relative entries so a relocated application keeps working. The + # package's own directory does not need to be added here: CMake already + # puts it in the consumer's runtime search path because the imported + # library is named by absolute path, which is also what makes an + # application built against the installed package start at all. + # + # --enable-new-dtags asks for DT_RUNPATH. Without it this linker writes + # the older DT_RPATH, which the loader searches BEFORE LD_LIBRARY_PATH and + # applies to a dependency's dependencies too, so a consumer could not + # point an instrumented or locally built runtime at their application. + # Measured on GNU ld 2.35: -rpath alone produces DT_RPATH, and with this + # flag the same link produces DT_RUNPATH. + set_property( + TARGET executorch::runtime + APPEND + PROPERTY INTERFACE_LINK_OPTIONS "LINKER:--enable-new-dtags" + "LINKER:-rpath,$ORIGIN" "LINKER:-rpath,$ORIGIN/../lib" + "LINKER:-rpath,${_executorch_package_root}/lib" + ) + endif() + endif() +endif() + +# Define an imported target for one shipped component library. +# +# A component is a prebuilt shared library next to the runtime, such as the CPU +# kernels or a delegate backend. Without a target for each one, a consumer has +# to find the file itself and decide how to keep it on the link line, which +# means depending on the wheel's private layout. The retention part matters +# most: a registration-only library has no symbol the application references, so +# a normal link drops it and its registration never runs. +# +# Call as: _executorch_define_component( ) +function(_executorch_define_component _suffix _library_name) + # Same reason the runtime target is skipped on older CMake: a component target + # exports an $ORIGIN-relative search path, and a version that writes it wrong + # produces a target that works in place and fails once deployed. + if(NOT _executorch_targets_supported) + return() + endif() + _executorch_find_library(_library "lib${_library_name}") + if(NOT _library) + return() + endif() + + set(_target "executorch::${_suffix}") + if(TARGET ${_target}) + # This file ran already in the same configure, because another subproject + # also called find_package. Redefining the target would be an error, so keep + # the one that is already there. + message(STATUS "executorch: ${_target} is already defined, reusing it") + # Still advertise it. This file runs again whenever another subproject calls + # find_package, and that run starts from an empty EXECUTORCH_LIBRARIES, so + # returning here would hand the second caller a list with the runtime but + # none of the components. A consumer linking that variable would then be + # missing its kernels and fail at load with an unregistered operator. + set(EXECUTORCH_LIBRARIES + ${EXECUTORCH_LIBRARIES} ${_target} + PARENT_SCOPE + ) + return() + endif() + add_library(${_target} SHARED IMPORTED) + set_target_properties( + ${_target} + PROPERTIES IMPORTED_LOCATION "${_library}" + INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" + INTERFACE_COMPILE_FEATURES cxx_std_17 + INTERFACE_COMPILE_DEFINITIONS C10_USING_CUSTOM_GENERATED_MACROS + ) + # Every component resolves the runtime from the same shared library, so record + # that rather than leaving a consumer to link both by hand. + if(TARGET executorch::runtime) + set_property( + TARGET ${_target} + APPEND + PROPERTY INTERFACE_LINK_LIBRARIES executorch::runtime + ) + endif() + # Guarded on Linux because these are GNU linker options. A wheel only ships + # these components on Linux, so a consumer configured for another system is + # either cross-compiling from the wrong package or has nothing to retain. + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set_property( + TARGET ${_target} + APPEND + PROPERTY INTERFACE_LINK_OPTIONS + # DT_RUNPATH rather than the older DT_RPATH, for the reason given + # on the runtime target: DT_RPATH outranks LD_LIBRARY_PATH and + # applies transitively, so it would stop a consumer overriding a + # packaged library. + "LINKER:--enable-new-dtags" + "LINKER:-rpath,$ORIGIN" + "LINKER:-rpath,$ORIGIN/../lib" + # One option per component rather than a shared push-state pair: + # CMake removes duplicate link options, so repeating the same + # push-state text for a second component silently drops its + # scoping and the library goes back to being --as-needed. Naming + # the library inside the same option keeps each one distinct. + # + # --no-as-needed applies only to what follows within the pushed + # state, so the pop restores whatever the consumer had. + "LINKER:--push-state,--no-as-needed,${_library},--pop-state" + ) + endif() + set(EXECUTORCH_LIBRARIES + ${EXECUTORCH_LIBRARIES} ${_target} + PARENT_SCOPE + ) +endfunction() + +_executorch_define_component(threadpool executorch_threadpool) + +# The merged CPU kernels. Documented as a component and asserted by the release +# checks, so it has to be defined here or a consumer following the documentation +# gets a bare name that CMake hands to the linker as a literal flag. +_executorch_define_component(kernels_optimized executorch_kernels_optimized) +# The profiler. A C++ application could not record timing data from an installed +# package before, because the implementation shipped only inside the Python +# extension. +_executorch_define_component(etdump executorch_etdump) + +# The switch a source build sets, on the runtime rather than on the thread pool +# target. The guarded declaration lives in a runtime header that every component +# exposes, and it selects between an extern declaration and a local inline +# definition. Putting it on the thread pool alone means a consumer with one +# translation unit linking that component and another linking only the kernels +# compiles two different definitions of the same function into one program, and +# the serial one silently wins wherever it was inlined. +# +# Only when the thread pool actually shipped. Packaging gates that library on +# the build flags it needs, so a wheel built without them has no thread pool, +# and switching the declaration on there would leave a consumer calling a +# function nothing defines. +if(TARGET executorch::runtime AND TARGET executorch::threadpool) + set_property( + TARGET executorch::runtime + APPEND + PROPERTY INTERFACE_COMPILE_DEFINITIONS ET_USE_THREADPOOL + ) + # The definition selects a declaration, and the thread pool library holds the + # only definition of what it declares, so a consumer linking just the runtime + # would fail to link. Carried on the runtime rather than left to the caller, + # since the caller cannot see which header a compile definition on an imported + # target switched. + set_property( + TARGET executorch::runtime + APPEND + PROPERTY INTERFACE_LINK_LIBRARIES executorch::threadpool + ) +endif() + +_executorch_define_component(backend_xnnpack executorch_backend_xnnpack) + +# Find prebuilt _portable_lib..so. This is the legacy contract used +# to build custom-op extensions against the Python module, and is kept working +# independently of the runtime target above. # Find python if(DEFINED ENV{CONDA_DEFAULT_ENV} AND NOT $ENV{CONDA_DEFAULT_ENV} STREQUAL @@ -45,6 +557,20 @@ execute_process( if(SYSCONFIG_RESULT EQUAL 0) message(STATUS "Sysconfig extension suffix: ${EXT_SUFFIX}") +elseif(_executorch_runtime_library) # Tested on the located library rather than + # the imported target, because the + # targets are not defined below the CMake version they need, and a C++ + # application on an older CMake is exactly the case this branch exists to keep + # working. A C++ application linking only the shared runtime does not need + # Python at all, so a missing interpreter must not fail its configure. Skip + # locating the Python extension instead; the legacy _portable_lib target is + # simply not offered in that case. + message( + STATUS + "Python not usable, skipping the Python extension: ${SYSCONFIG_ERROR}" + ) + set(EXT_SUFFIX "") + set(_portable_lib_LIBRARY "") else() message( FATAL_ERROR @@ -52,53 +578,98 @@ else() ) endif() -find_library( - _portable_lib_LIBRARY - NAMES _portable_lib${EXT_SUFFIX} - PATHS "${CMAKE_CURRENT_LIST_DIR}/../../extension/pybindings/" -) +if(EXT_SUFFIX) + # Tested directly rather than through find_library, for the same reason as the + # package root: the path and the file name are both already known, so a search + # only adds the consumer's find-root rules, which reroot an absolute wheel + # path into a cross-compile sysroot and report a present extension as missing. + set(_portable_lib_candidate + "${_executorch_package_root}/extension/pybindings/_portable_lib${EXT_SUFFIX}" + ) + if(EXISTS "${_portable_lib_candidate}") + set(_portable_lib_LIBRARY "${_portable_lib_candidate}") + else() + set(_portable_lib_LIBRARY "") + endif() +endif() + +if(NOT _portable_lib_LIBRARY) + # The interpreter that answered above is whichever python3 is on PATH, which + # is not necessarily the one this wheel was built for. A cp310 wheel inspected + # by a 3.12 interpreter yields a suffix that names no file here, and the + # package then reported itself as not found on a complete install. The shipped + # extension carries its own suffix in its name, so take it from the package. + file(GLOB _portable_lib_matches + "${_executorch_package_root}/extension/pybindings/_portable_lib.*" + ) + foreach(_candidate IN LISTS _portable_lib_matches) + if(_candidate MATCHES "\\.(so|pyd|dylib)$") + set(_portable_lib_LIBRARY "${_candidate}") + break() + endif() + endforeach() + unset(_portable_lib_matches) +endif() -set(EXECUTORCH_LIBRARIES) -set(EXECUTORCH_FOUND OFF) if(_portable_lib_LIBRARY) set(EXECUTORCH_FOUND ON) message( STATUS "ExecuTorch portable library is found at ${_portable_lib_LIBRARY}" ) - list(APPEND EXECUTORCH_LIBRARIES _portable_lib) - add_library(_portable_lib STATIC IMPORTED) - set(EXECUTORCH_INCLUDE_DIRS ${CMAKE_CURRENT_LIST_DIR}/../../include) + # Only when nothing else is linkable, which is the fused layout: a macOS wheel + # ships this extension and no separate runtime library, so the appends above + # never ran and this is the only thing there is to offer. On a split wheel the + # runtime is already there and this is skipped, because the extension carries + # unresolved interpreter symbols and a plain C++ application that links it + # fails with a page of PyUnicode_InternFromString style errors. + # + # Measured both layouts: split gives the runtime and its components, fused + # gives _portable_lib. Callers who specifically want the extension, such as a + # custom operator project, ask for the target by name rather than relying on + # this, and that target carries the C++20 requirement PyTorch's headers need + # while the runtime components require C++17. + if(NOT EXECUTORCH_LIBRARIES) + list(APPEND EXECUTORCH_LIBRARIES _portable_lib) + endif() + if(TARGET _portable_lib) + # This file ran already in the same configure, because another subproject + # called find_package too. No in-tree target uses this name, so it can only + # be the imported one defined below, and re-setting its properties to the + # same values is harmless. + message(STATUS "executorch: _portable_lib is already defined, reusing it") + else() + add_library(_portable_lib STATIC IMPORTED) + endif() + # PyTorch requires C++20, so pybindings must be compiled with C++20. set_target_properties( _portable_lib PROPERTIES IMPORTED_LOCATION "${_portable_lib_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}" - # 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. + # An interface requirement rather than CXX_STANDARD: an imported + # target compiles nothing itself, and CXX_STANDARD does not reach + # consumers, so a custom-op build linking this could still + # compile + # as C++17 and fail against headers that need C++20. INTERFACE_COMPILE_FEATURES cxx_std_20 + # The same definition the runtime target carries. A custom-op + # build that links only this target still compiles against the + # same headers and needs it too. + INTERFACE_COMPILE_DEFINITIONS C10_USING_CUSTOM_GENERATED_MACROS ) - # 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) + # against the extension exactly as it did before. Without this a custom + # operator links and then fails to load with an undefined runtime symbol. + # + # The file path rather than executorch::runtime, because that target is only + # defined on CMake 3.28 or newer while this one has no such requirement. + if(_executorch_runtime_library) set_property( TARGET _portable_lib APPEND - PROPERTY INTERFACE_LINK_LIBRARIES "${EXECUTORCH_RUNTIME_LIBRARY}" + PROPERTY INTERFACE_LINK_LIBRARIES "${_executorch_runtime_library}" ) # CMake adds a linked library's directory to the consumer's build tree # runtime search path and strips it on install, so an installed consumer @@ -108,7 +679,7 @@ if(_portable_lib_LIBRARY) # package location into a library the consumer ships onward. A consumer that # installs elsewhere adds these to its own INSTALL_RPATH. get_filename_component( - EXECUTORCH_RUNTIME_LIBRARY_DIR "${EXECUTORCH_RUNTIME_LIBRARY}" DIRECTORY + EXECUTORCH_RUNTIME_LIBRARY_DIR "${_executorch_runtime_library}" DIRECTORY ) get_filename_component( EXECUTORCH_PYTHON_EXTENSION_DIR "${_portable_lib_LIBRARY}" DIRECTORY @@ -118,13 +689,61 @@ 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. +# REQUIRED find_package would succeed even when nothing usable was located. set(executorch_FOUND ${EXECUTORCH_FOUND}) if(NOT executorch_FOUND AND executorch_FIND_REQUIRED) message( FATAL_ERROR - "Found the ExecuTorch package but could not locate the Python extension " - "inside it, so there is nothing to link." + "Found the ExecuTorch package but neither the shared runtime nor the Python " + "extension could be located inside it." ) endif() + +# Component requests are answered from the targets that were actually defined +# above, so a consumer asking for a component this wheel does not ship gets told +# at configure time rather than at link or load time. Without this a REQUIRED +# request for a missing component, or for a name that does not exist at all, +# would configure and then fail much later. +# +# The check is written out rather than using check_required_components, which +# comes from a module a package config cannot assume is already included. +foreach(_component ${executorch_FIND_COMPONENTS}) + if(TARGET executorch::${_component}) + set(executorch_${_component}_FOUND TRUE) + else() + set(executorch_${_component}_FOUND FALSE) + if(executorch_FIND_REQUIRED_${_component}) + set(executorch_FOUND FALSE) + # Naming the CMake version when that is the cause saves a consumer from + # concluding the component is missing from the package, which is the wrong + # thing to go looking for. + if(NOT _executorch_targets_supported) + # One string rather than several arguments. Several make a list, and + # message() joins a list with semicolons, which lands separators mid + # sentence. + string( + CONCAT + executorch_NOT_FOUND_MESSAGE + "the required component '${_component}' needs CMake 3.28 or newer, because older " + "versions write the \$ORIGIN token in a runtime search path incorrectly; this " + "package is otherwise usable through EXECUTORCH_LIBRARIES" + ) + else() + # One string rather than several arguments. Several make a list, and + # message() joins a list with semicolons, which lands separators mid + # sentence. + string( + CONCAT + executorch_NOT_FOUND_MESSAGE + "this ExecuTorch package does not provide the required component " + "'${_component}'. The installed wheel does not contain the library that " + "component wraps, either because it was not built with it or because this " + "platform's wheel ships the headers without the separate libraries" + ) + endif() + endif() + endif() +endforeach() +if(NOT executorch_FOUND AND executorch_FIND_REQUIRED) + message(FATAL_ERROR "${executorch_NOT_FOUND_MESSAGE}") +endif()