Skip to main content

cortiq_engine/
gpu.rs

1//! Facade for GPU backends: a single call entry point for qtensor/pipeline/
2//! linear_core. Job types and the threshold are canonical HERE; behind the
3//! facade dispatch goes to a platform backend:
4//!   - `gpu_metal` (Apple Silicon, unified memory + no-copy buffers);
5//!   - `gpu_wgpu` (C1: Vulkan/DX12/Metal — NVIDIA/Radeon/Intel/Apple,
6//!     weights resident in VRAM), available under `--features gpu`.
7//!
8//! Runtime selection via `CMF_GPU`: `1` — native Metal (macOS) or wgpu
9//! (other OSes); `wgpu` — force wgpu (including for the local
10//! Metal-via-wgpu parity test). Any backend refusal — `false` and the honest
11//! CPU path, no partial results.
12
13use cortiq_core::CmfModel;
14use std::cell::Cell;
15use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
16use std::sync::{Arc, OnceLock};
17
18thread_local! {
19    /// Index of the current forward layer (−1 = outside a numbered layer:
20    /// lm_head/embed — always allowed). The pipeline sets it before
21    /// each layer so that the GPU/CPU layer-split works.
22    static CUR_LAYER: Cell<i64> = const { Cell::new(-1) };
23    /// Inside `cpu_scope` every GPU gate reports disabled: the timed CPU
24    /// arm of a probe (and a class that lost its probe) must run PURE
25    /// CPU, or inner per-op hooks would re-enter the GPU and poison the
26    /// comparison.
27    static CPU_ONLY: Cell<bool> = const { Cell::new(false) };
28    /// "This op paid a one-off cost" (weight upload / first pipeline
29    /// build): backends set it, `probe_record` discards the sample so
30    /// only steady-state timings compete.
31    static PROBE_COLD: Cell<bool> = const { Cell::new(false) };
32}
33
34/// Run `f` with the GPU gates off on this thread (pure-CPU arm).
35pub fn cpu_scope<R>(f: impl FnOnce() -> R) -> R {
36    struct Restore(bool);
37    impl Drop for Restore {
38        fn drop(&mut self) {
39            CPU_ONLY.with(|c| c.set(self.0));
40        }
41    }
42    let previous = CPU_ONLY.with(|c| c.replace(true));
43    let _restore = Restore(previous);
44    f()
45}
46
47/// Backends: note a one-off cost (weight upload, buffer-cache fill) so
48/// the probe discards this sample.
49pub(crate) fn probe_note_cold() {
50    PROBE_COLD.with(|c| c.set(true));
51}
52
53/// Peek the cold flag without consuming it (`probe_record` consumes).
54/// Contention heuristics use this: a slow COLD op is a one-off build
55/// cost, not evidence the device is busy.
56pub(crate) fn probe_was_cold() -> bool {
57    PROBE_COLD.with(|c| c.get())
58}
59
60/// Pipeline: mark the current layer (or −1 outside layers) for layer-split.
61pub fn set_layer(l: i64) {
62    CUR_LAYER.with(|c| c.set(l));
63}
64
65/// The layer `set_layer` last marked on this thread (−1 outside layers).
66pub fn cur_layer() -> i64 {
67    CUR_LAYER.with(|c| c.get())
68}
69
70/// Parse `CMF_GPU_LAYERS` («0-19», «0,2,4», «0-9,30-39») once.
71/// None = no restriction (all layers on GPU). Garbage → also no restriction.
72fn layer_ranges() -> &'static Option<Vec<(i64, i64)>> {
73    static R: OnceLock<Option<Vec<(i64, i64)>>> = OnceLock::new();
74    R.get_or_init(|| {
75        let s = std::env::var("CMF_GPU_LAYERS").ok()?;
76        let mut v = Vec::new();
77        for part in s.split(',') {
78            let part = part.trim();
79            match part.split_once('-') {
80                Some((a, b)) => v.push((a.trim().parse().ok()?, b.trim().parse().ok()?)),
81                None => {
82                    let x: i64 = part.parse().ok()?;
83                    v.push((x, x));
84                }
85            }
86        }
87        Some(v)
88    })
89}
90
91fn layer_allowed() -> bool {
92    match layer_ranges() {
93        None => true,
94        Some(ranges) => {
95            let cur = CUR_LAYER.with(|c| c.get());
96            cur < 0 || ranges.iter().any(|(a, b)| cur >= *a && cur <= *b)
97        }
98    }
99}
100
101/// GPU allowed FOR THE CURRENT LAYER: backend is initialized AND the layer
102/// falls within `CMF_GPU_LAYERS` (GPU/CPU layer-split) AND we are not
103/// inside a `cpu_scope`. Op gates call this.
104pub fn enabled_here() -> bool {
105    !CPU_ONLY.with(|c| c.get()) && enabled() && layer_allowed()
106}
107
108// ── Runtime GPU-vs-CPU probe ────────────────────────────────────────────
109// CMF_GPU=1 does not TRUST that the device wins — it MEASURES. For each
110// op class the first calls alternate arms: GPU timed vs pure-CPU timed
111// (under cpu_scope). Cold GPU calls (weight upload / cache fill) are
112// discarded; after PROBE_SAMPLES clean samples per arm the faster arm is
113// chosen for the rest of the process. Rationale: submit+poll latency
114// differs by an order of magnitude across driver stacks (Metal/PCIe
115// ~3-4 ms, Vulkan/4090 ~0.3 ms) — a static threshold cannot know whether
116// per-op offload pays off HERE. CMF_GPU_PROBE=0 → always trust the GPU.
117
118/// GPU-eligible op classes, each with an independent probe.
119#[derive(Clone, Copy)]
120pub enum OpClass {
121    /// Whole FFN chain in one submission (dense / MoE block).
122    Ffn = 0,
123    /// Large hybrid CPU∥GPU matvec (lm_head class).
124    Matvec = 1,
125    /// Prefill GEMM (matmat).
126    Matmat = 2,
127    /// Batched matvecs of one input (QKV).
128    Batch = 3,
129    /// Prefill GEMM at image-diffusion widths (b ≥ 128). Probed apart
130    /// from `Matmat`: one imagegen process runs BOTH populations
131    /// (prompt encode b≈40 where the GPU wins big, DiT b≥256 where
132    /// the CPU AMX arm is competitive) — a single shared verdict locks
133    /// the wrong arm for whichever population samples second.
134    MatmatWide = 4,
135}
136
137/// Probe verdict for one call.
138pub enum ProbeArm {
139    /// Run the GPU path (during probing: timed, recorded).
140    Gpu,
141    /// Probing: run the CPU path under `cpu_scope`, timed, recorded.
142    CpuTimed,
143    /// Decided: CPU won — run the CPU path (under `cpu_scope`).
144    Cpu,
145}
146
147/// Clean samples per arm before a class decides.
148const PROBE_SAMPLES: u32 = 6;
149
150struct Probe {
151    /// 0 = probing, 1 = GPU won, 2 = CPU won.
152    state: AtomicU8,
153    flip: AtomicU32,
154    gpu_ns: AtomicU64,
155    gpu_n: AtomicU32,
156    cpu_ns: AtomicU64,
157    cpu_n: AtomicU32,
158    /// Best (minimum) sample per arm. The DECISION compares these:
159    /// means are poisoned by one-off cold costs the cold-flag cannot
160    /// see — e.g. the CPU arm's first mmap-cold expert matvec page
161    /// faults its weights in and reads 3× its steady state, which
162    /// locked the GPU arm on a 35B MoE at a 4× real-world loss. The
163    /// minimum is each arm's honest steady-state pace.
164    gpu_min: AtomicU64,
165    cpu_min: AtomicU64,
166}
167
168impl Probe {
169    const fn new() -> Self {
170        Self {
171            state: AtomicU8::new(0),
172            flip: AtomicU32::new(0),
173            gpu_ns: AtomicU64::new(0),
174            gpu_n: AtomicU32::new(0),
175            cpu_ns: AtomicU64::new(0),
176            cpu_n: AtomicU32::new(0),
177            gpu_min: AtomicU64::new(u64::MAX),
178            cpu_min: AtomicU64::new(u64::MAX),
179        }
180    }
181}
182
183static PROBES: [Probe; 5] = [
184    Probe::new(),
185    Probe::new(),
186    Probe::new(),
187    Probe::new(),
188    Probe::new(),
189];
190
191fn probe_on() -> bool {
192    static ON: OnceLock<bool> = OnceLock::new();
193    *ON.get_or_init(|| {
194        std::env::var("CMF_GPU_PROBE")
195            .map(|v| v != "0" && v != "off")
196            .unwrap_or(true)
197    })
198}
199
200/// q1 ops on the native Metal backend skip the probe entirely: the CPU
201/// q1 kernel is load-port-bound, the GPU one wins warm — and probe
202/// alternation itself cools the device between samples (measured: block
203/// times 5.8 ms warm vs 8.8 ms mixed). Other backends keep probing.
204pub fn q1_force() -> bool {
205    #[cfg(target_os = "macos")]
206    {
207        backend() == Backend::Metal
208    }
209    #[cfg(not(target_os = "macos"))]
210    {
211        false
212    }
213}
214
215/// Which arm should this GPU-eligible call take? Consult AFTER the
216/// eligibility gates (`enabled_here` / `min_rows`) so only real
217/// candidates alternate.
218pub fn probe_arm(c: OpClass) -> ProbeArm {
219    // Every arbitrated call starts with a clean cold flag: both the
220    // sample discard in `probe_record` and the contention kill-switch
221    // read it AFTER the op, so a stale note from a previous call on
222    // this thread must not leak in.
223    PROBE_COLD.with(|f| f.set(false));
224    if !probe_on() {
225        return ProbeArm::Gpu;
226    }
227    let p = &PROBES[c as usize];
228    match p.state.load(Ordering::Relaxed) {
229        1 => ProbeArm::Gpu,
230        2 => ProbeArm::Cpu,
231        _ => {
232            if p.flip.fetch_add(1, Ordering::Relaxed) % 2 == 0 {
233                ProbeArm::Gpu
234            } else {
235                ProbeArm::CpuTimed
236            }
237        }
238    }
239}
240
241/// Record a timed arm sample; on the `PROBE_SAMPLES`-th clean sample of
242/// BOTH arms the class decides for the rest of the process.
243pub fn probe_record(c: OpClass, gpu: bool, dur: std::time::Duration) {
244    let p = &PROBES[c as usize];
245    if p.state.load(Ordering::Relaxed) != 0 {
246        return;
247    }
248    if gpu && PROBE_COLD.with(|f| f.replace(false)) {
249        return; // one-off cost in this call — not a steady-state sample
250    }
251    let ns = dur.as_nanos().min(u64::MAX as u128) as u64;
252    if gpu {
253        p.gpu_ns.fetch_add(ns, Ordering::Relaxed);
254        p.gpu_n.fetch_add(1, Ordering::Relaxed);
255        p.gpu_min.fetch_min(ns, Ordering::Relaxed);
256    } else {
257        p.cpu_ns.fetch_add(ns, Ordering::Relaxed);
258        p.cpu_n.fetch_add(1, Ordering::Relaxed);
259        p.cpu_min.fetch_min(ns, Ordering::Relaxed);
260    }
261    let (gn, cn) = (
262        p.gpu_n.load(Ordering::Relaxed),
263        p.cpu_n.load(Ordering::Relaxed),
264    );
265    if gn >= 2 && cn >= 2 {
266        // Decide on each arm's BEST sample — the steady-state pace.
267        // Means carry one-off cold costs (mmap page-in on the CPU arm)
268        // that the cold-flag machinery cannot see.
269        let g = p.gpu_min.load(Ordering::Relaxed) as f64;
270        let cp = p.cpu_min.load(Ordering::Relaxed) as f64;
271        // Early verdict on a ≥3× gap — no reason to keep feeding the
272        // losing arm; close races take the full sample count.
273        if (gn < PROBE_SAMPLES || cn < PROBE_SAMPLES) && g < cp * 3.0 && cp < g * 3.0 {
274            return;
275        }
276        let winner = if g <= cp { 1 } else { 2 };
277        if p.state
278            .compare_exchange(0, winner, Ordering::Relaxed, Ordering::Relaxed)
279            .is_ok()
280        {
281            tracing::info!(
282                "gpu probe [{}]: gpu {:.2} ms vs cpu {:.2} ms per op → {}",
283                ["ffn", "matvec", "matmat", "qkv-batch", "matmat-wide"][c as usize],
284                g / 1e6,
285                cp / 1e6,
286                if winner == 1 { "gpu" } else { "cpu" },
287            );
288        }
289    }
290}
291
292/// Is the class still collecting samples? (Call sites use this to route
293/// cold-weight calls away from the GPU arm during probing.)
294pub fn probe_deciding(c: OpClass) -> bool {
295    probe_on() && PROBES[c as usize].state.load(Ordering::Relaxed) == 0
296}
297
298/// Probing helper: true — tensor `idx`'s quant weights are ALREADY
299/// device-resident (a clean GPU sample is possible now); false — they
300/// were not (the upload starts within the VRAM budget, so a later call
301/// finds them warm) or the tensor cannot go to the GPU at all. Keeps the
302/// probe from billing a full cold dispatch+readback to a sample it will
303/// discard anyway. The verdict needs only a couple of warm tensors, so
304/// probe-driven uploads are capped — the losing-GPU machine should not
305/// pay for uploading the whole layer stack it will never use; if the GPU
306/// wins, the rest uploads lazily on demand, in the same first-touch order.
307#[allow(unused_variables)]
308pub fn q8_resident_or_upload(model: &Arc<CmfModel>, idx: usize) -> bool {
309    static PROBE_UPLOADS: AtomicU32 = AtomicU32::new(0);
310    let may_upload = PROBE_UPLOADS.load(Ordering::Relaxed) < 4;
311    let resident = match backend() {
312        #[cfg(target_os = "macos")]
313        Backend::Metal => crate::gpu_metal::q8_resident_or_upload(model, idx, may_upload),
314        #[cfg(feature = "gpu")]
315        Backend::Wgpu => crate::gpu_wgpu::q8_resident_or_upload(model, idx, may_upload),
316        Backend::None => false,
317    };
318    if !resident && may_upload {
319        PROBE_UPLOADS.fetch_add(1, Ordering::Relaxed);
320    }
321    resident
322}
323
324/// Test hook: reset all probes to the undecided state.
325#[cfg(test)]
326pub(crate) fn probe_reset() {
327    for p in &PROBES {
328        p.state.store(0, Ordering::Relaxed);
329        p.flip.store(0, Ordering::Relaxed);
330        p.gpu_ns.store(0, Ordering::Relaxed);
331        p.gpu_n.store(0, Ordering::Relaxed);
332        p.cpu_ns.store(0, Ordering::Relaxed);
333        p.cpu_n.store(0, Ordering::Relaxed);
334    }
335}
336
337#[cfg(test)]
338mod probe_tests {
339    use super::*;
340    use std::time::Duration;
341
342    // One test fn: PROBES is process-global and probe_reset touches all
343    // classes — parallel test threads would race.
344    #[test]
345    fn probe_alternates_discards_cold_and_decides() {
346        probe_reset();
347        // Probing: arms alternate.
348        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::Gpu));
349        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::CpuTimed));
350
351        // A cold GPU sample (upload noted) must be discarded: feed a
352        // catastrophic cold sample, then clean fast-GPU samples — GPU
353        // wins only if the cold one did not count.
354        probe_note_cold();
355        probe_record(OpClass::Ffn, true, Duration::from_secs(1000));
356        for _ in 0..PROBE_SAMPLES {
357            probe_record(OpClass::Ffn, true, Duration::from_millis(1));
358            probe_record(OpClass::Ffn, false, Duration::from_millis(4));
359        }
360        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::Gpu));
361
362        // The reverse: a class where the CPU arm is faster decides CPU.
363        for _ in 0..PROBE_SAMPLES {
364            probe_record(OpClass::Matmat, true, Duration::from_millis(4));
365            probe_record(OpClass::Matmat, false, Duration::from_millis(1));
366        }
367        assert!(matches!(probe_arm(OpClass::Matmat), ProbeArm::Cpu));
368
369        // cpu_scope: gates off inside, restored after.
370        cpu_scope(|| CPU_ONLY.with(|c| assert!(c.get())));
371        CPU_ONLY.with(|c| assert!(!c.get()));
372        cpu_scope(|| {
373            cpu_scope(|| CPU_ONLY.with(|c| assert!(c.get())));
374            CPU_ONLY.with(|c| assert!(c.get()));
375        });
376        let _ = std::panic::catch_unwind(|| cpu_scope(|| panic!("scope test")));
377        CPU_ONLY.with(|c| assert!(!c.get()));
378        probe_reset();
379    }
380}
381
382/// Default row threshold: the GPU takes only larger matrices (lm_head
383/// class). Below it, the dispatch/readback cost does not pay off on unified memory.
384pub const GPU_MIN_ROWS: usize = 65_536;
385
386/// Effective threshold: `CMF_GPU_MIN_ROWS` overrides. Defaults differ
387/// by device class: on a DISCRETE card VRAM bandwidth pays off even for
388/// FFN/QKV-class matrices (4096), on unified memory only lm_head-class
389/// is worth the dispatch/readback (65536). Field case behind this: a
390/// 35B model on an RTX 4090 saw ~0 offload because every layer matrix
391/// sat below the old universal 65536.
392pub fn min_rows() -> usize {
393    if let Some(v) = std::env::var("CMF_GPU_MIN_ROWS")
394        .ok()
395        .and_then(|v| v.parse().ok())
396    {
397        return v;
398    }
399    if discrete() { 4096 } else { GPU_MIN_ROWS }
400}
401
402/// Is the active backend a discrete card (PCIe VRAM)?
403pub fn discrete() -> bool {
404    match backend() {
405        #[cfg(feature = "gpu")]
406        Backend::Wgpu => crate::gpu_wgpu::is_discrete(),
407        #[cfg(target_os = "macos")]
408        Backend::Metal => false, // UMA by the init() guard
409        Backend::None => false,
410    }
411}
412
413/// A single MoE-FFN job (an expert with its own weight), executed in one
414/// submission: (rows, cols, idx, row_scale) for gate/up/down + prescaled
415/// inputs + the down θ-field + the blending weight.
416pub struct MoeJob<'a> {
417    pub gate: (usize, usize, usize, &'a [f32]),
418    pub up: (usize, usize, usize, &'a [f32]),
419    pub down: (usize, usize, usize, &'a [f32]),
420    pub xs_gate: Vec<f32>,
421    pub xs_up: Vec<f32>,
422    pub down_col: &'a [f32],
423    pub w: f32,
424    /// q1 trio: scales live inside the 6-byte tiles (row_scale slices
425    /// empty, xs raw f32). Backends without a q1 kernel refuse the job.
426    pub q1: bool,
427    /// q4_tiled trio: scales inside the 18-byte tiles (row_scale
428    /// slices empty, xs raw f32) — the MoE-hybrid coder class.
429    pub q4t: bool,
430}
431
432/// A single independent batch matvec (GDN projections of one input).
433pub struct BatchJob<'a> {
434    pub idx: usize,
435    pub rows: usize,
436    pub cols: usize,
437    pub row_scale: &'a [f32],
438    pub xs: Vec<f32>,
439    /// q1 tensor: tile-embedded scales, raw f32 xs (see `MoeJob::q1`).
440    pub q1: bool,
441}
442
443#[derive(Clone, Copy, PartialEq, Eq)]
444enum Backend {
445    None,
446    #[cfg(target_os = "macos")]
447    Metal,
448    #[cfg(feature = "gpu")]
449    Wgpu,
450}
451
452fn backend() -> Backend {
453    #[cfg(feature = "gpu")]
454    if crate::gpu_wgpu::selected() {
455        return if crate::gpu_wgpu::enabled() {
456            Backend::Wgpu
457        } else {
458            Backend::None
459        };
460    }
461    #[cfg(target_os = "macos")]
462    if crate::gpu_metal::enabled() {
463        return Backend::Metal;
464    }
465    Backend::None
466}
467
468/// GPU enabled and initialized on the selected backend?
469/// Whether THIS build can bring a GPU up on THIS device: a compiled-in
470/// backend plus a live adapter. The mobile FFI exposes it so an app can
471/// tell "GPU off" from "GPU impossible" (a CPU-only .so ships no
472/// backend at all). Cached after the first call.
473pub fn backend_available() -> bool {
474    #[cfg(target_os = "macos")]
475    {
476        // The Metal path is always compiled on macOS.
477        true
478    }
479    #[cfg(all(feature = "gpu", not(target_os = "macos")))]
480    {
481        static AVAIL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
482        *AVAIL.get_or_init(crate::gpu_wgpu::adapter_probe)
483    }
484    #[cfg(all(not(feature = "gpu"), not(target_os = "macos")))]
485    {
486        false
487    }
488}
489
490pub fn enabled() -> bool {
491    backend() != Backend::None
492}
493
494/// Default-on condition for the wgpu whole-token graph: the wgpu
495/// backend on a DISCRETE adapter. NOT plain `enabled()` (macOS/Metal
496/// must not pay a per-token layer scan for a graph its backend
497/// refuses), and NOT integrated adapters: the graph's ~300 barriered
498/// dispatches per token are cheap on desktop immediate-mode GPUs but
499/// tiled mobile GPUs (Adreno/Mali) drain the pipeline at every barrier
500/// — field report: 0.2 tok/s on-graph vs 15 tok/s on the CPU. On
501/// integrated adapters the per-op probe path arbitrates each op class
502/// against the CPU instead; CMF_GPU_WGPU_GRAPH=1 still forces the
503/// graph anywhere.
504/// Is the wgpu backend active at all (any adapter)? Eligibility gate
505/// for the whole-token graph — whether it actually RUNS is decided by
506/// `wgpu_graph_default` (trusted on discrete) or the generation race.
507pub fn wgpu_active() -> bool {
508    #[cfg(feature = "gpu")]
509    {
510        matches!(backend(), Backend::Wgpu)
511    }
512    #[cfg(not(feature = "gpu"))]
513    {
514        false
515    }
516}
517
518pub fn wgpu_graph_default() -> bool {
519    #[cfg(feature = "gpu")]
520    {
521        matches!(backend(), Backend::Wgpu) && crate::gpu_wgpu::discrete_active()
522    }
523    #[cfg(not(feature = "gpu"))]
524    {
525        false
526    }
527}
528
529/// q8_row/q8_2f matvec, rows [row0, row0+rows). `xs` — prescaled by the θ-field.
530#[allow(clippy::too_many_arguments, unused_variables)]
531pub fn q8_matvec_range(
532    model: &Arc<CmfModel>,
533    idx: usize,
534    row0: usize,
535    row_scale: &[f32],
536    xs: &[f32],
537    rows: usize,
538    cols: usize,
539    out: &mut [f32],
540) -> bool {
541    match backend() {
542        #[cfg(target_os = "macos")]
543        Backend::Metal => {
544            crate::gpu_metal::q8_matvec_range(model, idx, row0, row_scale, xs, rows, cols, out)
545        }
546        #[cfg(feature = "gpu")]
547        Backend::Wgpu => {
548            crate::gpu_wgpu::q8_matvec_range(model, idx, row0, row_scale, xs, rows, cols, out)
549        }
550        Backend::None => false,
551    }
552}
553
554/// GEMM of a prefill batch: `pre` — prescaled inputs row-major [b, cols],
555/// out — row-major [b, rows].
556#[allow(clippy::too_many_arguments, unused_variables)]
557pub fn q8_matmat(
558    model: &Arc<CmfModel>,
559    idx: usize,
560    row_scale: &[f32],
561    pre: &[f32],
562    b: usize,
563    rows: usize,
564    cols: usize,
565    out: &mut [f32],
566) -> bool {
567    match backend() {
568        #[cfg(target_os = "macos")]
569        Backend::Metal => {
570            crate::gpu_metal::q8_matmat(model, idx, row_scale, pre, b, rows, cols, out)
571        }
572        #[cfg(feature = "gpu")]
573        Backend::Wgpu => crate::gpu_wgpu::q8_matmat(model, idx, row_scale, pre, b, rows, cols, out),
574        Backend::None => false,
575    }
576}
577
578/// q1 matvec: raw f32 activations, tile-embedded scales. Metal only
579/// for now (wgpu q1 WGSL is queued); false = CPU fallback.
580#[allow(unused_variables)]
581pub fn q1_matvec(
582    model: &Arc<CmfModel>,
583    idx: usize,
584    xs: &[f32],
585    rows: usize,
586    cols: usize,
587    out: &mut [f32],
588) -> bool {
589    match backend() {
590        #[cfg(target_os = "macos")]
591        Backend::Metal => crate::gpu_metal::q1_matvec(model, idx, xs, rows, cols, out),
592        #[cfg(feature = "gpu")]
593        Backend::Wgpu => crate::gpu_wgpu::q1_matvec(model, idx, xs, rows, cols, out),
594        Backend::None => false,
595    }
596}
597
598/// Whole attention sub-block on the wgpu token graph (drop-in for
599/// `qwen_attention`): normed hidden in, O-projection out, resident device
600/// K/V mirror. false = refusal / not the wgpu backend → CPU path.
601#[allow(clippy::too_many_arguments)]
602pub fn attn_dropin(
603    model: &Arc<CmfModel>,
604    kv_id: u64,
605    layer: usize,
606    normed: &[f32],
607    wq_idx: usize,
608    wk_idx: usize,
609    wv_idx: usize,
610    wo_idx: usize,
611    q_norm: Option<&[f32]>,
612    k_norm: Option<&[f32]>,
613    invf: &[f32],
614    nh: usize,
615    nkv: usize,
616    hd: usize,
617    rd: usize,
618    hidden: usize,
619    pos: usize,
620    cap: usize,
621    gemma: bool,
622    eps: f32,
623    cpu_k: &[Vec<f32>],
624    cpu_v: &[Vec<f32>],
625    out: &mut [f32],
626) -> bool {
627    match backend() {
628        #[cfg(feature = "gpu")]
629        Backend::Wgpu => crate::gpu_wgpu::attn_dropin_gpu(
630            model, kv_id, layer, normed, wq_idx, wk_idx, wv_idx, wo_idx, q_norm, k_norm, invf, nh,
631            nkv, hd, rd, hidden, pos, cap, gemma, eps, cpu_k, cpu_v, out,
632        ),
633        #[allow(unused_variables)]
634        _ => false,
635    }
636}
637
638/// One weight in the whole-token graph: tensor idx + a codec tag (0=q8_row,
639/// 1=q1, 2=q4_tiled, 3=q1t, 4=f32) + per-row scales (q8_row only) + the raw f32
640/// data (kind 4 only — small unquantized projections like GDN in_proj_a/b).
641pub struct GraphW<'a> {
642    pub idx: usize,
643    pub kind: u8,
644    pub row_scale: &'a [f32],
645    pub data: &'a [f32],
646}
647
648/// A layer's token-mixing op: standard attention or a GDN (linear-attention)
649/// block. The surrounding norms + SwiGLU FFN are common to both.
650pub enum GraphAttn<'a> {
651    Full {
652        wq: GraphW<'a>,
653        wk: GraphW<'a>,
654        wv: GraphW<'a>,
655        wo: GraphW<'a>,
656        q_norm: Option<&'a [f32]>,
657        k_norm: Option<&'a [f32]>,
658        /// (bq, bk, bv) attention biases (Qwen2). None ⇒ no bias.
659        bias: Option<(&'a [f32], &'a [f32], &'a [f32])>,
660        /// Qwen3.5 gated attention: wq emits 2·nh·hd (q||gate per head), the
661        /// attention output is scaled by sigmoid(gate) before the O projection.
662        output_gate: bool,
663        cpu_k: &'a [Vec<f32>],
664        cpu_v: &'a [Vec<f32>],
665    },
666    Gdn {
667        qkv: GraphW<'a>,
668        z: GraphW<'a>,
669        a: GraphW<'a>,
670        b: GraphW<'a>,
671        out: GraphW<'a>,
672        conv1d: &'a [f32],
673        a_log: &'a [f32],
674        dt_bias: &'a [f32],
675        norm: &'a [f32],
676        nv: usize,
677        nk: usize,
678        dk: usize,
679        dv: usize,
680        kk: usize,
681    },
682}
683
684/// Per-layer weights for the whole-token wgpu graph.
685pub struct GraphLayer<'a> {
686    pub input_norm: &'a [f32],
687    pub attn: GraphAttn<'a>,
688    pub post_norm: &'a [f32],
689    pub ffn: GraphFfn<'a>,
690}
691
692/// The FFN of one graph layer: a dense SwiGLU trio, or a routed MoE —
693/// router + top-k selection + all selected experts run ON DEVICE (the
694/// routing decision depends on the resident hidden state, so a CPU
695/// round-trip per layer would forfeit the one-submit design).
696pub enum GraphFfn<'a> {
697    Dense {
698        gate: GraphW<'a>,
699        up: GraphW<'a>,
700        down: GraphW<'a>,
701    },
702    Moe {
703        /// Router logits weight (f32, kind 4) `[n_exp, hidden]`.
704        router: GraphW<'a>,
705        /// Shared-expert sigmoid gate (f32) `[1, hidden]`.
706        shared_gate: GraphW<'a>,
707        /// Per-expert q4_tiled directory indices `(gate, up, down)`;
708        /// the SHARED expert rides as the LAST entry — the select
709        /// kernel pins it with the sigmoid weight.
710        experts: Vec<(usize, usize, usize)>,
711        /// Routed experts (shared excluded).
712        n_exp: usize,
713        top_k: usize,
714        inter: usize,
715        norm_topk: bool,
716    },
717}
718
719/// Whole-token decode graph on wgpu: the entire layer stack in ONE submit,
720/// hidden resident, one readback. Updates `h` in place. false = refusal.
721/// `loop_norm_at`: virtual layer indices after which `final_norm` is applied
722/// (Looped Transformer mid-stack norm). Empty for standard models.
723#[allow(clippy::too_many_arguments)]
724pub fn forward_token_graph(
725    model: &Arc<CmfModel>,
726    kv_id: u64,
727    layers: &[GraphLayer],
728    invf: &[f32],
729    h: &mut [f32],
730    nh: usize,
731    nkv: usize,
732    hd: usize,
733    rd: usize,
734    hidden: usize,
735    inter: usize,
736    position: usize,
737    cap: usize,
738    gemma: bool,
739    eps: f32,
740    lm_head: Option<(&GraphW, usize)>,
741    final_norm: &[f32],
742    logits: &mut Vec<f32>,
743    loop_norm_at: &[usize],
744) -> bool {
745    match backend() {
746        #[cfg(feature = "gpu")]
747        Backend::Wgpu => crate::gpu_wgpu::forward_token_graph(
748            model,
749            kv_id,
750            layers,
751            invf,
752            h,
753            nh,
754            nkv,
755            hd,
756            rd,
757            hidden,
758            inter,
759            position,
760            cap,
761            gemma,
762            eps,
763            lm_head,
764            final_norm,
765            logits,
766            loop_norm_at,
767        ),
768        #[allow(unused_variables)]
769        _ => {
770            let _ = (lm_head, final_norm, logits, loop_norm_at);
771            false
772        }
773    }
774}
775
776/// Batched prefill: k contiguous positions through the whole graph in one submit
777/// (projections/FFN as GEMMs, attention/GDN looped over scratch). `h` is
778/// [k·hidden] in/out; `positions` len k. wgpu only.
779#[allow(clippy::too_many_arguments)]
780pub fn forward_batch_graph(
781    model: &Arc<CmfModel>,
782    kv_id: u64,
783    layers: &[GraphLayer],
784    invf: &[f32],
785    h: &mut [f32],
786    nh: usize,
787    nkv: usize,
788    hd: usize,
789    rd: usize,
790    hidden: usize,
791    inter: usize,
792    positions: &[usize],
793    cap: usize,
794    gemma: bool,
795    eps: f32,
796    k: usize,
797) -> bool {
798    match backend() {
799        #[cfg(feature = "gpu")]
800        Backend::Wgpu => crate::gpu_wgpu::forward_batch_graph(
801            model, kv_id, layers, invf, h, nh, nkv, hd, rd, hidden, inter, positions, cap, gemma,
802            eps, k,
803        ),
804        _ => false,
805    }
806}
807
808/// Drop the wgpu token graph's device K/V mirror for a pipeline.
809pub fn graph_kv_reset(_kv_id: u64) {
810    #[cfg(feature = "gpu")]
811    if backend() == Backend::Wgpu {
812        crate::gpu_wgpu::kv_mirror_reset(_kv_id);
813    }
814}
815
816/// Ternary (q1t) BASE matvec on the GPU — fills `out` with the base dot; the
817/// caller adds the sparse overlay on the CPU. Metal only for now (wgpu q1t not
818/// yet written → CPU fallback).
819pub fn q1t_matvec(
820    model: &Arc<CmfModel>,
821    idx: usize,
822    xs: &[f32],
823    rows: usize,
824    cols: usize,
825    out: &mut [f32],
826) -> bool {
827    match backend() {
828        #[cfg(target_os = "macos")]
829        Backend::Metal => {
830            if metal_q1t_enabled() {
831                crate::gpu_metal::q1t_matvec(model, idx, xs, rows, cols, out)
832            } else {
833                false
834            }
835        }
836        #[cfg(feature = "gpu")]
837        Backend::Wgpu => crate::gpu_wgpu::q1t_matvec(model, idx, xs, rows, cols, out),
838        Backend::None => false,
839    }
840}
841
842/// q4_block matvec on the GPU — wgpu only (Metal drives q4_block through the
843/// whole-token graph, not a standalone matvec).
844#[allow(unused_variables)]
845pub fn q4b_matvec(
846    model: &Arc<CmfModel>,
847    idx: usize,
848    xs: &[f32],
849    rows: usize,
850    cols: usize,
851    out: &mut [f32],
852) -> bool {
853    match backend() {
854        #[cfg(target_os = "macos")]
855        Backend::Metal => false,
856        #[cfg(feature = "gpu")]
857        Backend::Wgpu => crate::gpu_wgpu::q4b_matvec(model, idx, xs, rows, cols, out),
858        Backend::None => false,
859    }
860}
861
862/// q1t batched GEMM (prefill) — base + overlay on-device (Metal simdgroup or
863/// wgpu register-blocked).
864pub fn q1t_matmat(
865    model: &Arc<CmfModel>,
866    idx: usize,
867    xs: &[f32],
868    b: usize,
869    rows: usize,
870    cols: usize,
871    out: &mut [f32],
872) -> bool {
873    match backend() {
874        #[cfg(target_os = "macos")]
875        // Batched prefill and single-token decode are both enabled. On the
876        // real 14.8B Q1T model prefill PPL was within 0.3% of CPU (7.942 vs
877        // 7.966), and the alignment-safe decode kernel reached 3.52e-6 max_rel.
878        Backend::Metal => crate::gpu_metal::q1t_matmat(model, idx, xs, b, rows, cols, out),
879        #[cfg(feature = "gpu")]
880        Backend::Wgpu => crate::gpu_wgpu::q1t_matmat(model, idx, xs, b, rows, cols, out),
881        Backend::None => false,
882    }
883}
884
885/// Native Metal Q1T switch. Enabled by default after the byte-packed Q1T
886/// fields were changed to alignment-safe loads; keep an explicit emergency
887/// fallback for device/driver diagnostics.
888#[cfg(target_os = "macos")]
889pub(crate) fn metal_q1t_enabled() -> bool {
890    std::env::var("CMF_METAL_Q1T")
891        .map(|v| v != "0" && !v.eq_ignore_ascii_case("off"))
892        .unwrap_or(true)
893}
894
895/// Batched q1 GEMM (prefill). wgpu only — Metal has its own block path.
896pub fn q1_matmat(
897    model: &Arc<CmfModel>,
898    idx: usize,
899    xs: &[f32],
900    b: usize,
901    rows: usize,
902    cols: usize,
903    out: &mut [f32],
904) -> bool {
905    match backend() {
906        #[cfg(feature = "gpu")]
907        Backend::Wgpu => crate::gpu_wgpu::q1_matmat(model, idx, xs, b, rows, cols, out),
908        #[allow(unused_variables)]
909        _ => false,
910    }
911}
912
913/// Contention kill for the wide imagegen GEMM/FFN paths: one grossly
914/// slow op under a work-proportional budget (fair-device ops are
915/// ≤~100 ms even at 1024px) means another process owns the device —
916/// verdicts are per-process, so CPU for the rest of this one.
917static MM_KILL: AtomicBool = AtomicBool::new(false);
918pub(crate) fn mm_killed() -> bool {
919    MM_KILL.load(Ordering::Relaxed)
920}
921pub(crate) fn mm_kill() {
922    MM_KILL.store(true, Ordering::Relaxed);
923}
924
925/// Fused DiT SwiGLU FFN on the device: g=X·W1ᵀ, u=X·W3ᵀ, silu(g)·u,
926/// y=·W2ᵀ — one command buffer, only X and Y cross the CPU boundary.
927#[allow(unused_variables, clippy::too_many_arguments)]
928pub fn q4t_ffn(
929    model: &Arc<CmfModel>,
930    w1: usize,
931    w3: usize,
932    w2: usize,
933    xs: &[f32],
934    b: usize,
935    hidden: usize,
936    inter: usize,
937    out: &mut [f32],
938) -> bool {
939    match backend() {
940        #[cfg(target_os = "macos")]
941        Backend::Metal => crate::gpu_metal::q4t_ffn(model, w1, w3, w2, xs, b, hidden, inter, out),
942        #[cfg(feature = "gpu")]
943        Backend::Wgpu => crate::gpu_wgpu::q4t_ffn(model, w1, w3, w2, xs, b, hidden, inter, out),
944        #[allow(unreachable_patterns)]
945        _ => false,
946    }
947}
948
949/// One whole modulated DiT block for `dit_block`: geometry, norm
950/// weights, AdaLN scale/gate vectors (gates pre-tanh'd), a per-token
951/// f32 RoPE cos/sin table, and the directory indices of the seven
952/// q4t projections. `x` is in-out `[n, hidden]`.
953pub struct DitBlockArgs<'a> {
954    pub n: usize,
955    pub hidden: usize,
956    pub inter: usize,
957    pub nh: usize,
958    pub nkv: usize,
959    pub hd: usize,
960    pub eps: f32,
961    pub rope_cos: &'a [f32],
962    pub rope_sin: &'a [f32],
963    pub norm1: &'a [f32],
964    pub norm2: &'a [f32],
965    pub ffn_norm1: &'a [f32],
966    pub ffn_norm2: &'a [f32],
967    pub norm_q: &'a [f32],
968    pub norm_k: &'a [f32],
969    pub s_msa: &'a [f32],
970    pub gate_msa: &'a [f32],
971    pub s_mlp: &'a [f32],
972    pub gate_mlp: &'a [f32],
973    pub wq: usize,
974    pub wk: usize,
975    pub wv: usize,
976    pub wo: usize,
977    pub w1: usize,
978    pub w3: usize,
979    pub w2: usize,
980}
981
982/// One whole modulated DiT block on the device — norms, qkv, RoPE,
983/// attention, residuals and the SwiGLU FFN in a single command
984/// buffer; only `x` crosses the CPU boundary (in and out).
985#[allow(unused_variables)]
986pub fn dit_block(model: &Arc<CmfModel>, a: &DitBlockArgs, x: &mut [f32]) -> bool {
987    match backend() {
988        #[cfg(target_os = "macos")]
989        Backend::Metal => crate::gpu_metal::dit_block(model, a, x),
990        _ => false,
991    }
992}
993
994/// One VAE resnet block for `vae_resnet`: norm/conv weights and the
995/// channel/shape geometry. `shortcut` is the 1×1 projection (w, b, k)
996/// when in/out channels differ.
997pub struct VaeResnetArgs<'a> {
998    pub groups: usize,
999    pub ic: usize,
1000    pub oc: usize,
1001    pub h: usize,
1002    pub w: usize,
1003    pub n1w: &'a [f32],
1004    pub n1b: &'a [f32],
1005    pub c1w: &'a [f32],
1006    pub c1b: &'a [f32],
1007    pub c1k: usize,
1008    pub n2w: &'a [f32],
1009    pub n2b: &'a [f32],
1010    pub c2w: &'a [f32],
1011    pub c2b: &'a [f32],
1012    pub c2k: usize,
1013    pub shortcut: Option<(&'a [f32], &'a [f32], usize)>,
1014}
1015
1016/// One whole VAE resnet block on the device (norm+silu → conv ×2 →
1017/// shortcut → add, one command buffer).
1018#[allow(unused_variables)]
1019pub fn vae_resnet(a: &VaeResnetArgs, x: &[f32], out: &mut [f32]) -> bool {
1020    match backend() {
1021        #[cfg(target_os = "macos")]
1022        Backend::Metal => crate::gpu_metal::vae_resnet(a, x, out),
1023        _ => false,
1024    }
1025}
1026
1027/// Nearest-2× upsample fused with the following conv — the small
1028/// pre-upsample image is what crosses the CPU boundary.
1029#[allow(unused_variables, clippy::too_many_arguments)]
1030pub fn vae_upsample_conv(
1031    w: &[f32],
1032    bias: &[f32],
1033    x: &[f32],
1034    ic: usize,
1035    oc: usize,
1036    h: usize,
1037    w_img: usize,
1038    k: usize,
1039    out: &mut [f32],
1040) -> bool {
1041    match backend() {
1042        #[cfg(target_os = "macos")]
1043        Backend::Metal => crate::gpu_metal::vae_upsample_conv(w, bias, x, ic, oc, h, w_img, k, out),
1044        _ => false,
1045    }
1046}
1047
1048/// VAE conv2d on the device (implicit GEMM — the CPU path pays for a
1049/// multi-GB im2col matrix at high resolutions).
1050#[allow(unused_variables, clippy::too_many_arguments)]
1051pub fn vae_conv2d(
1052    w: &[f32],
1053    bias: &[f32],
1054    x: &[f32],
1055    ic: usize,
1056    oc: usize,
1057    h: usize,
1058    w_img: usize,
1059    k: usize,
1060    out: &mut [f32],
1061) -> bool {
1062    match backend() {
1063        #[cfg(target_os = "macos")]
1064        Backend::Metal => crate::gpu_metal::vae_conv2d(w, bias, x, ic, oc, h, w_img, k, out),
1065        _ => false,
1066    }
1067}
1068
1069/// DiT full bidirectional attention on the device (all heads:
1070/// scores GEMM → row softmax → P·V → panel unstack, one command
1071/// buffer). Head-major inputs; out is [n, nh·hd].
1072#[allow(unused_variables, clippy::too_many_arguments)]
1073pub fn dit_attention(
1074    qh: &[f32],
1075    kh: &[f32],
1076    vh: &[f32],
1077    nh: usize,
1078    nkv: usize,
1079    n: usize,
1080    hd: usize,
1081    scale: f32,
1082    out: &mut [f32],
1083) -> bool {
1084    match backend() {
1085        #[cfg(target_os = "macos")]
1086        Backend::Metal => crate::gpu_metal::dit_attention(qh, kh, vh, nh, nkv, n, hd, scale, out),
1087        _ => false,
1088    }
1089}
1090
1091/// Batched q4t GEMM on the device (imagegen DiT prefill shapes).
1092/// Metal: q4t_mul_mm decodes the mmap-resident tiles inside the
1093/// GEMM's K loop. wgpu (Vulkan/DX12 → NVIDIA/AMD/Intel/Adreno/Mali):
1094/// the register-blocked WGSL twin, weights cached in VRAM.
1095#[allow(unused_variables)]
1096pub fn q4t_matmat(
1097    model: &Arc<CmfModel>,
1098    idx: usize,
1099    xs: &[f32],
1100    b: usize,
1101    rows: usize,
1102    cols: usize,
1103    out: &mut [f32],
1104) -> bool {
1105    match backend() {
1106        #[cfg(target_os = "macos")]
1107        Backend::Metal => crate::gpu_metal::q4t_matmat(model, idx, xs, b, rows, cols, out),
1108        #[cfg(feature = "gpu")]
1109        Backend::Wgpu => crate::gpu_wgpu::q4t_matmat(model, idx, xs, b, rows, cols, out),
1110        #[allow(unreachable_patterns)]
1111        _ => false,
1112    }
1113}
1114
1115/// Whole-block token-graph types re-exported from the Metal backend.
1116#[cfg(target_os = "macos")]
1117pub use crate::gpu_metal::{
1118    AttnDeviceParams, AttnGpuLayer, GdnGpuCfg, GdnGpuLayer, GraphDims, TokenGraph, kv_mirror_drop,
1119    kv_mirror_read_last, kv_mirror_take_imp,
1120};
1121
1122/// A BLOCK of consecutive q1 GDN layers in one submission (Metal only).
1123#[cfg(target_os = "macos")]
1124pub fn gdn_block(
1125    model: &Arc<CmfModel>,
1126    layers: &[GdnGpuLayer],
1127    states: &mut [&mut [f32]],
1128    cfg: &GdnGpuCfg,
1129    h: &mut [f32],
1130) -> bool {
1131    match backend() {
1132        Backend::Metal => crate::gpu_metal::gdn_block(model, layers, states, cfg, h),
1133        _ => false,
1134    }
1135}
1136
1137/// A layer's MoE-FFN in one submission (amortizing the dispatch cost).
1138#[allow(unused_variables)]
1139pub fn moe_block(model: &Arc<CmfModel>, jobs: &[MoeJob], out: &mut [f32]) -> bool {
1140    match backend() {
1141        #[cfg(target_os = "macos")]
1142        Backend::Metal => crate::gpu_metal::moe_block(model, jobs, out),
1143        #[cfg(feature = "gpu")]
1144        Backend::Wgpu => crate::gpu_wgpu::moe_block(model, jobs, out),
1145        Backend::None => false,
1146    }
1147}
1148
1149/// Independent matvecs of one input in a single submission (GDN projections).
1150#[allow(unused_variables)]
1151pub fn matvec_batch(model: &Arc<CmfModel>, jobs: &[BatchJob], out: &mut [&mut [f32]]) -> bool {
1152    match backend() {
1153        #[cfg(target_os = "macos")]
1154        Backend::Metal => crate::gpu_metal::matvec_batch(model, jobs, out),
1155        #[cfg(feature = "gpu")]
1156        Backend::Wgpu => crate::gpu_wgpu::matvec_batch(model, jobs, out),
1157        Backend::None => false,
1158    }
1159}
1160
1161// ── Whole-token wgpu graph race (generation granularity) ─────────────
1162// On integrated/mobile adapters the graph is neither trusted nor banned
1163// a priori — it RACES the normal path: generations alternate arms (the
1164// normal path first — known-good UX — then the graph), per-token wall
1165// times accumulate per arm, and once both arms have enough steady
1166// samples the faster one wins for the process. Arm switches happen ONLY
1167// at generation boundaries (`kv_cache.clear()` resets state), so the
1168// device KV mirror and the CPU cache never diverge mid-sequence. The
1169// single exception is the first-token bail: the very first decode token
1170// of a graph generation may be discarded and recomputed on the CPU
1171// path (the prompt KV is CPU-owned at that point, so this is safe) —
1172// a tiled mobile GPU that drains its pipeline at every barrier turns
1173// the ~300-dispatch graph into seconds per token (field report: 0.2
1174// tok/s vs 15 on the CPU), and one token is all it takes to see that.
1175static GRAPH_RACE_STATE: AtomicU8 = AtomicU8::new(0); // 0 racing, 1 graph won, 2 normal won
1176static GRAPH_RACE_FLIP: AtomicU32 = AtomicU32::new(0);
1177static GRAPH_RACE_ARM_GRAPH: AtomicU8 = AtomicU8::new(0); // this generation's arm
1178static GRAPH_RACE_TOK: AtomicU32 = AtomicU32::new(0); // token index within the generation
1179static GRAPH_NS: [AtomicU64; 2] = [AtomicU64::new(0), AtomicU64::new(0)]; // [normal, graph]
1180static GRAPH_N: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
1181
1182/// Steady per-token samples per arm before the race decides.
1183const GRAPH_RACE_SAMPLES: u32 = 4;
1184
1185/// Called at every generation start (fresh KV). Applies a pending
1186/// verdict and picks this generation's arm while racing.
1187pub fn graph_race_begin_generation() {
1188    GRAPH_RACE_TOK.store(0, Ordering::Relaxed);
1189    if GRAPH_RACE_STATE.load(Ordering::Relaxed) != 0 {
1190        return;
1191    }
1192    let (gn, cn) = (
1193        GRAPH_N[1].load(Ordering::Relaxed),
1194        GRAPH_N[0].load(Ordering::Relaxed),
1195    );
1196    if gn >= GRAPH_RACE_SAMPLES && cn >= GRAPH_RACE_SAMPLES {
1197        let g_avg = GRAPH_NS[1].load(Ordering::Relaxed) / gn as u64;
1198        let c_avg = GRAPH_NS[0].load(Ordering::Relaxed) / cn as u64;
1199        let verdict = if g_avg < c_avg { 1 } else { 2 };
1200        GRAPH_RACE_STATE.store(verdict, Ordering::Relaxed);
1201        tracing::info!(
1202            "wgpu graph race: graph {:.2} ms/tok vs normal {:.2} ms/tok -> {}",
1203            g_avg as f64 / 1e6,
1204            c_avg as f64 / 1e6,
1205            if verdict == 1 { "graph" } else { "normal path" }
1206        );
1207        return;
1208    }
1209    let flip = GRAPH_RACE_FLIP.fetch_add(1, Ordering::Relaxed);
1210    GRAPH_RACE_ARM_GRAPH.store((flip % 2 == 1) as u8, Ordering::Relaxed);
1211}
1212
1213/// Should this decode token try the graph? `trusted` (discrete adapter,
1214/// explicit env, or a GDN hybrid whose state lives on the device) skips
1215/// the race entirely.
1216pub fn graph_race_use_graph(trusted: bool) -> bool {
1217    if trusted {
1218        return true;
1219    }
1220    match GRAPH_RACE_STATE.load(Ordering::Relaxed) {
1221        1 => true,
1222        2 => false,
1223        _ => GRAPH_RACE_ARM_GRAPH.load(Ordering::Relaxed) == 1,
1224    }
1225}
1226
1227/// First decode token of a racing graph generation: hopeless already?
1228/// (>4x the normal path's per-token average AND over a second.) Settles
1229/// the race immediately; the caller discards the graph result and
1230/// recomputes this token on the normal path.
1231pub fn graph_race_first_token_hopeless(dur: std::time::Duration) -> bool {
1232    if GRAPH_RACE_STATE.load(Ordering::Relaxed) != 0 {
1233        return false;
1234    }
1235    let first = GRAPH_RACE_TOK.load(Ordering::Relaxed) == 0;
1236    let cn = GRAPH_N[0].load(Ordering::Relaxed);
1237    if !first || cn == 0 {
1238        return false;
1239    }
1240    let c_avg = GRAPH_NS[0].load(Ordering::Relaxed) / cn as u64;
1241    let ns = dur.as_nanos() as u64;
1242    if ns > 1_000_000_000 && ns > 4 * c_avg {
1243        GRAPH_RACE_STATE.store(2, Ordering::Relaxed);
1244        tracing::info!(
1245            "wgpu graph race: first graph token {:.0} ms vs normal {:.2} ms/tok — hopeless, normal path wins",
1246            ns as f64 / 1e6,
1247            c_avg as f64 / 1e6
1248        );
1249        return true;
1250    }
1251    false
1252}
1253
1254/// Record one decode-token wall time for the racing arm. The first
1255/// token of each generation is discarded (KV-mirror upload / cold
1256/// caches on the graph arm; cold mmap on the normal arm).
1257pub fn graph_race_record(used_graph: bool, dur: std::time::Duration) {
1258    if GRAPH_RACE_STATE.load(Ordering::Relaxed) != 0 {
1259        return;
1260    }
1261    let tok = GRAPH_RACE_TOK.fetch_add(1, Ordering::Relaxed);
1262    if tok == 0 {
1263        return;
1264    }
1265    let i = used_graph as usize;
1266    GRAPH_NS[i].fetch_add(dur.as_nanos() as u64, Ordering::Relaxed);
1267    GRAPH_N[i].fetch_add(1, Ordering::Relaxed);
1268}