Skip to main content

cortiq_engine/
gpu.rs

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