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