Skip to content

Split the runtime out of the Python bindings extension - #21610

Open
shoumikhin wants to merge 1 commit into
mainfrom
gh/shoumikhin/91/head
Open

Split the runtime out of the Python bindings extension#21610
shoumikhin wants to merge 1 commit into
mainfrom
gh/shoumikhin/91/head

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The problem

pip install executorch gives you the Python half of ExecuTorch and nothing a C++ program can
link. Everything is fused into one large Python extension file, so a C++ developer has to clone
the repository, sync submodules, and build from source.

There is a correctness problem underneath the packaging one. Because the runtime is fused into the
extension, anything else that needs it gets its own private copy, and two copies mean two
registries. A backend registered in one is invisible to the other.

The change

Build the runtime and the pieces around it as separate shared libraries, and make the Python
extension link them instead of embedding them. A shared library is a file a program loads at run
time, so several programs can use one copy instead of each carrying its own.

executorch/
  lib/libexecutorch.so                        the runtime
  lib/libexecutorch_kernels_optimized.so      CPU operator kernels
  lib/libexecutorch_backend_xnnpack.so        the XNNPACK delegate
  lib/libexecutorch_threadpool.so             one thread pool per process
  lib/libexecutorch_etdump.so                 the profiler
  extension/pybindings/_portable_lib.so       now 0.8 MB, links the above

The extension drops from about 11 MB to under a megabyte, because it no longer contains what it
now links.

Linux only, and only when the CUDA backend is off. macOS and Windows keep the fused extension
because the split relies on ELF sonames, the $ORIGIN search-path token and GNU linker options,
none of which apply there, and enabling it elsewhere now fails while configuring rather than much
later. A CUDA build also keeps the fused extension for now, because the CUDA libraries are not yet
shipped alongside the others.

Test plan

Built the wheel from source, installed it into a clean environment, and checked:

  • exactly one library defines each component, and it is the library that should own it. Counting
    owners alone would also pass on the old fused layout, which has exactly one too.
  • the Python extension defines none of them and resolves all of them from outside.
  • every shipped library loads with no unresolved dependency, and none of them searches a directory
    from the machine that built the wheel.
  • a custom operator library compiles and links against the shipped Python extension, which is the
    existing contract this must not break. Linking these libraries from a standalone C++ application
    needs the installed CMake package, which a later change adds.

Ran on Linux x86_64 and aarch64, including a Jetson device.

Not fixed here: these libraries bundle third-party code that torch also links, and both keep it
visible, so a process holds two definitions of symbols like pthreadpool_create. A caller reaches
whichever the loader found first. Fixing it means hiding or dropping the bundled copies, which is a
larger change.

Copilot AI lite review requested due to automatic review settings August 6, 2026 04:52
@shoumikhin

shoumikhin commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@pytorch-bot

pytorch-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🔗 Helpful Links

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

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

❌ 1 New Failure, 2 Unrelated Failures, 8 Unclassified Failures

As of commit 9331198 with merge base fb5eedc (image):

NEW FAILURE - The following job has 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:

BROKEN TRUNK - The following job failed but was 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 6, 2026

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

Copilot AI review requested due to automatic review settings August 6, 2026 04:58
shoumikhin added a commit that referenced this pull request Aug 6, 2026
`pip install executorch` ships the C++ runtime welded inside the Python
bindings extension. That one file, `_portable_lib.so`, is about 11 MB and
contains the runtime, the operator registries, the CPU kernels, the XNNPACK
delegate, the profiler and several extensions, all statically linked together.

A Python user does not notice, but it has a correctness cost. Two other
extensions in the same wheel, the training bindings and the language model
runner, each carry their own private copy of the runtime. A process that loads
more than one of them ends up with more than one backend registry and more than
one thread pool. Backends register themselves from a static initialiser into a
table that is only process-wide if the runtime is loaded once, so a second copy
means a backend can land in a table nothing else reads.

