Skip to content

fix: make the ExecuTorch runtime wheel build and its reference runner work - #4534

Merged
lanluo-nvidia merged 7 commits into
pytorch:mainfrom
shoumikhin:fix-executorch-wheel-static-libstdcxx
Aug 20, 2026
Merged

fix: make the ExecuTorch runtime wheel build and its reference runner work#4534
lanluo-nvidia merged 7 commits into
pytorch:mainfrom
shoumikhin:fix-executorch-wheel-static-libstdcxx

Conversation

@shoumikhin

@shoumikhinshoumikhin commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

The ExecuTorch runtime wheel build fails on main, on every row of the matrix:

[86%] Linking CXX shared library _portable_lib.so
checking _portable_lib.so links the C++ runtime statically
FATAL: _portable_lib.so has a dynamic libstdc++ dependency

The wheel is meant to carry its own copy of the C++ runtime, because the machine that builds it and the machine that runs it do not necessarily ship the same libstdc++. It asks for that with -static-libstdc++ and then checks the result. The check is right and the request was not being honoured.

With this change the build passes and produces torch_tensorrt_executorch_runtime-*.whl, the first successful run of that job since 2026-08-14.

Why it was failing

Three separate things, each hidden behind the last.

1. The removal ran too late. The extensions are created inside ExecuTorch's directory by add_subdirectory, and take their copy of the linker-flag variables at that moment. The removal sat further down the file, so it edited a value nothing reads. The build printed both, side by side:

CMAKE_SHARED_LINKER_FLAGS = [... --gc-sections --push-state,-as-needed -lm ...] <- fragment gone
link command = [... --gc-sections --push-state,-as-needed -lstdc++ ...] <- fragment present

2. Position does not save us, because of the linker. The fragment arrives wrapped in --as-needed ahead of the objects, which the default linker drops. This build passes -fuse-ld=gold, and gold keeps it. Measured in the release image on a shared object using std::string and exceptions, all with -static-libstdc++ -static-libgcc:

linker-lstdc++ presentlibstdc++ NEEDED
bfdyesno
goldyesyes
gold + LTOyesyes
gold + LTOnono

3. Removing it removes the only C++ runtime on the line. The Bazel toolchain hands CMake the C driver as CMAKE_CXX_COMPILER:

/opt/rh/gcc-toolset-13/root/usr/bin/gcc -fPIC ...

gcc links no C++ runtime and treats -static-libstdc++ as a no-op, which is exactly why the toolchain injected an explicit -lstdc++. Taking it away left exception_ptr::_M_addref undefined.

The fix

Remove the injected fragment before anything that links is defined, and put the static archive where an archive can actually resolve something. An archive only pulls members that resolve symbols already undefined when it is scanned, so ahead of the objects it contributes nothing. CMAKE_CXX_STANDARD_LIBRARIES is appended after the objects, which is the placement rules_foreign_cc documents for this exact problem.

link line (gcc driver, gold, LTO)UND _M_addreflibstdc++ NEEDED
dynamic -lstdc++ before objects21
nothing at all20
static archive before objects20
static archive after objects00

Changes

  • Remove the injected -lstdc++ before add_subdirectory, from the three CMAKE_{SHARED,MODULE,EXE}_LINKER_FLAGS. Those are the only variables that can carry it.
  • Append the static libstdc++.a, named by the path the compiler reports, to CMAKE_CXX_STANDARD_LIBRARIES so it lands after the objects.
  • Drop the pre-project() attempt on the *_INIT variables. The generated toolchain file re-sets those while project() runs, so it never took effect.
  • Add CMAKE_CXX_STANDARD_LIBRARIES to CMAKE_TRY_COMPILE_PLATFORM_VARIABLES, so configure probes keep the runtime.
  • Match the push/pop group as a unit, and the bare -lstdc++ with a token boundary, so -lstdc++fs and -lstdc++_nonshared survive and the group is never left unbalanced.
  • Move the check into check_static_cxx_runtime.sh, print the NEEDED entries and the link command on failure, and widen it past the single _M_addref symbol.
  • Print the resolved variables at configure time.

Testing

The wheel build passes in CI on this PR and produces the wheel.

Everything else measured in the release container image (manylinux2_28-builder, gcc 13.3.1), on a replica with both targets defined in a subdirectory, the gcc driver, gold, LTO and the fragment injected:

