Skip to content

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

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

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

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The wheel ships the runtime, kernels, delegate, thread pool and profiler as separate shared
libraries, but nothing outside Python can use them, because the installed CMake package names
none of them. A C++ application would have to hard-code paths into
the wheel's private layout.

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

Name each shipped library as a CMake component, so find_package locates them, and ship the
headers a caller needs. A component is just a name a consumer can ask for, and CMake reports a
missing one while configuring rather than at link time.

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

Each component records where the wheel keeps its libraries, so an application built against it
finds them without the caller setting a library search path.

Headers include the module and tensor entry points, the CPU kernel helpers, the allocator and data
loader concrete classes Module's constructors take, the profiler entry points, and the
FlatTensorDataMap and MergedDataMap types plus the .ptd file header a caller writing a .ptd needs.

CMake 3.28 or newer gets these targets. Older versions do not, because they write the $ORIGIN
marker (the "look next to me" token in a library search path) incorrectly:

3.24.3, 3.27.9   Makefiles double the dollar sign, Ninja drops the name
3.28.4, 3.31.8   both write the token correctly

That would produce a target that runs where it was built and fails once the application is copied
elsewhere, so no target is defined below 3.28. Those versions get plain variables instead:
EXECUTORCH_LIBRARIES with the runtime and every shipped library by path, plus
EXECUTORCH_INCLUDE_DIRS, EXECUTORCH_COMPILE_DEFINITIONS and EXECUTORCH_CXX_STANDARD. All four
are needed, because an imported target carries the definitions and the C++ standard along with the
library and a plain path carries neither. Linking the libraries alone stops at
#error "You need C++17 to compile ExecuTorch".

ET_USE_THREADPOOL is added to EXECUTORCH_COMPILE_DEFINITIONS on the pre-3.28 route when the
thread pool library ships. Without it the runtime header supplies a local inline serial fallback
for parallel_for, so a consumer following the documented recipe linked the thread pool library
and still ran serial code with no diagnostic.

Built the wheel, installed it into a clean environment, and built a C++ application against the
installed wheel alone:

  • the application links the runtime, runs a model, and matches eager PyTorch, and still runs after
    being copied away from the wheel.
  • asking for a component the wheel does not ship fails while configuring, naming the component.
  • a version request is honoured, including ranges.
  • shipped headers can be included on their own, and one entry point per shipped component also
    links against the shipped libraries. A small number are exempt because they need something outside
    the package: a Windows shim, a test framework, or a header that says in its own text not to
    include it directly. The exempt list is compiled too, so an entry that starts working is reported
    rather than left in place.
  • the thread pool probe compiles with ET_USE_THREADPOOL, on both the modern-CMake route (from the
    runtime target) and the pre-3.28 route (from EXECUTORCH_COMPILE_DEFINITIONS). Without it the
    header supplies a local inline definition and the probe linked identically whether or not the
    library was on the link line, so it could not detect the component being dropped. Measured both
    ways.
  • an application's runtime search path is recorded as DT_RUNPATH, not the older DT_RPATH. That
    matters because DT_RPATH is searched ahead of LD_LIBRARY_PATH and is inherited by
    dependencies, so a consumer could not point a locally built or instrumented runtime at their
    application. Verified by shadowing the runtime through LD_LIBRARY_PATH and watching the loader
    pick it up, which DT_RPATH ignores.
  • on real CMake 3.24 and 3.27, an application configures, builds and runs through the variables.
    Measured what each one contributes, with the consumer pinned to C++14 so its own standard does not
    hide the package's requirement: linking EXECUTORCH_LIBRARIES alone fails on a missing header,
    adding the include directories and definitions then fails on the C++ standard, and applying
    EXECUTORCH_CXX_STANDARD builds and loads a model. The kernels also need scoped retention there,
    because a registration-only library exports nothing the application references and the linker
    drops it, which showed up as "Missing operator" at run time rather than as a link error. The
    smoke test now runs the same shape automatically when EXECUTORCH_PRE_328_CMAKE points at an
    older cmake binary, so a future change on the fallback path fails a check rather than only
    showing up on the first user with older cmake.
  • find_package succeeds when the interpreter on PATH is not the one the wheel was built for. The
    extension's own file name carries its suffix, so asking a different interpreter for it reported a
    complete install as not found.

Ran on Linux x86_64 and aarch64. The macOS wheel keeps the fused extension and ships no separate
libraries, so these checks do not apply there and its smoke test does not run them.

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

❌ 43 New Failures, 1 Unrelated Failure, 39 Unclassified Failures

As of commit 93510b1 with merge base ed65b12 (image):

NEW FAILURES - The following jobs have failed:

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