This change builds each of those pieces once, as a shared library the wheel
ships, and makes the extensions link them instead of containing them:

    executorch/lib/libexecutorch.so                      the runtime
    executorch/lib/libexecutorch_kernels_optimized.so     CPU operator kernels
    executorch/lib/libexecutorch_backend_xnnpack.so       the XNNPACK delegate
    executorch/lib/libexecutorch_threadpool.so            the shared thread pool
    executorch/lib/libexecutorch_etdump.so                the profiler

`_portable_lib.so` keeps only the Python binding glue and drops from about 11 MB
to well under 1 MB. Every other consumer in the tree that used to embed its own
copy now resolves it from the shared library, so a process has one registry and
one thread pool no matter how many of them it loads.

A `.pte` file produces the same outputs, the same operators are registered, and
the same backends are available. The existing header set the wheel installs is
untouched, and a custom-operator build against the Python extension keeps
working: the extension is at the same path and records a dependency on each
shared library.

That contract does need one addition. A custom-operator library references
runtime symbols and used to get them from the extension itself, which contained
the runtime. Now that the extension links the runtime instead, the installed
package config puts the shipped runtime on the extension's imported target, so
those symbols resolve from where the definitions actually moved to. Without it a
custom operator still links but fails to load with an undefined runtime
symbol.

Making these libraries linkable by a standalone C++ application needs a package
config that declares them, which is a separate change on top of this one. This
change is only the split, because it touches the most widely used artifact in
the wheel and should be reviewable on its own.

The libraries are built only where a shared runtime is possible, so macOS, iOS,
Android, Windows and embedded builds keep linking static libraries exactly as
before. Those platforms either have no dynamic loader at all, or in the case of
Windows a runtime with no export annotations for a DLL, so the static path stays
and is what the second branch of each build condition preserves. Asking for the
shared build on one of them is now an error at configure time rather than a
confusing packaging failure much later.

Two parts of this apply everywhere, because they are correct everywhere rather
than only in a wheel. A backend or kernel library that registers itself from a
static initialiser is now retained on a consumer's link line, which keeps a
shared backend from being dropped under `--as-needed`. And a Python extension no
longer links the embedding form of the Python library, which otherwise leaves a
hard dependency on an absolute path to the build machine's interpreter. Both
change link lines where they apply, and both fix a real problem rather than only
enabling this one.

Test plan:

Everything below runs against an installed wheel in a new virtual environment,
from a directory holding no checkout, so the checks cannot silently inspect the
source tree instead of the package.

- The wheel ships the five libraries above, each with a versioned file name and
  a soname that matches it. A library without a version, which is what a
  leftover from an earlier build looks like, fails.
- `_portable_lib.so` defines none of the registry, kernel, thread pool or
  delegate symbols the shipped libraries own, and records a dependency on every
  one of those libraries. Its size is reported rather than asserted, since the
  number differs per architecture.
- For each of those symbol groups, exactly one shipped object defines it, and it
  is the library that is supposed to own it. Naming the owner matters: the
  monolithic layout also had exactly one definer of each, inside the extension.
- Every shipped library resolves its dependencies, and still resolves after
  being copied into a different directory with the build-tree paths stripped, so
  the wheel is relocatable rather than only working where it was built. No
  shipped library carries an absolute runtime search path.
- Every Python extension the wheel ships imports in a clean environment. The
  list is discovered from the installed package rather than written down, so an
  extension added later is covered without anyone remembering to add it.
- A custom operator still compiles against the shipped Python extension, which
  is the pre-existing contract for out-of-tree operators.
- The wheel's platform tag matches what it was built for.
- A model exported to a `.pte` and run through the Python bindings matches eager
  PyTorch within tolerance, and so does the same model delegated to XNNPACK. The
  delegated program is checked for the delegate's own identity, because a
  partitioner that claimed nothing would fall back to the CPU kernels and still
  match.
