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