diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py new file mode 100644 index 00000000000..1cf85a91124 --- /dev/null +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -0,0 +1,947 @@ +# 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 +#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) { + if (argc < 6) { + 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) { + worst = std::fmax(worst, std::fabs((double)actual[i] - (double)expected[i])); + } + 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_has_no_kernels(work_dir: Path) -> None: + """Linking only the runtime builds and loads, and cannot execute. + + Measured rather than assumed: libexecutorch.so defines no operator kernels, so an + application linking it alone loads a program and then reports every operator 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 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 kernels to execute") + + +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:]}" + ) + 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}" + ) + + package_dir = _installed_package_dir() + deployed = work_dir / "deployed" + deployed.mkdir(parents=True, exist_ok=True) + shutil.copy2(consumer, deployed / "consumer") + for library in sorted((package_dir / "lib").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 must not grow with the + number of components it links. + """ + 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"], + ) + # Equal, not merely non-zero. A second registry would show up as a different count + # once more registering libraries are linked. + # 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 reports the package version as "unknown" and accepts + any request, so a consumer pinning a minimum silently gets whatever is installed. + 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 " + "reports the version as unknown and accepts every 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 declares as a dependency rather + # than bundling headers for. Take the location from the toolkit itself, so this 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 = [] + 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: + if result.returncode == 0: + skipped_but_fine.append(str(relative)) + continue + 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) + ) + print(f"✓ all {len(headers)} shipped headers compile against the installed wheel") + + +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 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_documented_example_compiles(work_dir) + test_runtime_alone_links_but_has_no_kernels(work_dir) + test_kernels_component_runs_a_model(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/docs/source/using-executorch-cpp.md b/docs/source/using-executorch-cpp.md index 5505ade9573..82216711a80 100644 --- a/docs/source/using-executorch-cpp.md +++ b/docs/source/using-executorch-cpp.md @@ -40,6 +40,93 @@ 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::kernels_quantized` | quantized operator kernels, for a quantized model. | +| `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 no operators, 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) +``` + +### 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/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 18dd5c9784c..4ea01785bf9 100644 --- a/setup.py +++ b/setup.py @@ -83,6 +83,32 @@ format="%(asctime)s [%(levelname)s] %(message)s", ) +# Headers swept in by a directory copy that a consumer of the wheel cannot compile, 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 at compile time rather than here. +# Headers swept in by a directory copy that a consumer of the wheel cannot compile, 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 at compile time rather than here. +# +# Matched on the file name, so 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, since +# removing it would break the ones that need it. +_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. + "tensor_parser.h", + # A test helper, needing a test framework the wheel does not ship. + "error_matchers.h", + # Reads processor details through cpuinfo, whose headers the wheel does not publish. + "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. + "threadpool.h", + } +) + try: from tools.cmake.cmake_cache import CMakeCache except ImportError: @@ -822,10 +848,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 @@ -838,9 +875,33 @@ 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/", + # 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))) ) @@ -878,6 +939,80 @@ 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. + """ + candidates = entry.rglob("*.h") if entry.is_dir() else [entry] + return [ + src + for src in candidates + if src.name not in _UNSHIPPABLE_HEADERS and "test" not in src.parts + ] + 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..0321122c977 --- /dev/null +++ b/tools/cmake/executorch-wheel-config-version.cmake.in @@ -0,0 +1,36 @@ +# 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() diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index b5672415a69..fd53b04de69 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -8,7 +8,23 @@ # 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. The end state that removes the difference is a staged install whose +# generated targets file the wheel ships, so the source and wheel contracts +# become the same object rather than two things that must agree. # ------- # # Finds the ExecuTorch library @@ -17,12 +33,392 @@ # # 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_LIBRARIES -- Libraries to link against. Includes the prebuilt +# Python extension, whose Python symbols resolve inside an interpreter, so link +# the imported targets below instead when building a standalone application. +# 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 deliberately carries no operator kernels, 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. Versions 3.24 +# through 3.27 emit a doubled dollar with the Makefile generator and a bare +# dollar with Ninja, so a consumer builds and runs in place, because the +# absolute package directory is also recorded, then fails once it is deployed +# somewhere else. Silently defining a target that behaves that way is worse than +# not defining it, so the targets are skipped and a consumer that asked for one +# gets a message naming the reason. +if(CMAKE_VERSION VERSION_LESS 3.28) + set(_executorch_targets_supported FALSE) +else() + set(_executorch_targets_supported TRUE) +endif() + +# Everything is resolved relative to this file so the wheel stays relocatable: +# no absolute path from the machine that built it is baked in here. The file is +# installed both under share/cmake, which the historical contract uses, and +# under lib/cmake/executorch, which a plain CMAKE_PREFIX_PATH pointed at the +# package root can discover, so the root is located by a marker rather than a +# fixed depth. +# +# 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}" +) +foreach(_required_include ${EXECUTORCH_INCLUDE_DIRS}) + if(NOT EXISTS "${_required_include}") + message( + STATUS "ExecuTorch package at ${_executorch_package_root} is missing " + "${_required_include}, so nothing can compile against it." + ) + set(EXECUTORCH_INCLUDE_DIRS) + set(EXECUTORCH_LIBRARIES) + set(EXECUTORCH_FOUND OFF) + set(executorch_FOUND FALSE) + return() + endif() +endforeach() + +set(EXECUTORCH_LIBRARIES) +set(EXECUTORCH_FOUND OFF) + +# Locate one shipped library by base name. +# +# A wheel ships a single file per library, named for its SONAME, so the major is +# read from the shipped names rather than hardcoded here. Sets to the +# full path, or to an empty string when the wheel does not carry that library. +# +# This depends on an invariant on the build side: every library the wheel ships +# carries a VERSION and SOVERSION, so its file name ends in a major. A library +# built without them ships as a bare .so, and while the glob below still finds +# it, nothing then pins the major a consumer linked against, which is the +# guarantee the SONAME exists to provide. +function(_executorch_find_library _output _base_name) + set(${_output} + "" + PARENT_SCOPE + ) + file(GLOB _matches "${_executorch_package_root}/lib/${_base_name}.so" + "${_executorch_package_root}/lib/${_base_name}.so.*" + ) + list(LENGTH _matches _count) + if(_count EQUAL 0) + return() + endif() + # Highest major wins, so a package that somehow carries two does not silently + # select by string order. Natural ordering keeps .2 below .10. + list( + SORT _matches + COMPARE NATURAL + ORDER DESCENDING + ) + list(GET _matches 0 _selected) + set(${_output} + "${_selected}" + PARENT_SCOPE + ) +endfunction() + +# The prebuilt runtime. +_executorch_find_library(_executorch_runtime_library libexecutorch) +if(_executorch_runtime_library AND NOT _executorch_targets_supported) + message( + STATUS + "executorch: the prebuilt runtime is present but its imported targets need CMake 3.28 or " + "newer, because older versions write the \$ORIGIN token in a runtime search path " + "incorrectly. The long-standing EXECUTORCH_LIBRARIES and the prebuilt Python extension are " + "unaffected." + ) +elseif(_executorch_runtime_library) + set(EXECUTORCH_FOUND ON) + message(STATUS "ExecuTorch runtime found at ${_executorch_runtime_library}") + + # The documented contract is that a consumer can link ${EXECUTORCH_LIBRARIES}. + # Leaving it empty here would make find_package succeed while offering nothing + # linkable to anyone who has not moved to the imported target. + list(APPEND EXECUTORCH_LIBRARIES executorch::runtime) + + # This file can be processed more than once in a single configure, for example + # when several subprojects each call find_package(executorch). Creating the + # target twice is an error, so only define it once and set the properties + # either way. + if(TARGET executorch::runtime) + # 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 absolute entry cannot simply be dropped to avoid the question: an + # application built against the installed package fails to start without it. + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + # $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. + set_property( + TARGET executorch::runtime + APPEND + PROPERTY INTERFACE_LINK_OPTIONS "LINKER:-rpath,$ORIGIN" + "LINKER:-rpath,$ORIGIN/../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 + "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. +# +# Unconditional because the wheel always ships the thread pool alongside the +# runtime, so there is no shipped configuration where the serial fallback is +# correct. +if(TARGET executorch::runtime) + 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. + if(TARGET executorch::threadpool) + set_property( + TARGET executorch::runtime + APPEND + PROPERTY INTERFACE_LINK_LIBRARIES executorch::threadpool + ) + endif() +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 +441,17 @@ execute_process( if(SYSCONFIG_RESULT EQUAL 0) message(STATUS "Sysconfig extension suffix: ${EXT_SUFFIX}") +elseif(TARGET executorch::runtime) + # A C++ application linking only the shared runtime does not need Python at + # all, so a missing interpreter must not fail its configure. Skip locating the + # Python extension instead; the legacy _portable_lib target is simply not + # offered in that case. + message( + STATUS + "Python not usable, skipping the Python extension: ${SYSCONFIG_ERROR}" + ) + set(EXT_SUFFIX "") + set(_portable_lib_LIBRARY "") else() message( FATAL_ERROR @@ -52,66 +459,127 @@ 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() -set(EXECUTORCH_LIBRARIES) -set(EXECUTORCH_FOUND OFF) if(_portable_lib_LIBRARY) set(EXECUTORCH_FOUND ON) message( STATUS "ExecuTorch portable library is found at ${_portable_lib_LIBRARY}" ) list(APPEND EXECUTORCH_LIBRARIES _portable_lib) - add_library(_portable_lib STATIC IMPORTED) - set(EXECUTORCH_INCLUDE_DIRS ${CMAKE_CURRENT_LIST_DIR}/../../include) + if(TARGET _portable_lib) + # 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}" ) endif() endif() # find_package checks _FOUND, which is case-sensitive and does not # match the EXECUTORCH_FOUND spelling this file documents. Without this, a -# REQUIRED find_package succeeds even when nothing usable was located, and the -# consumer goes on to link nothing. +# 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 prebuilt libraries the components wrap are " + "built for Linux only, so a wheel for another platform installs " + "the headers and this package without them" + ) + endif() + endif() + endif() +endforeach() +if(NOT executorch_FOUND AND executorch_FIND_REQUIRED) + message(FATAL_ERROR "${executorch_NOT_FOUND_MESSAGE}") +endif()