Skip to content

Commit 2c4e238

Browse files
authored
fix(executorch): share the C++ runtime instead of shipping a second copy (#4592)
1 parent c124496 commit 2c4e238

3 files changed

Lines changed: 110 additions & 143 deletions

File tree

py/torch-tensorrt-executorch-runtime/native/CMakeLists.txt

Lines changed: 40 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,9 @@ set(EXECUTORCH_BUILD_TESTS OFF CACHE BOOL "" FORCE)
8282
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
8383

8484
# rules_foreign_cc puts an explicit dynamic -lstdc++ in the Bazel toolchain's linker
85-
# flags. Retaining it makes the wheel depend on the build host's CXXABI version, so
86-
# remove it before anything that links is defined.
85+
# flags, ahead of the object files and wrapped in --as-needed, where it resolves
86+
# nothing. Remove it before anything that links is defined, and put a working one back
87+
# below.
8788
#
8889
# Two things have to be right, and each was got wrong before.
8990
#
@@ -95,7 +96,7 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON)
9596
# WHY IT IS NOT HARMLESS WHERE IT SITS. It arrives wrapped in --as-needed, before the
9697
# object files, which with the default linker means it gets dropped. This build passes
9798
# -fuse-ld=gold, and gold keeps it anyway. Measured in the release image on a shared
98-
# object using std::string and exceptions, all with -static-libstdc++ -static-libgcc:
99+
# object using std::string and exceptions:
99100
#
100101
# bfd, -lstdc++ present -> no libstdc++ NEEDED
101102
# gold, -lstdc++ present -> libstdc++.so.6 NEEDED
@@ -128,46 +129,31 @@ foreach(_torch_tensorrt_linker_flags
128129
"[${${_torch_tensorrt_linker_flags}}]")
129130
endforeach()
130131

131-
# Removing the dynamic -lstdc++ is only half of it: something still has to supply the
132-
# C++ runtime. The Bazel toolchain hands CMake the C driver, gcc, as CMAKE_CXX_COMPILER,
133-
# and gcc links no C++ runtime at all. -static-libstdc++ is silently a no-op for it,
134-
# which is exactly why the toolchain injected an explicit -lstdc++ in the first place.
132+
# Removing that fragment is only half of it: something still has to supply the C++
133+
# runtime. The Bazel toolchain hands CMake the C driver, gcc, as CMAKE_CXX_COMPILER,
134+
# and gcc links no C++ runtime at all, which is exactly why the toolchain injected an
135+
# explicit -lstdc++ in the first place.
135136
#
136-
# So put the static archive in its place, and put it where an archive can actually do
137-
# something. An archive only pulls the members that resolve symbols already undefined
138-
# when it is scanned, so ahead of the object files it contributes nothing. CMake appends
139-
# CMAKE_CXX_STANDARD_LIBRARIES after the objects, which is the position that works, and
140-
# is the same placement rules_foreign_cc documents for exactly this problem.
137+
# So put one back, after the object files. CMake appends CMAKE_CXX_STANDARD_LIBRARIES
138+
# there, which is the position that works, and is the same placement rules_foreign_cc
139+
# documents for exactly this problem.
141140
#
142-
# Measured in the release image with the build's own flags (gcc driver, -fuse-ld=gold,
143-
# -flto=auto, --gc-sections) on a shared object that stores a std::exception_ptr:
141+
# Measured on a shared object that stores a std::exception_ptr and formats through an
142+
# ostringstream:
144143
#
145-
# UND _M_addref libstdc++ NEEDED
146-
# dynamic -lstdc++ before objects 2 1
147-
# nothing at all 2 0
148-
# the static archive before objects 2 0
149-
# the static archive after objects 0 0
144+
# UND _M_addref libstdc++ NEEDED
145+
# the toolchain's --as-needed group, pre-objects 0 0
146+
# nothing at all 0 0
147+
# -lstdc++ after the objects 0 1
150148
#
151-
# Only the last satisfies both halves of the check at the end of this file. Named with
152-
# -l: rather than whole-archived: forcing every member in is what previously collided
153-
# with libstdc++_nonshared.a, and nothing pulls that archive in now because the
154-
# libstdc++.so linker script that used to reference it is no longer on the link line.
155-
execute_process(
156-
COMMAND "${CMAKE_CXX_COMPILER}" -print-file-name=libstdc++.a
157-
OUTPUT_VARIABLE TORCH_TENSORRT_STATIC_LIBSTDCXX
158-
OUTPUT_STRIP_TRAILING_WHITESPACE)
159-
execute_process(
160-
COMMAND "${CMAKE_CXX_COMPILER}" -print-libgcc-file-name
161-
OUTPUT_VARIABLE TORCH_TENSORRT_STATIC_LIBGCC
162-
OUTPUT_STRIP_TRAILING_WHITESPACE)
163-
if(NOT EXISTS "${TORCH_TENSORRT_STATIC_LIBSTDCXX}" OR
164-
NOT EXISTS "${TORCH_TENSORRT_STATIC_LIBGCC}")
165-
message(FATAL_ERROR "A static C++ runtime is required to build the ExecuTorch runtime wheel")
166-
endif()
167-
# Named by the path the driver reports rather than as -l:libstdc++.a, so the archive
168-
# that gets linked is the one just checked for existence and not whichever copy a -L on
169-
# the link line happens to shadow it with.
170-
string(APPEND CMAKE_CXX_STANDARD_LIBRARIES " \"${TORCH_TENSORRT_STATIC_LIBSTDCXX}\"")
149+
# It has to be the dynamic library and not libstdc++.a. These artifacts share a C++ ABI
150+
# with libtorch_cpu.so, libc10.so, libtorch_python.so and libnvinfer.so.11, every one of
151+
# which already carries DT_NEEDED libstdc++.so.6 and needs no more than GLIBCXX_3.4.22.
152+
# A second static copy cannot be made safe in that company. Left visible it interposes
153+
# the real runtime for anything loaded alongside it, and hidden it gives this wheel
154+
# private __cxxabiv1 typeinfo and a private unwinder, so exceptions stop crossing the
155+
# boundary and dynamic_cast starts returning null.
156+
string(APPEND CMAKE_CXX_STANDARD_LIBRARIES " -lstdc++")
171157
# try_compile forwards CMAKE_EXE_LINKER_FLAGS but not CMAKE_CXX_STANDARD_LIBRARIES, so
172158
# without this a C++ probe loses the runtime the strip above removed and reports the
173159
# feature absent instead of failing.
@@ -236,29 +222,14 @@ target_link_libraries(portable_lib PRIVATE
236222
extension_threadpool
237223
CUDA::cudart TensorRT::nvinfer Threads::Threads)
238224

239-
# The build and validation containers do not necessarily provide the same libstdc++,
240-
# so both Python extensions carry their own copy. What supplies it is the static archive
241-
# appended to CMAKE_CXX_STANDARD_LIBRARIES near the top of this file, not the driver
242-
# flags below: CMAKE_CXX_COMPILER here is gcc, the C driver, which links no C++ runtime
243-
# and treats -static-libstdc++ as a no-op.
244-
#
245-
# The driver flags are still set, because they are what a C++ driver would need and the
246-
# toolchain is not ours to depend on. LINKER_LANGUAGE CXX is still set, because it is
247-
# what selects CMAKE_CXX_COMPILER and the CXX standard libraries for these targets.
248-
# Neither is sufficient on its own here.
249-
#
250-
# libstdc++.a is named, but not whole-archived. Forcing every member in is what
251-
# collided with libstdc++_nonshared.a before: on a Red Hat gcc-toolset, libstdc++.so is
252-
# a text linker script reading
225+
# What supplies the C++ runtime is the -lstdc++ appended to
226+
# CMAKE_CXX_STANDARD_LIBRARIES near the top of this file. LINKER_LANGUAGE CXX is what
227+
# selects CMAKE_CXX_COMPILER and those standard libraries for these targets, so it has
228+
# to be set on every one of them.
253229
#
254-
# INPUT ( /usr/lib64/libstdc++.so.6 -lstdc++_nonshared )
255-
#
256-
# so that archive used to arrive as an independent input and every forced member
257-
# collided. Nothing reaches it now, because that linker script is no longer on the link
258-
# line at all.
259230
# The CUDA backend brings two shared libraries of its own. They get the same treatment,
260-
# because one left on the host libstdc++ hands that dependency straight back to
261-
# _portable_lib.so through its own DT_NEEDED.
231+
# because libaoti_cuda_shims.so is the first DT_NEEDED of _portable_lib.so and so leads
232+
# the whole dlopen group in symbol search order.
262233
set(_torch_tensorrt_executorch_runtime_targets portable_lib data_loader)
263234
foreach(_torch_tensorrt_cuda_shared_target extension_cuda aoti_cuda_shims)
264235
get_target_property(_torch_tensorrt_cuda_shared_type
@@ -275,32 +246,8 @@ endforeach()
275246

276247
foreach(_torch_tensorrt_executorch_runtime_target
277248
IN LISTS _torch_tensorrt_executorch_runtime_targets)
278-
# Keep the C++ runtime self-contained. The compiler driver otherwise
279-
# appends a dynamic -lstdc++ even when libstdc++.a is a direct link input,
280-
# leaving CXXABI-versioned exception_ptr symbols for the host runtime.
281249
set_property(TARGET ${_torch_tensorrt_executorch_runtime_target}
282250
PROPERTY LINKER_LANGUAGE CXX)
283-
# Do not add libstdc++.a here, and in particular do not whole-archive it. The
284-
# driver flags below already select the static runtime, and on a Red Hat
285-
# gcc-toolset the toolchain adds a second archive of its own: libstdc++.so is a
286-
# text linker script, not an ELF object, and it reads
287-
#
288-
# INPUT ( /usr/lib64/libstdc++.so.6 -lstdc++_nonshared )
289-
#
290-
# so libstdc++_nonshared.a arrives as an independent input carrying the
291-
# newer-ABI symbols the base libstdc++.so.6 lacks. That archive is a strict
292-
# subset of libstdc++.a: measured in the release image with
293-
# `nm --defined-only -g`, counting T/W/B/D/R, 1003 definitions and none unique to
294-
# it. Counting all global types gives 1141 instead; "none unique" holds either
295-
# way, which is the part that matters. Whole-archiving libstdc++.a
296-
# forces every member in whether referenced or not, so all 1003 collide and the
297-
# link fails on whichever the linker reaches first.
298-
target_link_libraries(${_torch_tensorrt_executorch_runtime_target} PRIVATE
299-
"${TORCH_TENSORRT_STATIC_LIBGCC}")
300-
target_link_options(${_torch_tensorrt_executorch_runtime_target} PRIVATE
301-
-static-libstdc++
302-
-static-libgcc)
303-
304251
endforeach()
305252

306253
# The runtime wheel intentionally does not bundle PyTorch, TensorRT, or CUDA.
@@ -337,9 +284,9 @@ install(TARGETS extension_cuda aoti_cuda_shims LIBRARY DESTINATION lib)
337284
add_custom_target(torch_tensorrt_executorch_portable_lib ALL
338285
DEPENDS ${_torch_tensorrt_executorch_runtime_targets})
339286

340-
# Guard the two properties the removed whole-archive used to guarantee, on the real
341-
# artifacts: no dynamic dependency on the build host's libstdc++, and no undefined
342-
# exception_ptr::_M_addref. This wheel has no auditwheel step behind it.
287+
# Guard both halves of the C++ runtime contract on the real artifacts, because this
288+
# wheel has no auditwheel step behind it: nothing here may define libstdc++'s own
289+
# symbols, and anything referencing the runtime must declare it.
343290
#
344291
# ALL on the aggregate target above matters: a custom target without it is excluded
345292
# from the default build, and the default target is what the wheel build runs. The
@@ -348,22 +295,22 @@ add_custom_target(torch_tensorrt_executorch_portable_lib ALL
348295
# directory and those two come from ExecuTorch's subdirectory.
349296
#
350297
# The check lives in a script rather than an inline shell string so it can report what
351-
# it saw. A bare "has a dynamic libstdc++ dependency" does not say which input added
352-
# it, and the build logs do not print link lines, so the script prints both.
298+
# it saw. A bare "defines libstdc++ symbols" does not say which input added them, and
299+
# the build logs do not print link lines, so the script prints both.
353300
find_program(TORCH_TENSORRT_READELF NAMES readelf llvm-readelf)
354301
if(NOT TORCH_TENSORRT_READELF AND CMAKE_SYSTEM_NAME STREQUAL "Linux")
355302
message(FATAL_ERROR
356-
"readelf is required to verify the Python extensions link the C++ runtime "
357-
"statically. Install binutils, or set TORCH_TENSORRT_READELF to a readelf.")
303+
"readelf is required to verify the Python extensions share the C++ runtime. "
304+
"Install binutils, or set TORCH_TENSORRT_READELF to a readelf.")
358305
endif()
359306
if(TORCH_TENSORRT_READELF)
360307
foreach(_torch_tensorrt_checked_target
361308
IN LISTS _torch_tensorrt_executorch_runtime_targets)
362309
add_custom_command(TARGET torch_tensorrt_executorch_portable_lib POST_BUILD
363310
COMMAND "${CMAKE_COMMAND}" -E echo
364-
"checking $<TARGET_FILE_NAME:${_torch_tensorrt_checked_target}> links the C++ runtime statically"
311+
"checking $<TARGET_FILE_NAME:${_torch_tensorrt_checked_target}> shares the C++ runtime"
365312
COMMAND sh
366-
"${CMAKE_CURRENT_LIST_DIR}/check_static_cxx_runtime.sh"
313+
"${CMAKE_CURRENT_LIST_DIR}/check_shared_cxx_runtime.sh"
367314
"${TORCH_TENSORRT_READELF}"
368315
"$<TARGET_FILE:${_torch_tensorrt_checked_target}>"
369316
"$<TARGET_PROPERTY:${_torch_tensorrt_checked_target},BINARY_DIR>/CMakeFiles/${_torch_tensorrt_checked_target}.dir/link.txt"
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/bin/sh
2+
# Verify a shipped shared object shares the process C++ runtime instead of carrying
3+
# one of its own.
4+
#
5+
# These artifacts are loaded into a process that has already loaded libtorch and
6+
# TensorRT, which both bring libstdc++.so.6. A second copy here does not isolate
7+
# anything: libaoti_cuda_shims.so is the first DT_NEEDED of _portable_lib.so, so it
8+
# leads the dlopen group in symbol search order and every object loaded with it
9+
# resolves the C++ runtime against this wheel rather than against libstdc++.so.6.
10+
# Two libstdc++ builds then share one process, and a locale facet built by one gets
11+
# indexed with the other's std::locale::id, which lands a virtual call on the wrong
12+
# slot.
13+
#
14+
# This wheel has no auditwheel step behind it, so nothing else notices. A failure
15+
# prints the NEEDED entries and the link command, because the artifact property on
16+
# its own says nothing about which input produced it.
17+
#
18+
# Usage: check_shared_cxx_runtime.sh <readelf> <shared-object> [link-command-file]
19+
20+
set -u
21+
22+
readelf_bin="$1"
23+
target="$2"
24+
link_txt="${3:-}"
25+
26+
fail() {
27+
echo "FATAL: $*" >&2
28+
echo "--- NEEDED entries of ${target} ---" >&2
29+
"${readelf_bin}" -d "${target}" 2>&1 | grep NEEDED >&2 ||
30+
echo "(none, or readelf could not read it)" >&2
31+
if [ -n "${link_txt}" ] && [ -f "${link_txt}" ]; then
32+
echo "--- link command ---" >&2
33+
cat "${link_txt}" >&2
34+
else
35+
echo "--- link command unavailable (${link_txt:-no path given}) ---" >&2
36+
fi
37+
exit 1
38+
}
39+
40+
dyn=$("${readelf_bin}" -d "${target}") ||
41+
fail "could not inspect ${target} with ${readelf_bin}"
42+
syms=$("${readelf_bin}" -WsD "${target}") ||
43+
fail "could not read dynamic symbols of ${target}"
44+
45+
# Defined, not undefined, and only symbols that libstdc++ alone implements. A plain
46+
# _ZNSt or _ZSt prefix would reject a good artifact: every C++ shared object exports
47+
# weak instantiations of std:: templates from its own translation units, and those are
48+
# identical wherever they come from. The three families below are not. They are
49+
# emitted only by libstdc++'s own translation units, so a definition here means a
50+
# whole second runtime came in through libstdc++.a.
51+
defined=$(printf %s\\n "${syms}" |
52+
awk '($5 == "GLOBAL" || $5 == "WEAK") && $7 != "UND" { print $8 }' |
53+
sed 's/@.*//' |
54+
grep -E '^(__cxa_(throw|rethrow|begin_catch|end_catch|allocate_exception|free_exception)$|_ZTVN10__cxxabiv1|_ZNS[tK]?6locale)')
55+
if [ -n "${defined}" ]; then
56+
echo "--- libstdc++ symbols defined by ${target} ---" >&2
57+
printf %s\\n "${defined}" | head -20 >&2
58+
fail "${target} defines $(printf %s\\n "${defined}" | wc -l) libstdc++ symbols of its own"
59+
fi
60+
61+
# The other half, and the original failure this guard was written for. Undefined
62+
# runtime symbols are correct and expected once the runtime is shared, but only if
63+
# something declares where they come from. Without the NEEDED entry the extension
64+
# fails to import on a missing exception_ptr::_M_addref.
65+
if printf %s\\n "${syms}" | grep -qE 'UND +(_ZNS[tK]|_ZS[tT]|__cxa_|_ZNKS[tK])' &&
66+
! printf %s "${dyn}" | grep -qE 'NEEDED.*libstdc\+\+'; then
67+
fail "${target} references the C++ runtime but has no libstdc++ NEEDED entry"
68+
fi
69+
70+
exit 0

py/torch-tensorrt-executorch-runtime/native/check_static_cxx_runtime.sh

Lines changed: 0 additions & 50 deletions
This file was deleted.

0 commit comments

Comments
 (0)