Skip to content

Commit f4b272f

Browse files
committed
Build and publish CUDA wheels
## The problem The wheel can carry the CUDA delegate, but no published wheel contains one: there is no CUDA row in any workflow, so a GPU user has to build from source. ## The change Add the workflows that build and publish CUDA wheels for Linux x86_64 and aarch64, and a smoke test that checks each wheel from the artifact itself. The build machines for these rows have no GPU, so the smoke test does not execute a model; it verifies the CUDA libraries are present, that the declared runtime matches the wheel's CUDA version, that nothing resolves through the build machine's toolkit, and that the shipped device code covers every GPU architecture the row claims. ``` executorch-1.5.0-cp312-cp312-manylinux_2_28_x86_64.whl +cu130 ``` A release publishes CUDA 12.6, 13.0 and 13.2, for Python 3.10 through 3.13. A pull request builds a single row instead of all twelve, because a full matrix costs hours for little extra signal. Which GPU architectures each row compiles for is chosen per row rather than detected on the builder. Detecting it would produce a wheel carrying device code for whatever machine happened to build it, which installs fine and then fails at the first GPU call. The aarch64 CUDA 12.6 row also compiles for compute capability 8.7, which is an embedded module. Every other row lists only the architectures the published PyTorch build for that train covers, and by that rule 8.7 would be left out, because the generic aarch64 build of this train carries 8.0 and 9.0 only. It is included because this is the only row whose CUDA major version matches what that module's software release ships, and because this wheel declares no PyTorch dependency: a user there supplies the build that carries their architecture. Leaving 8.7 out does not protect them from a bad pairing, it only removes the device code they need. Without it, a model reaching one of the shipped optional operators, quantized matrix multiply, sort or random number generation, fails at the first launch on that device. Two guards keep a release honest: - if the shared matrix generator stops offering a combination this policy advertises, the step fails instead of quietly publishing fewer wheels. A missing job is otherwise a green check for a wheel that was never built. - if a row reaches the architecture list with no CUDA version, the build refuses rather than falling back to the builder's GPU. A TORCH_CUDA_ARCH_LIST that holds only named GPU families PyTorch accepts, such as "Hopper", now fails to configure instead of quietly leaving CMAKE_CUDA_ARCHITECTURES unset and taking the compiler default. The three named forms CMake itself understands, "native", "all" and "all-major", are rejected before this logic runs: torch resolves the list with its own bundled CUDA architecture module, which does not know those names and stops the configure. That is upstream behaviour, not something this change introduces or can work around, so a caller has to name architectures explicitly. Windows CUDA is deliberately absent. The separate shared libraries this wheel exists to ship are Linux only today, so a Windows CUDA wheel would carry a delegate a C++ application still could not link. ## Test plan - built the full release matrix, twelve wheels, and confirmed each one's contents match the row it claims: the CUDA libraries present, the CUDA runtime declared, and device code for every GPU architecture the row advertises. - ran a GPU model end to end from a CI-built wheel on three NVIDIA GPUs covering three device architectures, with output identical to eager PyTorch on each (largest absolute difference 0), and inspected the wheel for a fourth device it cannot execute on. - ran the matrix filter over generated inputs, including incomplete and malformed ones, and confirmed it refuses rather than publishing a partial release: a missing CUDA version, a missing python, or a python present on rows this policy does not build are each reported by name. - confirmed a CPU row still produces a CPU wheel on a builder that happens to have a CUDA toolkit installed. - the newest architecture also ships in its portable form, so a GPU newer than any in the row can still run by having the driver compile it at load time. Checked with `cuobjdump --list-ptx`, since `--list-elf` prints identical output whether or not the portable form is present. - every library that carries GPU device code covers the whole row on its own. - the declared CUDA packages are compared against the expected set in BOTH directions. A one-way comparison accepted a wheel that omitted required packages, and a name-suffix comparison accepted cross-train names because for CUDA 13 the suffix is empty. - the python axis is an allowlist, matching the CUDA axis. Testing only the disabled list let any python not on it through: a 3.9 row was emitted successfully. - `install_utils.py` is in both CUDA workflows' path filters. It owns the supported CUDA train list and the toolkit detection, so a change there previously ran no CUDA wheel job. - requesting the JetPack rows fails with its own reason instead of the generic empty-matrix message, since both of its lists are deliberately empty and no workflow asks for them. - torchao keeps its CUDA channel where that channel exists. Falling back to the plain nightly index was needed only on aarch64, where the CUDA channel publishes nothing, and doing it everywhere changed which torchao an x86_64 install resolves. - the CUDA smoke test now asserts the QnnBackend and OpenvinoBackend registrations that a CPU Linux row asserts. The CUDA build enables OpenVINO on every Linux architecture and downloads the QNN SDK on x86_64, so a CUDA wheel carries both backends; a previous premise that "a CUDA row is not built with them" was false, and dropping the checks meant those two backends were unverified on every CUDA wheel. Known gap: no automated job runs a CUDA model on real hardware before publication. Running a model on real hardware is a separate release-time step that a person owns today, not an automated job wired into these workflows. ghstack-source-id: 95d67c7 ghstack-comment-id: 5220374521 Pull-Request: #21668
1 parent d863e9c commit f4b272f