try_compile HAVE_CXX_RT=[1] (was [] without the probe fix)
portable_lib NEEDED libstdc++: 0 | guard: PASS
data_loader NEEDED libstdc++: 0 | guard: PASS

The widened check was verified both ways. A healthy object passes and loads; one linked without the runtime fails the check and fails to load with undefined symbol: _ZTISt9exception. The narrow _M_addref test passed that same broken object.

The next thing behind it, fixed here too

With the build green, executorch-runtime-test ran for the first time and failed further along, in the standalone C++ reference runner:

op__device_copy.cpp] Check failed (allocator != nullptr):
_h2d_copy: no device allocator registered for device_type=1
method.cpp] KernelCall failed at instruction 0:0 in et_copy::_h2d_copy.out: 0x20

A Torch-TensorRT export marks its delegate inputs and outputs as CUDA memory, so ExecuTorch places device copies around the delegate. Those kernels ask for the allocator registered for the CUDA device type, and in ExecuTorch 1.4.1 exactly one thing registers it, a static initializer in backends/cuda/runtime/cuda_backend.cpp. The runner was built without that backend, so it loaded the program, initialized the TensorRT engine, and stopped on the first instruction.

The last commit turns on EXECUTORCH_BUILD_CUDA for the example, plus the EXECUTORCH_BUILD_EXTENSION_TENSOR companion ExecuTorch's preset checker requires. Linking needs no change: the runner already links executorch::backends, and backends/cuda/CMakeLists.txt calls executorch_target_link_options_shared_lib(aoti_cuda_backend) so the static initializer is not dropped. That matters, measured on this toolchain:

linkresult
g++ main.o libbackend.ainitializer dropped
gold, --gc-sectionsinitializer dropped
gold, --gc-sections, --whole-archiveinitializer ran

Registering our own allocator instead is not an option: DeviceAllocatorRegistry::register_allocator aborts on a second registration for the same device type, which would break any process that also loads ExecuTorch's CUDA backend.

It is in this PR rather than its own because it cannot be tested anywhere else. Until the wheel build succeeds, the job that runs the reference runner never starts, so a separate PR for it could not be green or even exercised.

For reviewers: the CUDA toolkit is now required to build that example, and the build is larger. It adds no libtorch dependency, which verify-executorch-reference-runner.sh already asserts.

