Skip to content

Commit 7d9eb71

Browse files
authored
CUDA wheel: check every dependency, and give torch_cpu_library targets torch's compile settings back (#24087)
### Problem #24060 made the Linux CUDA wheel load next to a CPU-only PyTorch by linking `torch_cpu_library` instead of `torch`. Three things were too weak. 1. **The check looked only for `libtorch_cuda.so` and `libc10_cuda.so`.** `libnvrtc.so.13` and other CUDA libraries slipped through. 2. **The relinked targets lost torch's compile settings.** Linking `torch` also brings its include directories as system headers, `TORCH_CXX_FLAGS`, and on recent torch a C++ standard. `torch_cpu_library` misses most of them. So warnings inside torch's headers are reported as ours, and a configured `TORCH_CXX_FLAGS` is dropped. 3. **The comments** at the five link sites did not say why `torch_cpu_library` is used. At `aten_bridge` the reason was wrong. ### Fix - **Allow list in the CUDA wheel smoke test.** Every dependency a shipped library records must be in the wheel, a torch library that a CPU-only torch also has, a system library the manylinux policy allows, or from a package a plain install requires. - **Comments.** Each site now says `torch` records `libtorch_cuda` with a CUDA build of PyTorch, and `torch_cpu_library` does not. `aten_bridge` is now `STATIC`, as its comment assumes. - **Compile settings.** `find_package_torch` copies torch's interface include directories, compile options and compile features to `torch_cpu_library` once, so every target in the build that links `torch_cpu_library` gets them. The CUDA settings a CUDA build of PyTorch adds to `torch` are not copied. If there is no `torch_cpu_library`, as with a static libtorch, nothing is copied. ### Testing - A new CMake test checks that targets linking `torch_cpu_library`, also through a `PUBLIC` link, get the C++ API include directory, `TORCH_CXX_FLAGS` and, when torch sets one (the 2.16 nightly does, 2.14 does not), the C++ standard, also when torch was found first. A second test checks that `find_package_torch` configures when only a `torch` target exists. Both pass on Linux x86-64 with torch 2.14.0 (CPU, CUDA 13.0) and a 2.16 CUDA 13.2 nightly, and on macOS arm64 with 2.14.0 CPU. Each fails without its part of the change. - On Linux x86-64, the new wheel check passes on a CUDA wheel built with #24060, next to a CPU-only torch. It fails on a CUDA 13.2 nightly from before #24060, naming `libnvrtc.so.13` and three more. - With torch 2.14.0+cpu and GCC 11, the six relinked targets build. `op_allclose.cpp` with `-Wall -Wextra -Werror`: 25 errors in torch headers without the change, none with it. - With `BUILD_SHARED_LIBS=ON`, `aten_bridge` is now static and `_training_lib` builds.
1 parent 8916832 commit 7d9eb71

8 files changed

