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::{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/// Pipeline: mark the current layer (or −1 outside layers) for layer-split.
54pub fn set_layer(l: i64) {
55    CUR_LAYER.with(|c| c.set(l));
56}
57
58/// The layer `set_layer` last marked on this thread (−1 outside layers).
59pub fn cur_layer() -> i64 {
60    CUR_LAYER.with(|c| c.get())
61}
62
63/// Parse `CMF_GPU_LAYERS` («0-19», «0,2,4», «0-9,30-39») once.
64/// None = no restriction (all layers on GPU). Garbage → also no restriction.
65fn layer_ranges() -> &'static Option<Vec<(i64, i64)>> {
66    static R: OnceLock<Option<Vec<(i64, i64)>>> = OnceLock::new();
67    R.get_or_init(|| {
68        let s = std::env::var("CMF_GPU_LAYERS").ok()?;
69        let mut v = Vec::new();
70        for part in s.split(',') {
71            let part = part.trim();
72            match part.split_once('-') {
73                Some((a, b)) => v.push((a.trim().parse().ok()?, b.trim().parse().ok()?)),
74                None => {
75                    let x: i64 = part.parse().ok()?;
76                    v.push((x, x));
77                }
78            }
79        }
80        Some(v)
81    })
82}
83
84fn layer_allowed() -> bool {
85    match layer_ranges() {
86        None => true,
87        Some(ranges) => {
88            let cur = CUR_LAYER.with(|c| c.get());
89            cur < 0 || ranges.iter().any(|(a, b)| cur >= *a && cur <= *b)
90        }
91    }
92}
93
94/// GPU allowed FOR THE CURRENT LAYER: backend is initialized AND the layer
95/// falls within `CMF_GPU_LAYERS` (GPU/CPU layer-split) AND we are not
96/// inside a `cpu_scope`. Op gates call this.
97pub fn enabled_here() -> bool {
98    !CPU_ONLY.with(|c| c.get()) && enabled() && layer_allowed()
99}
100
101// ── Runtime GPU-vs-CPU probe ────────────────────────────────────────────
102// CMF_GPU=1 does not TRUST that the device wins — it MEASURES. For each
103// op class the first calls alternate arms: GPU timed vs pure-CPU timed
104// (under cpu_scope). Cold GPU calls (weight upload / cache fill) are
105// discarded; after PROBE_SAMPLES clean samples per arm the faster arm is
106// chosen for the rest of the process. Rationale: submit+poll latency
107// differs by an order of magnitude across driver stacks (Metal/PCIe
108// ~3-4 ms, Vulkan/4090 ~0.3 ms) — a static threshold cannot know whether
109// per-op offload pays off HERE. CMF_GPU_PROBE=0 → always trust the GPU.
110
111/// GPU-eligible op classes, each with an independent probe.
112#[derive(Clone, Copy)]
113pub enum OpClass {
114    /// Whole FFN chain in one submission (dense / MoE block).
115    Ffn = 0,
116    /// Large hybrid CPU∥GPU matvec (lm_head class).
117    Matvec = 1,
118    /// Prefill GEMM (matmat).
119    Matmat = 2,
120    /// Batched matvecs of one input (QKV).
121    Batch = 3,
122}
123
124/// Probe verdict for one call.
125pub enum ProbeArm {
126    /// Run the GPU path (during probing: timed, recorded).
127    Gpu,
128    /// Probing: run the CPU path under `cpu_scope`, timed, recorded.
129    CpuTimed,
130    /// Decided: CPU won — run the CPU path (under `cpu_scope`).
131    Cpu,
132}
133
134/// Clean samples per arm before a class decides.
135const PROBE_SAMPLES: u32 = 6;
136
137struct Probe {
138    /// 0 = probing, 1 = GPU won, 2 = CPU won.
139    state: AtomicU8,
140    flip: AtomicU32,
141    gpu_ns: AtomicU64,
142    gpu_n: AtomicU32,
143    cpu_ns: AtomicU64,
144    cpu_n: AtomicU32,
145}
146
147impl Probe {
148    const fn new() -> Self {
149        Self {
150            state: AtomicU8::new(0),
151            flip: AtomicU32::new(0),
152            gpu_ns: AtomicU64::new(0),
153            gpu_n: AtomicU32::new(0),
154            cpu_ns: AtomicU64::new(0),
155            cpu_n: AtomicU32::new(0),
156        }
157    }
158}
159
160static PROBES: [Probe; 4] = [Probe::new(), Probe::new(), Probe::new(), Probe::new()];
161
162fn probe_on() -> bool {
163    static ON: OnceLock<bool> = OnceLock::new();
164    *ON.get_or_init(|| {
165        std::env::var("CMF_GPU_PROBE")
166            .map(|v| v != "0" && v != "off")
167            .unwrap_or(true)
168    })
169}
170
171/// q1 ops on the native Metal backend skip the probe entirely: the CPU
172/// q1 kernel is load-port-bound, the GPU one wins warm — and probe
173/// alternation itself cools the device between samples (measured: block
174/// times 5.8 ms warm vs 8.8 ms mixed). Other backends keep probing.
175pub fn q1_force() -> bool {
176    #[cfg(target_os = "macos")]
177    {
178        backend() == Backend::Metal
179    }
180    #[cfg(not(target_os = "macos"))]
181    {
182        false
183    }
184}
185
186/// Which arm should this GPU-eligible call take? Consult AFTER the
187/// eligibility gates (`enabled_here` / `min_rows`) so only real
188/// candidates alternate.
189pub fn probe_arm(c: OpClass) -> ProbeArm {
190    if !probe_on() {
191        return ProbeArm::Gpu;
192    }
193    let p = &PROBES[c as usize];
194    match p.state.load(Ordering::Relaxed) {
195        1 => ProbeArm::Gpu,
196        2 => ProbeArm::Cpu,
197        _ => {
198            PROBE_COLD.with(|f| f.set(false));
199            if p.flip.fetch_add(1, Ordering::Relaxed) % 2 == 0 {
200                ProbeArm::Gpu
201            } else {
202                ProbeArm::CpuTimed
203            }
204        }
205    }
206}
207
208/// Record a timed arm sample; on the `PROBE_SAMPLES`-th clean sample of
209/// BOTH arms the class decides for the rest of the process.
210pub fn probe_record(c: OpClass, gpu: bool, dur: std::time::Duration) {
211    let p = &PROBES[c as usize];
212    if p.state.load(Ordering::Relaxed) != 0 {
213        return;
214    }
215    if gpu && PROBE_COLD.with(|f| f.replace(false)) {
216        return; // one-off cost in this call — not a steady-state sample
217    }
218    let ns = dur.as_nanos().min(u64::MAX as u128) as u64;
219    if gpu {
220        p.gpu_ns.fetch_add(ns, Ordering::Relaxed);
221        p.gpu_n.fetch_add(1, Ordering::Relaxed);
222    } else {
223        p.cpu_ns.fetch_add(ns, Ordering::Relaxed);
224        p.cpu_n.fetch_add(1, Ordering::Relaxed);
225    }
226    let (gn, cn) = (
227        p.gpu_n.load(Ordering::Relaxed),
228        p.cpu_n.load(Ordering::Relaxed),
229    );
230    if gn >= 2 && cn >= 2 {
231        let g = p.gpu_ns.load(Ordering::Relaxed) as f64 / gn as f64;
232        let cp = p.cpu_ns.load(Ordering::Relaxed) as f64 / cn as f64;
233        // Early verdict on a ≥3× gap — no reason to keep feeding the
234        // losing arm; close races take the full sample count.
235        if (gn < PROBE_SAMPLES || cn < PROBE_SAMPLES) && g < cp * 3.0 && cp < g * 3.0 {
236            return;
237        }
238        let winner = if g <= cp { 1 } else { 2 };
239        if p.state
240            .compare_exchange(0, winner, Ordering::Relaxed, Ordering::Relaxed)
241            .is_ok()
242        {
243            tracing::info!(
244                "gpu probe [{}]: gpu {:.2} ms vs cpu {:.2} ms per op → {}",
245                ["ffn", "matvec", "matmat", "qkv-batch"][c as usize],
246                g / 1e6,
247                cp / 1e6,
248                if winner == 1 { "gpu" } else { "cpu" },
249            );
250        }
251    }
252}
253
254/// Is the class still collecting samples? (Call sites use this to route
255/// cold-weight calls away from the GPU arm during probing.)
256pub fn probe_deciding(c: OpClass) -> bool {
257    probe_on() && PROBES[c as usize].state.load(Ordering::Relaxed) == 0
258}
259
260/// Probing helper: true — tensor `idx`'s quant weights are ALREADY
261/// device-resident (a clean GPU sample is possible now); false — they
262/// were not (the upload starts within the VRAM budget, so a later call
263/// finds them warm) or the tensor cannot go to the GPU at all. Keeps the
264/// probe from billing a full cold dispatch+readback to a sample it will
265/// discard anyway. The verdict needs only a couple of warm tensors, so
266/// probe-driven uploads are capped — the losing-GPU machine should not
267/// pay for uploading the whole layer stack it will never use; if the GPU
268/// wins, the rest uploads lazily on demand, in the same first-touch order.
269#[allow(unused_variables)]
270pub fn q8_resident_or_upload(model: &Arc<CmfModel>, idx: usize) -> bool {
271    static PROBE_UPLOADS: AtomicU32 = AtomicU32::new(0);
272    let may_upload = PROBE_UPLOADS.load(Ordering::Relaxed) < 4;
273    let resident = match backend() {
274        #[cfg(target_os = "macos")]
275        Backend::Metal => crate::gpu_metal::q8_resident_or_upload(model, idx, may_upload),
276        #[cfg(feature = "gpu")]
277        Backend::Wgpu => crate::gpu_wgpu::q8_resident_or_upload(model, idx, may_upload),
278        Backend::None => false,
279    };
280    if !resident && may_upload {
281        PROBE_UPLOADS.fetch_add(1, Ordering::Relaxed);
282    }
283    resident
284}
285
286/// Test hook: reset all probes to the undecided state.
287#[cfg(test)]
288pub(crate) fn probe_reset() {
289    for p in &PROBES {
290        p.state.store(0, Ordering::Relaxed);
291        p.flip.store(0, Ordering::Relaxed);
292        p.gpu_ns.store(0, Ordering::Relaxed);
293        p.gpu_n.store(0, Ordering::Relaxed);
294        p.cpu_ns.store(0, Ordering::Relaxed);
295        p.cpu_n.store(0, Ordering::Relaxed);
296    }
297}
298
299#[cfg(test)]
300mod probe_tests {
301    use super::*;
302    use std::time::Duration;
303
304    // One test fn: PROBES is process-global and probe_reset touches all
305    // classes — parallel test threads would race.
306    #[test]
307    fn probe_alternates_discards_cold_and_decides() {
308        probe_reset();
309        // Probing: arms alternate.
310        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::Gpu));
311        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::CpuTimed));
312
313        // A cold GPU sample (upload noted) must be discarded: feed a
314        // catastrophic cold sample, then clean fast-GPU samples — GPU
315        // wins only if the cold one did not count.
316        probe_note_cold();
317        probe_record(OpClass::Ffn, true, Duration::from_secs(1000));
318        for _ in 0..PROBE_SAMPLES {
319            probe_record(OpClass::Ffn, true, Duration::from_millis(1));
320            probe_record(OpClass::Ffn, false, Duration::from_millis(4));
321        }
322        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::Gpu));
323
324        // The reverse: a class where the CPU arm is faster decides CPU.
325        for _ in 0..PROBE_SAMPLES {
326            probe_record(OpClass::Matmat, true, Duration::from_millis(4));
327            probe_record(OpClass::Matmat, false, Duration::from_millis(1));
328        }
329        assert!(matches!(probe_arm(OpClass::Matmat), ProbeArm::Cpu));
330
331        // cpu_scope: gates off inside, restored after.
332        cpu_scope(|| CPU_ONLY.with(|c| assert!(c.get())));
333        CPU_ONLY.with(|c| assert!(!c.get()));
334        cpu_scope(|| {
335            cpu_scope(|| CPU_ONLY.with(|c| assert!(c.get())));
336            CPU_ONLY.with(|c| assert!(c.get()));
337        });
338        let _ = std::panic::catch_unwind(|| cpu_scope(|| panic!("scope test")));
339        CPU_ONLY.with(|c| assert!(!c.get()));
340        probe_reset();
341    }
342}
343
344/// Default row threshold: the GPU takes only larger matrices (lm_head
345/// class). Below it, the dispatch/readback cost does not pay off on unified memory.
346pub const GPU_MIN_ROWS: usize = 65_536;
347
348/// Effective threshold: `CMF_GPU_MIN_ROWS` overrides. Defaults differ
349/// by device class: on a DISCRETE card VRAM bandwidth pays off even for
350/// FFN/QKV-class matrices (4096), on unified memory only lm_head-class
351/// is worth the dispatch/readback (65536). Field case behind this: a
352/// 35B model on an RTX 4090 saw ~0 offload because every layer matrix
353/// sat below the old universal 65536.
354pub fn min_rows() -> usize {
355    if let Some(v) = std::env::var("CMF_GPU_MIN_ROWS")
356        .ok()
357        .and_then(|v| v.parse().ok())
358    {
359        return v;
360    }
361    if discrete() { 4096 } else { GPU_MIN_ROWS }
362}
363
364/// Is the active backend a discrete card (PCIe VRAM)?
365pub fn discrete() -> bool {
366    match backend() {
367        #[cfg(feature = "gpu")]
368        Backend::Wgpu => crate::gpu_wgpu::is_discrete(),
369        #[cfg(target_os = "macos")]
370        Backend::Metal => false, // UMA by the init() guard
371        Backend::None => false,
372    }
373}
374
375/// A single MoE-FFN job (an expert with its own weight), executed in one
376/// submission: (rows, cols, idx, row_scale) for gate/up/down + prescaled
377/// inputs + the down θ-field + the blending weight.
378pub struct MoeJob<'a> {
379    pub gate: (usize, usize, usize, &'a [f32]),
380    pub up: (usize, usize, usize, &'a [f32]),
381    pub down: (usize, usize, usize, &'a [f32]),
382    pub xs_gate: Vec<f32>,
383    pub xs_up: Vec<f32>,
384    pub down_col: &'a [f32],
385    pub w: f32,
386    /// q1 trio: scales live inside the 6-byte tiles (row_scale slices
387    /// empty, xs raw f32). Backends without a q1 kernel refuse the job.
388    pub q1: bool,
389}
390
391/// A single independent batch matvec (GDN projections of one input).
392pub struct BatchJob<'a> {
393    pub idx: usize,
394    pub rows: usize,
395    pub cols: usize,
396    pub row_scale: &'a [f32],
397    pub xs: Vec<f32>,
398    /// q1 tensor: tile-embedded scales, raw f32 xs (see `MoeJob::q1`).
399    pub q1: bool,
400}
401
402#[derive(Clone, Copy, PartialEq, Eq)]
403enum Backend {
404    None,
405    #[cfg(target_os = "macos")]
406    Metal,
407    #[cfg(feature = "gpu")]
408    Wgpu,
409}
410
411fn backend() -> Backend {
412    #[cfg(feature = "gpu")]
413    if crate::gpu_wgpu::selected() {
414        return if crate::gpu_wgpu::enabled() {
415            Backend::Wgpu
416        } else {
417            Backend::None
418        };
419    }
420    #[cfg(target_os = "macos")]
421    if crate::gpu_metal::enabled() {
422        return Backend::Metal;
423    }
424    Backend::None
425}
426
427/// GPU enabled and initialized on the selected backend?
428pub fn enabled() -> bool {
429    backend() != Backend::None
430}
431
432/// Default-on condition for the wgpu whole-token graph: the wgpu
433/// backend on a DISCRETE adapter. NOT plain `enabled()` (macOS/Metal
434/// must not pay a per-token layer scan for a graph its backend
435/// refuses), and NOT integrated adapters: the graph's ~300 barriered
436/// dispatches per token are cheap on desktop immediate-mode GPUs but
437/// tiled mobile GPUs (Adreno/Mali) drain the pipeline at every barrier
438/// — field report: 0.2 tok/s on-graph vs 15 tok/s on the CPU. On
439/// integrated adapters the per-op probe path arbitrates each op class
440/// against the CPU instead; CMF_GPU_WGPU_GRAPH=1 still forces the
441/// graph anywhere.
442/// Is the wgpu backend active at all (any adapter)? Eligibility gate
443/// for the whole-token graph — whether it actually RUNS is decided by
444/// `wgpu_graph_default` (trusted on discrete) or the generation race.
445pub fn wgpu_active() -> bool {
446    #[cfg(feature = "gpu")]
447    {
448        matches!(backend(), Backend::Wgpu)
449    }
450    #[cfg(not(feature = "gpu"))]
451    {
452        false
453    }
454}
455
456pub fn wgpu_graph_default() -> bool {
457    #[cfg(feature = "gpu")]
458    {
459        matches!(backend(), Backend::Wgpu) && crate::gpu_wgpu::discrete_active()
460    }
461    #[cfg(not(feature = "gpu"))]
462    {
463        false
464    }
465}
466
467/// q8_row/q8_2f matvec, rows [row0, row0+rows). `xs` — prescaled by the θ-field.
468#[allow(clippy::too_many_arguments, unused_variables)]
469pub fn q8_matvec_range(
470    model: &Arc<CmfModel>,
471    idx: usize,
472    row0: usize,
473    row_scale: &[f32],
474    xs: &[f32],
475    rows: usize,
476    cols: usize,
477    out: &mut [f32],
478) -> bool {
479    match backend() {
480        #[cfg(target_os = "macos")]
481        Backend::Metal => {
482            crate::gpu_metal::q8_matvec_range(model, idx, row0, row_scale, xs, rows, cols, out)
483        }
484        #[cfg(feature = "gpu")]
485        Backend::Wgpu => {
486            crate::gpu_wgpu::q8_matvec_range(model, idx, row0, row_scale, xs, rows, cols, out)
487        }
488        Backend::None => false,
489    }
490}
491
492/// GEMM of a prefill batch: `pre` — prescaled inputs row-major [b, cols],
493/// out — row-major [b, rows].
494#[allow(clippy::too_many_arguments, unused_variables)]
495pub fn q8_matmat(
496    model: &Arc<CmfModel>,
497    idx: usize,
498    row_scale: &[f32],
499    pre: &[f32],
500    b: usize,
501    rows: usize,
502    cols: usize,
503    out: &mut [f32],
504) -> bool {
505    match backend() {
506        #[cfg(target_os = "macos")]
507        Backend::Metal => {
508            crate::gpu_metal::q8_matmat(model, idx, row_scale, pre, b, rows, cols, out)
509        }
510        #[cfg(feature = "gpu")]
511        Backend::Wgpu => crate::gpu_wgpu::q8_matmat(model, idx, row_scale, pre, b, rows, cols, out),
512        Backend::None => false,
513    }
514}
515
516/// q1 matvec: raw f32 activations, tile-embedded scales. Metal only
517/// for now (wgpu q1 WGSL is queued); false = CPU fallback.
518#[allow(unused_variables)]
519pub fn q1_matvec(
520    model: &Arc<CmfModel>,
521    idx: usize,
522    xs: &[f32],
523    rows: usize,
524    cols: usize,
525    out: &mut [f32],
526) -> bool {
527    match backend() {
528        #[cfg(target_os = "macos")]
529        Backend::Metal => crate::gpu_metal::q1_matvec(model, idx, xs, rows, cols, out),
530        #[cfg(feature = "gpu")]
531        Backend::Wgpu => crate::gpu_wgpu::q1_matvec(model, idx, xs, rows, cols, out),
532        Backend::None => false,
533    }
534}
535
536/// Whole attention sub-block on the wgpu token graph (drop-in for
537/// `qwen_attention`): normed hidden in, O-projection out, resident device
538/// K/V mirror. false = refusal / not the wgpu backend → CPU path.
539#[allow(clippy::too_many_arguments)]
540pub fn attn_dropin(
541    model: &Arc<CmfModel>,
542    kv_id: u64,
543    layer: usize,
544    normed: &[f32],
545    wq_idx: usize,
546    wk_idx: usize,
547    wv_idx: usize,
548    wo_idx: usize,
549    q_norm: Option<&[f32]>,
550    k_norm: Option<&[f32]>,
551    invf: &[f32],
552    nh: usize,
553    nkv: usize,
554    hd: usize,
555    rd: usize,
556    hidden: usize,
557    pos: usize,
558    cap: usize,
559    gemma: bool,
560    eps: f32,
561    cpu_k: &[Vec<f32>],
562    cpu_v: &[Vec<f32>],
563    out: &mut [f32],
564) -> bool {
565    match backend() {
566        #[cfg(feature = "gpu")]
567        Backend::Wgpu => crate::gpu_wgpu::attn_dropin_gpu(
568            model, kv_id, layer, normed, wq_idx, wk_idx, wv_idx, wo_idx, q_norm, k_norm, invf, nh,
569            nkv, hd, rd, hidden, pos, cap, gemma, eps, cpu_k, cpu_v, out,
570        ),
571        #[allow(unused_variables)]
572        _ => false,
573    }
574}
575
576/// One weight in the whole-token graph: tensor idx + a codec tag (0=q8_row,
577/// 1=q1, 2=q4_tiled, 3=q1t, 4=f32) + per-row scales (q8_row only) + the raw f32
578/// data (kind 4 only — small unquantized projections like GDN in_proj_a/b).
579pub struct GraphW<'a> {
580    pub idx: usize,
581    pub kind: u8,
582    pub row_scale: &'a [f32],
583    pub data: &'a [f32],
584}
585
586/// A layer's token-mixing op: standard attention or a GDN (linear-attention)
587/// block. The surrounding norms + SwiGLU FFN are common to both.
588pub enum GraphAttn<'a> {
589    Full {
590        wq: GraphW<'a>,
591        wk: GraphW<'a>,
592        wv: GraphW<'a>,
593        wo: GraphW<'a>,
594        q_norm: Option<&'a [f32]>,
595        k_norm: Option<&'a [f32]>,
596        /// (bq, bk, bv) attention biases (Qwen2). None ⇒ no bias.
597        bias: Option<(&'a [f32], &'a [f32], &'a [f32])>,
598        /// Qwen3.5 gated attention: wq emits 2·nh·hd (q||gate per head), the
599        /// attention output is scaled by sigmoid(gate) before the O projection.
600        output_gate: bool,
601        cpu_k: &'a [Vec<f32>],
602        cpu_v: &'a [Vec<f32>],
603    },
604    Gdn {
605        qkv: GraphW<'a>,
606        z: GraphW<'a>,
607        a: GraphW<'a>,
608        b: GraphW<'a>,
609        out: GraphW<'a>,
610        conv1d: &'a [f32],
611        a_log: &'a [f32],
612        dt_bias: &'a [f32],
613        norm: &'a [f32],
614        nv: usize,
615        nk: usize,
616        dk: usize,
617        dv: usize,
618        kk: usize,
619    },
620}
621
622/// Per-layer weights for the whole-token wgpu graph.
623pub struct GraphLayer<'a> {
624    pub input_norm: &'a [f32],
625    pub attn: GraphAttn<'a>,
626    pub post_norm: &'a [f32],
627    pub gate: GraphW<'a>,
628    pub up: GraphW<'a>,
629    pub down: GraphW<'a>,
630}
631
632/// Whole-token decode graph on wgpu: the entire layer stack in ONE submit,
633/// hidden resident, one readback. Updates `h` in place. false = refusal.
634/// `loop_norm_at`: virtual layer indices after which `final_norm` is applied
635/// (Looped Transformer mid-stack norm). Empty for standard models.
636#[allow(clippy::too_many_arguments)]
637pub fn forward_token_graph(
638    model: &Arc<CmfModel>,
639    kv_id: u64,
640    layers: &[GraphLayer],
641    invf: &[f32],
642    h: &mut [f32],
643    nh: usize,
644    nkv: usize,
645    hd: usize,
646    rd: usize,
647    hidden: usize,
648    inter: usize,
649    position: usize,
650    cap: usize,
651    gemma: bool,
652    eps: f32,
653    lm_head: Option<(&GraphW, usize)>,
654    final_norm: &[f32],
655    logits: &mut Vec<f32>,
656    loop_norm_at: &[usize],
657) -> bool {
658    match backend() {
659        #[cfg(feature = "gpu")]
660        Backend::Wgpu => crate::gpu_wgpu::forward_token_graph(
661            model,
662            kv_id,
663            layers,
664            invf,
665            h,
666            nh,
667            nkv,
668            hd,
669            rd,
670            hidden,
671            inter,
672            position,
673            cap,
674            gemma,
675            eps,
676            lm_head,
677            final_norm,
678            logits,
679            loop_norm_at,
680        ),
681        #[allow(unused_variables)]
682        _ => {
683            let _ = (lm_head, final_norm, logits, loop_norm_at);
684            false
685        }
686    }
687}
688
689/// Batched prefill: k contiguous positions through the whole graph in one submit
690/// (projections/FFN as GEMMs, attention/GDN looped over scratch). `h` is
691/// [k·hidden] in/out; `positions` len k. wgpu only.
692#[allow(clippy::too_many_arguments)]
693pub fn forward_batch_graph(
694    model: &Arc<CmfModel>,
695    kv_id: u64,
696    layers: &[GraphLayer],
697    invf: &[f32],
698    h: &mut [f32],
699    nh: usize,
700    nkv: usize,
701    hd: usize,
702    rd: usize,
703    hidden: usize,
704    inter: usize,
705    positions: &[usize],
706    cap: usize,
707    gemma: bool,
708    eps: f32,
709    k: usize,
710) -> bool {
711    match backend() {
712        #[cfg(feature = "gpu")]
713        Backend::Wgpu => crate::gpu_wgpu::forward_batch_graph(
714            model, kv_id, layers, invf, h, nh, nkv, hd, rd, hidden, inter, positions, cap, gemma,
715            eps, k,
716        ),
717        _ => false,
718    }
719}
720
721/// Drop the wgpu token graph's device K/V mirror for a pipeline.
722pub fn graph_kv_reset(_kv_id: u64) {
723    #[cfg(feature = "gpu")]
724    if backend() == Backend::Wgpu {
725        crate::gpu_wgpu::kv_mirror_reset(_kv_id);
726    }
727}
728
729/// Ternary (q1t) BASE matvec on the GPU — fills `out` with the base dot; the
730/// caller adds the sparse overlay on the CPU. Metal only for now (wgpu q1t not
731/// yet written → CPU fallback).
732pub fn q1t_matvec(
733    model: &Arc<CmfModel>,
734    idx: usize,
735    xs: &[f32],
736    rows: usize,
737    cols: usize,
738    out: &mut [f32],
739) -> bool {
740    match backend() {
741        #[cfg(target_os = "macos")]
742        Backend::Metal => {
743            if metal_q1t_enabled() {
744                crate::gpu_metal::q1t_matvec(model, idx, xs, rows, cols, out)
745            } else {
746                false
747            }
748        }
749        #[cfg(feature = "gpu")]
750        Backend::Wgpu => crate::gpu_wgpu::q1t_matvec(model, idx, xs, rows, cols, out),
751        Backend::None => false,
752    }
753}
754
755/// q4_block matvec on the GPU — wgpu only (Metal drives q4_block through the
756/// whole-token graph, not a standalone matvec).
757#[allow(unused_variables)]
758pub fn q4b_matvec(
759    model: &Arc<CmfModel>,
760    idx: usize,
761    xs: &[f32],
762    rows: usize,
763    cols: usize,
764    out: &mut [f32],
765) -> bool {
766    match backend() {
767        #[cfg(target_os = "macos")]
768        Backend::Metal => false,
769        #[cfg(feature = "gpu")]
770        Backend::Wgpu => crate::gpu_wgpu::q4b_matvec(model, idx, xs, rows, cols, out),
771        Backend::None => false,
772    }
773}
774
775/// q1t batched GEMM (prefill) — base + overlay on-device (Metal simdgroup or
776/// wgpu register-blocked).
777pub fn q1t_matmat(
778    model: &Arc<CmfModel>,
779    idx: usize,
780    xs: &[f32],
781    b: usize,
782    rows: usize,
783    cols: usize,
784    out: &mut [f32],
785) -> bool {
786    match backend() {
787        #[cfg(target_os = "macos")]
788        // Batched prefill and single-token decode are both enabled. On the
789        // real 14.8B Q1T model prefill PPL was within 0.3% of CPU (7.942 vs
790        // 7.966), and the alignment-safe decode kernel reached 3.52e-6 max_rel.
791        Backend::Metal => crate::gpu_metal::q1t_matmat(model, idx, xs, b, rows, cols, out),
792        #[cfg(feature = "gpu")]
793        Backend::Wgpu => crate::gpu_wgpu::q1t_matmat(model, idx, xs, b, rows, cols, out),
794        Backend::None => false,
795    }
796}
797
798/// Native Metal Q1T switch. Enabled by default after the byte-packed Q1T
799/// fields were changed to alignment-safe loads; keep an explicit emergency
800/// fallback for device/driver diagnostics.
801#[cfg(target_os = "macos")]
802pub(crate) fn metal_q1t_enabled() -> bool {
803    std::env::var("CMF_METAL_Q1T")
804        .map(|v| v != "0" && !v.eq_ignore_ascii_case("off"))
805        .unwrap_or(true)
806}
807
808/// Batched q1 GEMM (prefill). wgpu only — Metal has its own block path.
809pub fn q1_matmat(
810    model: &Arc<CmfModel>,
811    idx: usize,
812    xs: &[f32],
813    b: usize,
814    rows: usize,
815    cols: usize,
816    out: &mut [f32],
817) -> bool {
818    match backend() {
819        #[cfg(feature = "gpu")]
820        Backend::Wgpu => crate::gpu_wgpu::q1_matmat(model, idx, xs, b, rows, cols, out),
821        #[allow(unused_variables)]
822        _ => false,
823    }
824}
825
826/// Whole-block token-graph types re-exported from the Metal backend.
827#[cfg(target_os = "macos")]
828pub use crate::gpu_metal::{
829    AttnDeviceParams, AttnGpuLayer, GdnGpuCfg, GdnGpuLayer, GraphDims, TokenGraph, kv_mirror_drop,
830    kv_mirror_read_last, kv_mirror_take_imp,
831};
832
833/// A BLOCK of consecutive q1 GDN layers in one submission (Metal only).
834#[cfg(target_os = "macos")]
835pub fn gdn_block(
836    model: &Arc<CmfModel>,
837    layers: &[GdnGpuLayer],
838    states: &mut [&mut [f32]],
839    cfg: &GdnGpuCfg,
840    h: &mut [f32],
841) -> bool {
842    match backend() {
843        Backend::Metal => crate::gpu_metal::gdn_block(model, layers, states, cfg, h),
844        _ => false,
845    }
846}
847
848/// A layer's MoE-FFN in one submission (amortizing the dispatch cost).
849#[allow(unused_variables)]
850pub fn moe_block(model: &Arc<CmfModel>, jobs: &[MoeJob], out: &mut [f32]) -> bool {
851    match backend() {
852        #[cfg(target_os = "macos")]
853        Backend::Metal => crate::gpu_metal::moe_block(model, jobs, out),
854        #[cfg(feature = "gpu")]
855        Backend::Wgpu => crate::gpu_wgpu::moe_block(model, jobs, out),
856        Backend::None => false,
857    }
858}
859
860/// Independent matvecs of one input in a single submission (GDN projections).
861#[allow(unused_variables)]
862pub fn matvec_batch(model: &Arc<CmfModel>, jobs: &[BatchJob], out: &mut [&mut [f32]]) -> bool {
863    match backend() {
864        #[cfg(target_os = "macos")]
865        Backend::Metal => crate::gpu_metal::matvec_batch(model, jobs, out),
866        #[cfg(feature = "gpu")]
867        Backend::Wgpu => crate::gpu_wgpu::matvec_batch(model, jobs, out),
868        Backend::None => false,
869    }
870}
871
872// ── Whole-token wgpu graph race (generation granularity) ─────────────
873// On integrated/mobile adapters the graph is neither trusted nor banned
874// a priori — it RACES the normal path: generations alternate arms (the
875// normal path first — known-good UX — then the graph), per-token wall
876// times accumulate per arm, and once both arms have enough steady
877// samples the faster one wins for the process. Arm switches happen ONLY
878// at generation boundaries (`kv_cache.clear()` resets state), so the
879// device KV mirror and the CPU cache never diverge mid-sequence. The
880// single exception is the first-token bail: the very first decode token
881// of a graph generation may be discarded and recomputed on the CPU
882// path (the prompt KV is CPU-owned at that point, so this is safe) —
883// a tiled mobile GPU that drains its pipeline at every barrier turns
884// the ~300-dispatch graph into seconds per token (field report: 0.2
885// tok/s vs 15 on the CPU), and one token is all it takes to see that.
886static GRAPH_RACE_STATE: AtomicU8 = AtomicU8::new(0); // 0 racing, 1 graph won, 2 normal won
887static GRAPH_RACE_FLIP: AtomicU32 = AtomicU32::new(0);
888static GRAPH_RACE_ARM_GRAPH: AtomicU8 = AtomicU8::new(0); // this generation's arm
889static GRAPH_RACE_TOK: AtomicU32 = AtomicU32::new(0); // token index within the generation
890static GRAPH_NS: [AtomicU64; 2] = [AtomicU64::new(0), AtomicU64::new(0)]; // [normal, graph]
891static GRAPH_N: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
892
893/// Steady per-token samples per arm before the race decides.
894const GRAPH_RACE_SAMPLES: u32 = 4;
895
896/// Called at every generation start (fresh KV). Applies a pending
897/// verdict and picks this generation's arm while racing.
898pub fn graph_race_begin_generation() {
899    GRAPH_RACE_TOK.store(0, Ordering::Relaxed);
900    if GRAPH_RACE_STATE.load(Ordering::Relaxed) != 0 {
901        return;
902    }
903    let (gn, cn) = (
904        GRAPH_N[1].load(Ordering::Relaxed),
905        GRAPH_N[0].load(Ordering::Relaxed),
906    );
907    if gn >= GRAPH_RACE_SAMPLES && cn >= GRAPH_RACE_SAMPLES {
908        let g_avg = GRAPH_NS[1].load(Ordering::Relaxed) / gn as u64;
909        let c_avg = GRAPH_NS[0].load(Ordering::Relaxed) / cn as u64;
910        let verdict = if g_avg < c_avg { 1 } else { 2 };
911        GRAPH_RACE_STATE.store(verdict, Ordering::Relaxed);
912        tracing::info!(
913            "wgpu graph race: graph {:.2} ms/tok vs normal {:.2} ms/tok -> {}",
914            g_avg as f64 / 1e6,
915            c_avg as f64 / 1e6,
916            if verdict == 1 { "graph" } else { "normal path" }
917        );
918        return;
919    }
920    let flip = GRAPH_RACE_FLIP.fetch_add(1, Ordering::Relaxed);
921    GRAPH_RACE_ARM_GRAPH.store((flip % 2 == 1) as u8, Ordering::Relaxed);
922}
923
924/// Should this decode token try the graph? `trusted` (discrete adapter,
925/// explicit env, or a GDN hybrid whose state lives on the device) skips
926/// the race entirely.
927pub fn graph_race_use_graph(trusted: bool) -> bool {
928    if trusted {
929        return true;
930    }
931    match GRAPH_RACE_STATE.load(Ordering::Relaxed) {
932        1 => true,
933        2 => false,
934        _ => GRAPH_RACE_ARM_GRAPH.load(Ordering::Relaxed) == 1,
935    }
936}
937
938/// First decode token of a racing graph generation: hopeless already?
939/// (>4x the normal path's per-token average AND over a second.) Settles
940/// the race immediately; the caller discards the graph result and
941/// recomputes this token on the normal path.
942pub fn graph_race_first_token_hopeless(dur: std::time::Duration) -> bool {
943    if GRAPH_RACE_STATE.load(Ordering::Relaxed) != 0 {
944        return false;
945    }
946    let first = GRAPH_RACE_TOK.load(Ordering::Relaxed) == 0;
947    let cn = GRAPH_N[0].load(Ordering::Relaxed);
948    if !first || cn == 0 {
949        return false;
950    }
951    let c_avg = GRAPH_NS[0].load(Ordering::Relaxed) / cn as u64;
952    let ns = dur.as_nanos() as u64;
953    if ns > 1_000_000_000 && ns > 4 * c_avg {
954        GRAPH_RACE_STATE.store(2, Ordering::Relaxed);
955        tracing::info!(
956            "wgpu graph race: first graph token {:.0} ms vs normal {:.2} ms/tok — hopeless, normal path wins",
957            ns as f64 / 1e6,
958            c_avg as f64 / 1e6
959        );
960        return true;
961    }
962    false
963}
964
965/// Record one decode-token wall time for the racing arm. The first
966/// token of each generation is discarded (KV-mirror upload / cold
967/// caches on the graph arm; cold mmap on the normal arm).
968pub fn graph_race_record(used_graph: bool, dur: std::time::Duration) {
969    if GRAPH_RACE_STATE.load(Ordering::Relaxed) != 0 {
970        return;
971    }
972    let tok = GRAPH_RACE_TOK.fetch_add(1, Ordering::Relaxed);
973    if tok == 0 {
974        return;
975    }
976    let i = used_graph as usize;
977    GRAPH_NS[i].fetch_add(dur.as_nanos() as u64, Ordering::Relaxed);
978    GRAPH_N[i].fetch_add(1, Ordering::Relaxed);
979}