metrale_gpu_runtime/cuda_backend/arch_preflight.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Refuse a GPU whose compute capability cannot run the kernels'
4//! architecture, before `MetraleCudaBackend::new` loads any module, with an
5//! error that names both ([`metrale_core::arch::ArchMismatch`]); also warn
6//! when the device's SM count differs from the target's `sm_count`.
7//!
8//! The rule is [`metrale_core::arch::ptx_arch_runs_on_device`]; the
9//! preflight's device queries are addressed by ordinal (`cuDeviceGet`), not by
10//! the calling thread's current context (see [`device_compute_capability_of`]).
11//!
12//! Owner: gpu-runtime (CUDA backend).
13//! Invariants:
14//! - `preflight_device_arch_with` returns `Ok` without a device query when
15//! the target records no architecture.
16//! - An SM-count mismatch or an unanswered SM-count query never fails the
17//! preflight.
18
19use anyhow::{Result, bail};
20
21use super::{cuCtxGetDevice, cuDeviceGet, cuDeviceGetAttribute};
22
23const CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR: u32 = 75;
24const CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR: u32 = 76;
25const CU_DEVICE_ATTRIBUTE_MULTIPROCESSOR_COUNT: u32 = 16;
26
27/// 2026-09-25: One `CUdevice_attribute` of `dev`, or an error with the
28/// driver status.
29fn device_attribute(attrib: u32, dev: i32) -> Result<i32> {
30 let mut value: i32 = 0;
31 let status = unsafe { cuDeviceGetAttribute(&mut value, attrib, dev) };
32 if status != 0 {
33 bail!("cuDeviceGetAttribute({attrib}) failed: status {status}");
34 }
35 Ok(value)
36}
37
38/// 2026-09-25: `(major, minor)` of an already-resolved `CUdevice`. Fails,
39/// rather than returning a default, when a query fails or `major <= 0`.
40fn compute_capability_of_device(dev: i32) -> Result<(u32, u32)> {
41 let major = device_attribute(CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR, dev)?;
42 let minor = device_attribute(CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR, dev)?;
43 if major <= 0 {
44 bail!("driver reported compute capability {major}.{minor} on device {dev}");
45 }
46 Ok((major as u32, minor as u32))
47}
48
49/// 2026-09-25: `(major, minor)` compute capability of the device of the
50/// calling thread's current context; fails without one. `--check-kernels`
51/// calls it after the backend is built (`kernel_gate.rs`
52/// `current_device_cc`). The preflight uses [`device_compute_capability_of`]
53/// instead.
54pub fn device_compute_capability() -> Result<(u32, u32)> {
55 let mut dev: i32 = 0;
56 let status = unsafe { cuCtxGetDevice(&mut dev) };
57 if status != 0 {
58 bail!("cuCtxGetDevice failed: status {status}");
59 }
60 compute_capability_of_device(dev)
61}
62
63/// 2026-09-25: `(major, minor)` compute capability of GPU `ordinal`, with no
64/// current context needed on the calling thread.
65///
66/// The preflight needs this form. `cuda_host::host` creates the context only
67/// on its first call; later calls return the existing host and leave the
68/// calling thread's current context alone. A later load can run on another
69/// thread (the TUI's `metrale-swap` thread, `tui/lib_state.rs`), where a
70/// context-addressed query has no context to read. The ignored GPU test
71/// `the_real_preflight_runs_on_a_thread_that_did_not_make_the_host` checks
72/// this form on such a thread.
73pub fn device_compute_capability_of(ordinal: usize) -> Result<(u32, u32)> {
74 let ordinal_i32 = i32::try_from(ordinal)
75 .map_err(|_| anyhow::anyhow!("GPU ordinal {ordinal} does not fit a CUdevice ordinal"))?;
76 let mut dev: i32 = 0;
77 let status = unsafe { cuDeviceGet(&mut dev, ordinal_i32) };
78 if status != 0 {
79 bail!("cuDeviceGet(ordinal {ordinal}) failed: status {status}");
80 }
81 compute_capability_of_device(dev)
82}
83
84/// 2026-09-25: Streaming multiprocessors on GPU `ordinal`, addressed by
85/// ordinal like [`device_compute_capability_of`]. Fails when the query fails
86/// or the count is <= 0.
87pub fn device_sm_count_of(ordinal: usize) -> Result<u32> {
88 let ordinal_i32 = i32::try_from(ordinal)
89 .map_err(|_| anyhow::anyhow!("GPU ordinal {ordinal} does not fit a CUdevice ordinal"))?;
90 let mut dev: i32 = 0;
91 let status = unsafe { cuDeviceGet(&mut dev, ordinal_i32) };
92 if status != 0 {
93 bail!("cuDeviceGet(ordinal {ordinal}) failed: status {status}");
94 }
95 let count = device_attribute(CU_DEVICE_ATTRIBUTE_MULTIPROCESSOR_COUNT, dev)?;
96 if count <= 0 {
97 bail!("driver reported {count} multiprocessors on device {dev}");
98 }
99 Ok(count as u32)
100}
101
102/// 2026-09-25: Compare the device's SM count with the target's
103/// `kernels/<hw>/HARDWARE.toml` `[hardware] sm_count`: `None` when they are
104/// equal, otherwise a warning naming both numbers and the file. The caller
105/// logs it and carries on (`preflight_device_arch_with`).
106pub fn check_sm_count(device_sms: u32, declared_sms: u32) -> Option<String> {
107 (device_sms != declared_sms).then(|| {
108 format!(
109 "this build's kernels are sized for {declared_sms} SMs \
110 (kernels/{hw}/HARDWARE.toml [hardware] sm_count) but the device \
111 reports {device_sms} — grid sizing that reads it will be off; \
112 serving is unaffected",
113 hw = metrale_kernels::TARGET_DEFAULTS.hw,
114 )
115 })
116}
117
118/// 2026-09-25: The verdict, without touching a GPU: `Ok(line to log)`, or the
119/// [`metrale_core::arch::ArchMismatch`] as the error. Testable with no CUDA.
120pub fn check_arch(compiled_arch: &str, device_cc: (u32, u32)) -> Result<String> {
121 if let Err(mismatch) = metrale_core::arch::ptx_arch_runs_on_device(compiled_arch, device_cc) {
122 // 2026-09-25: Returned typed, so `--check-kernels` can downcast it
123 // (`kernel_gate.rs`) instead of parsing the message.
124 return Err(mismatch.into());
125 }
126 Ok(format!(
127 "device CC {}.{}, kernels built for {compiled_arch}",
128 device_cc.0, device_cc.1
129 ))
130}
131
132/// 2026-09-25: The architecture string the preflight judges: the target's
133/// `ptx_arch` (`[hardware].arch` verbatim, e.g. `sm_90a`), not
134/// `target.arch`, which has the `a`/`f` suffix stripped. The suffix decides
135/// which devices the PTX runs on: plain `sm_90` passes on CC 10.0 and 12.1,
136/// `sm_90a` does not (`ptx_arch_runs_on_device`).
137///
138/// `None` when `ptx_arch` is empty; the preflight then warns and skips.
139pub fn preflight_arch(ptx_set: &metrale_kernels::TargetPtxSet) -> Option<&'static str> {
140 Some(ptx_set.ptx_arch).filter(|a| !a.is_empty())
141}
142
143/// 2026-09-25: Fail if kernels built for `compiled_arch` cannot run on GPU
144/// `ordinal`. Call it before `MetraleCudaBackend::new`, which loads the
145/// modules (`init_gpu_backend` does, through `kernel_gate::gate_device_arch`).
146///
147/// `compiled_arch` is `None` when the target records no architecture
148/// ([`preflight_arch`]); that is logged as a warning and returns `Ok` without
149/// a device query.
150pub fn preflight_device_arch(ordinal: usize, compiled_arch: Option<&str>) -> Result<()> {
151 preflight_device_arch_with(ordinal, compiled_arch, &DriverDeviceQuery)
152}
153
154/// 2026-09-25: The driver calls the preflight makes, behind a trait so a
155/// fake driver can test their order and the thread each runs on without a
156/// GPU (`arch_preflight_tests.rs`).
157pub(crate) trait DeviceQuery {
158 /// 2026-09-25: Initialise the process CUDA host on `ordinal`. In
159 /// [`DriverDeviceQuery`] this is `cuda_host::host`, which creates the
160 /// context on its first call only.
161 fn init_host(&self, ordinal: usize) -> Result<()>;
162
163 /// 2026-09-25: `(major, minor)` compute capability of GPU `ordinal`.
164 /// [`DriverDeviceQuery`] answers by ordinal, without a current context.
165 fn compute_capability(&self, ordinal: usize) -> Result<(u32, u32)>;
166
167 /// 2026-09-25: Streaming multiprocessors on GPU `ordinal`, for
168 /// [`check_sm_count`].
169 fn sm_count(&self, ordinal: usize) -> Result<u32>;
170}
171
172/// 2026-09-25: The production `DeviceQuery`: `cuda_host::host`, then
173/// ordinal-addressed attribute queries (`cuDeviceGet`).
174pub(crate) struct DriverDeviceQuery;
175
176impl DeviceQuery for DriverDeviceQuery {
177 fn init_host(&self, ordinal: usize) -> Result<()> {
178 crate::cuda_host::host(ordinal).map_err(|e| anyhow::anyhow!("{e}"))?;
179 Ok(())
180 }
181
182 fn compute_capability(&self, ordinal: usize) -> Result<(u32, u32)> {
183 device_compute_capability_of(ordinal)
184 }
185
186 fn sm_count(&self, ordinal: usize) -> Result<u32> {
187 device_sm_count_of(ordinal)
188 }
189}
190
191/// 2026-09-25: [`preflight_device_arch`] against an injected driver.
192pub(crate) fn preflight_device_arch_with(
193 ordinal: usize,
194 compiled_arch: Option<&str>,
195 query: &dyn DeviceQuery,
196) -> Result<()> {
197 let Some(compiled_arch) = compiled_arch else {
198 tracing::warn!(
199 "this build recorded no kernel architecture, so the GPU compute-capability \
200 preflight is skipped — expected under METRALE_SKIP_BUILD=1, a defect otherwise"
201 );
202 return Ok(());
203 };
204 // 2026-09-25: The host is initialised first; `MetraleRegistry::load`
205 // later uses the same process host (`cuda_host::host`). The queries below
206 // are addressed by ordinal, so they do not need a context current on
207 // this thread.
208 query.init_host(ordinal)?;
209 let device_cc = query.compute_capability(ordinal)?;
210 tracing::info!("{}", check_arch(compiled_arch, device_cc)?);
211 // 2026-09-25: The SM-count check only warns; a failed query is logged at
212 // debug level and the preflight still returns `Ok`.
213 match query.sm_count(ordinal) {
214 Ok(device_sms) => {
215 if let Some(warning) = check_sm_count(device_sms, metrale_kernels::TARGET_SM_COUNT) {
216 tracing::warn!("{warning}");
217 }
218 }
219 Err(e) => tracing::debug!("SM-count cross-check skipped: {e}"),
220 }
221 Ok(())
222}
223
224#[cfg(test)]
225#[path = "arch_preflight_tests.rs"]
226mod tests;