Lines changed: 252 additions & 30 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
import importlib.util
8+
import json
9+
import shlex
10+
import shutil
11+
import subprocess
12+
import sys
13+
import tempfile
14+
import unittest
15+
from pathlib import Path
16+
17+
ROOT = Path(__file__).resolve().parents[3]
18+
19+
20+
def _compile_flags(entry: dict) -> set:
21+
"""The flags of one compile command, without the compiler, source and outputs."""
22+
flags = set()
23+
arguments = iter(shlex.split(entry["command"])[1:])
24+
for argument in arguments:
25+
if argument in ("-o", "-c", "-MT", "-MF"):
26+
next(arguments)
27+
elif argument == "-isystem":
28+
flags.add(f"-isystem {next(arguments)}")
29+
elif argument != "-MD":
30+
flags.add(argument)
31+
return flags
32+
33+
34+
def _copied_from_torch(flag: str) -> bool:
35+
"""Whether a flag comes from what find_package_torch copies from torch.
36+
37+
A CUDA build of torch also gives torch the CUDA toolkit include dir and torch_cuda's
38+
definitions, which torch_cpu_library correctly does not carry.
39+
"""
40+
return (
41+
flag == "-DET_TORCH_CXX_FLAGS_PROBE"
42+
or flag.startswith("-std=")
43+
or (flag.startswith("-isystem ") and flag.endswith("torch/csrc/api/include"))
44+
)
45+
46+
47+
@unittest.skipIf(sys.platform == "win32", "Visual Studio writes no compile commands")
48+
class TestTorchCpuLibraryCMake(unittest.TestCase):
49+
def test_torch_cpu_library_gets_torch_settings(self):
50+
cmake = shutil.which("cmake")
51+
if cmake is None or importlib.util.find_spec("torch") is None:
52+
self.skipTest("CMake and torch are required")
53+
54+
found_first = (
55+
"add_torch_to_cmake_prefix_path()\nfind_package(Torch CONFIG REQUIRED)"
56+
)
57+
for find_torch in ("", found_first):
58+
with self.subTest(found_first=bool(find_torch)):
59+
self._check(cmake, find_torch)
60+
61+
def _check(self, cmake: str, find_torch: str) -> None:
62+
with tempfile.TemporaryDirectory() as directory:
63+
source = Path(directory)
64+
build = source / "build"
65+
(source / "probe.cpp").write_text("int probe() { return 0; }\n")
66+
(source / "CMakeLists.txt").write_text(
67+
f"""cmake_minimum_required(VERSION 3.24)
68+
project(TorchCpuLibraryProbe LANGUAGES CXX)
69+
include("${{EXECUTORCH_ROOT}}/tools/cmake/Utils.cmake")
70+
{find_torch}
71+
find_package_torch()
72+
add_library(links_torch OBJECT probe.cpp)
73+
target_link_libraries(links_torch PRIVATE torch)
74+
add_library(links_torch_cpu_library OBJECT probe.cpp)
75+
target_link_libraries(links_torch_cpu_library PRIVATE torch_cpu_library)
76+
add_library(links_it_publicly STATIC probe.cpp)
77+
target_link_libraries(links_it_publicly PUBLIC torch_cpu_library)
78+
add_library(consumer OBJECT probe.cpp)
79+
target_link_libraries(consumer PRIVATE links_it_publicly)
80+
"""
81+
)
82+
result = subprocess.run(
83+
[
84+
cmake,
85+
"-S",
86+
str(source),
87+
"-B",
88+
str(build),
89+
f"-DEXECUTORCH_ROOT={ROOT}",
90+
f"-DPYTHON_EXECUTABLE={sys.executable}",
91+
"-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
92+
# Recent torch sets no TORCH_CXX_FLAGS, so set one to check it travels too.
93+
"-DTORCH_CXX_FLAGS=-DET_TORCH_CXX_FLAGS_PROBE",
94+
],
95+
capture_output=True,
96+
text=True,
97+
)
98+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
99+
100+
flags = {}
101+
for entry in json.loads((build / "compile_commands.json").read_text()):
102+
target = Path(entry["output"]).parent.name.removesuffix(".dir")
103+
flags[target] = _compile_flags(entry)
104+
expected = {
105+
flag for flag in flags["links_torch"] if _copied_from_torch(flag)
106+
}
107+
self.assertIn("-DET_TORCH_CXX_FLAGS_PROBE", expected)
108+
self.assertTrue(any(flag.startswith("-isystem ") for flag in expected))
109+
for target in ("links_torch_cpu_library", "links_it_publicly", "consumer"):
110+
self.assertEqual(expected - flags[target], set(), target)
111+
112+
def test_find_package_torch_without_torch_cpu_library(self):
113+
"""A parent project or a static libtorch can define torch alone."""
114+
cmake = shutil.which("cmake")
115+
if cmake is None:
116+
self.skipTest("CMake is required")
117+
with tempfile.TemporaryDirectory() as directory:
118+
source = Path(directory)
119+
(source / "CMakeLists.txt").write_text(
120+
"""cmake_minimum_required(VERSION 3.24)
121+
project(TorchWithoutTorchCpuLibrary LANGUAGES NONE)
122+
include("${EXECUTORCH_ROOT}/tools/cmake/Utils.cmake")
123+
add_library(torch INTERFACE)
124+
find_package_torch()
125+
"""
126+
)
127+
result = subprocess.run(
128+
[
129+
cmake,
130+
"-S",
131+
str(source),
132+
"-B",
133+
str(source / "build"),
134+
f"-DEXECUTORCH_ROOT={ROOT}",
135+
],
136+
capture_output=True,
137+
text=True,
138+
)
139+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
140+
141+
142+
if __name__ == "__main__":
143+
unittest.main()

