Skip to content

Ship a C++ SDK in the wheel - #21639

Open
shoumikhin wants to merge 15 commits into
gh/shoumikhin/91/headfrom
gh/shoumikhin/92/head
Open

Ship a C++ SDK in the wheel#21639
shoumikhin wants to merge 15 commits into
gh/shoumikhin/91/headfrom
gh/shoumikhin/92/head

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The problem

The previous change split the runtime, kernels, delegate, thread pool and profiler out of the
Python extension into separate shared libraries. The wheel ships them, but nothing outside Python
can use them: the installed CMake package names none of the five, so a C++ application has no way
to link them without hard-coding paths into the wheel's private layout.

The headers have the same gap. The package installs the subset a custom-operator build needs,
which leaves out extension/module, the entry point the documentation tells a C++ application to
use. So the wheel ships the libraries to load and run a program and no way to call them.

The change

Names each shipped library as a CMake component, adds a version file so version requests are
checked, and ships the headers a caller needs.

find_package(executorch 1.5 REQUIRED COMPONENTS kernels_optimized)
target_link_libraries(my_app PRIVATE executorch::runtime
                                     executorch::kernels_optimized)
component library it resolves to
executorch::runtime libexecutorch.so
executorch::kernels_optimized libexecutorch_kernels_optimized.so
executorch::backend_xnnpack libexecutorch_backend_xnnpack.so
executorch::threadpool libexecutorch_threadpool.so
executorch::etdump libexecutorch_etdump.so

The names are namespaced because a name containing :: must be an alias or imported target, so
CMake reports a missing one while configuring and says which. A bare name becomes -lexecutorch
and fails later with a worse message, or resolves to an unrelated system library. That matters
more for a wheel than a source build: the wheel's contents depend on how it was built, so a
consumer asking for a delegate it does not carry should be told during configuration.

Each component also carries the retention its library needs. A registration-only library has no
symbol the application references, so the default --as-needed drops it and its static
initializer never runs, leaving a delegate that is linked and unregistered.

Before and after

BEFORE                                  AFTER

the libraries are shipped               find_package(executorch REQUIRED)
but nothing names them                  target_link_libraries(app PRIVATE
                                          executorch::runtime)

find_package(executorch 1.5)            the version is actually checked
accepts any version

#include <executorch/extension/         the header ships
  module/module.h>   not shipped

The runtime component carries the thread pool as well as the compile definition that needs it. That
definition switches a header from an inline serial helper to a bare declaration, and the thread pool
library holds the only definition of what it declares, so a consumer linking only the runtime failed
to link with an undefined reference.

The version file reports two different things, so they are filled separately. CMake compares the
numeric release, and the full version, including any prerelease suffix and local label, is what a
consumer pins against when an exact build pairing is required. Both were filled from the same
placeholder, so the full version came out truncated and a consumer comparing it would pass against a
different wheel. Deriving no numeric version now fails the build rather than writing 0, which would
otherwise satisfy every version request.

Test plan

A standalone application built outside the wheel, exporting a real model, running it from C++
through Module, and comparing against eager PyTorch, because a model that returns wrong numbers
without erroring satisfies every other check:

  • the version file carries the numeric release for CMake to compare AND the full build version
    separately, checked against several realistic version strings including local labels
  • find_package accepts the installed version and an older request, and rejects a newer one
  • the C++ example in the documentation compiles against the wheel, extracted from the page rather
    than copied, so the two cannot drift
  • linking only the runtime loads a program and reports every operator missing, which is the split
    working rather than a defect, and fails if the runtime starts carrying kernels again
  • adding the kernels component runs the model and matches eager PyTorch
  • adding the delegate runs a delegated model and matches
  • the same delegated program fails in an application that linked the kernels but not the
    delegate, which shows the component is what registers it
  • the application still runs after being copied away from the wheel with the absolute search path
    removed, so the package is relocatable
  • a consumer linking only the runtime compiles and links, which is what the thread pool being
    carried with its compile definition is for
  • one registry serves the process: an application linking five components sees exactly one more
    backend than one linking two

Ran against an installed wheel on Linux x86_64 and aarch64. The C++ output matches eager PyTorch
to 2.4e-07 in every executing case.

[ghstack-poisoned]
@shoumikhin

shoumikhin commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@pytorch-bot

pytorch-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/21639

Note: Links to docs will display an error until the docs builds have been completed.

❌ 455 Pending, 2 Unrelated Failures, 1 Unclassified Failure

As of commit c6bd747 with merge base 48741ac (image):

UNCLASSIFIED FAILURE - DrCI could not classify the following job because the workflow did not run on the merge base. The failure may be pre-existing on trunk or introduced by this PR:

BROKEN TRUNK - The following jobs failed but were present on the merge base:

👉 Rebase onto the `viable/strict` branch to avoid these failures

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 7, 2026
@github-actions github-actions Bot added ciflow/trunk module: arm Issues related to arm backend labels Aug 7, 2026
shoumikhin added a commit that referenced this pull request Aug 7, 2026
## Why