- All of the above on x86_64 and on aarch64. Both are necessary rather than
  redundant: linker retention behaves differently between them, and x86_64 has
  kept libraries that aarch64 dropped.

ghstack-source-id: d538f38
ghstack-comment-id: 5200527760
Pull-Request: #21610

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin shoumikhin added ciflow/trunk ciflow/binaries ciflow/binaries/all Release PRs with this label will build wheels for all python versions ciflow/nightly ciflow/cuda labels Aug 6, 2026
Copilot AI review requested due to automatic review settings August 6, 2026 15:51
shoumikhin added a commit that referenced this pull request Aug 6, 2026
`pip install executorch` ships the C++ runtime welded inside the Python
bindings extension. That one file, `_portable_lib.so`, is about 11 MB and
contains the runtime, the operator registries, the CPU kernels, the XNNPACK
delegate, the profiler and several extensions, all statically linked together.

A Python user does not notice, but it has a correctness cost. Two other
extensions in the same wheel, the training bindings and the language model
runner, each carry their own private copy of the runtime. A process that loads
more than one of them ends up with more than one backend registry and more than
one thread pool. Backends register themselves from a static initialiser into a
table that is only process-wide if the runtime is loaded once, so a second copy
means a backend can land in a table nothing else reads.

This change builds each of those pieces once, as a shared library the wheel
ships, and makes the extensions link them instead of containing them:

    executorch/lib/libexecutorch.so                      the runtime
    executorch/lib/libexecutorch_kernels_optimized.so     CPU operator kernels
    executorch/lib/libexecutorch_backend_xnnpack.so       the XNNPACK delegate
    executorch/lib/libexecutorch_threadpool.so            the shared thread pool
    executorch/lib/libexecutorch_etdump.so                the profiler

`_portable_lib.so` keeps only the Python binding glue and drops from about 11 MB
to well under 1 MB. Every other consumer in the tree that used to embed its own
copy now resolves it from the shared library, so a process has one registry and
one thread pool no matter how many of them it loads.

A `.pte` file produces the same outputs, the same operators are registered, and
the same backends are available. The existing header set the wheel installs is
untouched, and a custom-operator build against the Python extension keeps
working: the extension is at the same path and records a dependency on each
shared library.

That contract does need one addition. A custom-operator library references
runtime symbols and used to get them from the extension itself, which contained
the runtime. Now that the extension links the runtime instead, the installed
package config puts the shipped runtime on the extension's imported target, so
those symbols resolve from where the definitions actually moved to. Without it a
custom operator still links but fails to load with an undefined runtime
symbol.

Making these libraries linkable by a standalone C++ application needs a package
config that declares them, which is a separate change on top of this one. This
change is only the split, because it touches the most widely used artifact in
the wheel and should be reviewable on its own.

The libraries are built only where a shared runtime is possible, so macOS, iOS,
Android, Windows and embedded builds keep linking static libraries exactly as
before. Those platforms either have no dynamic loader at all, or in the case of
Windows a runtime with no export annotations for a DLL, so the static path stays
and is what the second branch of each build condition preserves. Asking for the
shared build on one of them is now an error at configure time rather than a
confusing packaging failure much later.

Two parts of this apply everywhere, because they are correct everywhere rather
than only in a wheel. A backend or kernel library that registers itself from a
static initialiser is now retained on a consumer's link line, which keeps a
shared backend from being dropped under `--as-needed`. And a Python extension no
longer links the embedding form of the Python library, which otherwise leaves a
hard dependency on an absolute path to the build machine's interpreter. Both
change link lines where they apply, and both fix a real problem rather than only
enabling this one.

Test plan:

Everything below runs against an installed wheel in a new virtual environment,
from a directory holding no checkout, so the checks cannot silently inspect the
source tree instead of the package.

- The wheel ships the five libraries above, each with a versioned file name and
  a soname that matches it. A library without a version, which is what a
  leftover from an earlier build looks like, fails.
