Skip to content

Commit 86c35bd

Browse files
committed
Ship a prebuilt C++ SDK in the ExecuTorch Linux wheel
## Why this is needed Today, using the ExecuTorch runtime from a C++ program means building ExecuTorch from source: clone the repo, sync submodules, and run a CMake build before you can compile and link your own runner. The pip wheel only ships the Python runtime module (`_portable_lib`) plus a small set of headers meant for authoring custom operators. There is no way to just `pip install executorch` and link a standalone C++ application against the runtime. This is friction for anyone whose deployment path is C++ (the common case for on-device inference) and who already has the wheel installed for export. The runtime is compiled during the wheel build and then discarded. This change ships the runtime as a linkable shared library, its public headers, and a CMake package config inside the Linux wheel, so a C++ program can link the ExecuTorch runtime with no source checkout and no separate build. ## What is inside Added to the Linux wheel (nothing removed; other platforms unchanged): - `executorch/lib/libexecutorch.so` (SONAME-versioned, with the standard `libexecutorch.so -> .so.1 -> .so.<version>` chain): the consolidated shared runtime. It bundles the runtime core plus the common runtime extensions (module, tensor, data_loader, flat_tensor, named_data_map). - `executorch/include/executorch/extension/...`: the public headers for the Module, Tensor, DataLoader, FlatTensor (.ptd reader), NamedDataMap, and header-only MallocMemoryAllocator APIs. Runtime/Program/backend headers were already shipped and are reused. - `executorch/share/cmake/executorch-config.cmake`: a CMake package config that exposes an `executorch::runtime` imported target (plus convenience aliases `executorch::core`, `executorch::extension_*`). - `executorch/utils/cmake_prefix_path`: a small helper (mirrors `torch.utils.cmake_prefix_path`) so CMake can find the config in one line. ## Why a shared library (not static archives) The runtime is shipped shared on purpose. ExecuTorch keeps a single process-global backend/kernel registry. Shipping the runtime as one shared `libexecutorch.so` lets a separately distributed backend or delegate shared library register into that one registry: the backend `.so` is built without its own copy of the runtime (its `register_backend` reference is undefined and resolves against `libexecutorch.so` at load), and its static-init registration runs when the `.so` is loaded (via whole-archive for a C++ app, or an explicit import/dlopen for Python, which is how ExecuTorch already ships the QNN backend today). Static archives would give each consumer its own private registry, which cannot support loading multiple independently distributed backends into one runtime. The set is intentionally libtorch-free and excludes the general CPU operator/kernel libraries, because a delegate supplies its own compute. It is also Linux only: the `.so` naming and SONAME symlink chain are Unix specific, so the Windows and macOS wheels are byte-identical to before. ## How to use it ```bash pip install executorch ``` ```cmake find_package(executorch CONFIG REQUIRED) add_executable(my_runner main.cpp) target_link_libraries(my_runner PRIVATE executorch::runtime) ``` ```bash cmake -S . -B build \ -DCMAKE_PREFIX_PATH="$(python -c 'import executorch.utils as u; print(u.cmake_prefix_path)')" cmake --build build ``` Existing consumers that use `find_package(executorch)` to link the Python `_portable_lib` for custom-op extensions keep working unchanged. The new C++ SDK availability is reported separately via `EXECUTORCH_SDK_FOUND`, so the legacy `EXECUTORCH_FOUND` / `EXECUTORCH_LIBRARIES` contract is preserved. ## Test plan Verified on Linux x86_64: - Built the wheel with `python setup.py bdist_wheel` and confirmed it contains `libexecutorch.so` with its SONAME symlink chain, the CMake config, the `utils` helper, and the new extension headers, and that headers with no shipped implementation are excluded. - Installed the wheel into a clean virtual environment and built a small standalone C++ runner against it with `find_package(executorch)` and `executorch.utils.cmake_prefix_path`. The runner compiled, linked, ran, and initialized the runtime. - Built a separate "coreless" backend shared library (no bundled runtime, `register_backend` left undefined) and confirmed that loading it against the installed `libexecutorch.so` registers the backend into the runtime's registry (`get_backend_class` goes from not-found to found). This is the mechanism that lets independently distributed backends coalesce into one runtime. - Confirmed the runner and the runtime link no libtorch/libc10 (via `ldd`). - Confirmed the Windows and macOS wheel code paths add nothing new, so those wheels are unaffected. - Lint and format pass (flake8, ufmt, cmake-format). Known limitation: the wheel-build step stores the SONAME symlinks as plain file copies rather than symlinks. Linking and loading still work because the SONAME target is present as a real file; a follow-up can preserve them as true symlinks to save space.
1 parent 0b13b6a commit 86c35bd