The previous change split the runtime, kernels, delegate, thread pool and profiler out
of the Python extension into five prebuilt shared libraries. The wheel ships them, but
nothing outside Python can use them: the installed CMake package config names none of
the five, so a C++ application has no way to link them without hard-coding paths into
the wheel's private layout.

    BEFORE                              AFTER

    pip install executorch              pip install executorch
      |                                   |
      v                                   v
    executorch/lib/*.so                 executorch/lib/*.so
      (shipped, but unnamed)              |
                                          v
    a C++ app must                      find_package(executorch REQUIRED)
    clone the repo and                    |
    build from source                     v
                                        target_link_libraries(app PRIVATE
                                          executorch::runtime)

## What this change does

Gives the shipped libraries a public contract:

    find_package(executorch 1.5 REQUIRED COMPONENTS kernels_optimized)
    target_link_libraries(my_app PRIVATE executorch::runtime
                                         executorch::kernels_optimized)

| target | library it resolves to |
| --- | --- |
| `executorch::runtime` | `libexecutorch.so` |
| `executorch::kernels_optimized` | `libexecutorch_kernels_optimized.so` |
| `executorch::backend_xnnpack` | `libexecutorch_backend_xnnpack.so` |
| `executorch::threadpool` | `libexecutorch_threadpool.so` |
| `executorch::etdump` | `libexecutorch_etdump.so` |

Namespaced rather than bare, because a name containing `::` must be an alias or
imported target, so CMake reports a missing one while configuring and names it. A bare
name is handed to the linker as `-lexecutorch`, which fails later with a worse message
or silently resolves to an unrelated system library. That matters more for a wheel than
for a source build: the wheel's contents depend on the options it was built with, so a
consumer asking for a delegate the wheel does not carry should be told during
configuration.

Each component target carries the retention its library needs. A registration-only
library has no symbol the application references, so the default `--as-needed` drops it
and its static initializer never runs, leaving a delegate that is linked and
unregistered. The options are scoped per library, because CMake removes duplicate
option text and a shared `--push-state` pair silently loses its scoping for the second
component.

## What to expect

Nothing changes for a Python user. This only adds a way to use the libraries the wheel
already shipped.

| | before | after |
| --- | --- | --- |
| C++ app links the runtime | build from source | `find_package(executorch)` |
| `find_package(executorch 1.5)` | any version accepted | version checked |
| headers for `Module` | not shipped | shipped |

The package also gains a version file, so `find_package(executorch 1.5 REQUIRED)`
answers correctly instead of accepting any request. Generated at packaging time rather
than checked in, because the version is only known then: `version.txt` gives the base
and a nightly overrides it. Without the file CMake reports the version as `unknown` and
accepts every request, so a consumer pinning a minimum silently gets whatever is
installed.

The headers move with the libraries. The package previously installed the subset a
custom-operator build needs, which does not include `extension/module`, the entry point
the documentation tells a C++ application to use. So the package shipped the libraries
to load and run a program and no way to call them. This adds `extension/module`, the
two directories holding the concrete allocator and loader a caller has to construct,
and `devtools/etdump`, whose library was already advertised as a component.

## Fixes from review of an earlier revision

The version file declared a variable for pinning an exact build that it never wrote, so
the config's own advice for that case compared against an empty string.

The thread pool switch sat on the thread pool target, while the header it guards is
exposed by every component and selects between a declaration and an inline definition.
A consumer naming that component in one translation unit and not another compiled two
definitions of the same function into one program, and the serial one silently won
wherever it was inlined. It now sits on the runtime, which every component depends on.

An interface link directory was carried with eleven lines defending it, while every
library already reaches the link line by absolute path. Removing it changes no build.

The relocation check skipped when `patchelf` was absent, which is indistinguishable
from a pass in the log. It now installs the tool and fails if it cannot.

Test plan:

A standalone application built from outside the wheel, in
`.ci/scripts/wheel/test_cpp_sdk.py`. It exports a real `.pte`, runs it from C++ through
`Module`, and compares the output against eager PyTorch, because a model that returns
wrong numbers without erroring satisfies every other check. Seven properties:

- `find_package` accepts the installed version and an older request, and rejects a
  newer one
- linking only the runtime loads a program and reports every operator missing, which is
  the split working rather than a defect, and it fails if the runtime starts carrying
  kernels again
- adding the kernels component runs the model and matches eager PyTorch
- adding the delegate runs a delegated model and matches
- the same delegated program fails in an application that linked the kernels but not
  the delegate, which is what shows the component is what registers it
- the application still runs after being copied away from the wheel with the absolute
  search path removed, so the package is relocatable rather than only working where it
  was built
- an application linking five components sees exactly one more backend than one linking
  two, so there is one registry in the process rather than one per component

Ran against an installed wheel on x86_64 and aarch64. All seven pass, with the C++
output matching eager PyTorch to 2.4e-07 in every executing case.

ghstack-source-id: bca8af0
ghstack-comment-id: 5215967468
Pull-Request: #21639
@shoumikhin shoumikhin added ciflow/periodic ciflow/binaries ciflow/binaries/all Release PRs with this label will build wheels for all python versions ciflow/nightly ciflow/cuda labels Aug 7, 2026
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/binaries/all Release PRs with this label will build wheels for all python versions ciflow/binaries ciflow/cuda ciflow/nightly ciflow/periodic ciflow/trunk CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. module: arm Issues related to arm backend

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant