How a cell gets its arch list¶
Last verified against cuda-wheels-forge @ 17a3d32 (2026-08-26).
Every build cell exports one environment variable that decides which GPUs the resulting wheel can run on:
TORCH_CUDA_ARCH_LIST="8.0 8.6 8.9 9.0+PTX 10.0 12.0+PTX"
This page is the whole story of where that string comes from, because almost every coverage bug the farm has shipped was a bug in this string — and the nastiest property of an arch bug is that it does not fail the build. A wheel built for the wrong GPUs compiles, links, imports, and uploads. The user finds out.
The one-paragraph version¶
There is a policy file with one arch list per CUDA version. A package may
override it. x86 and ARM are resolved by two different functions reading
two different tables. Whatever comes out is then forced to carry a +PTX
tail, normalised to spaces, and handed to the compiler. After the build, the
verify gate re-derives the same list and checks the wheel against it.
That last sentence is the trap the rest of this page keeps returning to: the gate compares the wheel to the list the package asked for, not to the list of GPUs that exist. Narrow the ask and the gate goes quiet.
Step 1 — the policy row¶
defaults/arch_policy.yml holds three tables:
| table | keyed by | used for |
|---|---|---|
arch_policy |
CUDA version | Linux x86-64 and Windows |
arch_exceptions |
cuda/torch-minor |
narrow, hand-maintained additions |
arch_policy_aarch64 |
CUDA version | Linux aarch64 |
policy_arch_list() (scripts/generate_matrix.py) picks one:
- If the platform is
linux_aarch64, it readsarch_policy_aarch64[cuda]and returns immediately. It never looks atarch_exceptions. - Otherwise it tries
arch_exceptions["<cuda>/<torch minor>"]first, then falls back toarch_policy[cuda]. - A missing CUDA key is a hard error, deliberately. Silence here is how the farm used to guess by mirroring PyTorch.
The exceptions table adds, it does not subtract
arch_exceptions was inverted on 2026-08-22. It used to record archs
being dropped; it now records the closed set of old torch minors that
still shipped sm_70, so a Volta cubin stays reachable inside those torch
processes. The policy rows carry the current floor. New torch minors
inherit the policy row and this table never grows again.
If you read it as a drop-list you will reach exactly the wrong conclusion about what a cell builds.
Why a policy at all, instead of mirroring torch¶
Mirroring upstream was the previous approach, and it was abandoned. The farm's arch list is its own policy. Two reasons:
- Torch's own arch list is chosen for torch's binary size budget, not for
which GPUs people own. It rotates
+PTXoff a maturing toolchain, which would silently remove the farm's forward-compat tail. - Half these packages have their own floors (natten is sm_80+, spconv drags
cumm's
supported_archestable behind it). A mirror cannot express that.
Step 2 — the package override¶
packages/<pkg>/arch_override.yml has four fields:
| field | lane | shape |
|---|---|---|
arch_list |
x86 / Windows | one string, all CUDA versions |
arch_list_by_cuda |
x86 / Windows | map, CUDA version → string |
arch_list_aarch64 |
ARM | one string |
arch_list_by_cuda_aarch64 |
ARM | map, CUDA version → string |
x86 / Windows priority¶
resolve_arch_list() (generate_matrix.py), highest wins:
- a per-combo
arch_listinside the package's ownbuild_matrix.combinations arch_list_by_cuda[cuda]arch_list- the policy row
Nothing resolved → KeyError, not a default.
ARM priority — and the trap¶
resolve_aarch64_arch_list() (generate_matrix.py) is a separate
function:
arch_list_by_cuda_aarch64[cuda]arch_list_aarch64arch_policy_aarch64[cuda]
The x86 fields are deliberately invisible to the ARM lane
A package with a carefully reasoned arch_list_by_cuda gets none of it
on ARM. That is intentional — an sm_86/sm_89 floor describes consumer
x86 GPUs and means nothing on SBSA — but it means:
- ARM is a default platform on every package, so this path runs for all of them, not for a pilot subset.
- A package whose gap is genuinely platform-independent (upstream ships no
sm_100kernel at all) must say so twice, in the x86 field and in the aarch64 field. - Fixing a coverage bug on x86 does not fix it on ARM, and vice versa. Ask "which of the four fields did I just edit?" every single time.
Step 3 — the two normalisations¶
Both resolvers end with the same two calls, in this order.
_ensure_ptx_on_highest_base() — the forward-compat tail¶
Finds the highest non-a token and appends +PTX if it is missing. Idempotent.
The reason is GPUs that do not exist yet. A cubin runs only on the arch it was built for; PTX is intermediate code the driver JITs at load time onto a newer arch. Without a PTX tail, a wheel is dead the day a new GPU ships.
Two things this is not:
PTX JITs forward only — measured
compute_120 PTX on an sm_86 device gives
cudaErrorNoKernelImageForDevice. A PTX tail is not a substitute for the
cubins below it, only insurance above them.
9.0+PTX can silently become arch-conditional PTX
Tokens ending in a (sm_90a, sm_100a) enable arch-specific features
and load only on that exact arch. A compute_90a PTX tail is dead
bytes and a false forward-compat promise. _ensure_ptx_on_highest_base
skips a tokens when choosing where to put the tail, but a package that
compiles a targets (sageattention compute_90a, natten 90a-real /
100a-real, torchao _C_cutlass_90a, sageattn3 sm_100a/120a) should
drop +PTX from its override rather than ship one.
flash_attn is the counter-example and the reason the distinction matters:
csrc/flash_attn is one CUTLASS-2.x kernel family with zero
__CUDA_ARCH__ < guards, its .target sm_120 carries no suffix, so its
PTX genuinely is portable. Verified by compiling it: 32 entries, 15360
mma.sync, byte-identical entry names to compute_80.
Cost, measured: +1.4% compile time, +25% wheel size (244MB → 304MB) — nvcc stores PTX pre-compressed, so it barely compresses in the zip while cubins compress ~3.9:1.
_normalize_arch_list() — spaces, not semicolons¶
Torch documents TORCH_CUDA_ARCH_LIST as space-separated. But
arch_policy.yml writes ;-separated rows while every arch_override.yml
writes space-separated ones — so which separator a build saw depended on
whether the package happened to have an override.
That asymmetry shipped a real bug: flash_attn's ARM lane has no aarch64
override, so it got the policy form, its setup.py parsed it with a plain
.split(), got one token, and emitted arch=compute_80;90 — which nvcc
rejected outright (2026-08-26). Before a later patch made that fatal, the same
bug silently built for nvcc's default arch only.
Fixed centrally because the inconsistency is the farm's, not the packages'.
Step 4 — the package's setup.py gets a vote you did not grant it¶
This is the failure mode that costs the most CI hours, because the farm's side is correct and the wheel is still wrong.
TORCH_CUDA_ARCH_LIST is a request. What a package does with it is entirely
up to the package.
flash_attn: a hardcoded if-ladder
Upstream setup.py gates every gencode on an exact token test:
if "80" in cuda_archs(): ... arch=compute_80,code=sm_80
if "90" in cuda_archs(): ...
if "100" in cuda_archs(): ...
if "120" in cuda_archs(): ...
Any arch outside that set matches no branch and is silently dropped —
no cubin, no PTX, no diagnostic. arch_policy_aarch64's 13.x row asks for
11.0 (Thor), so the cu13.0 ARM wheel shipped without sm_110 and only
C7 caught it, three hours in. The fix is a cuda_extra_archs() helper that
emits code=sm_X for whatever the ladder does not handle.
sageattention: a capability whitelist that continues
Its setup.py walks the requested arches, checks each against its own
whitelist, and silently continues on anything it does not recognise. The
farm can ask for sm_100 all day; the wheel will not contain it.
sageattn3: cubins with no PTX
Ships sm_100a/sm_120a cubins and no PTX at all — dead on everything
below Blackwell and on everything above it.
How to tell in advance: read the package's setup.py for the string
TORCH_CUDA_ARCH_LIST and for -gencode. If the arch list is consumed by
torch.utils.cpp_extension you are fine. If the package parses it itself, read
that parser.
Step 5 — the gate, and why it is not the safety net you think¶
scripts/verify_wheel.py check C7 arch_sass runs cuobjdump over every
extension in the built wheel and compares against the cell's arch list.
It does four things, in order:
- Family check — a missing arch family (
sm_1xentirely absent) fails. - Exact archs —
expected - actual - waived. This used to be a warning, which meant you could drop every consumer Ampere and Ada cubin and still exit 0 as long as onesm_80survived, because the family check compares majors. Promoted tofail. - PTX —
expected_ptx - ptx - waived. The+PTXmarker used to be discarded before comparison, so a wheel that declared forward-compat and shipped none looked identical to one that shipped it. - Suffix folding —
sm_90aandsm_100fnormalise tosm_90/sm_100before comparison, or every Hopper wheel reads as "missing sm_90".
Two ways this gate is quiet when it should not be¶
It compares against the resolved list
C7 re-derives the arch list from the same YAML the build used. Narrowing an override to match a coverage gap moves both sides of the comparison and converts a real gap into a green check with no residual signal anywhere. This is the mechanical reason arch-narrowing is banned as a fix.
A wheel with no device code at all only warns
if not sass and not ptx:
rep.add("arch_sass", "warn", "UNVERIFIED: no SASS/PTX visible ...")
return
binary_census is satisfied by the .so merely
existing, and C8 import succeeds because an empty extension still imports —
kernels only fail at launch. So a wheel containing no GPU code can pass the
whole gate. This is a known hole, not a design decision; the proposed fix is
to make UNVERIFIED fail unless the package sets allow_pure_python.
The waiver¶
verify.allow_missing_archs is the only sanctioned way to accept a gap, and it
is for documented upstream absences only — upstream ships no kernel for that
arch, full stop. It is not for "the build was slow" or "it did not compile".
The standing rule, and what to do instead¶
Never narrow an arch list to make a build pass.
The rule exists because of the C7 property above: narrowing is the one class of "fix" that removes both the problem and the evidence. It has bitten twice — spconv lost its Blackwell cubins under a comment asserting the trim changed nothing about the shipped wheel, and a later edit to the same file dropped Ada and nobody noticed for two days.
When an arch genuinely will not compile, the alternatives that preserve coverage:
| symptom | real fix |
|---|---|
cg::labeled_partition unsupported |
__CUDA_ARCH__ >= 700 guard with a fallback (patch_lib.guard_labeled_partition) |
atomicAdd(double*) unsupported below sm_60 |
the documented atomicCAS shim |
| a vendored dep raised its floor | pin the dep below the bump |
| upstream's arch table lacks the token | patch the table (cumm's supported_arches) or emit the gencode yourself (flash_attn's cuda_extra_archs) |
| MSVC cannot parse an arch's templates | strip that arch on Windows only, in the patch, and say so in sharding_platforms/the patch comment |
| upstream genuinely has no kernel | verify.allow_missing_archs with the evidence in the comment |
Reading a real arch list¶
8.0 8.6 8.9 9.0 10.0 12.0+PTX
| token | GPU |
|---|---|
7.5 |
Turing (RTX 20xx, T4) |
8.0 |
Ampere DC (A100) |
8.6 |
Ampere consumer (RTX 30xx) |
8.9 |
Ada (RTX 40xx, L40S) |
9.0 |
Hopper (H100) |
10.0 |
Blackwell DC (B200) |
11.0 |
Thor (SBSA only — this is why the ARM table has a row x86 does not) |
12.0 |
Blackwell consumer (RTX 50xx) |
+PTX on the last token = JIT path onto whatever comes next. A major
version bump breaks binary compatibility: compute_100 PTX cannot JIT onto
sm_110, and compute_120 PTX cannot either. That is why 11.0 needs its own
cubin and cannot be covered by its neighbours.
Checklist before you touch an arch list¶
- Which lane? x86, Windows and ARM read different fields.
- Are you widening or narrowing? Narrowing needs a reason that is not "the build failed".
- Does the package's
setup.pyactually honourTORCH_CUDA_ARCH_LIST? - Will
_ensure_ptx_on_highest_baseput a+PTXtail on anatoken? - After the build, read C7's
datablock —expected/sass/ptx/source.source: nonemeans nothing was measured. See Step 5 -- the gate.