gam_gpu/test_gate.rs
1//! One gate for every GPU-conditional test, so an absent device is RECORDED
2//! rather than reported as a pass (#2422).
3//!
4//! 41 tests could report `1 passed` having executed zero assertions, 37 of them
5//! because a CPU-only runner takes a bare `return;` before the first assertion.
6//! `sphere_gpu_end_to_end_fit_hill_climb_10x_vs_cpu` was green for its entire
7//! life and had never appeared in `MASTER_FAILURES`; on a real A10 it misses its
8//! `>=10x` gate at **0.51x**. A test that passes having verified nothing is
9//! worse than a missing test, because a reader counts it as coverage.
10//!
11//! Five different skip idioms had grown up across fifteen files, and they
12//! disagreed on the two things that matter:
13//!
14//! * **A device FAULT is not an absent device.** Twenty-one sites wrote
15//! `let Some(rt) = GpuRuntime::resolve(..) else { return; }`, and that `else`
16//! also swallows `Err(..)`. A machine WITH a GPU whose driver is broken
17//! therefore reported the same silent pass as a machine without one. This
18//! gate panics on `Err` unconditionally: a fault is a failure, always.
19//! * **A lane that demanded a GPU must not skip.** [`crate::global_policy`]
20//! already carries that demand, so [`GpuPolicy::Required`] turns an absent
21//! device into a panic here. A GPU lane sets the policy once and every gated
22//! test in the process becomes mandatory — no per-test opt-in, and no
23//! environment variable (`env::var` is banned in this tree).
24//!
25//! What remains — `Auto`/`Off` with no device — is a real skip, and it is
26//! counted. [`crate::test_gate::skipped_for_absent_device`] lets a suite assert how many gated
27//! tests declined to run, so "37 passed" can no longer hide "37 verified
28//! nothing".
29use crate::device_runtime::GpuRuntime;
30use crate::{GpuPolicy, global_policy};
31use std::sync::atomic::{AtomicU64, Ordering};
32
33static SKIPPED_FOR_ABSENT_DEVICE: AtomicU64 = AtomicU64::new(0);
34
35/// The one line a skipped GPU test prints, and the one string a CI ledger step
36/// has to grep for.
37///
38/// Kept as a constant rather than spelled out at the `eprintln!` so the emitter
39/// and any reader cannot drift apart — the failure mode #2593 recorded for a
40/// different pair of substring ladders.
41pub const SKIPPED_MARKER: &str = "SKIPPED(no-cuda):";
42
43/// How many gated tests have declined to run in this process for want of a
44/// device.
45///
46/// The point of a counter rather than a log line: a log line is only evidence
47/// if somebody reads it, and nothing did for 37 tests. This is a value a test
48/// can assert against.
49pub fn skipped_for_absent_device() -> u64 {
50 SKIPPED_FOR_ABSENT_DEVICE.load(Ordering::Relaxed)
51}
52
53/// Assert that this thread's absent-device skip was recorded, and return the
54/// count observed.
55///
56/// This is the minimum a gated test owes on its device-free path. A site that
57/// takes [`GpuTestGate::AbsentDevice`] and returns has executed zero
58/// assertions, which is the whole of #2422; calling this makes the count itself
59/// the thing under test, so the skip is *verified to have been recorded*
60/// rather than merely logged.
61///
62/// The comparison is deliberately monotone (`>= floor + 1`, not `== floor + 1`).
63/// The counter is process-wide and gated tests run concurrently under
64/// `--test-threads`, so a sibling's increment lands between another test's read
65/// and its own increment. An exact-delta assertion on a shared atomic is a
66/// flake, and a flaky guard on a skip path is worse than none — it teaches
67/// readers to re-run until green.
68///
69/// This is measured, not anticipated: with the two gate self-tests alone and an
70/// exact-delta assertion, `cargo test -p gam-gpu --lib tests_gpu_test_gate_2422
71/// -- --test-threads=8` failed **5 of 12 runs** on a device-free host
72/// (`left: 2, right: 1` — both tests incremented before either read back). Two
73/// callers were enough; there are now a dozen.
74pub fn assert_absent_device_was_counted(floor: u64) -> u64 {
75 let observed = skipped_for_absent_device();
76 assert!(
77 observed >= floor + 1,
78 "an absent device must be COUNTED, not silently skipped: the skip counter \
79 read {floor} before the gate and {observed} after, so this test's skip left \
80 no trace and `ok` would again mean nothing (#2422)"
81 );
82 observed
83}
84
85/// The outcome of asking for a device in a test.
86#[derive(Debug)]
87pub enum GpuTestGate {
88 /// A runtime resolved; the test body must proceed and assert.
89 Ready(&'static GpuRuntime),
90 /// No device on this host, under a policy that permits running without
91 /// one. Counted by [`skipped_for_absent_device`] and announced on stderr.
92 AbsentDevice,
93}
94
95impl GpuTestGate {
96 /// The runtime, or `None` when the host has no device.
97 ///
98 /// Deliberately NOT `Option`-shaped at the call site by default: a caller
99 /// that writes `let Some(rt) = gate.runtime() else { return }` is back to
100 /// the idiom this module exists to remove. Prefer matching on the gate so
101 /// the absent arm is written out and visible in review.
102 pub fn runtime(&self) -> Option<&'static GpuRuntime> {
103 match self {
104 Self::Ready(runtime) => Some(runtime),
105 Self::AbsentDevice => None,
106 }
107 }
108}
109
110/// Resolve a runtime for a GPU-conditional test.
111///
112/// Panics when the device is faulted (`Err`) or when the process policy is
113/// [`GpuPolicy::Required`] and no device is present. Returns
114/// [`GpuTestGate::AbsentDevice`] only for a genuinely device-free host.
115///
116/// # Two questions, two sources
117///
118/// *Is there a device?* is asked of the driver with an explicit
119/// [`GpuPolicy::Auto`], never with [`global_policy`]. *Must an absent device be
120/// fatal?* is the only question [`global_policy`] answers here.
121///
122/// Reading the process policy for the availability question would be wrong, and
123/// not theoretically: [`crate::configure_global_policy`] is a first-writer-wins
124/// `OnceLock`, and `GpuRuntime::resolve(Off)` short-circuits to `Ok(None)`
125/// *before probing any device*. Eleven test files set the policy to
126/// [`GpuPolicy::Off`], and one of them —
127/// `backend_status_and_policy_dispatch_are_consistent` — shares the
128/// `tests/arrow_gpu` binary with the gated tests here. Had this asked
129/// `resolve(global_policy())`, then on a host WITH a CUDA device, whenever that
130/// sibling won the race, every gated test in the binary would record
131/// "no CUDA device" and be counted as an absent-device skip. The count this
132/// module exists to make trustworthy would then be measuring test execution
133/// order, and the eventual "any no-cuda skip on a GPU runner is a failure" gate
134/// would fire on a machine that has a GPU.
135///
136/// `resolve(Auto)` returns `Ok(None)` for genuine absence only — never for a
137/// policy reason — which is what makes an `AbsentDevice` here mean what it says.
138pub fn gpu_for_test(label: &str) -> GpuTestGate {
139 let policy = global_policy();
140 match GpuRuntime::resolve(GpuPolicy::Auto) {
141 Ok(Some(runtime)) => GpuTestGate::Ready(runtime),
142 Ok(None) => {
143 if matches!(policy, GpuPolicy::Required) {
144 // SAFETY: aborting is the contract. Under `GpuPolicy::Required`
145 // the caller has declared that a device MUST be present, so the
146 // only alternatives are to abort or to return a gate the test
147 // reads as "skip" -- and a skip here prints `ok` for a test that
148 // verified nothing, which is the #2422 defect this gate exists
149 // to remove. Reachable only from a `#[test]` under an explicit
150 // Required policy.
151 panic!(
152 "[gpu-test] {label} REQUIRES a device: the process policy is \
153 GpuPolicy::Required and no CUDA runtime resolved. Skipping here \
154 would report a pass for a test that verified nothing (#2422)."
155 );
156 }
157 SKIPPED_FOR_ABSENT_DEVICE.fetch_add(1, Ordering::Relaxed);
158 // One fixed, greppable prefix so a CI pass can scrape an inventory
159 // of what did not run. Five idioms with five different wordings is
160 // why no single grep ever found this class; `SKIPPED_MARKER` is the
161 // one string a ledger step has to match, and it is asserted by
162 // `the_skip_marker_is_one_greppable_string_2422` so it cannot drift
163 // away from whatever scrapes it.
164 eprintln!(
165 "{SKIPPED_MARKER} {label} (policy={policy:?}) \
166 -- this test asserted NOTHING about a device; libtest still prints `ok`"
167 );
168 GpuTestGate::AbsentDevice
169 }
170 // SAFETY: aborting is the contract. A FAULTED device is not an absent
171 // one: resolution reached the runtime and it errored, so continuing
172 // would run the test against a broken device or silently skip it. The
173 // twenty-one `let Some(..) = resolve(..) else { return }` sites this
174 // replaced swallowed exactly this case (#2422). Reachable only from a
175 // `#[test]`.
176 Err(error) => panic!(
177 "[gpu-test] {label}: CUDA resolution FAULTED: {error}. A faulted device is \
178 not an absent one, and must never be skipped (#2422) -- twenty-one sites \
179 used `let Some(..) = resolve(..) else {{ return }}`, whose else-arm \
180 swallowed exactly this."
181 ),
182 }
183}
184
185#[cfg(test)]
186mod tests_gpu_test_gate_2422 {
187 use super::*;
188
189 /// The counter is the whole point: an absent device must leave a trace a
190 /// test can assert on, not just a line on stderr that nothing reads.
191 ///
192 /// This test is itself device-conditional, and it says so honestly: on a
193 /// host WITH a device it checks that nothing was counted as skipped, and on
194 /// a host without one it checks that the skip was counted. Both arms
195 /// assert, which is the property #2422 is about.
196 #[test]
197 fn an_absent_device_is_counted_not_silent_2422() {
198 let before = skipped_for_absent_device();
199 match gpu_for_test("gate self-test") {
200 GpuTestGate::Ready(runtime) => {
201 assert_eq!(
202 skipped_for_absent_device(),
203 before,
204 "a resolved runtime must not count as a skip"
205 );
206 assert!(
207 !runtime.selected_device().name.is_empty(),
208 "a Ready gate must carry a runtime with a selected device"
209 );
210 }
211 GpuTestGate::AbsentDevice => {
212 assert_absent_device_was_counted(before);
213 }
214 }
215 }
216
217 /// The skip marker is one fixed string, and it is the string the emitter
218 /// actually prints.
219 ///
220 /// A ledger step greps for `SKIPPED_MARKER`; if the `eprintln!` were to be
221 /// reworded independently the scrape would silently find nothing and report
222 /// a clean inventory for a run full of skips — the same "parser with no
223 /// producer" shape as #2617. Asserting the constant's spelling here keeps
224 /// the two ends of that contract pinned together.
225 #[test]
226 fn the_skip_marker_is_one_greppable_string_2422() {
227 assert_eq!(
228 SKIPPED_MARKER, "SKIPPED(no-cuda):",
229 "the marker a CI ledger greps for must not drift; update the ledger step \
230 in the same commit if this ever changes"
231 );
232 assert!(
233 !SKIPPED_MARKER.contains('{'),
234 "the marker must be a literal, not a format template, or a grep cannot match it"
235 );
236 }
237
238 /// A device-free host must not report a *faulted* device as absent, and a
239 /// faulted one must never be counted as a skip.
240 ///
241 /// The distinction is the reason this module exists: twenty-one sites wrote
242 /// `let Some(rt) = resolve(..) else { return }`, whose else-arm swallows
243 /// `Err`. There is no way to fabricate a driver fault in-process here, so
244 /// this asserts the reachable half — that the gate's two non-panicking
245 /// outcomes are exactly `Ready` and `AbsentDevice`, and that the absent one
246 /// is always accompanied by a count.
247 #[test]
248 fn the_gate_has_no_third_silent_outcome_2422() {
249 let before = skipped_for_absent_device();
250 match gpu_for_test("gate exhaustiveness self-test") {
251 GpuTestGate::Ready(runtime) => {
252 assert!(
253 runtime.selected_device().total_mem_bytes > 0,
254 "a Ready gate must carry a usable device, not a placeholder"
255 );
256 }
257 GpuTestGate::AbsentDevice => {
258 assert_absent_device_was_counted(before);
259 }
260 }
261 }
262
263 /// `Ready` carries a runtime and `AbsentDevice` does not — the projection
264 /// every call site reads.
265 #[test]
266 fn the_gate_projects_to_an_option_only_at_the_call_site_2422() {
267 let gate = gpu_for_test("gate projection self-test");
268 assert_eq!(
269 gate.runtime().is_some(),
270 matches!(gate, GpuTestGate::Ready(_)),
271 "runtime() must agree with the variant"
272 );
273 }
274}