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