‎.ci/scripts/wheel/test_cuda_linux.py‎

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -143,25 +143,79 @@ def test_cuda_libraries_resolve_relatively() -> None:
143143
print(f"✓ {name} resolves the CUDA runtime relatively ({relative[0]})")
144144

145145

146-
def test_no_library_needs_cuda_torch() -> None:
147-
"""No shipped library may record a dependency on PyTorch's own CUDA libraries.
146+
# The torch libraries a shipped library may link. A CUDA build of torch adds libtorch_cuda,
147+
# libc10_cuda and more, which a CPU-only torch does not have.
148+
_ALLOWED_TORCH_LIBRARIES = frozenset(
149+
{"libtorch.so", "libtorch_cpu.so", "libtorch_python.so", "libc10.so"}
150+
)
151+
152+
# The system libraries the manylinux policy lets a wheel record, and the loader.
153+
# libatomic is one of them; the aarch64 build links it for sentencepiece.
154+
_SYSTEM_LIBRARIES = frozenset(
155+
{
156+
"libatomic.so.1",
157+
"libc.so.6",
158+
"libm.so.6",
159+
"libdl.so.2",
160+
"libpthread.so.0",
161+
"librt.so.1",
162+
"libstdc++.so.6",
163+
"libgcc_s.so.1",
164+
"ld-linux-x86-64.so.2",
165+
"ld-linux-aarch64.so.1",
166+
}
167+
)
168+
148169

149-
The CUDA delegate needs only the CUDA runtime. A library that names libtorch_cuda or
150-
libc10_cuda cannot load next to a CPU-only torch, so the whole wheel then fails to import
151-
there even though nothing in it calls into those libraries.
170+
def _declared_requirement_libraries() -> Set[str]:
171+
"""The shared library file names installed by the packages a plain install requires."""
172+
import importlib.metadata as metadata
173+
174+
from packaging.requirements import Requirement
175+
176+
names: Set[str] = set()
177+
for line in metadata.requires("executorch") or []:
178+
requirement = Requirement(line)
179+
# Skip extras and requirements for other platforms, which a plain install does not get.
180+
if requirement.marker and not requirement.marker.evaluate({"extra": ""}):
181+
continue
182+
try:
183+
files = metadata.files(requirement.name) or []
184+
except metadata.PackageNotFoundError:
185+
continue
186+
names.update(Path(file).name for file in files if ".so" in Path(file).name)
187+
return names
188+
189+
190+
def test_recorded_dependencies_are_allowed() -> None:
191+
"""Every library a shipped library records must be on an allow list.
192+
193+
The list is: shipped in the wheel, a torch library a CPU-only torch also has, the system
194+
C and C++ runtime, or installed by a package a plain install requires. A name outside
195+
it, such as libtorch_cuda or libnvrtc, may be missing next to a CPU-only torch, and
196+
then the library that records it fails to load there.
152197
"""
153198
package_dir = _package_dir()
154-
cuda_torch = {"libtorch_cuda.so", "libc10_cuda.so"}
199+
libraries = test_shared_libraries._shipped_shared_objects(package_dir)
200+
allowed = (
201+
{library.name for library in libraries}
202+
| _ALLOWED_TORCH_LIBRARIES
203+
| _SYSTEM_LIBRARIES
204+
| _declared_requirement_libraries()
205+
)
155206
offenders = {}
156-
for library in test_shared_libraries._shipped_shared_objects(package_dir):
157-
needed = test_shared_libraries._recorded_dependencies(library) & cuda_torch
158-
if needed:
159-
offenders[str(library.relative_to(package_dir))] = sorted(needed)
207+
for library in libraries:
208+
unexpected = test_shared_libraries._recorded_dependencies(library) - allowed
209+
if unexpected:
210+
offenders[str(library.relative_to(package_dir))] = sorted(unexpected)
160211
assert not offenders, (
161-
f"these libraries depend on PyTorch's CUDA libraries, so the wheel cannot be imported "
162-
f"with a CPU-only torch: {offenders}"
212+
f"these libraries record dependencies outside the allow list, so they may fail to "
213+
f"load next to a CPU-only torch: {offenders}"
214+
)
215+
print(
216+
f"✓ every dependency recorded by the {len(libraries)} shipped libraries is on the "
217+
f"allow list"
163218
)
164-
print("✓ no shipped library depends on PyTorch's CUDA libraries")
165219

166220

167221
def _row_architectures() -> list[str]:
@@ -607,7 +661,7 @@ def test_a_model_runs_through_the_delegate() -> None:
607661
test_cuda_libraries_are_shipped()
608662
test_cuda_runtime_is_declared()
609663
test_cuda_libraries_resolve_relatively()
610-
test_no_library_needs_cuda_torch()
664+
test_recorded_dependencies_are_allowed()
611665
test_device_code_covers_the_row()
612666
test_portable_device_code_is_present()
613667
test_the_delegate_registers()

‎CMakeLists.txt‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1439,7 +1439,7 @@ if(EXECUTORCH_BUILD_PYBIND)
14391439
TORCH_PYTHON_LIBRARY torch_python PATHS "${TORCH_INSTALL_PREFIX}/lib"
14401440
)
14411441
add_library(
1442-
aten_bridge
1442+
aten_bridge STATIC
14431443
${CMAKE_CURRENT_SOURCE_DIR}/extension/aten_util/aten_bridge.cpp
14441444
)
14451445
set_target_properties(aten_bridge PROPERTIES CXX_STANDARD 20)
@@ -1450,9 +1450,8 @@ if(EXECUTORCH_BUILD_PYBIND)
14501450
aten_bridge PUBLIC C10_USING_CUSTOM_GENERATED_MACROS
14511451
)
14521452
target_compile_options(aten_bridge PUBLIC ${_pybind_compile_options})
1453-
# torch_cpu_library is the CPU half of torch. Linking torch would also force
1454-
# libtorch_cuda into DT_NEEDED in a CUDA build, and a CPU-only torch could
1455-
# then not load this library.
1453+
# aten_bridge is static, so _training_lib gets this link. torch would record
1454+
# libtorch_cuda with a CUDA build of PyTorch; torch_cpu_library does not.
14561455
if(EXECUTORCH_BUILD_SHARED)
14571456
target_link_libraries(
14581457
aten_bridge PRIVATE torch_cpu_library c10 executorch_shared

‎extension/llm/custom_ops/CMakeLists.txt‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,8 @@ if(EXECUTORCH_BUILD_KERNELS_LLM_AOT)
230230
endif()
231231
executorch_target_link_shared_runtime(custom_ops_aot_lib)
232232

233-
# torch_cpu_library is the CPU half of torch. Linking torch would also force
234-
# libtorch_cuda into DT_NEEDED in a CUDA build, and a CPU-only torch could
235-
# then not load this library.
233+
# torch would record libtorch_cuda with a CUDA build of PyTorch, so a CPU-only
234+
# torch could not load this. torch_cpu_library does not.
236235
target_link_libraries(
237236
custom_ops_aot_lib PUBLIC cpublas torch_cpu_library extension_tensor
238237
extension_threadpool

‎extension/llm/runner/CMakeLists.txt‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,8 @@ if(EXECUTORCH_BUILD_PYBIND)
122122
)
123123
# Link with the extension_llm_runner library and its dependencies.
124124
#
125-
# torch_cpu_library is the CPU half of torch. Linking torch would also force
126-
# libtorch_cuda into DT_NEEDED in a CUDA build, and a CPU-only torch could
127-
# then not load this library.
125+
# torch would record libtorch_cuda with a CUDA build of PyTorch, so a CPU-only
126+
# torch could not load this. torch_cpu_library does not.
128127
target_link_libraries(
129128
_llm_runner PRIVATE extension_llm_runner tokenizers::tokenizers
130129
portable_lib ${TORCH_PYTHON_LIBRARY} torch_cpu_library

‎extension/training/CMakeLists.txt‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,8 @@ if(EXECUTORCH_BUILD_PYBIND)
5353
# is resolved from it rather than from the whole-archive-forcing static
5454
# `executorch` target, so this module shares the one backend registry.
5555
#
56-
# torch_cpu_library is the CPU half of torch. Linking torch would also force
57-
# libtorch_cuda into DT_NEEDED in a CUDA build, and a CPU-only torch could
58-
# then not load this library.
56+
# torch would record libtorch_cuda with a CUDA build of PyTorch, so a CPU-only
57+
# torch could not load this. torch_cpu_library does not.
5958
if(EXECUTORCH_BUILD_SHARED)
6059
set(_pybind_training_dep_libs ${TORCH_PYTHON_LIBRARY} etdump aten_bridge
6160
torch_cpu_library extension_training

‎tools/cmake/Codegen.cmake‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,9 +334,8 @@ function(gen_custom_ops_aot_lib)
334334
set_target_properties(${GEN_LIB_NAME} PROPERTIES CXX_STANDARD 20)
335335
target_compile_definitions(${GEN_LIB_NAME} PRIVATE USE_ATEN_LIB=1)
336336
include_directories(${TORCH_INCLUDE_DIRS})
337-
# torch_cpu_library is the CPU half of torch. Linking torch would also force
338-
# libtorch_cuda into DT_NEEDED in a CUDA build, and a CPU-only torch could
339-
# then not load this library.
337+
# torch would record libtorch_cuda with a CUDA build of PyTorch, so a CPU-only
338+
# torch could not load this. torch_cpu_library does not.
340339
target_link_libraries(${GEN_LIB_NAME} PRIVATE torch_cpu_library)
341340

342341
executorch_target_link_options_shared_lib(${GEN_LIB_NAME})

‎tools/cmake/Utils.cmake‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,36 @@ macro(find_package_torch)
278278
add_torch_to_cmake_prefix_path()
279279
find_package(Torch CONFIG REQUIRED)
280280
endif()
281+
# TorchConfig gives its include dirs, compile flags and C++ standard to torch
282+
# only. Our targets link torch_cpu_library instead, so copy them there once,
283+
# also when the project found torch before calling this. This affects every
284+
# target in the build that links torch_cpu_library.
285+
if(TARGET torch_cpu_library)
286+
get_target_property(
287+
_torch_settings_copied torch_cpu_library EXECUTORCH_TORCH_SETTINGS_COPIED
288+
)
289+
if(NOT _torch_settings_copied)
290+
foreach(_torch_property
291+
INTERFACE_INCLUDE_DIRECTORIES INTERFACE_COMPILE_OPTIONS
292+
INTERFACE_COMPILE_FEATURES
293+
)
294+
get_target_property(_torch_value torch ${_torch_property})
295+
if(_torch_value)
296+
set_property(
297+
TARGET torch_cpu_library
298+
APPEND
299+
PROPERTY ${_torch_property} ${_torch_value}
300+
)
301+
endif()
302+
endforeach()
303+
set_property(
304+
TARGET torch_cpu_library PROPERTY EXECUTORCH_TORCH_SETTINGS_COPIED TRUE
305+
)
306+
endif()
307+
unset(_torch_settings_copied)
308+
unset(_torch_property)
309+
unset(_torch_value)
310+
endif()
281311
endmacro()
282312

283313
# Modify ${targetName}'s INTERFACE_INCLUDE_DIRECTORIES by wrapping each entry in

0 commit comments

Comments
 (0)