Skip to content

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

Open
shoumikhin wants to merge 9 commits into
mainfrom
gh/shoumikhin/91/head
Open

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

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Why

Today pip install executorch ships one big file that has everything welded
inside it. Every extension in the wheel that needs the runtime gets its own
private copy.

That is not just wasteful, it is incorrect. A backend registers itself into a
table when its library loads. That table is only shared across the process if
the runtime is loaded once. With three copies there are three tables, and a
backend can register into a table that nothing else ever reads.

BEFORE                                   AFTER

_portable_lib.so    ~11 MB               _portable_lib.so     <1 MB
  runtime            copy 1                just binding glue
  registries                                      |
  CPU kernels                                     |
  XNNPACK                                  _training_lib.so
  threadpool                                      |
  profiler                                        |
                                           _llm_runner.so
_training_lib.so                                  |
  runtime            copy 2                       |
  ...                                             v
                                         lib/libexecutorch.so  (one copy)
_llm_runner.so                           lib/libexecutorch_kernels_optimized.so
  runtime            copy 3              lib/libexecutorch_backend_xnnpack.so
  ...                                    lib/libexecutorch_threadpool.so
                                         lib/libexecutorch_etdump.so

3 registries, 3 thread pools             1 registry, 1 thread pool

What this change does

Builds each piece once as its own shared library, and makes the extensions link
those instead of containing them.

library what it is
libexecutorch.so the runtime
libexecutorch_kernels_optimized.so CPU operator kernels
libexecutorch_backend_xnnpack.so the XNNPACK delegate
libexecutorch_threadpool.so the shared thread pool
libexecutorch_etdump.so the profiler

They land in executorch/lib/ inside the installed package.

What to expect

For a Python user, nothing changes. Same imports, same results.

before after
_portable_lib.so size ~11 MB under 1 MB
wheel size about the same
copies of the runtime in a process one per extension one
backend registries one per extension one
thread pools one per extension one
model outputs identical

The wheel does not get smaller. The bytes move out of the extension and into
lib/, so the total is similar. What changes is that there is now one copy
instead of one per extension.

Every consumer the wheel ships now resolves the runtime from the shared library,
so however many of these a process loads, they share one registry and one thread
pool.

Scope

Linux only, because the mechanism is Linux-only: ELF sonames, $ORIGIN runtime
search paths, and GNU linker options to keep a registration-only library on a
link line. macOS, iOS, Android, Windows and embedded builds take the existing
branch and link static libraries exactly as before.

One behaviour does change there. EXECUTORCH_BUILD_SHARED used to configure on
macOS and produce something packaging could not use. It is now a configure error
that says why. The option was added for Linux distribution packaging and nothing
in the repository or CI enabled it elsewhere, so failing early is clearer than
failing later when packaging looks for a .so the build never made.

Making these libraries linkable by a standalone C++ application needs a package
config that declares them. That 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.

Two things this does not fix

Quantized kernels are not split out yet. They still build into
libquantized_ops_aot_lib.so beside the Python extension rather than into a
library a C++ application could link. They are rewired to resolve the runtime
from libexecutorch.so, so they do take part in the single-registry fix; only
the C++-linkable split is deferred.

Bundled third-party code still collides with torch. These libraries bundle
code that torch also links, and both keep it visible, so a process holds two
definitions of symbols like pthreadpool_create and several hundred xnn_*
ones, and a caller reaches whichever the loader found first. That is unchanged
from before, where the extension bundled the same code. Hiding it wholesale does
not work: with --exclude-libs,ALL the optimized kernels on aarch64 can no
longer resolve cpuinfo_initialize from the thread pool, which they do across a
library boundary. Doing it properly needs an explicit export list, so it is left
to its own change. The tests here assert one owner among the libraries this wheel
ships, which is what this change controls.

Fixes to the installed CMake package config

A consumer now has a real reason to use this file, and it had three bugs:

  • find_package(executorch REQUIRED) reported success even when it found
    nothing, because CMake checks the case-sensitive executorch_FOUND and the
    file only set EXECUTORCH_FOUND.
  • The Python extension was declared as a static library when it is a shared
    object.
  • The C++20 requirement used CXX_STANDARD, which an imported target does not
    pass to anything linking it, so a consumer following the documented pattern
    compiled as C++17 and failed against headers needing C++20.