The ExecuTorch runtime wheel build fails on main. Every row of the matrix stops
at the same place:
[86%] Linking CXX shared library _portable_lib.so
checking _portable_lib.so links the C++ runtime statically
FATAL: _portable_lib.so has a dynamic libstdc++ dependency
The wheel is meant to carry its own copy of the C++ runtime, because the machine
that builds it and the machine that runs it do not necessarily ship the same
libstdc++. The build asks for that with -static-libstdc++ and then checks the
result. The check is right and the request is not being honoured.
What decides the outcome is where an extra -lstdc++ sits on the link line.
Measured in the release image, on a shared object that uses std::string and
throws:
-static-libstdc++ -static-libgcc no libstdc++ NEEDED
... plus -lstdc++ before the object files no libstdc++ NEEDED
... plus -lstdc++ after the object files libstdc++.so.6 NEEDED
Before the objects, nothing is undefined yet, so --as-needed drops the library.
After them the libstdc++ symbols are still unresolved, because -static-libstdc++
makes the compiler driver append the static archive at the very end, so
--as-needed keeps it and the dependency stays in the binary.
CMake puts CMAKE_MODULE_LINKER_FLAGS and CMAKE_SHARED_LINKER_FLAGS before the
object files, and CMAKE_CXX_STANDARD_LIBRARIES after them. The previous code
removed the injected -lstdc++ from the two flags variables only, which are the
positions where it was already harmless, and left the one position where it is
not. This removes it from the standard-libraries variables as well.
The match is now a regular expression instead of one exact string, so a change
in spacing cannot make it silently stop matching, and the resolved value of each
variable is printed at configure time.
The check itself moves out of an inline shell string into
check_static_cxx_runtime.sh. It now prints the NEEDED entries and the link
command when it trips. The previous message named the problem but not the input
that caused it, and the build logs do not print link lines, so a failure gave a
reader nothing to work from.
Tested in the release container image (manylinux2_28-builder, gcc 13.3.1):
* Reproduced the three link-order cases in the table above.
* With an -lstdc++ injected through CMAKE_CXX_STANDARD_LIBRARIES, a shared
library keeps libstdc++.so.6 and the check script reports FAIL. With this
change applied to the same project, the dependency is gone and the script
reports PASS. Confirmed again with extra spaces inside the injected fragment.
* Exercised check_static_cxx_runtime.sh against a clean object (exit 0), an
object with the dependency (exit 1, prints the NEEDED entries and the link
command), a missing file, and a broken readelf. The last two exit 1 rather
than passing quietly.
Torch-TensorRT Github Bot added 3 commits August 19, 2026 18:37
First attempt removed the injected -lstdc++ from the right variables in the wrong
place, and CI showed exactly that. The new diagnostics printed the resolved
variable with the fragment gone, and the generated link command right beside it
with the fragment still present:
CMAKE_SHARED_LINKER_FLAGS = [... -Wl,--gc-sections -Wl,--push-state,-as-needed -lm ...]
link command = [... -Wl,--gc-sections -Wl,--push-state,-as-needed -lstdc++ ...]
The extensions are created inside ExecuTorch's directory by add_subdirectory, and
they take their copy of these variables at that point. The removal ran further
down the file, so it edited a value nothing reads. Confirmed by stripping before
and after add_subdirectory in a small project and reading the subdirectory
target's own link.txt: before, the fragment is gone from the link line; after, it
is still there.
The earlier note that the fragment is harmless where it sits was also wrong, and
this corrects it. It arrives wrapped in --as-needed ahead of the object files,
which the default linker does drop. This build passes -fuse-ld=gold, and gold
keeps it regardless. Measured in the release image on a shared object that uses
std::string and throws, every case with -static-libstdc++ -static-libgcc:
bfd, -lstdc++ present -> no libstdc++ NEEDED
gold, -lstdc++ present -> libstdc++.so.6 NEEDED
gold+lto, -lstdc++ present -> libstdc++.so.6 NEEDED
gold+lto, -lstdc++ removed -> no libstdc++ NEEDED
So the fragment has to go wherever it sits, and it has to go before anything that
links is defined.
The pre-project() attempt on the *_INIT variables is removed. The generated
toolchain file re-sets those while project() runs, so it never took effect, and
keeping a second mechanism that does nothing only makes the working one harder to
find.
Tested in the release container image (manylinux2_28-builder, gcc 13.3.1):
* Reproduced all four linker cases in the table above.
* Stripped before and after add_subdirectory and read the subdirectory target's
link.txt: -lstdc++ present after, absent before.
* Exercised check_static_cxx_runtime.sh against a clean object, an object with
the dependency, a missing file and a broken readelf. The last two exit non-zero
rather than passing quietly.
Removing the injected -lstdc++ fixed the dynamic dependency and then failed the
other half of the same check:
FATAL: _portable_lib.so has an undefined exception_ptr::_M_addref
The reason is the driver. The Bazel toolchain hands CMake gcc as
CMAKE_CXX_COMPILER, and gcc links no C++ runtime at all, so -static-libstdc++ is
silently a no-op for it. That injected -lstdc++ was the only thing supplying
libstdc++ on the whole link line. Taking it away left the runtime missing.
Every earlier measurement here used g++ and so never showed this, which is why
the first two attempts looked right and were not.
The fix is to put the static archive where the dynamic library used to be, except
that position matters for an archive in a way it does not for a shared library.
An archive only pulls the members that resolve symbols already undefined when it
is scanned, so ahead of the object files it contributes nothing at all. CMake
appends CMAKE_CXX_STANDARD_LIBRARIES after the objects, which is the position
that works, and is the same placement rules_foreign_cc documents for this exact
problem.
Measured in the release image with the build's own flags, meaning the gcc driver,
-fuse-ld=gold, -flto=auto and --gc-sections, on a shared object that stores a
std::exception_ptr:
UND _M_addref libstdc++ NEEDED
dynamic -lstdc++ before objects 2 1
nothing at all 2 0
-l:libstdc++.a before objects 2 0
-l:libstdc++.a after objects 0 0
Only the last satisfies both halves of the check. It is named with -l: rather than
whole-archived, because forcing every member in is what previously collided with
libstdc++_nonshared.a, and nothing pulls that archive in now: the libstdc++.so
linker script that used to reference it is no longer on the link line.
Tested in the release container image (manylinux2_28-builder, gcc 13.3.1), on a
project shaped like this one, with the target defined in a subdirectory, the C
driver, gold and LTO, and the toolchain fragment injected:
without this change UND _M_addref: 2 | libstdc++ NEEDED: 1 | check FAIL
with this change UND _M_addref: 0 | libstdc++ NEEDED: 0 | check PASS
Follow-up on review of the three commits before it. The mechanism was right; the
text around it was left describing the diagnosis those commits abandoned, and the
check had a gap.
The comment block above the link settings asserted the opposite of the code, three
separate ways: that LINKER_LANGUAGE CXX is what makes -static-libstdc++ take
effect, that the driver flags alone are sufficient, and that nothing names
libstdc++.a on the link line. All three were true of a C++ driver and are false
here, where CMAKE_CXX_COMPILER is gcc. The last one is the dangerous one: acting
on it means deleting the line that supplies the runtime, which restores the
failure. Rewritten to say what the code does.
The check looked for exception_ptr::_M_addref alone. That symbol is emitted only
when something copies an exception_ptr, so an artifact that never does passed
while being unloadable. Measured here, comparing a shared object linked with the
static archive against the same one linked without it:
pattern healthy unloadable
UND .*_M_addref 0 0
UND .*(_ZSt|_ZNSt|__cxa_|__gxx_personality) 6 46
UND .*(_ZS|_ZN|_ZT|__gxx_personality) 0 40
The middle row was the suggested replacement and cannot be used: __cxa_atexit and
__cxa_finalize come from glibc and are undefined in a healthy artifact. The last
row is what the check now uses. Verified both ways: the healthy object passes and
loads, the other fails the check and fails to load with "undefined symbol:
_ZTISt9exception".
Also here:
* try_compile forwards CMAKE_EXE_LINKER_FLAGS but not CMAKE_CXX_STANDARD_LIBRARIES,
so a C++ probe lost the runtime and reported the feature absent instead of
failing. Added to CMAKE_TRY_COMPILE_PLATFORM_VARIABLES. Measured: a
check_cxx_source_compiles on std::string goes from [] back to [1].
* The strip covered five variables. Only three can carry the fragment, and
touching CMAKE_C_STANDARD_LIBRARIES defined an empty normal variable that
shadows the cache entry enable_language(C) creates later.
* The removal had no token boundary, so -lstdc++fs became fs and
-lstdc++_nonshared became _nonshared. It also split the push/pop group when
another library sat inside it, which both linkers reject outright with
"unbalanced --push-state/--pop-state". Now the whole group is matched as a unit
and the bare form separately, with a boundary. All eight cases verified.
* The archive is named by the path the compiler reports, which the file already
probed and then never used, rather than by -l:libstdc++.a. That removes any
question of which copy a -L on the link line resolves to.
* The script header said a failure prints the dynamic section; it prints the
NEEDED entries.
Tested in the release container image (manylinux2_28-builder, gcc 13.3.1), on a
replica with both targets defined in a subdirectory, the gcc driver, gold, LTO and
the toolchain fragment injected:
try_compile HAVE_CXX_RT=[1]
portable_lib NEEDED libstdc++: 0 | guard: PASS
data_loader NEEDED libstdc++: 0 | guard: PASS
…UDA allocator
The reference runner cannot run the programs this project exports. It loads one,
initializes the TensorRT engine, then stops on the very first instruction:
op__device_copy.cpp] Check failed (allocator != nullptr):
_h2d_copy: no device allocator registered for device_type=1
method.cpp] KernelCall failed at instruction 0:0 in et_copy::_h2d_copy.out: 0x20
main.cpp] execute() failed on run 0: 0x20
A Torch-TensorRT export marks its delegate inputs and outputs as CUDA memory, so
ExecuTorch's PropagateDevicePass places et_copy::_h2d_copy and _d2h_copy around
the delegate. Those kernels ask the runtime for the allocator registered for the
CUDA device type. In ExecuTorch 1.4.1 the only thing that registers one is the
CUDA backend, from a static initializer in backends/cuda/runtime/cuda_backend.cpp,
and the runner was built without it.
So the option is not optional here, even for a program that uses only the TensorRT
delegate. The copies are on the default export path, the one where the caller
passes host tensors, which is exactly what this runner does.
Linking is already handled: the runner links executorch::backends, which includes
aoti_cuda_backend once the option is on, and backends/cuda/CMakeLists.txt calls
executorch_target_link_options_shared_lib on it, so the static initializer is not
dropped. That matters, because a static initializer in a plain static library is
dropped when nothing references it. Measured on the release toolchain with the
flags this build uses:
plain g++ main.o libbackend.a initializer dropped
gold, --gc-sections initializer dropped
gold, --gc-sections, --whole-archive initializer ran
EXECUTORCH_BUILD_EXTENSION_TENSOR is the companion ExecuTorch's own preset checker
requires alongside EXECUTORCH_BUILD_CUDA.
This was invisible until now. The ExecuTorch runtime wheel build had not completed
since 2026-08-14, so the job that runs this check never reached the runner.
Note for reviewers: the CUDA toolkit is now required to build the example, and the
build is larger. It does not add a libtorch dependency, which the verification
script already asserts.
@shoumikhinshoumikhin changed the title fix: keep the ExecuTorch runtime wheel off the build host's libstdc++fix: make the ExecuTorch runtime wheel build and its reference runner workAug 20, 2026
Torch-TensorRT Github Bot added 2 commits August 19, 2026 23:37
The previous commit widened the check from exception_ptr::_M_addref to any
undefined mangled C++ symbol. That turned a build that had just passed into a
failure:
FATAL: _portable_lib.so has undefined C++ runtime symbols
--- NEEDED entries ---
libm, libtorch_python, libtorch, libcudart, libnvinfer,
libtorch_cpu, libtorch_cuda, libc10, libpthread, libc, ld-linux
Note what is absent from that list: libstdc++.so.6. The C++ runtime work in the
earlier commits is doing its job. What failed is the check itself.
This extension links libtorch_cpu, libc10 and libtorch_python, so it legitimately
carries undefined _ZN... symbols that resolve from those shared libraries at load
time. The wider pattern cannot tell those apart from a genuinely missing runtime,
so it rejects a good artifact.
The pattern was measured, but on a shared object that linked nothing else, where
every undefined C++ symbol really did mean a missing runtime. That is not the
artifact being checked.
The narrow check is restored, with its own weakness written down: _M_addref
appears only when something copies an exception_ptr, so it misses an artifact
that never does. Doing better needs to know which NEEDED library supplies each
undefined symbol, which is more than a grep, and is not worth blocking this on.
Everything else from the previous commit stays: the corrected comments, the
try_compile fix, the token boundary in the removal, the two dead variables, and
the script header wording.
With the wheel building and the C++ reference runner working, the Python half of
the same test now fails in the same place:
load_model.py:24 outputs = program.forward(x)
runtime.py:62 loaded.execute(inputs)
RuntimeError: method->execute() failed with error 0x20
Same cause as the runner, other side of the fence. A Torch-TensorRT export marks
its delegate inputs and outputs as CUDA memory, so ExecuTorch places device copies
around the delegate, and those copies look up the allocator registered for the
CUDA device type. In ExecuTorch 1.4.1 only the CUDA backend registers one, from a
static initializer in backends/cuda/runtime/cuda_backend.cpp. The wheel's
_portable_lib.so did not have it.
ExecuTorch's own published wheel registers only XnnpackBackend, so before this
there was no Python runtime anywhere that could execute a program this project
exports.
Turning the option on also means shipping what the backend needs at run time. The
build produces two shared libraries that ExecuTorch's wheel does not publish, and
the extensions carry a DT_NEEDED on both:
libextension_cuda.so owns the caller-stream thread-local every CUDA-capable
delegate reads
libaoti_cuda_shims.so the runtime half of the CUDA backend
Both now ship beside the extensions, in the Bazel output list, the CMake install
rule and the wheel build's copy step. data_loader gets the same $ORIGIN search
path _portable_lib already had, so finding them does not depend on which module
Python imports first. The static C++ runtime settings and their check cover every
shipped shared object rather than only the two Python extensions, because one left
on the host libstdc++ hands that dependency back to _portable_lib.so through its
own DT_NEEDED.
The runtime test now asserts CudaBackend is registered, beside the existing
TensorRTBackend and XnnpackBackend assertions, so a wheel that quietly loses it
fails loudly.
Configure-time guards name both libraries, so a rename upstream is a clear error
rather than an install rule that silently drops something the extensions load.

@lanluo-nvidialanluo-nvidia left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for fixing this issue, I was stuck on it for a while.

@lanluo-nvidia
lanluo-nvidia merged commit 6b04279 into pytorch:mainAug 20, 2026
77 of 79 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@shoumikhin@lanluo-nvidia