Skip to content

Commit bf802cb

Browse files
committed
Ship the CUDA delegate in a CUDA-enabled ExecuTorch wheel
## Why this is needed The previous change ships a default Linux wheel with the core runtime as a linkable libexecutorch.so, but no GPU delegate. To actually run a model on an NVIDIA GPU (including a coalesced TensorRT + CUDA .pte), a C++ program also needs the CUDA delegate as a loadable library. This follows the way PyTorch distributes CUDA: the default wheel has the CPU runtime, and a CUDA-specific wheel (built on a CUDA machine / installed from a CUDA index) adds the GPU binaries. Here the CUDA-enabled ExecuTorch wheel ships the CUDA delegate shared libraries alongside the same core runtime, so a C++ consumer can link and load the CUDA backend with no source build. Today the CUDA backend only exists as a static archive that is baked into the Python module (_portable_lib.so). There is no standalone loadable backend a C++ program can use, the way QNN already ships libqnn_executorch_backend.so. This change adds that. ## What is inside Added only when the wheel is built with CUDA (Linux): - executorch/lib/libexecutorch_cuda_backend.so: a loadable CUDA delegate. It wraps the existing static CUDA backend so that loading the library runs its "CudaBackend" registration and registers into the one process-wide runtime in libexecutorch.so. It is libtorch-free. - executorch/lib/libextension_cuda.so: the CUDA caller-stream extension. It is a single shared library on purpose so its per-thread caller-stream state has exactly one instance across the whole process (needed so a TensorRT delegate and the CUDA backend share one stream). - executorch/include/executorch/extension/cuda/*.h: the caller-stream headers. - CMake targets executorch::cuda_backend and executorch::extension_cuda in the shipped executorch-config.cmake, defined only when the libraries are present. The default (CPU) wheel is unchanged: none of these are added unless the wheel is built with CUDA. Windows and macOS wheels are unchanged. ## How to use it ```bash pip install executorch # from a CUDA-enabled index/build ``` ```cmake find_package(executorch CONFIG REQUIRED) add_executable(my_runner main.cpp) # Linking the CUDA backend force-loads it so it registers on startup. target_link_libraries(my_runner PRIVATE executorch::runtime executorch::cuda_backend) ``` ```bash cmake -S . -B build \ -DCMAKE_PREFIX_PATH="$(python -c 'import executorch.utils as u; print(u.cmake_prefix_path)')" cmake --build build ``` At runtime, get_backend_class("CudaBackend") returns the registered backend. ## Test plan Verified on Linux x86_64 with an NVIDIA H100 and CUDA 12.8: - Built the wheel with CUDA enabled and confirmed it contains libexecutorch_cuda_backend.so and libextension_cuda.so under executorch/lib/, the CUDA headers, and the CMake config. - Installed the wheel into a clean virtual environment and built a standalone C++ program via find_package(executorch) linking executorch::cuda_backend. The program compiled, linked, ran, and get_backend_class("CudaBackend") returned non-null, both when the backend was linked directly and when it was dlopen'd. - Confirmed it runs with no LD_LIBRARY_PATH: the shipped libraries resolve each other through an $ORIGIN rpath because they are co-located in executorch/lib/. - Confirmed the program and the CUDA backend link no libtorch/libc10 (via ldd). - Confirmed there is a single libextension_cuda.so that both the backend and the consumer resolve to (the one-instance caller-stream requirement). Root-caused and fixed a load-time abort during development: the CUDA backend static archive already carries a whole-archive link option, so also wrapping it in an explicit whole-archive linked it twice and ran its static registration twice, hitting a duplicate-registration check. The fix links it once. Known follow-ups: a CUDA wheel CI job to exercise this automatically, and running auditwheel on release so the shipped rpath is limited to $ORIGIN. ghstack-source-id: 2098e23 ghstack-comment-id: 5124122149 Pull-Request: #21478
1 parent c2a6b8b commit bf802cb

4 files changed

Lines changed: 200 additions & 0 deletions

File tree

