atlas_core/arch.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Does the PTX this binary carries run on the GPU it was handed?
4//!
5//! Atlas compiles ONE SM architecture per build — `kernels/<hw>/HARDWARE.toml`
6//! `[hardware].arch` picks it, there is no fatbin and no multi-`-gencode`. So a
7//! binary and a GPU can simply disagree, and until this module existed nothing
8//! checked: the mismatch surfaced as an opaque driver failure inside
9//! `cuModuleLoadData` (`CUDA_ERROR_NO_BINARY_FOR_GPU` /
10//! `CUDA_ERROR_UNSUPPORTED_PTX_VERSION`) that names neither the arch we built
11//! nor the card in the box.
12//!
13//! Pure and dependency-free on purpose: the rules are a property of NVIDIA's
14//! PTX ABI, not of any backend, so they belong where both the CUDA preflight
15//! and the `--check-kernels` reporter can reach them without a GPU.
16
17/// The suffix on an `sm_XY…` architecture string, which is what decides how
18/// far the compiled code travels.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SmSuffix {
21 /// No suffix — portable PTX. JIT-compiles on any device with a compute
22 /// capability at or above the compiled one.
23 None,
24 /// `a` — architecture-specific. Emits instructions that exist on exactly
25 /// one architecture (Hopper `wgmma`, its TMA descriptors), so it is never
26 /// forward-compatible.
27 Arch,
28 /// `f` — family-specific, added in CUDA 12.9. Runs on devices of the same
29 /// major family at or above the compiled compute capability.
30 Family,
31}
32
33/// A parsed `sm_XY[a|f]` architecture string.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct SmArch {
36 /// Compute-capability major (the `12` of `sm_121f`).
37 pub major: u32,
38 /// Compute-capability minor (the `1` of `sm_121f`).
39 pub minor: u32,
40 /// Which compatibility rule this arch obeys.
41 pub suffix: SmSuffix,
42}
43
44/// Parse an NVIDIA `sm_XY[a|f]` architecture string.
45///
46/// Returns `None` for anything that is not an NVIDIA SM arch — `gfx1151`
47/// (SCALE/HIP), `metal3.1`, or junk. `None` is therefore the "not an NVIDIA
48/// target" marker callers test against.
49///
50/// The digits split NVIDIA's way: the LAST digit is the minor version, the
51/// rest is the major (`sm_90` = 9.0, `sm_100` = 10.0, `sm_121` = 12.1).
52pub fn parse_sm_arch(arch: &str) -> Option<SmArch> {
53 let rest = arch.strip_prefix("sm_")?;
54 let (digits, suffix) = match rest.as_bytes().last()? {
55 b'a' => (&rest[..rest.len() - 1], SmSuffix::Arch),
56 b'f' => (&rest[..rest.len() - 1], SmSuffix::Family),
57 _ => (rest, SmSuffix::None),
58 };
59 // Two digits minimum: one for the major, one for the minor. `sm_9` is not
60 // a thing NVIDIA emits, and guessing at it would invent a compatibility
61 // verdict from a typo.
62 if digits.len() < 2 || !digits.bytes().all(|b| b.is_ascii_digit()) {
63 return None;
64 }
65 let (major, minor) = digits.split_at(digits.len() - 1);
66 Some(SmArch {
67 major: major.parse().ok()?,
68 minor: minor.parse().ok()?,
69 suffix,
70 })
71}
72
73/// Which `kernels/<hw>/` target ships for a device compute capability.
74///
75/// Deliberately tiny and explicit — it exists so the mismatch message can tell
76/// an operator what to rebuild instead of leaving them to guess. `None` means
77/// Atlas ships nothing for that GPU, and is the honest answer for every CC not
78/// listed: naming a target that cannot run either would send someone to
79/// rebuild an image that fails the same way (SM 10.3 Blackwell Ultra against
80/// the 10.0 `sm_100a` build is the live example).
81///
82/// HAND-MAINTAINED, and deliberately so, even though
83/// `kernels/<hw>/HARDWARE.toml` already declares `compute_capability` for each
84/// of these. Deriving it would mean either a build script that reads the
85/// kernels tree — atlas-core is a leaf crate with no build script and no TOML
86/// parser, and this function has to work inside a shipped binary that carries
87/// no `kernels/` at all — or baking the table in at build time, which trades a
88/// three-line table for a code generator. The gap that choice leaves is that
89/// the two can silently disagree, and
90/// `atlas-kernels/tests/target_hints.rs` closes it: it reads every
91/// `vendor = "nvidia"` HARDWARE.toml and asserts this function maps its
92/// declared `compute_capability` back to its own directory name.
93pub fn target_hint(device_cc: (u32, u32)) -> Option<&'static str> {
94 match device_cc {
95 (9, 0) => Some("hopper"),
96 // Blackwell datacentre. NOT (10, 3): B300/GB300 are `sm_103a`, a
97 // separate arch-specific target that does not exist in `kernels/`.
98 (10, 0) => Some("b200"),
99 (12, 1) => Some("gb10"),
100 _ => None,
101 }
102}
103
104/// The compiled kernels cannot run on the device in front of them.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ArchMismatch {
107 /// The arch string the kernels were compiled for, verbatim.
108 pub compiled_arch: String,
109 /// The parsed form of `compiled_arch`, so the message can say WHY.
110 pub compiled: SmArch,
111 /// `(major, minor)` compute capability the driver reports for this device.
112 pub device_cc: (u32, u32),
113}
114
115impl std::fmt::Display for ArchMismatch {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 let (major, minor) = self.device_cc;
118 let arch = &self.compiled_arch;
119 let reason = match self.compiled.suffix {
120 SmSuffix::None => format!(
121 "portable PTX for {arch} needs compute capability {}.{} or later",
122 self.compiled.major, self.compiled.minor
123 ),
124 SmSuffix::Arch => format!(
125 "architecture-specific PTX ({arch}) runs only on compute capability {}.{}",
126 self.compiled.major, self.compiled.minor
127 ),
128 SmSuffix::Family => format!(
129 "family-specific PTX ({arch}) runs only on compute capability {}.{} or later \
130 within the {}.x family",
131 self.compiled.major, self.compiled.minor, self.compiled.major
132 ),
133 };
134 let fix = match target_hint(self.device_cc) {
135 Some(hw) => format!(
136 "rebuild with ATLAS_TARGET_HW={hw} (kernels/{hw}/HARDWARE.toml arch must match \
137 this GPU) or use the image built for this GPU"
138 ),
139 None => format!(
140 "no shipped target matches compute capability {major}.{minor} \
141 (kernels/<hw>/HARDWARE.toml arch must match this GPU) — \
142 use the image built for this GPU"
143 ),
144 };
145 write!(
146 f,
147 "kernels compiled for {arch} cannot run on this GPU \
148 (compute capability {major}.{minor}): {reason}; fix: {fix}"
149 )
150 }
151}
152
153impl std::error::Error for ArchMismatch {}
154
155/// Can PTX compiled for `compiled_arch` run on a device of `device_cc`?
156///
157/// ORACLE: NVIDIA CUDA C++ Programming Guide, "Application Compatibility" →
158/// *PTX Compatibility*, plus NVIDIA's CUDA 12.9 announcement of family-specific
159/// features (developer.nvidia.com/blog/nvidia-blackwell-and-nvidia-cuda-12-9-
160/// introduce-family-specific-architecture-features/, re-read 2026-09-05):
161/// `compute_100f` "is compatible with all CC 10.x devices (sm_100, sm_103)",
162/// while the `a` suffix "is not forward-compatible with any future GPU
163/// architecture". Exactly three rules:
164///
165/// 1. plain `sm_XY` runs on any device with CC >= X.Y (JIT forward-compat);
166/// 2. `sm_XYa` runs ONLY on CC == X.Y;
167/// 3. `sm_XYf` runs on the same major family with CC >= X.Y.
168///
169/// A `compiled_arch` that is not an NVIDIA SM arch (`gfx1151`, `metal3.1`)
170/// returns `Ok(())`: a CUDA compute capability says nothing about it, and
171/// inventing a verdict would fail every AMD and Apple build. Callers that need
172/// to know use [`parse_sm_arch`], whose `None` is the marker.
173pub fn ptx_arch_runs_on_device(
174 compiled_arch: &str,
175 device_cc: (u32, u32),
176) -> Result<(), ArchMismatch> {
177 let Some(compiled) = parse_sm_arch(compiled_arch) else {
178 return Ok(());
179 };
180 let compiled_cc = (compiled.major, compiled.minor);
181 let runs = match compiled.suffix {
182 SmSuffix::None => device_cc >= compiled_cc,
183 SmSuffix::Arch => device_cc == compiled_cc,
184 SmSuffix::Family => device_cc.0 == compiled.major && device_cc >= compiled_cc,
185 };
186 if runs {
187 return Ok(());
188 }
189 Err(ArchMismatch {
190 compiled_arch: compiled_arch.to_string(),
191 compiled,
192 device_cc,
193 })
194}
195
196#[cfg(test)]
197#[path = "arch_tests.rs"]
198mod tests;