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