Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,069 changes: 1,069 additions & 0 deletions .ci/scripts/wheel/test_cpp_sdk.py

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions .ci/scripts/wheel/test_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions .ci/scripts/wheel/test_linux_aarch64.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions README-wheel.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ The prebuilt `executorch.runtime` module included in this package provides a way
to run ExecuTorch `.pte` files, with some restrictions:
* Only [core ATen operators](docs/source/ir-ops-set-definition.md) are linked into the prebuilt module
* Only the [XNNPACK backend delegate](docs/source/backends/xnnpack/xnnpack-overview.md) is linked into the prebuilt module.
* \[macOS only] [Core ML](docs/source/backends/coreml/coreml-overview.md) and [MPS](docs/source/backends/mps/mps-overview.md) backend
are also linked into the prebuilt module.
* \[macOS only] [Core ML](docs/source/backends/coreml/coreml-overview.md) backend is
also linked into the prebuilt module.
* \[Linux x86_64] [QNN](docs/source/backends-qualcomm.md) backend is linked into the prebuilt module.
* \[Linux] [OpenVINO](docs/source/build-run-openvino.md) backend is also linked into the
prebuilt module. OpenVINO requires the runtime to be installed separately:
Expand Down
1 change: 0 additions & 1 deletion devtools/etdump/etdump_flatcc.h
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ class ETDumpGen : public ::executorch::runtime::EventTracer {
public:
ETDumpGen(::executorch::runtime::Span<uint8_t> buffer = {nullptr, (size_t)0});
~ETDumpGen() override;
void clear_builder();

void create_event_block(const char* name) override;
virtual ::executorch::runtime::EventTracerEntry start_profiling(
Expand Down
86 changes: 86 additions & 0 deletions docs/source/using-executorch-cpp.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,92 @@ 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 <executorch/extension/module/module.h>
#include <executorch/extension/tensor/tensor.h>

#include <cstdio>
#include <vector>

using namespace executorch::extension;

int main() {
Module module("model.pte");

std::vector<float> data(2 * 8, 1.0f);
auto input = make_tensor_ptr({2, 8}, data.data());

const auto result = module.forward(input);
if (!result.ok()) {
std::printf("forward failed: 0x%x\n", (unsigned)result.error());
return 1;
}
std::printf("ok, %zu outputs\n", result->size());
return 0;
}
```

#### What each component provides

Ask for the components your model needs. A component the wheel was not built with is
reported while CMake configures, rather than failing later at link time.

| Component | What it provides |
| --- | --- |
| `executorch::runtime` | the program loader and executor. Always present. |
| `executorch::kernels_optimized` | CPU operator kernels. Needed for any operator a delegate does not claim. |
| `executorch::backend_xnnpack` | the XNNPACK delegate. |
| `executorch::threadpool` | the shared thread pool. |
| `executorch::etdump` | the profiler. |

The runtime on its own loads a program but registers only primitive operators, not the
kernels a model computes with, so a model that is not fully delegated needs a kernel
component too. Linking a delegate is what registers it: a program delegated to XNNPACK
fails to load in an application that did not link `executorch::backend_xnnpack`.

To require a minimum version, pass it to `find_package`:

```cmake
find_package(executorch 1.0 REQUIRED)
```

### 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.

```
Expand Down
6 changes: 2 additions & 4 deletions extension/memory_allocator/memory_allocator_utils.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,10 @@
#include <executorch/runtime/core/result.h>
#include <executorch/runtime/platform/compiler.h>

using executorch::runtime::Error;
using executorch::runtime::Result;
namespace executorch::extension::utils {

// Util to get alighment adjusted allocation size
inline Result<size_t> get_aligned_size(size_t size, size_t alignment) {
inline runtime::Result<size_t> get_aligned_size(size_t size, size_t alignment) {
// The minimum alignment that malloc() is guaranteed to provide.
static constexpr size_t kMallocAlignment = alignof(std::max_align_t);
if (alignment > kMallocAlignment) {
Expand All @@ -31,7 +29,7 @@ inline Result<size_t> get_aligned_size(size_t size, size_t alignment) {
const size_t extra = alignment - 1;
if ET_UNLIKELY (extra >= SIZE_MAX - size) {
ET_LOG(Error, "Malloc size overflow: size=%zu + extra=%zu", size, extra);
return Result<size_t>(Error::InvalidArgument);
return runtime::Result<size_t>(runtime::Error::InvalidArgument);
}
size += extra;
}
Expand Down
1 change: 1 addition & 0 deletions runtime/executor/platform_memory_allocator.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include <cstdint>

#include <c10/util/safe_numerics.h>
#include <executorch/runtime/core/exec_aten/exec_aten.h>
#include <executorch/runtime/core/memory_allocator.h>
#include <executorch/runtime/platform/log.h>
#include <executorch/runtime/platform/platform.h>
Expand Down
168 changes: 163 additions & 5 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,42 @@
format="%(asctime)s [%(levelname)s] %(message)s",
)

# Headers swept in by a directory copy that a consumer of the wheel cannot use, because each needs
# something the wheel does not carry. Publishing one is worse than leaving it out: the failure arrives in
# someone else's project rather than here.
#
# Matched on the path ending, not the bare file name. Two different headers here share the name
# tensor_util.h, one a widely included utility and one a test helper, so a name match either kept the test
# helper or removed the utility everything needs.
#
# Only headers that nothing else the wheel installs includes belong here. A header other shipped headers
# pull in must keep shipping even when it cannot be compiled on its own.
_UNSHIPPABLE_HEADERS = frozenset(
{
# Needs a header generated when the schema is compiled, which in turn needs the FlatBuffers C++
# headers. Those are a third-party library this wheel does not vendor.
"runtime/executor/tensor_parser.h",
# A test helper, needing a test framework the wheel does not ship.
"runtime/core/testing_util/error_matchers.h",
# Reads processor details through cpuinfo, whose headers the wheel does not publish.
"extension/threadpool/cpuinfo_utils.h",
# Holds a pthreadpool member by value, so it needs that library's header, which the wheel does not
# publish either. The component it belongs to is a link dependency the runtime carries, not
# something a consumer includes.
"extension/threadpool/threadpool.h",
# Declares CPUCachingAllocator, whose implementation is in a component no shipped library links,
# so including it compiles and then fails at link time with an undefined reference.
"extension/memory_allocator/cpu_caching_malloc_allocator.h",
# Declares BundledModule, which is built only for the Python bindings, so its implementation is in
# the Python extension. A C++ application cannot link that, and building the source instead needs
# bundled-program headers the wheel does not publish.
"extension/module/bundled_module.h",
# Declares FileDescriptorDataLoader, whose implementation is in no CMake target at all, so no
# shipped library defines it. Including it compiles and then fails at link time.
"extension/data_loader/file_descriptor_data_loader.h",
}
)

try:
from tools.cmake.cmake_cache import CMakeCache
except ImportError:
Expand Down Expand Up @@ -829,10 +865,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 <prefix>/lib/cmake/<name>, not <prefix>/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
Expand All @@ -845,9 +892,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)))
)
Expand Down Expand Up @@ -885,6 +956,93 @@ def run(self):
self.mkpath(os.path.dirname(dst_file))
self.copy_file(src_file, dst_file, preserve_mode=False)

if not _is_minimal_build():
self._write_cmake_version_file(dst_root)

def _write_cmake_version_file(self, dst_root: str) -> None:
"""Write the CMake package version file, so `find_package(executorch 1.2)` works.

Generated rather than copied, because the version is only known here:
version.txt gives the base and BUILD_VERSION overrides it for a nightly. A
checked-in file would go stale the first time either changed.
"""
template = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"tools",
"cmake",
"executorch-wheel-config-version.cmake.in",
)
with open(template) as handle:
contents = handle.read()
# Only the numeric release part. A Python version can carry a local segment
# such as "1.5.0+cpu" or a development suffix, and `find_package(executorch
# 1.5.0+cpu)` is rejected by CMake as an invalid argument, so a consumer could
# not name the version this file reports. Strip to the dotted numbers CMake
# can compare, which is what a consumer asks for in practice.
# Two variables with different jobs. CMake compares PACKAGE_VERSION, so it has to be the
# numeric release and nothing else. EXECUTORCH_BUILD_VERSION is documented as the full
# version, which is what a consumer pinning an exact build compares against, so filling it
# from the numeric part would make that comparison pass against a different wheel.
build_version = Version.string()
cmake_version = re.match(r"\d+(?:\.\d+)*", build_version)
if not cmake_version:
# A version file claiming 0 would satisfy every version request, which is worse than
# not building at all.
raise RuntimeError(
f"cannot derive a numeric CMake version from {build_version!r}; the version file "
"would claim 0 and satisfy every version request"
)
contents = contents.replace("@EXECUTORCH_VERSION@", cmake_version.group(0))
contents = contents.replace("@EXECUTORCH_BUILD_VERSION@", build_version)
# CMake only reads a version file that sits beside the configuration file it found, so this
# goes to both locations the configuration is installed to. Writing it to one would leave a
# version request silently unchecked when the other location was used.
for destination in (
os.path.join(dst_root, "share", "cmake", "executorch-config-version.cmake"),
os.path.join(
dst_root,
"lib",
"cmake",
"executorch",
"executorch-config-version.cmake",
),
):
self.mkpath(os.path.dirname(destination))
with open(destination, "w") as handle:
handle.write(contents)


