From 8f0ae45b90a26f6af654c71e9cd8ae9b5fa21e46 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 22 Oct 2026 11:30:43 -0700 Subject: [PATCH 1/3] Check every dependency of the CUDA wheel, not two names The CUDA wheel smoke test checked that no shipped library records libtorch_cuda.so or libc10_cuda.so. That misses other libraries that only a CUDA build of torch installs. The wheel before the linking fix also recorded libnvrtc.so.13 on the LLM runner module. With the two names removed, the old check passed while that module still failed to import next to a CPU-only torch. Turn it into an allow list. Every recorded dependency must be shipped in the wheel, be one of the torch libraries a CPU-only torch also has, be part of the C and C++ system runtime, or be installed by a package that a plain install of the wheel requires (today that is the CUDA runtime). A requirement behind an extra, or for another platform, is skipped, so an optional package that happens to be installed does not widen the list. The check compares names only. It does not load anything. --- .ci/scripts/wheel/test_cuda_linux.py | 82 +++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/.ci/scripts/wheel/test_cuda_linux.py b/.ci/scripts/wheel/test_cuda_linux.py index a934c501aa5..8743def60c4 100644 --- a/.ci/scripts/wheel/test_cuda_linux.py +++ b/.ci/scripts/wheel/test_cuda_linux.py @@ -143,25 +143,79 @@ def test_cuda_libraries_resolve_relatively() -> None: print(f"✓ {name} resolves the CUDA runtime relatively ({relative[0]})") -def test_no_library_needs_cuda_torch() -> None: - """No shipped library may record a dependency on PyTorch's own CUDA libraries. +# The torch libraries a shipped library may link. A CUDA build of torch adds libtorch_cuda, +# libc10_cuda and more, which a CPU-only torch does not have. +_ALLOWED_TORCH_LIBRARIES = frozenset( + {"libtorch.so", "libtorch_cpu.so", "libtorch_python.so", "libc10.so"} +) + +# The system libraries the manylinux policy lets a wheel record, and the loader. +# libatomic is one of them; the aarch64 build links it for sentencepiece. +_SYSTEM_LIBRARIES = frozenset( + { + "libatomic.so.1", + "libc.so.6", + "libm.so.6", + "libdl.so.2", + "libpthread.so.0", + "librt.so.1", + "libstdc++.so.6", + "libgcc_s.so.1", + "ld-linux-x86-64.so.2", + "ld-linux-aarch64.so.1", + } +) + - The CUDA delegate needs only the CUDA runtime. A library that names libtorch_cuda or - libc10_cuda cannot load next to a CPU-only torch, so the whole wheel then fails to import - there even though nothing in it calls into those libraries. +def _declared_requirement_libraries() -> Set[str]: + """The shared library file names installed by the packages a plain install requires.""" + import importlib.metadata as metadata + + from packaging.requirements import Requirement + + names: Set[str] = set() + for line in metadata.requires("executorch") or []: + requirement = Requirement(line) + # Skip extras and requirements for other platforms, which a plain install does not get. + if requirement.marker and not requirement.marker.evaluate({"extra": ""}): + continue + try: + files = metadata.files(requirement.name) or [] + except metadata.PackageNotFoundError: + continue + names.update(Path(file).name for file in files if ".so" in Path(file).name) + return names + + +def test_recorded_dependencies_are_allowed() -> None: + """Every library a shipped library records must be on an allow list. + + The list is: shipped in the wheel, a torch library a CPU-only torch also has, the system + C and C++ runtime, or installed by a package a plain install requires. A name outside + it, such as libtorch_cuda or libnvrtc, may be missing next to a CPU-only torch, and + then the library that records it fails to load there. """ package_dir = _package_dir() - cuda_torch = {"libtorch_cuda.so", "libc10_cuda.so"} + libraries = test_shared_libraries._shipped_shared_objects(package_dir) + allowed = ( + {library.name for library in libraries} + | _ALLOWED_TORCH_LIBRARIES + | _SYSTEM_LIBRARIES + | _declared_requirement_libraries() + ) offenders = {} - for library in test_shared_libraries._shipped_shared_objects(package_dir): - needed = test_shared_libraries._recorded_dependencies(library) & cuda_torch - if needed: - offenders[str(library.relative_to(package_dir))] = sorted(needed) + for library in libraries: + unexpected = test_shared_libraries._recorded_dependencies(library) - allowed + if unexpected: + offenders[str(library.relative_to(package_dir))] = sorted(unexpected) assert not offenders, ( - f"these libraries depend on PyTorch's CUDA libraries, so the wheel cannot be imported " - f"with a CPU-only torch: {offenders}" + f"these libraries record dependencies outside the allow list, so they may fail to " + f"load next to a CPU-only torch: {offenders}" + ) + print( + f"✓ every dependency recorded by the {len(libraries)} shipped libraries is on the " + f"allow list" ) - print("✓ no shipped library depends on PyTorch's CUDA libraries") def _row_architectures() -> list[str]: @@ -607,7 +661,7 @@ def test_a_model_runs_through_the_delegate() -> None: test_cuda_libraries_are_shipped() test_cuda_runtime_is_declared() test_cuda_libraries_resolve_relatively() - test_no_library_needs_cuda_torch() + test_recorded_dependencies_are_allowed() test_device_code_covers_the_row() test_portable_device_code_is_present() test_the_delegate_registers() From 766d630d66a4485d8e688ed313b77c55e432dc78 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 22 Oct 2026 11:31:09 -0700 Subject: [PATCH 2/3] Fix the torch_cpu_library comments and make aten_bridge static The same three-line comment sat at five link sites. It explained why the line does not link torch, but not what torch_cpu_library does differently, and "in a CUDA build" read as our own CUDA option rather than how PyTorch was built. Each site now says that torch records libtorch_cuda with a CUDA build of PyTorch, so a CPU-only torch could not load the library, and that torch_cpu_library does not. At aten_bridge the old reason did not apply. aten_bridge is a static library and is never loaded on its own. Its link matters because it is passed on to _training_lib. But it was only static by default: with BUILD_SHARED_LIBS=ON it became a shared library. Mark it STATIC so the comment holds in every layout. Nothing installs it and only _training_lib links it. --- CMakeLists.txt | 7 +++---- extension/llm/custom_ops/CMakeLists.txt | 5 ++--- extension/llm/runner/CMakeLists.txt | 5 ++--- extension/training/CMakeLists.txt | 5 ++--- tools/cmake/Codegen.cmake | 5 ++--- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 22b091a20d7..7ae92a457b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1439,7 +1439,7 @@ if(EXECUTORCH_BUILD_PYBIND) TORCH_PYTHON_LIBRARY torch_python PATHS "${TORCH_INSTALL_PREFIX}/lib" ) add_library( - aten_bridge + aten_bridge STATIC ${CMAKE_CURRENT_SOURCE_DIR}/extension/aten_util/aten_bridge.cpp ) set_target_properties(aten_bridge PROPERTIES CXX_STANDARD 20) @@ -1450,9 +1450,8 @@ if(EXECUTORCH_BUILD_PYBIND) aten_bridge PUBLIC C10_USING_CUSTOM_GENERATED_MACROS ) target_compile_options(aten_bridge PUBLIC ${_pybind_compile_options}) - # torch_cpu_library is the CPU half of torch. Linking torch would also force - # libtorch_cuda into DT_NEEDED in a CUDA build, and a CPU-only torch could - # then not load this library. + # aten_bridge is static, so _training_lib gets this link. torch would record + # libtorch_cuda with a CUDA build of PyTorch; torch_cpu_library does not. if(EXECUTORCH_BUILD_SHARED) target_link_libraries( aten_bridge PRIVATE torch_cpu_library c10 executorch_shared diff --git a/extension/llm/custom_ops/CMakeLists.txt b/extension/llm/custom_ops/CMakeLists.txt index 0defe625305..7844b3dbcd0 100644 --- a/extension/llm/custom_ops/CMakeLists.txt +++ b/extension/llm/custom_ops/CMakeLists.txt @@ -230,9 +230,8 @@ if(EXECUTORCH_BUILD_KERNELS_LLM_AOT) endif() executorch_target_link_shared_runtime(custom_ops_aot_lib) - # torch_cpu_library is the CPU half of torch. Linking torch would also force - # libtorch_cuda into DT_NEEDED in a CUDA build, and a CPU-only torch could - # then not load this library. + # torch would record libtorch_cuda with a CUDA build of PyTorch, so a CPU-only + # torch could not load this. torch_cpu_library does not. target_link_libraries( custom_ops_aot_lib PUBLIC cpublas torch_cpu_library extension_tensor extension_threadpool diff --git a/extension/llm/runner/CMakeLists.txt b/extension/llm/runner/CMakeLists.txt index b787491a013..3a0f92cefc2 100644 --- a/extension/llm/runner/CMakeLists.txt +++ b/extension/llm/runner/CMakeLists.txt @@ -122,9 +122,8 @@ if(EXECUTORCH_BUILD_PYBIND) ) # Link with the extension_llm_runner library and its dependencies. # - # torch_cpu_library is the CPU half of torch. Linking torch would also force - # libtorch_cuda into DT_NEEDED in a CUDA build, and a CPU-only torch could - # then not load this library. + # torch would record libtorch_cuda with a CUDA build of PyTorch, so a CPU-only + # torch could not load this. torch_cpu_library does not. target_link_libraries( _llm_runner PRIVATE extension_llm_runner tokenizers::tokenizers portable_lib ${TORCH_PYTHON_LIBRARY} torch_cpu_library diff --git a/extension/training/CMakeLists.txt b/extension/training/CMakeLists.txt index 307c3503b87..bf3d9b6cc7f 100644 --- a/extension/training/CMakeLists.txt +++ b/extension/training/CMakeLists.txt @@ -53,9 +53,8 @@ if(EXECUTORCH_BUILD_PYBIND) # is resolved from it rather than from the whole-archive-forcing static # `executorch` target, so this module shares the one backend registry. # - # torch_cpu_library is the CPU half of torch. Linking torch would also force - # libtorch_cuda into DT_NEEDED in a CUDA build, and a CPU-only torch could - # then not load this library. + # torch would record libtorch_cuda with a CUDA build of PyTorch, so a CPU-only + # torch could not load this. torch_cpu_library does not. if(EXECUTORCH_BUILD_SHARED) set(_pybind_training_dep_libs ${TORCH_PYTHON_LIBRARY} etdump aten_bridge torch_cpu_library extension_training diff --git a/tools/cmake/Codegen.cmake b/tools/cmake/Codegen.cmake index c4997ef6e99..3f1ef2bf2ad 100644 --- a/tools/cmake/Codegen.cmake +++ b/tools/cmake/Codegen.cmake @@ -334,9 +334,8 @@ function(gen_custom_ops_aot_lib) set_target_properties(${GEN_LIB_NAME} PROPERTIES CXX_STANDARD 20) target_compile_definitions(${GEN_LIB_NAME} PRIVATE USE_ATEN_LIB=1) include_directories(${TORCH_INCLUDE_DIRS}) - # torch_cpu_library is the CPU half of torch. Linking torch would also force - # libtorch_cuda into DT_NEEDED in a CUDA build, and a CPU-only torch could - # then not load this library. + # torch would record libtorch_cuda with a CUDA build of PyTorch, so a CPU-only + # torch could not load this. torch_cpu_library does not. target_link_libraries(${GEN_LIB_NAME} PRIVATE torch_cpu_library) executorch_target_link_options_shared_lib(${GEN_LIB_NAME}) From 12e8521a8dc6733c227aa417eaafd19d5341a4b5 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 22 Oct 2026 11:32:10 -0700 Subject: [PATCH 3/3] Give targets that link torch_cpu_library torch's compile settings back Linking the torch target does more than link. TorchConfig also gives it TORCH_INCLUDE_DIRS, which CMake treats as system headers because the target is imported, TORCH_CXX_FLAGS, and on recent torch a minimum C++ standard (C++20). torch_cpu_library, which our targets now link instead so that a CPU-only torch can load them, only passes on torch/include. So those targets changed. torch/csrc/api/include became a normal include directory, so warnings inside torch's headers are now reported as if they were our code. And a TORCH_CXX_FLAGS set at configure time no longer reached them. For example, compiling kernels/portable/cpu/op_allclose.cpp with -Wall -Wextra -Werror went from no errors to 25 errors, all inside torch's headers. Default builds were not affected. find_package_torch now copies torch's interface include directories, compile options and compile features to torch_cpu_library. It does this once, also when the project found torch before calling it. Every target that links torch_cpu_library, directly or through a PUBLIC link such as custom_ops_aot_lib, now gets these settings, and a new link site cannot forget them. With a CUDA build of PyTorch, torch also carries the CUDA include directory and definitions of torch_cuda_library. Those are not copied, since these targets do not use CUDA. Changing torch_cpu_library itself also affects any other target in the build that links it. When there is no torch_cpu_library, for example with a static libtorch or a parent project that defines its own torch target, nothing is copied. A new test configures a small project and checks that a target linking torch_cpu_library, directly or through a PUBLIC link, gets torch's C++ API include directory, TORCH_CXX_FLAGS and, when torch sets one, its C++ standard. It checks both when find_package_torch finds torch and when the project found it first. A second test checks that find_package_torch still configures when only a torch target exists. Both pass with CPU and CUDA builds of torch. The first fails without this change, and the second fails if the copy does not first check that torch_cpu_library exists. --- .../tests/test_torch_cpu_library_cmake.py | 143 ++++++++++++++++++ tools/cmake/Utils.cmake | 30 ++++ 2 files changed, 173 insertions(+) create mode 100644 .ci/scripts/tests/test_torch_cpu_library_cmake.py diff --git a/.ci/scripts/tests/test_torch_cpu_library_cmake.py b/.ci/scripts/tests/test_torch_cpu_library_cmake.py new file mode 100644 index 00000000000..e3885ef8ec2 --- /dev/null +++ b/.ci/scripts/tests/test_torch_cpu_library_cmake.py @@ -0,0 +1,143 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import importlib.util +import json +import shlex +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] + + +def _compile_flags(entry: dict) -> set: + """The flags of one compile command, without the compiler, source and outputs.""" + flags = set() + arguments = iter(shlex.split(entry["command"])[1:]) + for argument in arguments: + if argument in ("-o", "-c", "-MT", "-MF"): + next(arguments) + elif argument == "-isystem": + flags.add(f"-isystem {next(arguments)}") + elif argument != "-MD": + flags.add(argument) + return flags + + +def _copied_from_torch(flag: str) -> bool: + """Whether a flag comes from what find_package_torch copies from torch. + + A CUDA build of torch also gives torch the CUDA toolkit include dir and torch_cuda's + definitions, which torch_cpu_library correctly does not carry. + """ + return ( + flag == "-DET_TORCH_CXX_FLAGS_PROBE" + or flag.startswith("-std=") + or (flag.startswith("-isystem ") and flag.endswith("torch/csrc/api/include")) + ) + + +@unittest.skipIf(sys.platform == "win32", "Visual Studio writes no compile commands") +class TestTorchCpuLibraryCMake(unittest.TestCase): + def test_torch_cpu_library_gets_torch_settings(self): + cmake = shutil.which("cmake") + if cmake is None or importlib.util.find_spec("torch") is None: + self.skipTest("CMake and torch are required") + + found_first = ( + "add_torch_to_cmake_prefix_path()\nfind_package(Torch CONFIG REQUIRED)" + ) + for find_torch in ("", found_first): + with self.subTest(found_first=bool(find_torch)): + self._check(cmake, find_torch) + + def _check(self, cmake: str, find_torch: str) -> None: + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) + build = source / "build" + (source / "probe.cpp").write_text("int probe() { return 0; }\n") + (source / "CMakeLists.txt").write_text( + f"""cmake_minimum_required(VERSION 3.24) +project(TorchCpuLibraryProbe LANGUAGES CXX) +include("${{EXECUTORCH_ROOT}}/tools/cmake/Utils.cmake") +{find_torch} +find_package_torch() +add_library(links_torch OBJECT probe.cpp) +target_link_libraries(links_torch PRIVATE torch) +add_library(links_torch_cpu_library OBJECT probe.cpp) +target_link_libraries(links_torch_cpu_library PRIVATE torch_cpu_library) +add_library(links_it_publicly STATIC probe.cpp) +target_link_libraries(links_it_publicly PUBLIC torch_cpu_library) +add_library(consumer OBJECT probe.cpp) +target_link_libraries(consumer PRIVATE links_it_publicly) +""" + ) + result = subprocess.run( + [ + cmake, + "-S", + str(source), + "-B", + str(build), + f"-DEXECUTORCH_ROOT={ROOT}", + f"-DPYTHON_EXECUTABLE={sys.executable}", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + # Recent torch sets no TORCH_CXX_FLAGS, so set one to check it travels too. + "-DTORCH_CXX_FLAGS=-DET_TORCH_CXX_FLAGS_PROBE", + ], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + flags = {} + for entry in json.loads((build / "compile_commands.json").read_text()): + target = Path(entry["output"]).parent.name.removesuffix(".dir") + flags[target] = _compile_flags(entry) + expected = { + flag for flag in flags["links_torch"] if _copied_from_torch(flag) + } + self.assertIn("-DET_TORCH_CXX_FLAGS_PROBE", expected) + self.assertTrue(any(flag.startswith("-isystem ") for flag in expected)) + for target in ("links_torch_cpu_library", "links_it_publicly", "consumer"): + self.assertEqual(expected - flags[target], set(), target) + + def test_find_package_torch_without_torch_cpu_library(self): + """A parent project or a static libtorch can define torch alone.""" + cmake = shutil.which("cmake") + if cmake is None: + self.skipTest("CMake is required") + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) + (source / "CMakeLists.txt").write_text( + """cmake_minimum_required(VERSION 3.24) +project(TorchWithoutTorchCpuLibrary LANGUAGES NONE) +include("${EXECUTORCH_ROOT}/tools/cmake/Utils.cmake") +add_library(torch INTERFACE) +find_package_torch() +""" + ) + result = subprocess.run( + [ + cmake, + "-S", + str(source), + "-B", + str(source / "build"), + f"-DEXECUTORCH_ROOT={ROOT}", + ], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index aaec3d2c8de..0f1b64d28dd 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -278,6 +278,36 @@ macro(find_package_torch) add_torch_to_cmake_prefix_path() find_package(Torch CONFIG REQUIRED) endif() + # TorchConfig gives its include dirs, compile flags and C++ standard to torch + # only. Our targets link torch_cpu_library instead, so copy them there once, + # also when the project found torch before calling this. This affects every + # target in the build that links torch_cpu_library. + if(TARGET torch_cpu_library) + get_target_property( + _torch_settings_copied torch_cpu_library EXECUTORCH_TORCH_SETTINGS_COPIED + ) + if(NOT _torch_settings_copied) + foreach(_torch_property + INTERFACE_INCLUDE_DIRECTORIES INTERFACE_COMPILE_OPTIONS + INTERFACE_COMPILE_FEATURES + ) + get_target_property(_torch_value torch ${_torch_property}) + if(_torch_value) + set_property( + TARGET torch_cpu_library + APPEND + PROPERTY ${_torch_property} ${_torch_value} + ) + endif() + endforeach() + set_property( + TARGET torch_cpu_library PROPERTY EXECUTORCH_TORCH_SETTINGS_COPIED TRUE + ) + endif() + unset(_torch_settings_copied) + unset(_torch_property) + unset(_torch_value) + endif() endmacro() # Modify ${targetName}'s INTERFACE_INCLUDE_DIRECTORIES by wrapping each entry in