FLAKY - The following job failed but was likely due to flakiness present on trunk:

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]
Comment thread setup.py
#
# 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we clean these up?

@digantdesai

Copy link
Copy Markdown
Contributor

@claude review this PR.

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Claude finished @digantdesai's task in 3m 0s —— View job


Review: Ship a C++ SDK in the wheel

  • Read the CMake config changes
  • Read setup.py changes
  • Read the test harness
  • Review docs and header changes
  • Address digantdesai's inline comment on setup.py:98

This is a solid, unusually well-verified PR. The test suite in test_cpp_sdk.py is the strongest part: it builds real applications from outside the source tree, checks positive and negative cases (a delegated model must fail without the delegate), verifies relocation via patchelf, distinguishes DT_RUNPATH from DT_RPATH, and exercises the pre-3.28 fallback path. The failure messages consistently explain why a check exists, which is what makes this kind of packaging test worth keeping. The CMake config's handling of the $ORIGIN / older-CMake split and the registration-only-library retention (--no-as-needed) is careful and correct.

A few observations, mostly minor.

_UNSHIPPABLE_HEADERS has a redundant entry (this is digantdesai's question)

runtime/core/testing_util/error_matchers.h (setup.py:104) is already excluded by the test-directory rule in _headers_to_install:

and not any(part.startswith("test") for part in src.parts[:-1])

Its parent directory is testing_util, which starts with "test", so the header is filtered out before _is_unshippable_header is ever consulted. The _UNSHIPPABLE_HEADERS entry for it is dead. To digant's "should we clean these up?" — yes, this one can be dropped; the comment even labels it "A test helper," which is exactly what the directory rule already handles. The other entries (tensor_parser.h, cpuinfo_utils.h, threadpool.h, cpu_caching_malloc_allocator.h, bundled_module.h, file_descriptor_data_loader.h) are genuine — they live in shipped directories and are pulled in by the sweep, so they need explicit exclusion. Fix this →

Worth also noting the two exempt lists live in different files and phrase the same exemptions differently: _UNSHIPPABLE_HEADERS (setup.py, controls what ships) and needs_more_than_the_wheel (test_cpp_sdk.py:842, controls what the compile check skips). They're intentionally not identical — the setup.py list removes headers, the test list keeps-but-skips headers that ship because other headers include them — but a reader has to reconstruct that. A one-line cross-reference in each would help the next person keep them consistent.

Public API removal in etdump_flatcc.h

clear_builder() is removed from ETDumpGen (devtools/etdump/etdump_flatcc.h:77). I confirmed there are no in-tree callers, so nothing breaks here, but this is a public header method — if it was ever part of the shipped surface it's technically a breaking change for an external caller. Given it's a devtools profiler internal, that's almost certainly fine; flagging only so it's a conscious call rather than incidental.

Comment density vs. CLAUDE.md

The repo's CLAUDE.md asks for minimal, self-documenting comments. executorch-wheel-config.cmake and the new setup.py helpers run well past that — many comments are 8–15 lines. In fairness, most of this is non-obvious global context (measured CMake version behavior, why $ORIGIN ordering matters, why --no-as-needed per-component), which is exactly the exception the guideline carves out. But some paragraphs restate what the code plainly does (e.g. the "already defined, reusing it" blocks appear three times with near-identical multi-line prose). Trimming the restating ones would bring this closer to house style without losing the hard-won rationale.

Minor

  • docs/source/using-executorch-cpp.md: the new ### Building from source heading is followed by two blank lines before the existing paragraph. Harmless, but lint may flag it.
  • test_cpp_sdk.py:557 iterates ("lib", "backends/cuda") for the relocation copy. That's fine, but the hardcoded backends/cuda will silently miss any future backend that ships its own .so outside lib/. A glob over the package's backend dirs would be more durable; not blocking.

Nothing here is a correctness blocker. The redundant _UNSHIPPABLE_HEADERS entry is the one concrete cleanup that answers the open review comment.
· branch gh/shoumikhin/92/head

@shoumikhin

Copy link
Copy Markdown
Contributor Author

These are the headers a directory copy sweeps in that a consumer cannot actually use, so the
list is what keeps them out of the wheel rather than something left over to delete.

I would rather not remove the list, and here is the measurement behind that. Compiling every
shipped header on its own against the installed wheel gives 117 of 120 passing. The three that
do not are all correct as they are:

  • extension/data_loader/mman_windows.h needs _mingw.h, so it only compiles on Windows.
  • c10/util/complex_math.h and c10/util/complex_utils.h both start with
    #error "... is not meant to be individually included. Include c10/util/complex.h instead.",
    which is upstream's own rule.

Each of the seven entries is excluded for a reason that a consumer would otherwise hit as a
compile or link failure in their own project, for example threadpool.h holds a pthreadpool
member by value and that library's headers are not published, and
file_descriptor_data_loader.h is in no CMake target so nothing shipped defines it.

I also checked the one thing that would make an exclusion wrong: whether a header the wheel
does ship includes an excluded one, which would leave a broken include chain. The only such
includer is extension/llm/custom_ops/op_sdpa_impl.h, and the wheel does not ship that either,
so there is no chain to break.

Happy to reduce the list if you would rather ship a header and let it fail in the consumer's
build, but my reading is that failing here is better than failing in someone else's project.

[ghstack-poisoned]
[ghstack-poisoned]
@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks, this was a useful review. All six points are addressed.

The redundant _UNSHIPPABLE_HEADERS entry: confirmed and removed. I did not want to take it
on reading alone, so I tested each of the seven entries by removing it on its own and comparing
the resulting installed header set:

baseline installs 739 headers
  extension/data_loader/file_descriptor_data_loader.h        LIVE, would add 1
  extension/memory_allocator/cpu_caching_malloc_allocator.h  LIVE, would add 1
  extension/module/bundled_module.h                          LIVE, would add 1
  extension/threadpool/cpuinfo_utils.h                       LIVE, would add 1
  extension/threadpool/threadpool.h                          LIVE, would add 1
  runtime/executor/tensor_parser.h                           LIVE, would add 1
  runtime/core/testing_util/error_matchers.h                 DEAD (rule already covers it)

Exactly one dead entry, the one you named. Removing it leaves the installed set identical at 739
headers, so it is gone. The other six each really do remove a header.

The two lists living in different files: each now points at the other and says why they
differ, that one decides what ships and the other decides what is compiled on its own, and that a
header belongs in exactly one of them.

The hardcoded ("lib", "backends/cuda"): you are right that the comment already claimed
"every directory the wheel ships a library in" while the code named two. It now searches the
package instead. On the macOS wheel that finds 8 libraries rather than 6, and loses none.

clear_builder(): a conscious call, not incidental. It is a devtools profiler internal with
no callers, and it was only reachable because the header shipped.

The repeated "already defined, reusing it" prose: trimmed at the one site where a block
comment directly above already explained it. The other two keep theirs because they have no such
comment above them.

The double blank line in the docs: fixed.

Each fix went into this commit rather than the tip, so the change stays self-contained.

"""


_CONSUMER_SOURCE = r"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just an idea, not a blocker, should we try to run one of our example runners and build it using the whl? Dogfooding style :p

"""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"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have documentation around which shared lib contains what (at a high level) and which one a user should link when?

public:
ETDumpGen(::executorch::runtime::Span<uint8_t> buffer = {nullptr, (size_t)0});
~ETDumpGen() override;
void clear_builder();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

intetional?

Comment thread docs/source/using-executorch-cpp.md Outdated
}
```

#### What each component provides

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok this is what I was looking for.

[ghstack-poisoned]
@shoumikhin

Copy link
Copy Markdown
Contributor Author

On the clear_builder() removal in devtools/etdump/etdump_flatcc.h:

intetional?

It was intentional but it does not belong in this PR, so I am putting it back. Thanks for catching
it.

For the record on what it actually is, since it looked like a public API removal. On main it is a
declaration with no definition anywhere in the tree:

$ git grep -n clear_builder origin/main -- '*.cpp' '*.h' '*.mm'
origin/main:devtools/etdump/etdump_flatcc.h:77:  void clear_builder();

So any caller would have failed to link, and there are no callers in the tree. Deleting it is a
reasonable cleanup, but it is unrelated to shipping a C++ SDK in the wheel, and bundling it here
makes this diff carry a second unrelated change. I have restored the declaration so this PR only
does the one thing it says.

On the other two:

should we try to run one of our example runners and build it using the whl? Dogfooding style :p

I like this and I think it belongs as a follow-up rather than here. The test already builds real
applications from outside the source tree and links only installed artifacts, including the
documented C++ example from the docs, so the linking contract is covered. Building an actual example
runner would additionally cover its own CMake, which is a different thing worth testing on its own.

Do we have documentation around which shared lib contains what (at a high level) and which one a
user should link when?

Yes, that is what the section you found in docs/source/using-executorch-cpp.md is for: it lists
each shipped library, says what it provides, and says when you need to name it rather than relying
on ${EXECUTORCH_LIBRARIES}. If a specific library is unclear there, tell me which one and I will
expand that entry.

[ghstack-poisoned]
[ghstack-poisoned]
[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.

2 participants