def _headers_to_install(entry: Path):
"""The headers a copy list entry publishes, skipping any a consumer could not or should not use.

A directory entry publishes everything under it, and a file entry publishes just that file. A header a
consumer cannot compile is worse than an absent one, because the failure lands in their project rather
than here, and the directory entries sweep in a few of those.

Test directories are skipped as a whole rather than by name. They hold mocks and stubs for this
project's own tests, nothing the wheel installs includes them, and a consumer linking a mock allocator
or a stub platform would get behaviour no release intends. Matched on any part starting with "test", so
a directory named testing_util counts too, which a plain equality check missed.
"""
candidates = entry.rglob("*.h") if entry.is_dir() else [entry]
return [
src
for src in candidates
if not _is_unshippable_header(src)
and not any(part.startswith("test") for part in src.parts[:-1])
]


def _is_unshippable_header(src: Path) -> bool:
"""Whether a header is on the list of ones a consumer of the wheel could not compile.

Compared on the path ending rather than the file name. Two headers here are both called
tensor_util.h, one a utility that many shipped headers include and one a test helper, so matching the
bare name either kept the helper or removed the utility everything needs.
"""
posix = src.as_posix()
return any(posix.endswith(entry) for entry in _UNSHIPPABLE_HEADERS)


class Buck2EnvironmentFixer(contextlib.AbstractContextManager):
"""Removes HOME from the environment when running as root.
Expand Down
Loading
Loading