backends/cuda/CMakeLists.txt

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,63 @@ install(
236236
EXPORT ExecuTorchTargets
237237
DESTINATION lib
238238
)
239+
240+
# Loadable CUDA backend shared library for the C++ SDK / pip wheel.
241+
#
242+
# aoti_cuda_backend above is a static archive whose static initializer registers
243+
# the "CudaBackend". Today that archive is only force-linked into
244+
# _portable_lib.so for Python. To let a standalone C++ program (or a coalesced
245+
# TensorRT+CUDA .pte) load the CUDA delegate the same way QNN ships
246+
# libqnn_executorch_backend.so, wrap it in a shared library that whole-archives
247+
# aoti_cuda_backend so the registration constructor is retained and runs when
248+
# the .so is loaded. The backend resolves executorch runtime / extension_cuda
249+
# symbols from the co-shipped libexecutorch.so and libextension_cuda.so at load
250+
# time, so this stays libtorch-free.
251+
#
252+
# Requires EXECUTORCH_BUILD_SHARED: this target links the shared runtime
253+
# (executorch_shared -> libexecutorch.so), which only exists in a shared build.
254+
# Gating here (not just in setup.py) keeps ordinary CUDA source/editable builds
255+
# with the default EXECUTORCH_BUILD_SHARED=OFF working. Not built on MSVC: the
256+
# wheel C++ SDK is Linux only.
257+
if(NOT _cuda_is_msvc_toolchain AND EXECUTORCH_BUILD_SHARED)
258+
add_library(
259+
executorch_cuda_backend SHARED runtime/executorch_cuda_backend_lib.cpp
260+
)
261+
# All dependencies are PRIVATE: this wrapper exists only to bundle the
262+
# whole-archived static backend into one loadable .so. aoti_cuda_backend
263+
# already carries a whole-archive INTERFACE link option
264+
# (executorch_target_link_options_shared_lib above), so linking it plainly
265+
# force-loads its "CudaBackend" registration into THIS .so once. Keeping it
266+
# PRIVATE means the static archive is not re-exported to consumers of
267+
# executorch_cuda_backend: otherwise a consumer would link the archive again
268+
# and run the registration a second time, hitting the duplicate-registration
269+
# ET_CHECK. Linking executorch_shared (not static executorch_core) makes the
270+
# runtime symbols resolve from the one process-wide libexecutorch.so at load
271+
# (DT_NEEDED) rather than embedding a second registry.
272+
target_link_libraries(
273+
executorch_cuda_backend PRIVATE aoti_cuda_backend executorch_shared
274+
extension_cuda
275+
)
276+
# Runtime search path for the SHIPPED library. In the wheel this .so lives in
277+
# executorch/lib/ alongside libexecutorch.so and libextension_cuda.so
278+
# ($ORIGIN), while its DT_NEEDED libaoti_cuda_shims.so is shipped in
279+
# executorch/backends/cuda/ ($ORIGIN/../backends/cuda). CUDA runtime libraries
280+
# (libcudart, libcurand) are expected from the environment (system CUDA or the
281+
# torch CUDA wheel), so no absolute toolkit path is baked in.
282+
#
283+
# BUILD_WITH_INSTALL_RPATH makes the built artifact use INSTALL_RPATH directly
284+
# and suppresses CMake's automatic absolute build-tree RPATH entries. This is
285+
# required because the wheel packages the build-tree .so as-is (it does not
286+
# run cmake --install), so without this the shipped .so would carry absolute
287+
# /.../pip-out/... paths and fail to relocate to a user machine.
288+
set_target_properties(
289+
executorch_cuda_backend
290+
PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE INSTALL_RPATH
291+
"$ORIGIN:$ORIGIN/../backends/cuda"
292+
)
293+
install(
294+
TARGETS executorch_cuda_backend
295+
EXPORT ExecuTorchTargets
296+
DESTINATION lib
297+
)
298+
endif()
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
// This translation unit intentionally contains no symbols. The
10+
// executorch_cuda_backend shared library exists solely to bundle the
11+
// whole-archived aoti_cuda_backend static library so its "CudaBackend"
12+
// registration constructor is retained and runs when the .so is loaded. CMake
13+
// requires a SHARED target to have at least one source file.

