spark_runtime/cuda_backend/
arch_preflight.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Refuse to load kernels the GPU cannot run, BEFORE the driver does it badly.
4//!
5//! Atlas compiles one SM architecture per build, and the driver's answer to a
6//! mismatch is `CUDA_ERROR_NO_BINARY_FOR_GPU` (or
7//! `CUDA_ERROR_UNSUPPORTED_PTX_VERSION`) raised inside `cuModuleLoadData` — an
8//! error that names neither the arch in the binary nor the card in the box. An
9//! operator who boots the published gb10 image on an H100 gets that, and
10//! nothing to act on.
11//!
12//! So this runs first: two `cuDeviceGetAttribute` calls, the pure rule from
13//! [`atlas_core::arch`], and a message that names both sides. The rule itself
14//! lives in atlas-core because `--check-kernels` reports it too.
15//!
16//! The capability query is addressed BY ORDINAL (`cuDeviceGet`), not by "the
17//! calling thread's current context" (`cuCtxGetDevice`). That is not a style
18//! preference — see [`device_compute_capability_of`]. `cuDeviceGetAttribute`
19//! and `cuCtxGetDevice` were already declared for the SM-count query;
20//! `cuDeviceGet` is the one addition, alongside them.
21
22use anyhow::{Result, bail};
23
24use super::{cuCtxGetDevice, cuDeviceGet, cuDeviceGetAttribute};
25
26/// `CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR` — CUDA driver API enum 75.
27const CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR: u32 = 75;
28/// `CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR` — CUDA driver API enum 76.
29const CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR: u32 = 76;
30/// `CU_DEVICE_ATTRIBUTE_MULTIPROCESSOR_COUNT` — CUDA driver API enum 16.
31const CU_DEVICE_ATTRIBUTE_MULTIPROCESSOR_COUNT: u32 = 16;
32
33/// One `CUdevice_attribute` on `dev`, or the driver status that refused it.
34fn device_attribute(attrib: u32, dev: i32) -> Result<i32> {
35    let mut value: i32 = 0;
36    let status = unsafe { cuDeviceGetAttribute(&mut value, attrib, dev) };
37    if status != 0 {
38        bail!("cuDeviceGetAttribute({attrib}) failed: status {status}");
39    }
40    Ok(value)
41}
42
43/// `(major, minor)` of an already-resolved `CUdevice`.
44///
45/// Fails loudly rather than guessing: a fabricated compute capability would
46/// turn this preflight into a rubber stamp.
47fn compute_capability_of_device(dev: i32) -> Result<(u32, u32)> {
48    let major = device_attribute(CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR, dev)?;
49    let minor = device_attribute(CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR, dev)?;
50    if major <= 0 {
51        bail!("driver reported compute capability {major}.{minor} on device {dev}");
52    }
53    Ok((major as u32, minor as u32))
54}
55
56/// `(major, minor)` compute capability of the calling context's device.
57///
58/// Requires a current CUDA context, exactly like `sm_count_cu` next door, so
59/// it is only safe to call from a thread that has one. `--check-kernels` runs
60/// it after the backend is up, which is such a thread. **The preflight does
61/// not** — see [`device_compute_capability_of`].
62pub fn device_compute_capability() -> Result<(u32, u32)> {
63    let mut dev: i32 = 0;
64    let status = unsafe { cuCtxGetDevice(&mut dev) };
65    if status != 0 {
66        bail!("cuCtxGetDevice failed: status {status}");
67    }
68    compute_capability_of_device(dev)
69}
70
71/// `(major, minor)` compute capability of GPU `ordinal`, with NO current
72/// context required on the calling thread.
73///
74/// This exists because the context-addressed spelling above is wrong for a
75/// preflight, and quietly so. `cuda_host::host(ordinal)` binds a context only
76/// while it INITIALISES: once its `OnceLock` is populated it hands back an
77/// `Arc` clone and touches no thread-current state. A TUI Library swap runs
78/// the new load on a fresh `atlas-swap` thread while the previous model's
79/// context was made current on the scheduler thread, so on the swap thread
80/// `cuCtxGetDevice` has no context to read and returns
81/// `CUDA_ERROR_INVALID_CONTEXT` — failing the requested load AND the attempt
82/// to restore the old model, leaving the host with no model at all.
83///
84/// `cuDeviceGet` reads no thread-current state (NVIDIA's context API
85/// documents the thread-current requirement as belonging to `cuCtx*`, not to
86/// device enumeration), so the preflight needs no bind and cannot be made
87/// wrong by which thread it runs on. `cuInit` is still a precondition, and the
88/// `host(ordinal)` call in `preflight_device_arch_with` is what satisfies it.
89pub fn device_compute_capability_of(ordinal: usize) -> Result<(u32, u32)> {
90    let ordinal_i32 = i32::try_from(ordinal)
91        .map_err(|_| anyhow::anyhow!("GPU ordinal {ordinal} does not fit a CUdevice ordinal"))?;
92    let mut dev: i32 = 0;
93    let status = unsafe { cuDeviceGet(&mut dev, ordinal_i32) };
94    if status != 0 {
95        bail!("cuDeviceGet(ordinal {ordinal}) failed: status {status}");
96    }
97    compute_capability_of_device(dev)
98}
99
100/// Streaming multiprocessors on GPU `ordinal`, with NO current context
101/// required — addressed the same way, and for the same reason, as
102/// [`device_compute_capability_of`].
103pub fn device_sm_count_of(ordinal: usize) -> Result<u32> {
104    let ordinal_i32 = i32::try_from(ordinal)
105        .map_err(|_| anyhow::anyhow!("GPU ordinal {ordinal} does not fit a CUdevice ordinal"))?;
106    let mut dev: i32 = 0;
107    let status = unsafe { cuDeviceGet(&mut dev, ordinal_i32) };
108    if status != 0 {
109        bail!("cuDeviceGet(ordinal {ordinal}) failed: status {status}");
110    }
111    let count = device_attribute(CU_DEVICE_ATTRIBUTE_MULTIPROCESSOR_COUNT, dev)?;
112    if count <= 0 {
113        bail!("driver reported {count} multiprocessors on device {dev}");
114    }
115    Ok(count as u32)
116}
117
118/// Does the running device have the SM count this build's kernels were sized
119/// for? `None` when it agrees, `Some(warning)` when it does not.
120///
121/// # Why this WARNS and does not fail
122///
123/// `kernels/<hw>/HARDWARE.toml` `[hardware] sm_count` is a grid-sizing input.
124/// A wrong value costs a suboptimal grid, never a wrong answer, and refusing
125/// to boot on — say — an H100 PCIe with a different bin would be a regression
126/// dressed as rigour.
127///
128/// It exists at all because the defect it guards was exactly a silent wrong
129/// constant: `atlas_core::device::sm121::NUM_SMS = 48`, named after one part,
130/// compiled into a build for another, where a grid sized from it then ran 24
131/// CTAs on 132 SMs for a whole campaign. A one-line boot warning naming both
132/// numbers is what would have caught it in round 1.
133pub fn check_sm_count(device_sms: u32, declared_sms: u32) -> Option<String> {
134    (device_sms != declared_sms).then(|| {
135        format!(
136            "this build's kernels are sized for {declared_sms} SMs \
137             (kernels/{hw}/HARDWARE.toml [hardware] sm_count) but the device \
138             reports {device_sms} — grid sizing that reads it will be off; \
139             serving is unaffected",
140            hw = atlas_kernels::TARGET_DEFAULTS.hw,
141        )
142    })
143}
144
145/// The verdict, without touching a GPU: `Ok(line to log)` or the mismatch.
146///
147/// Split out so the decision is testable on a host with no CUDA at all, which
148/// is every machine CI runs on.
149pub fn check_arch(compiled_arch: &str, device_cc: (u32, u32)) -> Result<String> {
150    if let Err(mismatch) = atlas_core::arch::ptx_arch_runs_on_device(compiled_arch, device_cc) {
151        // Keep the device facts typed so --check-kernels can report an early
152        // refusal without parsing this error's human-readable message.
153        return Err(mismatch.into());
154    }
155    Ok(format!(
156        "device CC {}.{}, kernels built for {compiled_arch}",
157        device_cc.0, device_cc.1
158    ))
159}
160
161/// Which architecture string a resolved target's preflight must judge.
162///
163/// A `TargetPtxSet` carries two readings of one `[hardware].arch`
164/// declaration, and only one of them can answer this question:
165///
166/// * `target.arch` is the BASE SM (`sm_90`, `sm_121`) — the identity the
167///   registry, `KernelTarget`'s constants and every gate baseline are keyed
168///   by. Its feature suffix has been stripped, so `sm_90a` arrives as plain
169///   `sm_90`, which the forward-compat rule says runs on any CC >= 9.0.
170/// * `ptx_arch` is the declaration VERBATIM (`sm_90a`, `sm_121f`) — what nvcc
171///   was handed, suffix and all. The suffix IS the compatibility rule.
172///
173/// Passing the base SM here is not a slightly weaker check, it is the wrong
174/// one: Hopper-only PTX would pass on a B200 (CC 10.0) or a GB10 (12.1) and
175/// then fail inside `cuModuleLoadData` — the driver error with no useful
176/// nouns in it that this whole module exists to pre-empt.
177///
178/// `None` when the target records no architecture, which the caller warns
179/// about and skips rather than treating as a pass.
180pub fn preflight_arch(ptx_set: &atlas_kernels::TargetPtxSet) -> Option<&'static str> {
181    Some(ptx_set.ptx_arch).filter(|a| !a.is_empty())
182}
183
184/// Fail fast if this binary's kernels cannot run on GPU `ordinal`.
185///
186/// Call this BEFORE constructing the backend: `AtlasCudaBackend::new` loads
187/// every PTX module, and the point is to answer before the driver does.
188///
189/// `compiled_arch` is `None` when the build recorded no architecture — the
190/// `ATLAS_SKIP_BUILD=1` stub registry compiles nothing and can attest to
191/// nothing. That is warned and skipped, never treated as a pass: a check with
192/// no input has no opinion, and inventing one would make the stub build claim
193/// hardware compatibility it never tested.
194pub fn preflight_device_arch(ordinal: usize, compiled_arch: Option<&str>) -> Result<()> {
195    preflight_device_arch_with(ordinal, compiled_arch, &DriverDeviceQuery)
196}
197
198/// The two driver facts the preflight needs, behind a seam.
199///
200/// Not indirection for its own sake: the property that broke here — WHICH
201/// THREAD each of the two runs on, and whether the second depends on the
202/// first having run on that same thread — is invisible to any test that can
203/// only call the real driver, and CI has no GPU to call it with. Behind this
204/// trait the ordering contract is assertable on a bare host.
205pub(crate) trait DeviceQuery {
206    /// Initialise the process CUDA host on `ordinal` (`cuInit`, primary
207    /// context). Idempotent, and — the whole point — binds a context to the
208    /// CALLING thread on the first call only.
209    fn init_host(&self, ordinal: usize) -> Result<()>;
210
211    /// `(major, minor)` compute capability of GPU `ordinal`.
212    ///
213    /// Takes the ordinal, so an implementation CAN answer without a current
214    /// context; [`DriverDeviceQuery`] is the one that does.
215    fn compute_capability(&self, ordinal: usize) -> Result<(u32, u32)>;
216
217    /// Streaming multiprocessors on GPU `ordinal`, for the [`check_sm_count`]
218    /// cross-check.
219    fn sm_count(&self, ordinal: usize) -> Result<u32>;
220}
221
222/// The production `DeviceQuery`: the process CUDA host, then `cuDeviceGet`.
223pub(crate) struct DriverDeviceQuery;
224
225impl DeviceQuery for DriverDeviceQuery {
226    fn init_host(&self, ordinal: usize) -> Result<()> {
227        atlas_core::cuda_host::host(ordinal).map_err(|e| anyhow::anyhow!("{e}"))?;
228        Ok(())
229    }
230
231    fn compute_capability(&self, ordinal: usize) -> Result<(u32, u32)> {
232        device_compute_capability_of(ordinal)
233    }
234
235    fn sm_count(&self, ordinal: usize) -> Result<u32> {
236        device_sm_count_of(ordinal)
237    }
238}
239
240/// [`preflight_device_arch`] against an injected driver.
241pub(crate) fn preflight_device_arch_with(
242    ordinal: usize,
243    compiled_arch: Option<&str>,
244    query: &dyn DeviceQuery,
245) -> Result<()> {
246    let Some(compiled_arch) = compiled_arch else {
247        tracing::warn!(
248            "this build recorded no kernel architecture, so the GPU compute-capability \
249             preflight is skipped — expected under ATLAS_SKIP_BUILD=1, a defect otherwise"
250        );
251        return Ok(());
252    };
253    // Initialise the process CUDA host first, for `cuInit` and so the backend
254    // reuses this context rather than creating a second one — NOT to make a
255    // context current, which on any thread after the first it does not do.
256    // The capability query below is addressed by ordinal precisely so that
257    // does not matter; see `device_compute_capability_of`.
258    query.init_host(ordinal)?;
259    let device_cc = query.compute_capability(ordinal)?;
260    tracing::info!("{}", check_arch(compiled_arch, device_cc)?);
261    // The SM-count cross-check (#928). A driver that will not answer is not a
262    // reason to refuse a boot the arch check already passed — it is one fewer
263    // cross-check, logged as such.
264    match query.sm_count(ordinal) {
265        Ok(device_sms) => {
266            if let Some(warning) = check_sm_count(device_sms, atlas_kernels::TARGET_SM_COUNT) {
267                tracing::warn!("{warning}");
268            }
269        }
270        Err(e) => tracing::debug!("SM-count cross-check skipped: {e}"),
271    }
272    Ok(())
273}
274
275#[cfg(test)]
276#[path = "arch_preflight_tests.rs"]
277mod tests;