Skip to content

Commit a538545

Browse files
committed
Ship a linkable shared runtime and make the Python bindings use it
Today `pip install executorch` gives you the Python half of ExecuTorch but nothing a C++ application can link. The wheel installs a header subset and an `executorch-config.cmake`, but that config only locates the Python extension so custom-op builds can compile against it: it defines no runtime library and no CMake targets. Writing a small C++ program that loads and runs a `.pte` means cloning the repo, syncing submodules, and building from source. This change ships the runtime as a real library and exposes it through `find_package`, so a standalone application needs nothing but the wheel: find_package(executorch REQUIRED) target_link_libraries(my_app PRIVATE executorch::runtime) `executorch::runtime` is an imported target whose location is resolved relative to the config file itself, so the package stays relocatable and no path from the machine that built the wheel is baked into it. A consumer picks up the wheel's library directory in its RUNPATH, plus `$ORIGIN`-relative entries so an application deployed next to a copy of the runtime keeps working without `LD_LIBRARY_PATH`. The Python extensions have to move to the shared runtime in the same change. Backends register themselves into a single process-wide table owned by the runtime, and that table is only process-wide if exactly one loaded library defines it. The Python extension statically embeds the runtime today, so adding a shared library beside it would give a process two independent registries, and a backend could register into the one nobody reads. The extensions therefore link the shared runtime instead of whole-archiving the static libraries, leaving exactly one registry owner for both the Python and C++ paths. Getting that right needs two linker details. The shared runtime is named through a link option rather than an ordinary dependency, because CMake orders link libraries so a static archive precedes what it depends on, which would let the archive satisfy the runtime symbols first. It is also wrapped in `--no-as-needed`, because a shared library with no already-referenced symbol at the point it appears on the link line can be dropped, and a later static archive would then supply the registry after all. Registration still happens through static initializers exactly as before. No new plugin or loader ABI is introduced. The shared runtime is Linux-only for the wheel. macOS C++ consumers are served by the existing Swift package distribution, and the runtime has no export annotations for a Windows DLL. Every other build keeps linking the static libraries, because the new behavior is gated on the existing `EXECUTORCH_BUILD_SHARED` option, so iOS, Android, and embedded builds are unaffected. Test plan: The wheel smoke test now covers this on Linux, so it is checked in CI rather than only by hand. It asserts that exactly one shipped library defines the backend registry, builds a standalone C++ program that only calls `find_package(executorch)` and links `executorch::runtime`, runs it without `LD_LIBRARY_PATH`, and checks the resulting binary depends on the shipped runtime with a relocatable RUNPATH. The check was confirmed to fail when a second registry definition is introduced deliberately. The wheel workflows now also run when any `CMakeLists.txt` or anything under `tools/cmake` changes, since those files decide what the wheel contains. Built the wheel from a clean checkout on Linux x86_64 and on Linux aarch64, then verified each against a fresh virtual environment with a normal dependency-resolving install: - `nm -DC` across every shipped shared object shows exactly one definition of the registry entry points; the Python extensions import them rather than defining their own. - A C++ program whose CMake only calls `find_package(executorch)` and links `executorch::runtime` compiles against the installed wheel with no source checkout, then loads a `.pte` and lists its methods. - `readelf -d` on that program lists the versioned runtime in `DT_NEEDED` with `$ORIGIN`-relative RUNPATH entries, and it runs without `LD_LIBRARY_PATH`. - A C++ binary that links only `executorch::runtime` and loads the Python extension sees the backends that extension registered, which is the one-registry property stated above. - `import executorch`, the registered backend list, and `.pte` execution through the Python bindings are unchanged, with outputs matching eager PyTorch. - With `EXECUTORCH_BUILD_SHARED` off, the Python extension still builds self-contained and no shared runtime is produced, so the previous behavior is intact. - Configured with CMake 3.28 as well as 3.31, since the project supports 3.24 and up and the two differ in how strictly they treat link features. ghstack-source-id: 401229a ghstack-comment-id: 5139976402 Pull-Request: #21514
1 parent d632341 commit a538545

20 files changed

Lines changed: 562 additions & 84 deletions

File tree

.ci/scripts/wheel/test_cpp_sdk.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""Checks that the installed wheel is usable as a C++ SDK.
8+
9+
The wheel ships a prebuilt runtime library plus a CMake package config, so a
10+
standalone application can find_package(executorch) and link
11+
executorch::runtime without building ExecuTorch from source. These checks run
12+
against the installed wheel only; they never look at the source tree's build
13+
directory.
14+
15+
Two properties are verified:
16+
17+
1. Exactly one shipped library defines the backend registry. Backends register
18+
into a process-wide table owned by the runtime, so a second definition would
19+
silently give the process two tables and let a backend register into the one
20+
nobody reads.
21+
2. A C++ consumer builds and runs against the wheel, and records a dependency
22+
on the shipped runtime with a relocatable RUNPATH.
23+
"""
24+
25+
import os
26+
import re
27+
import shutil
28+
import subprocess
29+
from pathlib import Path
30+
31+
# Registry entry points. A second definer of any of these means a second
32+
# process-wide registry.
33+
_REGISTRY_SYMBOLS = (
34+
"executorch::runtime::register_backend",
35+
"executorch::runtime::get_num_registered_backends",
36+
"executorch::runtime::get_backend_class",
37+
)
38+
39+
# `nm -DC` prints "<hexaddr> <kind> <name>" for a definition and
40+
# " U <name>" for an undefined reference.
41+
_DEFINED = re.compile(r"^[0-9a-fA-F]+\s+(?P<kind>[A-Za-z])\s+(?P<name>.+)$")
42+
43+
# Symbol kinds that mean the object owns the code or storage.
44+
_OWNING_KINDS = frozenset("TtBbDdGgSsRrWV")
45+
46+
_CONSUMER_SOURCE = """\
47+
#include <executorch/runtime/backend/interface.h>
48+
#include <executorch/runtime/platform/runtime.h>
49+
50+
#include <cstdio>
51+
52+
int main() {
53+
executorch::runtime::runtime_init();
54+
std::printf(
55+
"registered backends: %zu\\n",
56+
(size_t)executorch::runtime::get_num_registered_backends());
57+
return 0;
58+
}
59+
"""
60+
61+
_CONSUMER_CMAKE = """\
62+
cmake_minimum_required(VERSION 3.24)
63+
project(executorch_wheel_consumer CXX)
64+
find_package(executorch REQUIRED)
65+
add_executable(consumer consumer.cpp)
66+
target_link_libraries(consumer PRIVATE executorch::runtime)
67+
"""
68+
69+
70+
def _installed_package_dir() -> Path:
71+
"""The installed executorch package, never the source checkout."""
72+
import executorch
73+
74+
return Path(list(executorch.__path__)[0]).resolve()
75+
76+
77+
def _shipped_shared_objects(package_dir: Path):
78+
return [
79+
path
80+
for path in sorted(package_dir.rglob("*.so*"))
81+
if path.is_file() and not path.is_symlink()
82+
]
83+
84+
85+
def _defines_symbol(library: Path, symbol: str) -> bool:
86+
result = subprocess.run(
87+
["nm", "-DC", str(library)], capture_output=True, text=True, check=False
88+
)
89+
for line in result.stdout.splitlines():
90+
if symbol not in line:
91+
continue
92+
match = _DEFINED.match(line)
93+
if (
94+
match
95+
and match.group("name").startswith(symbol)
96+
and match.group("kind") in _OWNING_KINDS
97+
):
98+
return True
99+
return False
100+
101+
102+
def test_single_backend_registry() -> None:
103+
"""Exactly one shipped library may define the backend registry."""
104+
assert shutil.which("nm") is not None, "nm is required to inspect the wheel"
105+
106+
package_dir = _installed_package_dir()
107+
libraries = _shipped_shared_objects(package_dir)
108+
assert libraries, f"no shared libraries found under {package_dir}"
109+
110+
for symbol in _REGISTRY_SYMBOLS:
111+
definers = [lib for lib in libraries if _defines_symbol(lib, symbol)]
112+
pretty = [str(lib.relative_to(package_dir)) for lib in definers]
113+
assert len(definers) == 1, (
114+
f"expected exactly one library to define {symbol}, found "
115+
f"{len(definers)}: {pretty}. More than one definition means the "
116+
f"process has more than one backend registry."
117+
)
118+
print(f"✓ single backend registry across {len(libraries)} shipped libraries")
119+
120+
121+
def test_cpp_consumer(work_dir: Path) -> None:
122+
"""A standalone C++ app builds and runs against the installed wheel."""
123+
assert shutil.which("cmake") is not None, "cmake is required to build a consumer"
124+
125+
package_dir = _installed_package_dir()
126+
config = package_dir / "share" / "cmake" / "executorch-config.cmake"
127+
assert config.is_file(), f"wheel is missing its CMake package config: {config}"
128+
129+
source_dir = work_dir / "consumer"
130+
build_dir = work_dir / "consumer-build"
131+
source_dir.mkdir(parents=True, exist_ok=True)
132+
(source_dir / "consumer.cpp").write_text(_CONSUMER_SOURCE)
133+
(source_dir / "CMakeLists.txt").write_text(_CONSUMER_CMAKE)
134+
135+
subprocess.run(
136+
[
137+
"cmake",
138+
"-S",
139+
str(source_dir),
140+
"-B",
141+
str(build_dir),
142+
f"-DCMAKE_PREFIX_PATH={config.parent}",
143+
],
144+
check=True,
145+
)
146+
subprocess.run(["cmake", "--build", str(build_dir)], check=True)
147+
148+
consumer = build_dir / "consumer"
149+
# No LD_LIBRARY_PATH: the imported target is responsible for making the
150+
# shipped runtime findable.
151+
environment = {
152+
key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH"
153+
}
154+
subprocess.run([str(consumer)], check=True, env=environment)
155+
print("✓ C++ consumer builds and runs against the installed wheel")
156+
157+
assert shutil.which("readelf") is not None, "readelf is required to check the ELF"
158+
159+
dynamic = subprocess.run(
160+
["readelf", "-d", str(consumer)], capture_output=True, text=True, check=True
161+
).stdout
162+
assert "libexecutorch.so" in dynamic, (
163+
"the consumer does not depend on the shipped runtime; "
164+
f"dynamic section was:\n{dynamic}"
165+
)
166+
assert "$ORIGIN" in dynamic, (
167+
"the consumer has no $ORIGIN-relative RUNPATH, so it is not "
168+
f"relocatable; dynamic section was:\n{dynamic}"
169+
)
170+
print("✓ consumer depends on the shipped runtime with a relocatable RUNPATH")
171+
172+
173+
def run_tests(work_dir: Path) -> None:
174+
test_single_backend_registry()
175+
test_cpp_consumer(work_dir)

.ci/scripts/wheel/test_linux.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
# LICENSE file in the root directory of this source tree.
88

99
import platform
10+
import tempfile
11+
from pathlib import Path
1012

1113
import test_base
14+
import test_cpp_sdk
1215
from examples.models import Backend, Model
1316

1417
if __name__ == "__main__":
@@ -41,6 +44,12 @@
4144

4245
test_base.test_cmsis_nn_install()
4346

47+
# The wheel ships a prebuilt C++ runtime and a CMake package config, so
48+
# check that a standalone application can actually link and run against
49+
# them, and that the process still has a single backend registry.
50+
with tempfile.TemporaryDirectory() as work_dir:
51+
test_cpp_sdk.run_tests(Path(work_dir))
52+
4453
test_base.run_tests(
4554
model_tests=[
4655
test_base.ModelTest(

.ci/scripts/wheel/test_linux_aarch64.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
# This source code is licensed under the BSD-style license found in the
66
# LICENSE file in the root directory of this source tree.
77

8+
import tempfile
9+
from pathlib import Path
10+
811
import test_base
12+
import test_cpp_sdk
913
from examples.models import Backend, Model
1014

1115
if __name__ == "__main__":
@@ -26,6 +30,12 @@
2630
), f"OpenvinoBackend not found in registered backends: {registered}"
2731
print("✓ OpenvinoBackend is registered")
2832

33+
# The wheel ships a prebuilt C++ runtime and a CMake package config, so check
34+
# that a standalone application can actually link and run against them, and
35+
# that the process still has a single backend registry.
36+
with tempfile.TemporaryDirectory() as work_dir:
37+
test_cpp_sdk.run_tests(Path(work_dir))
38+
2939
test_base.run_tests(
3040
model_tests=[
3141
test_base.ModelTest(

.github/workflows/build-wheels-aarch64-linux.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ on:
66
paths:
77
- .ci/**/*
88
- .github/workflows/build-wheels-aarch64-linux.yml
9+
- '**/CMakeLists.txt'
910
- examples/**/*
1011
- pyproject.toml
1112
- setup.py
13+
- tools/cmake/**/*
1214
push:
1315
branches:
1416
- nightly

.github/workflows/build-wheels-linux.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ on:
66
paths:
77
- .ci/**/*
88
- .github/workflows/build-wheels-linux.yml
9+
- '**/CMakeLists.txt'
910
- examples/**/*
1011
- pyproject.toml
1112
- setup.py
13+
- tools/cmake/**/*
1214
push:
1315
branches:
1416
- nightly

.github/workflows/build-wheels-macos.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ on:
66
paths:
77
- .ci/**/*
88
- .github/workflows/build-wheels-macos.yml
9+
- '**/CMakeLists.txt'
910
- examples/**/*
1011
- pyproject.toml
1112
- setup.py
13+
- tools/cmake/**/*
1214
push:
1315
branches:
1416
- nightly

.github/workflows/build-wheels-windows.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ on:
55
paths:
66
- .ci/**/*
77
- .github/workflows/build-wheels-windows.yml
8+
- '**/CMakeLists.txt'
89
- examples/**/*
910
- pyproject.toml
1011
- setup.py
12+
- tools/cmake/**/*
1113
push:
1214
branches:
1315
- nightly

0 commit comments

Comments
 (0)