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