Skip to main content

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}