It also needs one addition. A custom-operator library references runtime symbols
and used to get them from the extension, which contained the runtime. Now that
the extension links the runtime instead, the config puts the shipped runtime on
the extension's imported target so those symbols resolve from where the
definitions moved. Without it a custom operator links and then fails to load with
an undefined runtime symbol.

Changes that apply on every platform

Three fixes here are correct everywhere, not 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, so a shared backend is not dropped
    under --as-needed.
  • A Python extension no longer links the embedding form of the Python library.
    This matters only where the interpreter was built with a shared libpython; the
    release interpreter is static, so the published wheel does not show it today.
  • The runtime search path the quantized AOT library uses to reach the Python
    extension named a directory that does not exist, extensions/pybindings
    instead of extension/pybindings, on both the Apple and the ELF branch.
    Corrected on both. The dependency-closure check fails without it.

Fixes from review of an earlier revision

A retention helper was silently doing nothing on static targets. PRIVATE
link options are dropped on a static library, while PRIVATE link libraries
still propagate as $<LINK_ONLY:...>. So a consumer got the shared runtime with
no --no-as-needed around it, which is the exact thing the helper exists to
prevent. Reachable, because the operators libraries are static unless a caller
asks for shared. It is now a fatal error on a static target, and the operators
library only calls it when shared; a static one is extracted whole into its
consumer, which is what retains the runtime.

The checks ran in an order that let the weakest hide the strongest. The
platform tag check sat in the middle, and when it failed it took the
custom-operator, runtime-path and numeric-parity checks with it in every wheel
job. Those now run first and the tag check runs last, because a wrong answer
there means the wheel is labelled wrong, not that the split is broken.

The tag comparison's own test re-implemented the comparison instead of calling
it
, so it could not have caught the bug it was written for. Both the wheel
check and a unit test now call one function, and that unit test moved to
.ci/scripts/tests/ where it runs without building a wheel. It immediately found
that the architecture list matched macosx_11_0_arm64, so arm64 is gone from
it; Linux uses aarch64.

Seven places looked for a tool with a helper that also checks beside the
interpreter, then invoked the bare name
, which the shell resolves through
PATH only. A tool found by the fallback would pass the check and fail to run.

Also declares py-cpuinfo, which backends/qualcomm/__init__.py raises
ImportError without, so that extension could not be imported from a clean
install of this wheel.

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 soname matching its file
    name. A name packaging does not produce, which is what a leftover from an
    earlier build looks like, fails. That happened: a wheel once picked up three
    libraries from a different revision and passed every symbol check.
  • _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 them. Its size is reported rather than asserted, since the number
    differs per architecture and Python version.
  • For each symbol group, exactly one shipped object defines it, and it is the
    library supposed to own it. Naming the owner matters: the old layout also had
    exactly one definer of each, inside the extension.
  • Every shipped library resolves its dependencies, and still does after being
    copied to a different directory with 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.
  • 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 names the architecture 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 CPU kernels and still match.
  • All of the above on x86_64 and aarch64. Both are necessary rather than
    redundant: linker retention differs between them, and x86_64 has kept libraries
    that aarch64 dropped.

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

Stack from ghstack (oldest at bottom):

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

❌ 2 New Failures, 11 Pending, 20 Unclassified Failures