setup.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,12 @@ def run(self):
884884
"extension/named_data_map/merged_data_map.h",
885885
"extension/memory_allocator/malloc_memory_allocator.h",
886886
"extension/memory_allocator/memory_allocator_utils.h",
887+
# CUDA caller-stream extension headers. Harmless on a CPU
888+
# wheel (headers only); the matching libextension_cuda.so and
889+
# the executorch::extension_cuda / executorch::cuda_backend
890+
# CMake targets are shipped/defined only in a CUDA wheel.
891+
"extension/cuda/caller_stream.h",
892+
"extension/cuda/export.h",
887893
]
888894
if sys.platform == "linux"
889895
else []
@@ -1130,6 +1136,11 @@ def run(self): # noqa C901
11301136
if cmake_cache.is_enabled("EXECUTORCH_BUILD_CUDA"):
11311137
cmake_build_args += ["--target", "aoti_cuda_backend"]
11321138
cmake_build_args += ["--target", "aoti_common_shims_slim"]
1139+
# Loadable CUDA delegate + caller-stream shared libs for the C++
1140+
# SDK, built only for a wheel that also builds the shared runtime.
1141+
if cmake_cache.is_enabled("EXECUTORCH_BUILD_SHARED"):
1142+
cmake_build_args += ["--target", "executorch_cuda_backend"]
1143+
cmake_build_args += ["--target", "extension_cuda"]
11331144

11341145
if cmake_cache.is_enabled("EXECUTORCH_BUILD_EXTENSION_MODULE"):
11351146
cmake_build_args += ["--target", "extension_module"]
@@ -1313,6 +1324,45 @@ def run(self): # noqa C901
13131324
if sys.platform == "linux"
13141325
else []
13151326
),
1327+
# CUDA delegate binaries for the C++ SDK, shipped only in a
1328+
# CUDA-enabled wheel (PyTorch-style: the default wheel has the core
1329+
# runtime, the CUDA-index wheel adds these). Co-located with
1330+
# libexecutorch.so in executorch/lib/ so a C++ consumer, or a
1331+
# coalesced TensorRT+CUDA .pte, can load and register the CUDA
1332+
# delegate. executorch_cuda_backend whole-archives the backend so
1333+
# its "CudaBackend" registration runs on load; extension_cuda
1334+
# carries the single process-wide caller-stream TLS. Both are
1335+
# unversioned .so, so a plain dynamic-lib copy is enough. Gated on
1336+
# BUILD_SHARED as well as BUILD_CUDA: executorch_cuda_backend links
1337+
# the shared runtime and is only defined/built when SHARED is on,
1338+
# so packaging must require the same to avoid referencing an
1339+
# unbuilt artifact.
1340+
*(
1341+
[
1342+
BuiltFile(
1343+
src_dir="%CMAKE_CACHE_DIR%/backends/cuda/",
1344+
src_name="executorch_cuda_backend",
1345+
dst="executorch/lib/",
1346+
is_dynamic_lib=True,
1347+
dependent_cmake_flags=[
1348+
"EXECUTORCH_BUILD_CUDA",
1349+
"EXECUTORCH_BUILD_SHARED",
1350+
],
1351+
),
1352+
BuiltFile(
1353+
src_dir="%CMAKE_CACHE_DIR%/extension/cuda/",
1354+
src_name="extension_cuda",
1355+
dst="executorch/lib/",
1356+
is_dynamic_lib=True,
1357+
dependent_cmake_flags=[
1358+
"EXECUTORCH_BUILD_CUDA",
1359+
"EXECUTORCH_BUILD_SHARED",
1360+
],
1361+
),
1362+
]
1363+
if sys.platform == "linux"
1364+
else []
1365+
),
13161366
]
13171367
),
13181368
],

tools/cmake/executorch-wheel-config.cmake

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,4 +175,81 @@ if(_executorch_shared_LIBRARY)
175175
)
176176
endif()
177177
endforeach()
178+
179+
# CUDA delegate targets. Present only in a CUDA-enabled wheel, so each target
180+
# is defined only when its shared library is shipped in executorch/lib/. These
181+
# are real separate shared libraries (not aliases of libexecutorch.so):
182+
# extension_cuda holds the one process-wide caller-stream TLS, and
183+
# cuda_backend whole-archives the CUDA delegate so loading it registers
184+
# "CudaBackend" into the runtime.
185+
find_library(
186+
_executorch_extension_cuda_LIBRARY
187+
NAMES extension_cuda
188+
PATHS "${_executorch_sdk_libdir}"
189+
NO_DEFAULT_PATH
190+
)
191+
if(_executorch_extension_cuda_LIBRARY AND NOT TARGET
192+
executorch::extension_cuda
193+
)
194+
# caller_stream.h includes <cuda_runtime.h> and the real target links
195+
# CUDA::cudart PUBLIC, so reproduce that usage requirement. Use a QUIET,
196+
# non-REQUIRED lookup: a consumer that only wants executorch::runtime from a
197+
# CUDA-built wheel must still be able to find_package(executorch) on a
198+
# machine that has the CUDA runtime but no development toolkit. If the
199+
# toolkit is absent we simply skip defining the optional CUDA targets rather
200+
# than failing the whole package.
201+
find_package(CUDAToolkit QUIET)
202+
if(CUDAToolkit_FOUND)
203+
add_library(executorch::extension_cuda SHARED IMPORTED)
204+
set_target_properties(
205+
executorch::extension_cuda
206+
PROPERTIES IMPORTED_LOCATION "${_executorch_extension_cuda_LIBRARY}"
207+
INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}"
208+
INTERFACE_COMPILE_FEATURES cxx_std_17
209+
INTERFACE_LINK_LIBRARIES CUDA::cudart
210+
)
211+
endif()
212+
endif()
213+
214+
find_library(
215+
_executorch_cuda_backend_LIBRARY
216+
NAMES executorch_cuda_backend
217+
PATHS "${_executorch_sdk_libdir}"
218+
NO_DEFAULT_PATH
219+
)
220+
if(_executorch_cuda_backend_LIBRARY
221+
AND TARGET executorch::extension_cuda
222+
AND NOT TARGET executorch::cuda_backend
223+
)
224+
# Requires executorch::extension_cuda (only defined when the CUDA toolkit
225+
# was found above), since the backend links it. If the toolkit is
226+
# unavailable the optional CUDA targets are simply not defined.
227+
add_library(executorch::cuda_backend SHARED IMPORTED)
228+
set_target_properties(
229+
executorch::cuda_backend
230+
PROPERTIES IMPORTED_LOCATION "${_executorch_cuda_backend_LIBRARY}"
231+
INTERFACE_INCLUDE_DIRECTORIES "${EXECUTORCH_INCLUDE_DIRS}"
232+
INTERFACE_COMPILE_FEATURES cxx_std_17
233+
)
234+
set_property(
235+
TARGET executorch::cuda_backend
236+
APPEND
237+
PROPERTY INTERFACE_LINK_LIBRARIES executorch::runtime
238+
executorch::extension_cuda
239+
)
240+
# This library is loaded purely for its side effect: its static initializer
241+
# registers "CudaBackend". A consumer references no symbol from it, so under
242+
# -Wl,--as-needed (or -lexecutorch_cuda_backend) the linker would drop it
243+
# from DT_NEEDED and the registration would never run. Force it to stay
244+
# linked with --no-as-needed around this one library on ELF toolchains.
245+
if(NOT APPLE AND NOT WIN32)
246+
set_property(
247+
TARGET executorch::cuda_backend
248+
APPEND
249+
PROPERTY
250+
INTERFACE_LINK_OPTIONS
251+
"SHELL:-Wl,--no-as-needed,${_executorch_cuda_backend_LIBRARY},--as-needed"
252+
)
253+
endif()
254+
endif()
178255
endif()

0 commit comments

Comments
 (0)