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;