9 files changed

Lines changed: 1110 additions & 4 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env bash
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+
# GPU architectures to compile device code for, chosen per release row rather than detected from
9+
# the build machine.
10+
#
11+
# Without this the build compiles for whichever GPU the builder happens to have. The wheel then
12+
# installs on every machine the row claims and fails when a model runs on a different generation,
13+
# with an error that looks like a model problem rather than a packaging one. Detection is the right
14+
# default for a local build and the wrong one for a published artifact.
15+
#
16+
# The value is published as TORCH_CUDA_ARCH_LIST rather than CMAKE_CUDA_ARCHITECTURES, because
17+
# PyTorch's CMake rejects the latter and overrides it, so setting only that reduces the build to a
18+
# single detected architecture.
19+
20+
# The architectures each row serves. Two rules decide the list, and they pull in opposite directions.
21+
#
22+
# The upper end follows the published PyTorch build for that train, read from its own library rather than
23+
# chosen by reasoning about which GPUs matter. A delegate is only useful where torch already runs, and an
24+
# architecture torch supports but this wheel omits produces a wheel that installs and then fails at the
25+
# first kernel launch. Two omissions found that way were the GPU on the runner that tests these wheels,
26+
# and a common desktop card.
27+
#
28+
# The lower end does NOT follow torch. It stops at 8.0 even though torch reaches further down, because one
29+
# source here compiles an integer matrix-multiply path only at 8.0 and above. Below that a user gets a
30+
# delegate that loads, runs most models, and fails on one needing that operator, which is worse than a row
31+
# that never claimed the device. So these lists are narrower than torch at the bottom on purpose.
32+
_cuda_arch_x86_64_cu130="8.0 8.6 8.9 9.0 10.0 12.0"
33+
_cuda_arch_x86_64_cu132="${_cuda_arch_x86_64_cu130}"
34+
35+
# The architectures the published aarch64 PyTorch CUDA build covers, read from its own library on an ARM
36+
# machine, for the same reason as the x86_64 rows above. Includes the ARM module whose train matches.
37+
_cuda_arch_aarch64_cu130="8.0 9.0 10.0 11.0 12.0"
38+
_cuda_arch_aarch64_cu132="${_cuda_arch_aarch64_cu130}"
39+
40+
# The older CUDA train.
41+
#
42+
# The two architectures do not carry identical lists, because each covers what the published PyTorch
43+
# build for that architecture covers, and those differ. Matching them to each other instead would mean
44+
# advertising a GPU on one architecture that PyTorch cannot serve there.
45+
#
46+
# The smaller embedded modules are deliberately absent, with one exception. An embedded-only
47+
# architecture in a generic wheel would advertise a device the row cannot otherwise serve, since
48+
# those devices also need the CUDA, TensorRT and PyTorch pinned by their own software release
49+
# rather than the ones a generic wheel resolves.
50+
#
51+
# 8.7 is that exception. This is the only row whose CUDA major matches what that module's software
52+
# release ships, and the wheel declares no PyTorch, so the user supplies the build that carries
53+
# their architecture. Omitting it does not protect them from a bad pairing, it only removes the
54+
# device code they need.
55+
#
56+
# The floor is 8.0 rather than the oldest architecture PyTorch still carries. One of these sources compiles
57+
# an integer matrix-multiply path only at 8.0 and newer, so an older architecture would get a delegate that
58+
# loads, runs most models, and fails on one that needs that operator. Claiming hardware the delegate only
59+
# partly serves is the same problem the embedded modules have, so the row leaves it out for the same reason.
60+
_cuda_arch_x86_64_cu126="8.0 8.6 8.9 9.0"
61+
_cuda_arch_aarch64_cu126="8.0 8.7 9.0"
62+
63+
# A CUDA train with no architecture list would leave the build detecting the builder's GPU, which is
64+
# the failure this file exists to prevent. Adding a train to the release matrix without adding its
65+
# architectures should fail loudly rather than silently produce a single-GPU wheel.
66+
_executorch_unknown_train() {
67+
echo "cuda_arch_list.sh: no GPU architecture list for CUDA train '$1' on $(uname -m)." >&2
68+
echo "Add one before building this row, or the wheel ships device code for one GPU only." >&2
69+
return 64
70+
}
71+
72+
# The architectures for the current row, space separated in the dotted form PyTorch expects.
73+
executorch_cuda_arch_list() {
74+
local machine
75+
machine="$(uname -m)"
76+
# The wheel build exports the row's CUDA train as CU_VERSION. DESIRED_CUDA is the name of the
77+
# matrix field rather than of the variable, so reading only that leaves every row falling back to
78+
# detecting the builder's GPU.
79+
local train="${CU_VERSION:-${DESIRED_CUDA:-}}"
80+
# A CPU row names no CUDA train and needs no architectures, so it is not an error.
81+
#
82+
# A CUDA row always names one, so an empty value there means the row lost it. Treating that as a CPU
83+
# row let the build fall back to detecting the builder's GPU, which produces a wheel carrying device
84+
# code for whatever machine happened to build it while every check still reports green.
85+
case "${train}" in
86+
"" | cpu | CPU | none | NONE)
87+
if [ "${EXECUTORCH_BUILD_CUDA:-}" = "1" ]; then
88+
echo "this is a CUDA build but the row's CUDA version is '${train}', which names no CUDA" >&2
89+
echo "train. Refusing to detect the builder GPU instead." >&2
90+
return 65
91+
fi
92+
return 0
93+
;;
94+
esac
95+
# The value arrives as cu130, while some callers pass 13.0 instead.
96+
train="${train#cu}"
97+
train="${train//./}"
98+
99+
case "${machine}" in
100+
aarch64 | arm64)
101+
case "${train}" in
102+
126) printf '%s' "${_cuda_arch_aarch64_cu126}" ;;
103+
130) printf '%s' "${_cuda_arch_aarch64_cu130}" ;;
104+
132) printf '%s' "${_cuda_arch_aarch64_cu132}" ;;
105+
*) _executorch_unknown_train "${train}" ;;
106+
esac
107+
;;
108+
x86_64)
109+
case "${train}" in
110+
126) printf '%s' "${_cuda_arch_x86_64_cu126}" ;;
111+
130) printf '%s' "${_cuda_arch_x86_64_cu130}" ;;
112+
132) printf '%s' "${_cuda_arch_x86_64_cu132}" ;;
113+
*) _executorch_unknown_train "${train}" ;;
114+
esac
115+
;;
116+
*) _executorch_unknown_train "${train}" ;;
117+
esac
118+
}
119+
120+
# The same list with a portable form appended for the newest architecture, so a GPU newer than any
121+
# in the row can still run the wheel by compiling that form at load time. Without it a newer GPU
122+
# gets no usable code at all.
123+
executorch_cuda_arch_list_with_ptx() {
124+
local dotted top
125+
# Propagate a failed lookup rather than reporting an empty list, since a caller cannot tell an
126+
# unknown row from a CPU row and the unknown one must not pass silently.
127+
dotted="$(executorch_cuda_arch_list)" || return $?
128+
[ -n "${dotted}" ] || return 0
129+
top="${dotted##* }"
130+
printf '%s %s+PTX' "${dotted}" "${top}"
131+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
# This file is sourced into the environment before building a pip wheel. It
8+
# should typically only contain shell variable assignments. Be sure to export
9+
# any variables so that subprocesses will see them.
10+
11+
source "${GITHUB_WORKSPACE}/${REPOSITORY}/.ci/scripts/wheel/envvar_base.sh"
12+
13+
# Ask for the CUDA delegate explicitly rather than letting the build detect a toolkit. A detected
14+
# build is fine locally, but a release row states what it is producing, and a row that silently
15+
# produced a CPU wheel because the toolkit was missing would publish under a CUDA name.
16+
export EXECUTORCH_BUILD_CUDA=1
17+
export CMAKE_ARGS="${CMAKE_ARGS} -DEXECUTORCH_BUILD_CUDA=ON"
18+
19+
# Fail the build if CUDA is not actually present. Without this the packaging step would look for
20+
# CUDA libraries that were never built and report a confusing missing-file error several minutes
21+
# after the real problem.
22+
if [ ! -x "${CUDA_HOME:-/usr/local/cuda}/bin/nvcc" ]; then
23+
echo "EXECUTORCH_BUILD_CUDA is set but no nvcc was found. This row cannot build a CUDA wheel." >&2
24+
exit 1
25+
fi
26+
27+
# Compile device code for the GPUs this release row claims, rather than for whichever GPU the
28+
# builder happens to have. A wheel built by detection alone installs on every machine the row covers
29+
# and then fails when a model runs on a different generation.
30+
source "${GITHUB_WORKSPACE}/${REPOSITORY}/.ci/scripts/wheel/cuda_arch_list.sh"
31+
# The status is checked rather than only the output. An unrecognised row makes the lookup fail, and
32+
# this file is sourced rather than run under a failing-command shell, so ignoring the status would
33+
# leave the variable unset and let the build fall back to detecting the builder's own GPU. That is
34+
# exactly the outcome this is meant to prevent, and it would ship quietly.
35+
if ! _executorch_cuda_arch="$(executorch_cuda_arch_list_with_ptx)"; then
36+
echo "could not resolve GPU architectures for CU_VERSION=${CU_VERSION:-unset}" >&2
37+
exit 1
38+
fi
39+
if [ -n "${_executorch_cuda_arch}" ]; then
40+
export TORCH_CUDA_ARCH_LIST="${_executorch_cuda_arch}"
41+
echo "building device code for: ${TORCH_CUDA_ARCH_LIST}"
42+
fi

0 commit comments

Comments
 (0)