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::{AtomicU32, AtomicU64, AtomicU8, 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    CPU_ONLY.with(|c| c.set(true));
37    let r = f();
38    CPU_ONLY.with(|c| c.set(false));
39    r
40}
41
42/// Backends: note a one-off cost (weight upload, buffer-cache fill) so
43/// the probe discards this sample.
44pub(crate) fn probe_note_cold() {
45    PROBE_COLD.with(|c| c.set(true));
46}
47
48/// Pipeline: mark the current layer (or −1 outside layers) for layer-split.
49pub fn set_layer(l: i64) {
50    CUR_LAYER.with(|c| c.set(l));
51}
52
53/// The layer `set_layer` last marked on this thread (−1 outside layers).
54pub fn cur_layer() -> i64 {
55    CUR_LAYER.with(|c| c.get())
56}
57
58/// Parse `CMF_GPU_LAYERS` («0-19», «0,2,4», «0-9,30-39») once.
59/// None = no restriction (all layers on GPU). Garbage → also no restriction.
60fn layer_ranges() -> &'static Option<Vec<(i64, i64)>> {
61    static R: OnceLock<Option<Vec<(i64, i64)>>> = OnceLock::new();
62    R.get_or_init(|| {
63        let s = std::env::var("CMF_GPU_LAYERS").ok()?;
64        let mut v = Vec::new();
65        for part in s.split(',') {
66            let part = part.trim();
67            match part.split_once('-') {
68                Some((a, b)) => v.push((a.trim().parse().ok()?, b.trim().parse().ok()?)),
69                None => {
70                    let x: i64 = part.parse().ok()?;
71                    v.push((x, x));
72                }
73            }
74        }
75        Some(v)
76    })
77}
78
79fn layer_allowed() -> bool {
80    match layer_ranges() {
81        None => true,
82        Some(ranges) => {
83            let cur = CUR_LAYER.with(|c| c.get());
84            cur < 0 || ranges.iter().any(|(a, b)| cur >= *a && cur <= *b)
85        }
86    }
87}
88
89/// GPU allowed FOR THE CURRENT LAYER: backend is initialized AND the layer
90/// falls within `CMF_GPU_LAYERS` (GPU/CPU layer-split) AND we are not
91/// inside a `cpu_scope`. Op gates call this.
92pub fn enabled_here() -> bool {
93    !CPU_ONLY.with(|c| c.get()) && enabled() && layer_allowed()
94}
95
96// ── Runtime GPU-vs-CPU probe ────────────────────────────────────────────
97// CMF_GPU=1 does not TRUST that the device wins — it MEASURES. For each
98// op class the first calls alternate arms: GPU timed vs pure-CPU timed
99// (under cpu_scope). Cold GPU calls (weight upload / cache fill) are
100// discarded; after PROBE_SAMPLES clean samples per arm the faster arm is
101// chosen for the rest of the process. Rationale: submit+poll latency
102// differs by an order of magnitude across driver stacks (Metal/PCIe
103// ~3-4 ms, Vulkan/4090 ~0.3 ms) — a static threshold cannot know whether
104// per-op offload pays off HERE. CMF_GPU_PROBE=0 → always trust the GPU.
105
106/// GPU-eligible op classes, each with an independent probe.
107#[derive(Clone, Copy)]
108pub enum OpClass {
109    /// Whole FFN chain in one submission (dense / MoE block).
110    Ffn = 0,
111    /// Large hybrid CPU∥GPU matvec (lm_head class).
112    Matvec = 1,
113    /// Prefill GEMM (matmat).
114    Matmat = 2,
115    /// Batched matvecs of one input (QKV).
116    Batch = 3,
117}
118
119/// Probe verdict for one call.
120pub enum ProbeArm {
121    /// Run the GPU path (during probing: timed, recorded).
122    Gpu,
123    /// Probing: run the CPU path under `cpu_scope`, timed, recorded.
124    CpuTimed,
125    /// Decided: CPU won — run the CPU path (under `cpu_scope`).
126    Cpu,
127}
128
129/// Clean samples per arm before a class decides.
130const PROBE_SAMPLES: u32 = 6;
131
132struct Probe {
133    /// 0 = probing, 1 = GPU won, 2 = CPU won.
134    state: AtomicU8,
135    flip: AtomicU32,
136    gpu_ns: AtomicU64,
137    gpu_n: AtomicU32,
138    cpu_ns: AtomicU64,
139    cpu_n: AtomicU32,
140}
141
142impl Probe {
143    const fn new() -> Self {
144        Self {
145            state: AtomicU8::new(0),
146            flip: AtomicU32::new(0),
147            gpu_ns: AtomicU64::new(0),
148            gpu_n: AtomicU32::new(0),
149            cpu_ns: AtomicU64::new(0),
150            cpu_n: AtomicU32::new(0),
151        }
152    }
153}
154
155static PROBES: [Probe; 4] = [Probe::new(), Probe::new(), Probe::new(), Probe::new()];
156
157fn probe_on() -> bool {
158    static ON: OnceLock<bool> = OnceLock::new();
159    *ON.get_or_init(|| {
160        std::env::var("CMF_GPU_PROBE")
161            .map(|v| v != "0" && v != "off")
162            .unwrap_or(true)
163    })
164}
165
166/// q1 ops on the native Metal backend skip the probe entirely: the CPU
167/// q1 kernel is load-port-bound, the GPU one wins warm — and probe
168/// alternation itself cools the device between samples (measured: block
169/// times 5.8 ms warm vs 8.8 ms mixed). Other backends keep probing.
170pub fn q1_force() -> bool {
171    #[cfg(target_os = "macos")]
172    {
173        backend() == Backend::Metal
174    }
175    #[cfg(not(target_os = "macos"))]
176    {
177        false
178    }
179}
180
181/// Which arm should this GPU-eligible call take? Consult AFTER the
182/// eligibility gates (`enabled_here` / `min_rows`) so only real
183/// candidates alternate.
184pub fn probe_arm(c: OpClass) -> ProbeArm {
185    if !probe_on() {
186        return ProbeArm::Gpu;
187    }
188    let p = &PROBES[c as usize];
189    match p.state.load(Ordering::Relaxed) {
190        1 => ProbeArm::Gpu,
191        2 => ProbeArm::Cpu,
192        _ => {
193            PROBE_COLD.with(|f| f.set(false));
194            if p.flip.fetch_add(1, Ordering::Relaxed) % 2 == 0 {
195                ProbeArm::Gpu
196            } else {
197                ProbeArm::CpuTimed
198            }
199        }
200    }
201}
202
203/// Record a timed arm sample; on the `PROBE_SAMPLES`-th clean sample of
204/// BOTH arms the class decides for the rest of the process.
205pub fn probe_record(c: OpClass, gpu: bool, dur: std::time::Duration) {
206    let p = &PROBES[c as usize];
207    if p.state.load(Ordering::Relaxed) != 0 {
208        return;
209    }
210    if gpu && PROBE_COLD.with(|f| f.replace(false)) {
211        return; // one-off cost in this call — not a steady-state sample
212    }
213    let ns = dur.as_nanos().min(u64::MAX as u128) as u64;
214    if gpu {
215        p.gpu_ns.fetch_add(ns, Ordering::Relaxed);
216        p.gpu_n.fetch_add(1, Ordering::Relaxed);
217    } else {
218        p.cpu_ns.fetch_add(ns, Ordering::Relaxed);
219        p.cpu_n.fetch_add(1, Ordering::Relaxed);
220    }
221    let (gn, cn) = (p.gpu_n.load(Ordering::Relaxed), p.cpu_n.load(Ordering::Relaxed));
222    if gn >= 2 && cn >= 2 {
223        let g = p.gpu_ns.load(Ordering::Relaxed) as f64 / gn as f64;
224        let cp = p.cpu_ns.load(Ordering::Relaxed) as f64 / cn as f64;
225        // Early verdict on a ≥3× gap — no reason to keep feeding the
226        // losing arm; close races take the full sample count.
227        if (gn < PROBE_SAMPLES || cn < PROBE_SAMPLES) && g < cp * 3.0 && cp < g * 3.0 {
228            return;
229        }
230        let winner = if g <= cp { 1 } else { 2 };
231        if p
232            .state
233            .compare_exchange(0, winner, Ordering::Relaxed, Ordering::Relaxed)
234            .is_ok()
235        {
236            tracing::info!(
237                "gpu probe [{}]: gpu {:.2} ms vs cpu {:.2} ms per op → {}",
238                ["ffn", "matvec", "matmat", "qkv-batch"][c as usize],
239                g / 1e6,
240                cp / 1e6,
241                if winner == 1 { "gpu" } else { "cpu" },
242            );
243        }
244    }
245}
246
247/// Is the class still collecting samples? (Call sites use this to route
248/// cold-weight calls away from the GPU arm during probing.)
249pub fn probe_deciding(c: OpClass) -> bool {
250    probe_on() && PROBES[c as usize].state.load(Ordering::Relaxed) == 0
251}
252
253/// Probing helper: true — tensor `idx`'s quant weights are ALREADY
254/// device-resident (a clean GPU sample is possible now); false — they
255/// were not (the upload starts within the VRAM budget, so a later call
256/// finds them warm) or the tensor cannot go to the GPU at all. Keeps the
257/// probe from billing a full cold dispatch+readback to a sample it will
258/// discard anyway. The verdict needs only a couple of warm tensors, so
259/// probe-driven uploads are capped — the losing-GPU machine should not
260/// pay for uploading the whole layer stack it will never use; if the GPU
261/// wins, the rest uploads lazily on demand, in the same first-touch order.
262#[allow(unused_variables)]
263pub fn q8_resident_or_upload(model: &Arc<CmfModel>, idx: usize) -> bool {
264    static PROBE_UPLOADS: AtomicU32 = AtomicU32::new(0);
265    let may_upload = PROBE_UPLOADS.load(Ordering::Relaxed) < 4;
266    let resident = match backend() {
267        #[cfg(target_os = "macos")]
268        Backend::Metal => crate::gpu_metal::q8_resident_or_upload(model, idx, may_upload),
269        #[cfg(feature = "gpu")]
270        Backend::Wgpu => crate::gpu_wgpu::q8_resident_or_upload(model, idx, may_upload),
271        Backend::None => false,
272    };
273    if !resident && may_upload {
274        PROBE_UPLOADS.fetch_add(1, Ordering::Relaxed);
275    }
276    resident
277}
278
279/// Test hook: reset all probes to the undecided state.
280#[cfg(test)]
281pub(crate) fn probe_reset() {
282    for p in &PROBES {
283        p.state.store(0, Ordering::Relaxed);
284        p.flip.store(0, Ordering::Relaxed);
285        p.gpu_ns.store(0, Ordering::Relaxed);
286        p.gpu_n.store(0, Ordering::Relaxed);
287        p.cpu_ns.store(0, Ordering::Relaxed);
288        p.cpu_n.store(0, Ordering::Relaxed);
289    }
290}
291
292#[cfg(test)]
293mod probe_tests {
294    use super::*;
295    use std::time::Duration;
296
297    // One test fn: PROBES is process-global and probe_reset touches all
298    // classes — parallel test threads would race.
299    #[test]
300    fn probe_alternates_discards_cold_and_decides() {
301        probe_reset();
302        // Probing: arms alternate.
303        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::Gpu));
304        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::CpuTimed));
305
306        // A cold GPU sample (upload noted) must be discarded: feed a
307        // catastrophic cold sample, then clean fast-GPU samples — GPU
308        // wins only if the cold one did not count.
309        probe_note_cold();
310        probe_record(OpClass::Ffn, true, Duration::from_secs(1000));
311        for _ in 0..PROBE_SAMPLES {
312            probe_record(OpClass::Ffn, true, Duration::from_millis(1));
313            probe_record(OpClass::Ffn, false, Duration::from_millis(4));
314        }
315        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::Gpu));
316
317        // The reverse: a class where the CPU arm is faster decides CPU.
318        for _ in 0..PROBE_SAMPLES {
319            probe_record(OpClass::Matmat, true, Duration::from_millis(4));
320            probe_record(OpClass::Matmat, false, Duration::from_millis(1));
321        }
322        assert!(matches!(probe_arm(OpClass::Matmat), ProbeArm::Cpu));
323
324        // cpu_scope: gates off inside, restored after.
325        cpu_scope(|| CPU_ONLY.with(|c| assert!(c.get())));
326        CPU_ONLY.with(|c| assert!(!c.get()));
327        probe_reset();
328    }
329}
330
331/// Default row threshold: the GPU takes only larger matrices (lm_head
332/// class). Below it, the dispatch/readback cost does not pay off on unified memory.
333pub const GPU_MIN_ROWS: usize = 65_536;
334
335/// Effective threshold: `CMF_GPU_MIN_ROWS` overrides. Defaults differ
336/// by device class: on a DISCRETE card VRAM bandwidth pays off even for
337/// FFN/QKV-class matrices (4096), on unified memory only lm_head-class
338/// is worth the dispatch/readback (65536). Field case behind this: a
339/// 35B model on an RTX 4090 saw ~0 offload because every layer matrix
340/// sat below the old universal 65536.
341pub fn min_rows() -> usize {
342    if let Some(v) = std::env::var("CMF_GPU_MIN_ROWS").ok().and_then(|v| v.parse().ok()) {
343        return v;
344    }
345    if discrete() {
346        4096
347    } else {
348        GPU_MIN_ROWS
349    }
350}
351
352/// Is the active backend a discrete card (PCIe VRAM)?
353pub fn discrete() -> bool {
354    match backend() {
355        #[cfg(feature = "gpu")]
356        Backend::Wgpu => crate::gpu_wgpu::is_discrete(),
357        #[cfg(target_os = "macos")]
358        Backend::Metal => false, // UMA by the init() guard
359        Backend::None => false,
360    }
361}
362
363/// A single MoE-FFN job (an expert with its own weight), executed in one
364/// submission: (rows, cols, idx, row_scale) for gate/up/down + prescaled
365/// inputs + the down θ-field + the blending weight.
366pub struct MoeJob<'a> {
367    pub gate: (usize, usize, usize, &'a [f32]),
368    pub up: (usize, usize, usize, &'a [f32]),
369    pub down: (usize, usize, usize, &'a [f32]),
370    pub xs_gate: Vec<f32>,
371    pub xs_up: Vec<f32>,
372    pub down_col: &'a [f32],
373    pub w: f32,
374    /// q1 trio: scales live inside the 6-byte tiles (row_scale slices
375    /// empty, xs raw f32). Backends without a q1 kernel refuse the job.
376    pub q1: bool,
377}
378
379/// A single independent batch matvec (GDN projections of one input).
380pub struct BatchJob<'a> {
381    pub idx: usize,
382    pub rows: usize,
383    pub cols: usize,
384    pub row_scale: &'a [f32],
385    pub xs: Vec<f32>,
386    /// q1 tensor: tile-embedded scales, raw f32 xs (see `MoeJob::q1`).
387    pub q1: bool,
388}
389
390#[derive(Clone, Copy, PartialEq, Eq)]
391enum Backend {
392    None,
393    #[cfg(target_os = "macos")]
394    Metal,
395    #[cfg(feature = "gpu")]
396    Wgpu,
397}
398
399fn backend() -> Backend {
400    #[cfg(feature = "gpu")]
401    if crate::gpu_wgpu::selected() {
402        return if crate::gpu_wgpu::enabled() { Backend::Wgpu } else { Backend::None };
403    }
404    #[cfg(target_os = "macos")]
405    if crate::gpu_metal::enabled() {
406        return Backend::Metal;
407    }
408    Backend::None
409}
410
411/// GPU enabled and initialized on the selected backend?
412pub fn enabled() -> bool {
413    backend() != Backend::None
414}
415
416/// q8_row/q8_2f matvec, rows [row0, row0+rows). `xs` — prescaled by the θ-field.
417#[allow(clippy::too_many_arguments, unused_variables)]
418pub fn q8_matvec_range(
419    model: &Arc<CmfModel>,
420    idx: usize,
421    row0: usize,
422    row_scale: &[f32],
423    xs: &[f32],
424    rows: usize,
425    cols: usize,
426    out: &mut [f32],
427) -> bool {
428    match backend() {
429        #[cfg(target_os = "macos")]
430        Backend::Metal => {
431            crate::gpu_metal::q8_matvec_range(model, idx, row0, row_scale, xs, rows, cols, out)
432        }
433        #[cfg(feature = "gpu")]
434        Backend::Wgpu => {
435            crate::gpu_wgpu::q8_matvec_range(model, idx, row0, row_scale, xs, rows, cols, out)
436        }
437        Backend::None => false,
438    }
439}
440
441/// GEMM of a prefill batch: `pre` — prescaled inputs row-major [b, cols],
442/// out — row-major [b, rows].
443#[allow(clippy::too_many_arguments, unused_variables)]
444pub fn q8_matmat(
445    model: &Arc<CmfModel>,
446    idx: usize,
447    row_scale: &[f32],
448    pre: &[f32],
449    b: usize,
450    rows: usize,
451    cols: usize,
452    out: &mut [f32],
453) -> bool {
454    match backend() {
455        #[cfg(target_os = "macos")]
456        Backend::Metal => {
457            crate::gpu_metal::q8_matmat(model, idx, row_scale, pre, b, rows, cols, out)
458        }
459        #[cfg(feature = "gpu")]
460        Backend::Wgpu => {
461            crate::gpu_wgpu::q8_matmat(model, idx, row_scale, pre, b, rows, cols, out)
462        }
463        Backend::None => false,
464    }
465}
466
467/// q1 matvec: raw f32 activations, tile-embedded scales. Metal only
468/// for now (wgpu q1 WGSL is queued); false = CPU fallback.
469#[allow(unused_variables)]
470pub fn q1_matvec(
471    model: &Arc<CmfModel>,
472    idx: usize,
473    xs: &[f32],
474    rows: usize,
475    cols: usize,
476    out: &mut [f32],
477) -> bool {
478    match backend() {
479        #[cfg(target_os = "macos")]
480        Backend::Metal => crate::gpu_metal::q1_matvec(model, idx, xs, rows, cols, out),
481        #[cfg(feature = "gpu")]
482        Backend::Wgpu => crate::gpu_wgpu::q1_matvec(model, idx, xs, rows, cols, out),
483        Backend::None => false,
484    }
485}
486
487/// Whole attention sub-block on the wgpu token graph (drop-in for
488/// `qwen_attention`): normed hidden in, O-projection out, resident device
489/// K/V mirror. false = refusal / not the wgpu backend → CPU path.
490#[allow(clippy::too_many_arguments)]
491pub fn attn_dropin(
492    model: &Arc<CmfModel>,
493    kv_id: u64,
494    layer: usize,
495    normed: &[f32],
496    wq_idx: usize,
497    wk_idx: usize,
498    wv_idx: usize,
499    wo_idx: usize,
500    q_norm: Option<&[f32]>,
501    k_norm: Option<&[f32]>,
502    invf: &[f32],
503    nh: usize,
504    nkv: usize,
505    hd: usize,
506    rd: usize,
507    hidden: usize,
508    pos: usize,
509    cap: usize,
510    gemma: bool,
511    eps: f32,
512    cpu_k: &[Vec<f32>],
513    cpu_v: &[Vec<f32>],
514    out: &mut [f32],
515) -> bool {
516    match backend() {
517        #[cfg(feature = "gpu")]
518        Backend::Wgpu => crate::gpu_wgpu::attn_dropin_gpu(
519            model, kv_id, layer, normed, wq_idx, wk_idx, wv_idx, wo_idx, q_norm, k_norm, invf,
520            nh, nkv, hd, rd, hidden, pos, cap, gemma, eps, cpu_k, cpu_v, out,
521        ),
522        _ => false,
523    }
524}
525
526/// One weight in the whole-token graph: tensor idx + a codec tag (0=q8_row,
527/// 1=q1, 2=q4_tiled, 3=q1t, 4=f32) + per-row scales (q8_row only) + the raw f32
528/// data (kind 4 only — small unquantized projections like GDN in_proj_a/b).
529pub struct GraphW<'a> {
530    pub idx: usize,
531    pub kind: u8,
532    pub row_scale: &'a [f32],
533    pub data: &'a [f32],
534}
535
536/// A layer's token-mixing op: standard attention or a GDN (linear-attention)
537/// block. The surrounding norms + SwiGLU FFN are common to both.
538pub enum GraphAttn<'a> {
539    Full {
540        wq: GraphW<'a>,
541        wk: GraphW<'a>,
542        wv: GraphW<'a>,
543        wo: GraphW<'a>,
544        q_norm: Option<&'a [f32]>,
545        k_norm: Option<&'a [f32]>,
546        /// (bq, bk, bv) attention biases (Qwen2). None ⇒ no bias.
547        bias: Option<(&'a [f32], &'a [f32], &'a [f32])>,
548        /// Qwen3.5 gated attention: wq emits 2·nh·hd (q||gate per head), the
549        /// attention output is scaled by sigmoid(gate) before the O projection.
550        output_gate: bool,
551        cpu_k: &'a [Vec<f32>],
552        cpu_v: &'a [Vec<f32>],
553    },
554    Gdn {
555        qkv: GraphW<'a>,
556        z: GraphW<'a>,
557        a: GraphW<'a>,
558        b: GraphW<'a>,
559        out: GraphW<'a>,
560        conv1d: &'a [f32],
561        a_log: &'a [f32],
562        dt_bias: &'a [f32],
563        norm: &'a [f32],
564        nv: usize,
565        nk: usize,
566        dk: usize,
567        dv: usize,
568        kk: usize,
569    },
570}
571
572/// Per-layer weights for the whole-token wgpu graph.
573pub struct GraphLayer<'a> {
574    pub input_norm: &'a [f32],
575    pub attn: GraphAttn<'a>,
576    pub post_norm: &'a [f32],
577    pub gate: GraphW<'a>,
578    pub up: GraphW<'a>,
579    pub down: GraphW<'a>,
580}
581
582/// Whole-token decode graph on wgpu: the entire layer stack in ONE submit,
583/// hidden resident, one readback. Updates `h` in place. false = refusal.
584#[allow(clippy::too_many_arguments)]
585pub fn forward_token_graph(
586    model: &Arc<CmfModel>,
587    kv_id: u64,
588    layers: &[GraphLayer],
589    invf: &[f32],
590    h: &mut [f32],
591    nh: usize,
592    nkv: usize,
593    hd: usize,
594    rd: usize,
595    hidden: usize,
596    inter: usize,
597    position: usize,
598    cap: usize,
599    gemma: bool,
600    eps: f32,
601    lm_head: Option<(&GraphW, usize)>,
602    final_norm: &[f32],
603    logits: &mut Vec<f32>,
604) -> bool {
605    match backend() {
606        #[cfg(feature = "gpu")]
607        Backend::Wgpu => crate::gpu_wgpu::forward_token_graph(
608            model, kv_id, layers, invf, h, nh, nkv, hd, rd, hidden, inter, position, cap, gemma, eps,
609            lm_head, final_norm, logits,
610        ),
611        _ => {
612            let _ = (lm_head, final_norm, logits);
613            false
614        }
615    }
616}
617
618/// Batched prefill: k contiguous positions through the whole graph in one submit
619/// (projections/FFN as GEMMs, attention/GDN looped over scratch). `h` is
620/// [k·hidden] in/out; `positions` len k. wgpu only.
621#[allow(clippy::too_many_arguments)]
622pub fn forward_batch_graph(
623    model: &Arc<CmfModel>,
624    kv_id: u64,
625    layers: &[GraphLayer],
626    invf: &[f32],
627    h: &mut [f32],
628    nh: usize,
629    nkv: usize,
630    hd: usize,
631    rd: usize,
632    hidden: usize,
633    inter: usize,
634    positions: &[usize],
635    cap: usize,
636    gemma: bool,
637    eps: f32,
638    k: usize,
639) -> bool {
640    match backend() {
641        #[cfg(feature = "gpu")]
642        Backend::Wgpu => crate::gpu_wgpu::forward_batch_graph(
643            model, kv_id, layers, invf, h, nh, nkv, hd, rd, hidden, inter, positions, cap, gemma, eps, k,
644        ),
645        _ => false,
646    }
647}
648
649/// Drop the wgpu token graph's device K/V mirror for a pipeline.
650pub fn graph_kv_reset(_kv_id: u64) {
651    #[cfg(feature = "gpu")]
652    if backend() == Backend::Wgpu {
653        crate::gpu_wgpu::kv_mirror_reset(_kv_id);
654    }
655}
656
657/// Ternary (q1t) BASE matvec on the GPU — fills `out` with the base dot; the
658/// caller adds the sparse overlay on the CPU. Metal only for now (wgpu q1t not
659/// yet written → CPU fallback).
660pub fn q1t_matvec(
661    model: &Arc<CmfModel>,
662    idx: usize,
663    xs: &[f32],
664    rows: usize,
665    cols: usize,
666    out: &mut [f32],
667) -> bool {
668    match backend() {
669        #[cfg(target_os = "macos")]
670        Backend::Metal => crate::gpu_metal::q1t_matvec(model, idx, xs, rows, cols, out),
671        #[cfg(feature = "gpu")]
672        Backend::Wgpu => crate::gpu_wgpu::q1t_matvec(model, idx, xs, rows, cols, out),
673        Backend::None => false,
674    }
675}
676
677/// q4_block matvec on the GPU — wgpu only (Metal drives q4_block through the
678/// whole-token graph, not a standalone matvec).
679#[allow(unused_variables)]
680pub fn q4b_matvec(
681    model: &Arc<CmfModel>,
682    idx: usize,
683    xs: &[f32],
684    rows: usize,
685    cols: usize,
686    out: &mut [f32],
687) -> bool {
688    match backend() {
689        #[cfg(target_os = "macos")]
690        Backend::Metal => false,
691        #[cfg(feature = "gpu")]
692        Backend::Wgpu => crate::gpu_wgpu::q4b_matvec(model, idx, xs, rows, cols, out),
693        Backend::None => false,
694    }
695}
696
697/// q1t batched GEMM (prefill) — base + overlay on-device (Metal simdgroup or
698/// wgpu register-blocked).
699pub fn q1t_matmat(
700    model: &Arc<CmfModel>,
701    idx: usize,
702    xs: &[f32],
703    b: usize,
704    rows: usize,
705    cols: usize,
706    out: &mut [f32],
707) -> bool {
708    match backend() {
709        #[cfg(target_os = "macos")]
710        Backend::Metal => crate::gpu_metal::q1t_matmat(model, idx, xs, b, rows, cols, out),
711        #[cfg(feature = "gpu")]
712        Backend::Wgpu => crate::gpu_wgpu::q1t_matmat(model, idx, xs, b, rows, cols, out),
713        Backend::None => false,
714    }
715}
716
717/// Batched q1 GEMM (prefill). wgpu only — Metal has its own block path.
718pub fn q1_matmat(
719    model: &Arc<CmfModel>,
720    idx: usize,
721    xs: &[f32],
722    b: usize,
723    rows: usize,
724    cols: usize,
725    out: &mut [f32],
726) -> bool {
727    match backend() {
728        #[cfg(feature = "gpu")]
729        Backend::Wgpu => crate::gpu_wgpu::q1_matmat(model, idx, xs, b, rows, cols, out),
730        _ => false,
731    }
732}
733
734/// Whole-block token-graph types re-exported from the Metal backend.
735#[cfg(target_os = "macos")]
736pub use crate::gpu_metal::{
737    kv_mirror_drop, kv_mirror_read_last, kv_mirror_take_imp, AttnDeviceParams, AttnGpuLayer,
738    GdnGpuCfg, GdnGpuLayer, GraphDims, TokenGraph,
739};
740
741/// A BLOCK of consecutive q1 GDN layers in one submission (Metal only).
742#[cfg(target_os = "macos")]
743pub fn gdn_block(
744    model: &Arc<CmfModel>,
745    layers: &[GdnGpuLayer],
746    states: &mut [&mut [f32]],
747    cfg: &GdnGpuCfg,
748    h: &mut [f32],
749) -> bool {
750    match backend() {
751        Backend::Metal => crate::gpu_metal::gdn_block(model, layers, states, cfg, h),
752        _ => false,
753    }
754}
755
756/// A layer's MoE-FFN in one submission (amortizing the dispatch cost).
757#[allow(unused_variables)]
758pub fn moe_block(model: &Arc<CmfModel>, jobs: &[MoeJob], out: &mut [f32]) -> bool {
759    match backend() {
760        #[cfg(target_os = "macos")]
761        Backend::Metal => crate::gpu_metal::moe_block(model, jobs, out),
762        #[cfg(feature = "gpu")]
763        Backend::Wgpu => crate::gpu_wgpu::moe_block(model, jobs, out),
764        Backend::None => false,
765    }
766}
767
768/// Independent matvecs of one input in a single submission (GDN projections).
769#[allow(unused_variables)]
770pub fn matvec_batch(model: &Arc<CmfModel>, jobs: &[BatchJob], out: &mut [&mut [f32]]) -> bool {
771    match backend() {
772        #[cfg(target_os = "macos")]
773        Backend::Metal => crate::gpu_metal::matvec_batch(model, jobs, out),
774        #[cfg(feature = "gpu")]
775        Backend::Wgpu => crate::gpu_wgpu::matvec_batch(model, jobs, out),
776        Backend::None => false,
777    }
778}