As of commit 2889647 with merge base 28a7fac (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:

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.

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

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

shoumikhin added a commit that referenced this pull request Aug 7, 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. The bytes move into `lib/` rather than disappearing, so the
wheel itself stays about the same size; what changes is that there is now one copy
instead of one per extension. Every consumer the wheel ships that used to embed its
own copy now resolves it from the shared library, so however many of these a process
loads, they share one registry and one thread pool. That is scoped to what this wheel
ships; the paragraph on bundled third-party code below explains what it does not
cover. The size is reported by the tests rather than asserted, because the number
differs per architecture and Python version.

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 this mechanism applies. It names them with an
ELF soname, records `$ORIGIN` runtime search paths, and uses GNU linker options to
keep a registration-only library on a link line. None of that carries to Apple,
which is served by the Swift package distribution, or to Windows, where the
runtime carries no export annotations for a DLL. So macOS, iOS, Android, Windows
and embedded builds take the second branch of each build condition and link the
static libraries, as they did before.

One behaviour does change there. `EXECUTORCH_BUILD_SHARED` previously configured
on macOS and produced something packaging could not use; it is now a configure
error naming the reason. That is a deliberate narrowing: the option was added for
Linux distribution packaging, nothing in the repository or CI enabled it
elsewhere, and failing at configure time is clearer than failing later when
packaging looks for a `.so` the build never emitted.

One piece named in the RFC is not split out here. The quantized kernels still
build into `libquantized_ops_aot_lib.so` beside the Python extension rather than
into a shared library a C++ application could link. They are rewired to resolve the
runtime from `libexecutorch.so`, so they take part in the single-registry fix; only
the C++-linkable split is deferred.

One thing this does not fix. These libraries bundle third-party code that torch
also links, and both keep it visible, so the process holds two definitions of
symbols like `pthreadpool_create` and several hundred `xnn_*` and a caller reaches
whichever the loader resolved first. That is unchanged from the monolithic layout,
where the extension bundled the same code. Hiding it wholesale does not work: with
`--exclude-libs,ALL` the optimized kernels on aarch64 can no longer resolve
`cpuinfo_initialize` from the thread pool, which they do across a library boundary.
Doing it properly needs an explicit export list, so it is left to its own change.
The tests here assert one owner among the libraries this wheel ships, which is what
this change controls.

Three 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. That matters only where the
interpreter was built with a shared libpython: there the extension records a
dependency on it, and the release build's interpreter is static, so the published
wheel does not show the problem today. Both change link lines where they apply. And the runtime search path the quantized
AOT library uses to reach the Python extension named a directory that does not
exist, `extensions/pybindings` rather than `extension/pybindings`, on both the Apple
and the ELF branch. Corrected on both, which changes the recorded path on every
platform that builds it, not only in a wheel. The dependency-closure check fails
without it.

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: 2081d24
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.

[ghstack-poisoned]
Copilot AI review requested due to automatic review settings August 7, 2026 05:59
shoumikhin added a commit that referenced this pull request Aug 7, 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. The bytes move into `lib/` rather than disappearing, so the
wheel itself stays about the same size; what changes is that there is now one copy
instead of one per extension. Every consumer the wheel ships that used to embed its
own copy now resolves it from the shared library, so however many of these a process
loads, they share one registry and one thread pool. That is scoped to what this wheel
ships; the paragraph on bundled third-party code below explains what it does not
cover. The size is reported by the tests rather than asserted, because the number
differs per architecture and Python version.

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.

The package config the wheel installs also had three defects that this change
corrects, because a consumer now has a real reason to use it:
`find_package(executorch REQUIRED)` reported success even when it found nothing,
since CMake checks the case-sensitive `executorch_FOUND` and the file only set
`EXECUTORCH_FOUND`; the Python extension was declared as a static library when it
is a shared object; and the C++20 requirement was expressed with `CXX_STANDARD`,
which an imported target does not pass to anything that links it, so a consumer
following the documented pattern compiled as C++17 and failed against headers that
need C++20.

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 this mechanism applies. It names them with an
ELF soname, records `$ORIGIN` runtime search paths, and uses GNU linker options to
keep a registration-only library on a link line. None of that carries to Apple,
which is served by the Swift package distribution, or to Windows, where the
runtime carries no export annotations for a DLL. So macOS, iOS, Android, Windows
and embedded builds take the second branch of each build condition and link the
static libraries, as they did before.

One behaviour does change there. `EXECUTORCH_BUILD_SHARED` previously configured
on macOS and produced something packaging could not use; it is now a configure
error naming the reason. That is a deliberate narrowing: the option was added for
Linux distribution packaging, nothing in the repository or CI enabled it
elsewhere, and failing at configure time is clearer than failing later when
packaging looks for a `.so` the build never emitted.

One piece named in the RFC is not split out here. The quantized kernels still
build into `libquantized_ops_aot_lib.so` beside the Python extension rather than
into a shared library a C++ application could link. They are rewired to resolve the
runtime from `libexecutorch.so`, so they take part in the single-registry fix; only
the C++-linkable split is deferred.

One thing this does not fix. These libraries bundle third-party code that torch
also links, and both keep it visible, so the process holds two definitions of
symbols like `pthreadpool_create` and several hundred `xnn_*` and a caller reaches
whichever the loader resolved first. That is unchanged from the monolithic layout,
where the extension bundled the same code. Hiding it wholesale does not work: with
`--exclude-libs,ALL` the optimized kernels on aarch64 can no longer resolve
`cpuinfo_initialize` from the thread pool, which they do across a library boundary.
Doing it properly needs an explicit export list, so it is left to its own change.
The tests here assert one owner among the libraries this wheel ships, which is what
this change controls.

Three 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. That matters only where the
interpreter was built with a shared libpython: there the extension records a
dependency on it, and the release build's interpreter is static, so the published
wheel does not show the problem today. Both change link lines where they apply. And the runtime search path the quantized
AOT library uses to reach the Python extension named a directory that does not
exist, `extensions/pybindings` rather than `extension/pybindings`, on both the Apple
and the ELF branch. Corrected on both, which changes the recorded path on every
platform that builds it, not only in a wheel. The dependency-closure check fails
without it.

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: 73e34be
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.

[ghstack-poisoned]
Copilot AI review requested due to automatic review settings August 7, 2026 07:18
shoumikhin added a commit that referenced this pull request Aug 7, 2026
## Why

Today `pip install executorch` ships one big file that has everything welded
inside it. Every extension in the wheel that needs the runtime gets its own
private copy.

That is not just wasteful, it is incorrect. A backend registers itself into a
table when its library loads. That table is only shared across the process if
the runtime is loaded once. With three copies there are three tables, and a
backend can register into a table that nothing else ever reads.

    BEFORE                                   AFTER

    _portable_lib.so    ~11 MB               _portable_lib.so     <1 MB
      runtime            copy 1                just binding glue
      registries                                      |
      CPU kernels                                     |
      XNNPACK                                  _training_lib.so
      threadpool                                      |
      profiler                                        |
                                               _llm_runner.so
    _training_lib.so                                  |
      runtime            copy 2                       |
      ...                                             v
                                             lib/libexecutorch.so  (one copy)
    _llm_runner.so                           lib/libexecutorch_kernels_optimized.so
      runtime            copy 3              lib/libexecutorch_backend_xnnpack.so
      ...                                    lib/libexecutorch_threadpool.so
                                             lib/libexecutorch_etdump.so

    3 registries, 3 thread pools             1 registry, 1 thread pool

## What this change does

Builds each piece once as its own shared library, and makes the extensions link
those instead of containing them.

| library                              | what it is             |
| ------------------------------------ | ---------------------- |
| `libexecutorch.so`                   | the runtime            |
| `libexecutorch_kernels_optimized.so` | CPU operator kernels   |
| `libexecutorch_backend_xnnpack.so`   | the XNNPACK delegate   |
| `libexecutorch_threadpool.so`        | the shared thread pool |
| `libexecutorch_etdump.so`            | the profiler           |

They land in `executorch/lib/` inside the installed package.

## What to expect

For a Python user, nothing changes. Same imports, same results.

| |before|after|
|---|---|---|
|`_portable_lib.so` size|~11 MB|under 1 MB|
|wheel size|—|about the same|
|copies of the runtime in a process|one per extension|one|
|backend registries|one per extension|one|
|thread pools|one per extension|one|
|model outputs|—|identical|

The wheel does not get smaller. The bytes move out of the extension and into
`lib/`, so the total is similar. What changes is that there is now one copy
instead of one per extension.

Every consumer the wheel ships now resolves the runtime from the shared library,
so however many of these a process loads, they share one registry and one thread
pool.

## Scope

Linux only, because the mechanism is Linux-only: ELF sonames, `$ORIGIN` runtime
search paths, and GNU linker options to keep a registration-only library on a
link line. macOS, iOS, Android, Windows and embedded builds take the existing
branch and link static libraries exactly as before.

One behaviour does change there. `EXECUTORCH_BUILD_SHARED` used to configure on
macOS and produce something packaging could not use. It is now a configure error
that says why. The option was added for Linux distribution packaging and nothing
in the repository or CI enabled it elsewhere, so failing early is clearer than
failing later when packaging looks for a `.so` the build never made.

Making these libraries linkable by a standalone C++ application needs a package
config that declares them. That 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.

## Two things this does not fix

**Quantized kernels are not split out yet.** They still build into
`libquantized_ops_aot_lib.so` beside the Python extension rather than into a
library a C++ application could link. They are rewired to resolve the runtime
from `libexecutorch.so`, so they do take part in the single-registry fix; only
the C++-linkable split is deferred.

**Bundled third-party code still collides with torch.** These libraries bundle
code that torch also links, and both keep it visible, so a process holds two
definitions of symbols like `pthreadpool_create` and several hundred `xnn_*`
ones, and a caller reaches whichever the loader found first. That is unchanged
from before, where the extension bundled the same code. Hiding it wholesale does
not work: with `--exclude-libs,ALL` the optimized kernels on aarch64 can no
longer resolve `cpuinfo_initialize` from the thread pool, which they do across a
library boundary. Doing it properly needs an explicit export list, so it is left
to its own change. The tests here assert one owner among the libraries this wheel
ships, which is what this change controls.

## Fixes to the installed CMake package config

A consumer now has a real reason to use this file, and it had three bugs:

- `find_package(executorch REQUIRED)` reported success even when it found
  nothing, because CMake checks the case-sensitive `executorch_FOUND` and the
  file only set `EXECUTORCH_FOUND`.
- The Python extension was declared as a static library when it is a shared
  object.
- The C++20 requirement used `CXX_STANDARD`, which an imported target does not
  pass to anything linking it, so a consumer following the documented pattern
  compiled as C++17 and failed against headers needing C++20.

It also needs one addition. A custom-operator library references runtime symbols
and used to get them from the extension, which contained the runtime. Now that
the extension links the runtime instead, the config puts the shipped runtime on
the extension's imported target so those symbols resolve from where the
definitions moved. Without it a custom operator links and then fails to load with
an undefined runtime symbol.

## Changes that apply on every platform

Three fixes here are correct everywhere, not 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, so a shared backend is not dropped
  under `--as-needed`.
- A Python extension no longer links the embedding form of the Python library.
  This matters only where the interpreter was built with a shared libpython; the
  release interpreter is static, so the published wheel does not show it today.
- The runtime search path the quantized AOT library uses to reach the Python
  extension named a directory that does not exist, `extensions/pybindings`
  instead of `extension/pybindings`, on both the Apple and the ELF branch.
  Corrected on both. The dependency-closure check fails without it.

## Fixes from review of an earlier revision

**A retention helper was silently doing nothing on static targets.** `PRIVATE`
link options are dropped on a static library, while `PRIVATE` link libraries
still propagate as `$<LINK_ONLY:...>`. So a consumer got the shared runtime with
no `--no-as-needed` around it, which is the exact thing the helper exists to
prevent. Reachable, because the operators libraries are static unless a caller
asks for shared. It is now a fatal error on a static target, and the operators
library only calls it when shared; a static one is extracted whole into its
consumer, which is what retains the runtime.

**The checks ran in an order that let the weakest hide the strongest.** The
platform tag check sat in the middle, and when it failed it took the
custom-operator, runtime-path and numeric-parity checks with it in every wheel
job. Those now run first and the tag check runs last, because a wrong answer
there means the wheel is labelled wrong, not that the split is broken.

**The tag comparison's own test re-implemented the comparison instead of calling
it**, so it could not have caught the bug it was written for. Both the wheel
check and a unit test now call one function, and that unit test moved to
`.ci/scripts/tests/` where it runs without building a wheel. It immediately found
that the architecture list matched `macosx_11_0_arm64`, so `arm64` is gone from
it; Linux uses `aarch64`.

**Seven places looked for a tool with a helper that also checks beside the
interpreter, then invoked the bare name**, which the shell resolves through
`PATH` only. A tool found by the fallback would pass the check and fail to run.

Also declares `py-cpuinfo`, which `backends/qualcomm/__init__.py` raises
`ImportError` without, so that extension could not be imported from a clean
install of this wheel.

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 soname matching its file
  name. A name packaging does not produce, which is what a leftover from an
  earlier build looks like, fails. That happened: a wheel once picked up three
  libraries from a different revision and passed every symbol check.
- `_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 them. Its size is reported rather than asserted, since the number
  differs per architecture and Python version.
- For each symbol group, exactly one shipped object defines it, and it is the
  library supposed to own it. Naming the owner matters: the old layout also had
  exactly one definer of each, inside the extension.
- Every shipped library resolves its dependencies, and still does after being
  copied to a different directory with 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.
- 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 names the architecture 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 CPU kernels and still match.
- All of the above on x86_64 and aarch64. Both are necessary rather than
  redundant: linker retention differs between them, and x86_64 has kept libraries
  that aarch64 dropped.

ghstack-source-id: 4474691
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/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