3 files changed

Lines changed: 294 additions & 21 deletions

File tree

setup.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,27 @@ def get_dynamic_lib_name(name: str) -> str:
320320
return f"lib{name}.so"
321321

322322

323+
def _read_soname(lib_path: Path) -> Optional[str]:
324+
"""Return the ELF SONAME of a shared library, or None if unavailable.
325+
326+
Used to recreate the SONAME symlink for a versioned .so in the wheel. Best
327+
effort: any failure (non-ELF, no readelf, non-Linux) returns None.
328+
"""
329+
try:
330+
out = subprocess.run(
331+
["readelf", "-d", os.fspath(lib_path)],
332+
capture_output=True,
333+
text=True,
334+
check=True,
335+
).stdout
336+
except Exception:
337+
return None
338+
for line in out.splitlines():
339+
if "SONAME" in line and "[" in line and "]" in line:
340+
return line[line.index("[") + 1 : line.index("]")]
341+
return None
342+
343+
323344
def get_executable_name(name: str) -> str:
324345
if _is_windows():
325346
return name + ".exe"
@@ -508,6 +529,43 @@ def inplace_dir(self, installer: "InstallerBuildExt") -> Path:
508529
return Path(package_dir)
509530

510531

532+
class BuiltSharedLib(BuiltFile):
533+
"""Installs a SONAME-versioned shared library plus its symlink chain.
534+
535+
A normal ``BuiltFile`` copies one file. A shared library like
536+
``libexecutorch.so.1.4.0`` also needs the loader-visible SONAME symlink
537+
(``libexecutorch.so.1``) and the developer symlink (``libexecutorch.so``),
538+
or a consumer that links ``-lexecutorch`` fails at runtime because the
539+
SONAME recorded in dependents cannot be found. This recreates that chain in
540+
the wheel, matching a standard ``cmake --install`` layout.
541+
"""
542+
543+
def __init__(self, src_dir: str, src_name: str, dst: str):
544+
# src_name is the base library name (e.g. "executorch"); the real file
545+
# is libexecutorch.so.<version>, resolved by glob in src_path().
546+
super().__init__(
547+
src_dir=src_dir,
548+
src_name=f"lib{src_name}.so.*",
549+
dst=dst,
550+
dependent_cmake_flags=[],
551+
)
552+
553+
def src_path(self, installer: "InstallerBuildExt") -> Path:
554+
# The glob matches the versioned real file and any symlinks; pick the
555+
# regular file (the real library), not the symlinks.
556+
build_dir = self._get_build_dir(installer)
557+
pattern = self.src.replace("%CMAKE_CACHE_DIR%/", "")
558+
matches = [
559+
p for p in build_dir.glob(pattern) if p.is_file() and not p.is_symlink()
560+
]
561+
if len(matches) != 1:
562+
raise ValueError(
563+
f"Expecting exactly 1 real shared library matching {self.src} "
564+
f"in {build_dir}, found {matches}."
565+
)
566+
return matches[0]
567+
568+
511569
class BuiltExtension(_BaseExtension):
512570
"""An extension that installs a python extension that was built by cmake."""
513571

@@ -663,6 +721,31 @@ def build_extension(self, ext: _BaseExtension) -> None:
663721
# Copy the file.
664722
self.copy_file(os.fspath(src_file), os.fspath(dst_file))
665723

724+
# For a SONAME-versioned shared library, also recreate the symlink chain
725+
# (libexecutorch.so -> libexecutorch.so.<major> -> libexecutorch.so.<ver>)
726+
# so `-lexecutorch` links and the SONAME resolves at load time.
727+
if isinstance(ext, BuiltSharedLib):
728+
self._create_soname_symlinks(src_file, dst_file)
729+
730+
def _create_soname_symlinks(self, src_file: Path, dst_file: Path) -> None:
731+
real_name = dst_file.name # e.g. libexecutorch.so.1.4.0
732+
link_dir = dst_file.parent
733+
# SONAME (e.g. libexecutorch.so.1) read from the built library; fall back
734+
# to none if unreadable. The dev symlink drops all version suffixes.
735+
links = set()
736+
soname = _read_soname(src_file)
737+
if soname and soname != real_name:
738+
links.add(soname)
739+
dev_name = real_name.split(".so")[0] + ".so"
740+
if dev_name != real_name:
741+
links.add(dev_name)
742+
for link_name in links:
743+
link_path = link_dir / link_name
744+
if link_path.exists() or link_path.is_symlink():
745+
link_path.unlink()
746+
# Relative link so the wheel is relocatable.
747+
os.symlink(real_name, os.fspath(link_path))
748+
666749
# Ensure that the destination file is writable, even if the source was
667750
# not. build_py does this by passing preserve_mode=False to copy_file,
668751
# but that would clobber the X bit on any executables. TODO(dbort): This
@@ -749,6 +832,38 @@ def run(self):
749832
src_to_dst.append(
750833
(str(src), os.path.join("include/executorch", str(src)))
751834
)
835+
# Delegate-only C++ SDK headers: the Program/Module/Tensor/DataLoader/
836+
# .ptd APIs a standalone C++ runner needs, so it can link the prebuilt
837+
# runtime without an ExecuTorch source tree. These are listed
838+
# explicitly (not rglob) so we only advertise APIs whose implementation
839+
# archive is actually shipped in executorch/lib/. Excluded on purpose:
840+
# bundled_module.h (needs the separate bundled_module archive),
841+
# flat_tensor/serialize/serialize.h (serializer .cpp not shipped),
842+
# file_descriptor_data_loader.h (impl not in the shipped archive), and
843+
# cpu_caching_malloc_allocator.h (needs a memory_allocator archive we
844+
# do not ship). flat_tensor_header.h IS shipped: it is the .ptd reader.
845+
# Linux only, matching the SDK archives below; keeps Windows/macOS
846+
# wheels unchanged.
847+
sdk_headers = (
848+
[
849+
"extension/module/module.h",
850+
"extension/data_loader/buffer_data_loader.h",
851+
"extension/data_loader/file_data_loader.h",
852+
"extension/data_loader/mmap_data_loader.h",
853+
"extension/data_loader/mman.h",
854+
"extension/data_loader/mman_windows.h",
855+
"extension/data_loader/shared_ptr_data_loader.h",
856+
"extension/flat_tensor/flat_tensor_data_map.h",
857+
"extension/flat_tensor/serialize/flat_tensor_header.h",
858+
"extension/named_data_map/merged_data_map.h",
859+
"extension/memory_allocator/malloc_memory_allocator.h",
860+
"extension/memory_allocator/memory_allocator_utils.h",
861+
]
862+
if sys.platform == "linux"
863+
else []
864+
)
865+
for src in sdk_headers:
866+
src_to_dst.append((src, os.path.join("include/executorch", src)))
752867
for src, dst in src_to_dst:
753868
dst = os.path.join(dst_root, dst)
754869

@@ -900,6 +1015,12 @@ def run(self): # noqa C901
9001015
):
9011016
cmake_configuration_args += ["-DEXECUTORCH_BUILD_OPENVINO=ON"]
9021017

1018+
# Build the consolidated shared runtime libexecutorch.so so the wheel can
1019+
# ship a linkable C++ SDK (see the executorch_shared build target and the
1020+
# packaging step). Linux only; the SDK is not shipped on Windows/macOS.
1021+
if not minimal_build and sys.platform == "linux":
1022+
cmake_configuration_args += ["-DEXECUTORCH_BUILD_SHARED=ON"]
1023+
9031024
with Buck2EnvironmentFixer():
9041025
# Generate the cmake cache from scratch to ensure that the cache state
9051026
# is predictable.
@@ -954,6 +1075,16 @@ def run(self): # noqa C901
9541075
# list explicitly rather than relying on each flag being OFF.
9551076
cmake_build_args += ["--target", "flatbuffers_ep"]
9561077
else:
1078+
# Delegate-only C++ SDK: ship the consolidated shared runtime
1079+
# libexecutorch.so (the executorch_shared target), which bundles the
1080+
# runtime core plus the common extensions (module, tensor,
1081+
# data_loader, flat_tensor, named_data_map). Shared is required so a
1082+
# separately distributed backend/delegate .so can register into the
1083+
# one process-global registry inside libexecutorch.so. Build it
1084+
# explicitly, Linux only, so packaging below always finds it.
1085+
if sys.platform == "linux":
1086+
cmake_build_args += ["--target", "executorch_shared"]
1087+
9571088
if cmake_cache.is_enabled("EXECUTORCH_BUILD_PYBIND"):
9581089
cmake_build_args += ["--target", "portable_lib"]
9591090
cmake_build_args += ["--target", "data_loader"]
@@ -1125,6 +1256,28 @@ def run(self): # noqa C901
11251256
modpath="executorch.backends.qualcomm.python.PyQnnManagerAdaptor",
11261257
dependent_cmake_flags=["EXECUTORCH_BUILD_QNN"],
11271258
),
1259+
# Delegate-only C++ SDK: the consolidated shared runtime
1260+
# libexecutorch.so (core + module/tensor/data_loader/flat_tensor/
1261+
# named_data_map), plus its SONAME symlink chain. Shipping it
1262+
# shared (not static archives) lets a separately distributed
1263+
# backend/delegate .so register into the one process-global
1264+
# registry inside libexecutorch.so, which is what coalesced
1265+
# multi-backend .pte execution requires. Paired with
1266+
# executorch-config.cmake (executorch::runtime target) and
1267+
# executorch.utils.cmake_prefix_path. Linux only: the .so naming
1268+
# and symlink chain are Unix specific, so Windows/macOS wheels are
1269+
# unchanged.
1270+
*(
1271+
[
1272+
BuiltSharedLib(
1273+
src_dir="%CMAKE_CACHE_DIR%/",
1274+
src_name="executorch",
1275+
dst="executorch/lib/",
1276+
)
1277+
]
1278+
if sys.platform == "linux"
1279+
else []
1280+
),
11281281
]
11291282
),
11301283
],

src/executorch/utils/__init__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
"""Utilities for locating ExecuTorch's packaged assets.
8+
9+
``cmake_prefix_path`` points at the directory that contains the installed
10+
ExecuTorch CMake package config, so a C++ project can discover it with:
11+
12+
cmake -DCMAKE_PREFIX_PATH="$(python -c 'import executorch.utils as u; print(u.cmake_prefix_path)')"
13+
"""
14+
15+
import os as _os
16+
17+
# Mirror torch.utils.cmake_prefix_path: <package_root>/share/cmake. This file
18+
# lives at <package_root>/utils/__init__.py, so go up one level.
19+
cmake_prefix_path = _os.path.join(
20+
_os.path.dirname(_os.path.dirname(__file__)), "share", "cmake"
21+
)
22+
23+
__all__ = ["cmake_prefix_path"]

tools/cmake/executorch-wheel-config.cmake

Lines changed: 118 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,24 @@
1919
# EXECUTORCH_INCLUDE_DIRS -- The include directories for ExecuTorch
2020
# EXECUTORCH_LIBRARIES -- Libraries to link against
2121
#
22-
cmake_minimum_required(VERSION 3.19)
22+
# In addition to the legacy variables above, this config defines namespaced
23+
# imported targets for the prebuilt delegate-only C++ SDK when the corresponding
24+
# static libraries are shipped in the wheel (see the "C++ SDK targets" section
25+
# below):
26+
#
27+
# executorch::core -- executorch_core (runtime, no ops)
28+
# executorch::runtime -- executorch (adds primitive ops)
29+
# executorch::extension_data_loader executorch::extension_flat_tensor
30+
# executorch::extension_named_data_map executorch::extension_tensor
31+
# executorch::extension_module
32+
#
33+
cmake_minimum_required(VERSION 3.24)
2334

24-
# Find prebuilt _portable_lib.<EXT_SUFFIX>.so. This file should be installed
25-
# under <site-packages>/executorch/share/cmake
35+
# ---------------------------------------------------------------------------
36+
# Legacy: discover the CPython _portable_lib extension for custom-op authors.
37+
# This keeps `find_package(executorch)` working for prebuilt custom-op
38+
# extensions that link the Python runtime module, unchanged from before.
39+
# ---------------------------------------------------------------------------
2640

2741
# Find python
2842
if(DEFINED ENV{CONDA_DEFAULT_ENV} AND NOT $ENV{CONDA_DEFAULT_ENV} STREQUAL
@@ -43,36 +57,119 @@ execute_process(
4357
OUTPUT_STRIP_TRAILING_WHITESPACE
4458
)
4559

60+
set(EXECUTORCH_INCLUDE_DIRS
61+
"${CMAKE_CURRENT_LIST_DIR}/../../include"
62+
"${CMAKE_CURRENT_LIST_DIR}/../../include/executorch/runtime/core/portable_type/c10"
63+
)
64+
set(EXECUTORCH_LIBRARIES)
65+
set(EXECUTORCH_FOUND OFF)
66+
67+
# Only discover the portable Python module when we could read EXT_SUFFIX;
68+
# probing with an empty suffix would match a wrong/generic file. A missing
69+
# suffix is not fatal because a pure-C++ consumer (see the C++ SDK section
70+
# below) does not need the Python extension at all.
4671
if(SYSCONFIG_RESULT EQUAL 0)
4772
message(STATUS "Sysconfig extension suffix: ${EXT_SUFFIX}")
73+
find_library(
74+
_portable_lib_LIBRARY
75+
NAMES _portable_lib${EXT_SUFFIX}
76+
PATHS "${CMAKE_CURRENT_LIST_DIR}/../../extension/pybindings/"
77+
)
4878
else()
4979
message(
50-
FATAL_ERROR
51-
"Failed to retrieve sysconfig config var EXT_SUFFIX: ${SYSCONFIG_ERROR}"
80+
WARNING
81+
"Failed to retrieve sysconfig config var EXT_SUFFIX: ${SYSCONFIG_ERROR}. "
82+
"The _portable_lib Python runtime target will not be available; the C++ "
83+
"SDK targets (executorch::*) are unaffected."
5284
)
5385
endif()
5486

55-
find_library(
56-
_portable_lib_LIBRARY
57-
NAMES _portable_lib${EXT_SUFFIX}
58-
PATHS "${CMAKE_CURRENT_LIST_DIR}/../../extension/pybindings/"
59-
)
60-
61-
set(EXECUTORCH_LIBRARIES)
62-
set(EXECUTORCH_FOUND OFF)
6387
if(_portable_lib_LIBRARY)
6488
set(EXECUTORCH_FOUND ON)
6589
message(
6690
STATUS "ExecuTorch portable library is found at ${_portable_lib_LIBRARY}"
6791
)
6892
list(APPEND EXECUTORCH_LIBRARIES _portable_lib)
69-
add_library(_portable_lib STATIC IMPORTED)
70-
set(EXECUTORCH_INCLUDE_DIRS ${CMAKE_CURRENT_LIST_DIR}/../../include)
71-
# PyTorch requires C++20, so pybindings must be compiled with C++20.
72-
set_target_properties(
73-
_portable_lib
74-
PROPERTIES IMPORTED_LOCATION "${_portable_lib_LIBRARY}"
75-
INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}"
76-
CXX_STANDARD 20
93+
if(NOT TARGET _portable_lib)
94+
add_library(_portable_lib STATIC IMPORTED)
95+
# PyTorch requires C++20, so pybindings must be compiled with C++20.
96+
set_target_properties(
97+
_portable_lib
98+
PROPERTIES IMPORTED_LOCATION "${_portable_lib_LIBRARY}"
99+
INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}"
100+
CXX_STANDARD 20
101+
)
102+
endif()
103+
endif()
104+
105+
# ---------------------------------------------------------------------------
106+
# C++ SDK targets (delegate-only). Defined only when the prebuilt static
107+
# archives are present in the wheel (they are shipped alongside this config
108+
# under ../../lib). This lets a C++ application link the ExecuTorch runtime and
109+
# the common runtime extensions without an ExecuTorch source checkout:
110+
#
111+
# find_package(executorch REQUIRED) target_link_libraries(app PRIVATE
112+
# executorch::runtime executorch::extension_module executorch::extension_tensor)
113+
#
114+
# The set is intentionally libtorch-free and excludes CPU operator/kernel
115+
# libraries; delegates (e.g. TensorRT, CUDA) supply their own compute. If your
116+
# model needs portable CPU operators, link a kernel library in addition.
117+
# ---------------------------------------------------------------------------
118+
119+
get_filename_component(
120+
_executorch_sdk_root "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE
121+
)
122+
set(_executorch_sdk_libdir "${_executorch_sdk_root}/lib")
123+
124+
# EXECUTORCH_SDK_FOUND is separate from EXECUTORCH_FOUND on purpose: the legacy
125+
# EXECUTORCH_FOUND / EXECUTORCH_LIBRARIES contract describes the _portable_lib
126+
# Python runtime for custom-op authors. Overloading it here would let existing
127+
# `if(EXECUTORCH_FOUND) link(${EXECUTORCH_LIBRARIES})` code enter its branch
128+
# with an empty library list. C++ SDK consumers should check the imported target
129+
# (e.g. `if(TARGET executorch::runtime)`) or EXECUTORCH_SDK_FOUND.
130+
#
131+
# The C++ SDK ships one shared library, libexecutorch.so, which bundles the
132+
# runtime core plus the common runtime extensions (module, tensor, data_loader,
133+
# flat_tensor, named_data_map). Shared (not static archives) is required so that
134+
# a separately distributed backend/delegate shared library can register into the
135+
# one process-global registry that lives in libexecutorch.so. A backend .so is
136+
# built "coreless" (its register_backend reference is undefined and resolves
137+
# against libexecutorch.so at load), then force-loaded so its static-init
138+
# registration runs.
139+
set(EXECUTORCH_SDK_FOUND OFF)
140+
find_library(
141+
_executorch_shared_LIBRARY
142+
NAMES executorch
143+
PATHS "${_executorch_sdk_libdir}"
144+
NO_DEFAULT_PATH
145+
)
146+
if(_executorch_shared_LIBRARY)
147+
set(EXECUTORCH_SDK_FOUND ON)
148+
if(NOT TARGET executorch::runtime)
149+
add_library(executorch::runtime SHARED IMPORTED)
150+
set_target_properties(
151+
executorch::runtime
152+
PROPERTIES IMPORTED_LOCATION "${_executorch_shared_LIBRARY}"
153+
INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}"
154+
INTERFACE_COMPILE_FEATURES cxx_std_17
155+
INTERFACE_COMPILE_DEFINITIONS
156+
"C10_USING_CUSTOM_GENERATED_MACROS"
157+
)
158+
endif()
159+
160+
# Convenience aliases. libexecutorch.so already contains the core and these
161+
# extensions, so all names resolve to the one shared library. Provided so
162+
# consumer CMake can name what it uses without depending on the bundling
163+
# layout.
164+
foreach(_alias core extension_module extension_tensor extension_data_loader
165+
extension_flat_tensor extension_named_data_map
77166
)
167+
if(NOT TARGET executorch::${_alias})
168+
add_library(executorch::${_alias} INTERFACE IMPORTED)
169+
set_property(
170+
TARGET executorch::${_alias} PROPERTY INTERFACE_LINK_LIBRARIES
171+
executorch::runtime
172+
)
173+
endif()
174+
endforeach()
78175
endif()

0 commit comments

Comments
 (0)