- `_portable_lib.so` defines none of the registry, kernel, thread pool or
  delegate symbols the shipped libraries own, and records a dependency on every
  one of those libraries. Its size is reported rather than asserted, since the
  number differs per architecture.
- For each of those symbol groups, exactly one shipped object defines it, and it
  is the library that is supposed to own it. Naming the owner matters: the
  monolithic layout also had exactly one definer of each, inside the extension.
- Every shipped library resolves its dependencies, and still resolves after
  being copied into a different directory with the build-tree paths stripped, so
  the wheel is relocatable rather than only working where it was built. No
  shipped library carries an absolute runtime search path.
- Every Python extension the wheel ships imports in a clean environment. The
  list is discovered from the installed package rather than written down, so an
  extension added later is covered without anyone remembering to add it.
- A custom operator still compiles against the shipped Python extension, which
  is the pre-existing contract for out-of-tree operators.
- The wheel's platform tag matches what it was built for.
- A model exported to a `.pte` and run through the Python bindings matches eager
  PyTorch within tolerance, and so does the same model delegated to XNNPACK. The
  delegated program is checked for the delegate's own identity, because a
  partitioner that claimed nothing would fall back to the CPU kernels and still
  match.
- All of the above on x86_64 and on aarch64. Both are necessary rather than
  redundant: linker retention behaves differently between them, and x86_64 has
  kept libraries that aarch64 dropped.

ghstack-source-id: f879887
ghstack-comment-id: 5200527760
Pull-Request: #21610

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude review

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

## The problem

`pip install executorch` gives you the Python half of ExecuTorch and nothing a C++ program can
link. Everything is fused into one large Python extension file, so a C++ developer has to clone
the repository, sync submodules, and build from source.

There is a correctness problem underneath the packaging one. Because the runtime is fused into the
extension, anything else that needs it gets its own private copy, and two copies mean two
registries. A backend registered in one is invisible to the other.

## The change

Build the runtime and the pieces around it as separate shared libraries, and make the Python
extension link them instead of embedding them. A shared library is a file a program loads at run
time, so several programs can use one copy instead of each carrying its own.

```
executorch/
  lib/libexecutorch.so                        the runtime
  lib/libexecutorch_kernels_optimized.so      CPU operator kernels
  lib/libexecutorch_backend_xnnpack.so        the XNNPACK delegate
  lib/libexecutorch_threadpool.so             one thread pool per process
  lib/libexecutorch_etdump.so                 the profiler
  extension/pybindings/_portable_lib.so       now 0.8 MB, links the above
```

The extension drops from about 11 MB to under a megabyte, because it no longer contains what it
now links.

Linux only, and only when the CUDA backend is off. macOS and Windows keep the fused extension
because the split relies on ELF sonames, the `$ORIGIN` search-path token and GNU linker options,
none of which apply there, and enabling it elsewhere now fails while configuring rather than much
later. A CUDA build also keeps the fused extension for now, because the CUDA libraries are not yet
shipped alongside the others.

## Test plan

Built the wheel from source, installed it into a clean environment, and checked:

- exactly one library defines each component, and it is the library that should own it. Counting
  owners alone would also pass on the old fused layout, which has exactly one too.
- the Python extension defines none of them and resolves all of them from outside.
- every shipped library loads with no unresolved dependency, and none of them searches a directory
  from the machine that built the wheel.
- a custom operator library compiles and links against the shipped Python extension, which is the
  existing contract this must not break. Linking these libraries from a standalone C++ application
  needs the installed CMake package, which a later change adds.

Ran on Linux x86_64 and aarch64, including a Jetson device.

Not fixed here: these libraries bundle third-party code that torch also links, and both keep it
visible, so a process holds two definitions of symbols like `pthreadpool_create`. A caller reaches
whichever the loader found first. Fixing it means hiding or dropping the bundled copies, which is a
larger change.

ghstack-source-id: 965d8df
ghstack-comment-id: 5200527760
Pull-Request: #21610

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants