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: name the device once at init. The probe cache is keyed by
48/// it, because a verdict is a property of THIS silicon and nothing else.
49/// First writer wins: a process runs one backend, and on the rare host
50/// where two initialize, the one that came up first is the one in use.
51pub fn probe_set_device(label: &str) {
52    let _ = DEVICE_LABEL.set(label.to_string());
53}
54
55fn device_label() -> &'static str {
56    DEVICE_LABEL.get().map(String::as_str).unwrap_or("unknown")
57}
58
59static DEVICE_LABEL: std::sync::OnceLock<String> = std::sync::OnceLock::new();
60
61/// Somewhere this process may write small caches.
62///
63/// `std::env::temp_dir()` is NOT that place on Android: with no `TMPDIR`
64/// it answers `/tmp`, which does not exist in an app sandbox, and every
65/// write fails silently — measured, after the pipeline cache appeared to
66/// work in a shell (where `TMPDIR=/data/local/tmp`) and did nothing at
67/// all in the app. The loader points this at the model's own directory,
68/// which is somewhere the caller already writes.
69static CACHE_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
70
71/// Loader: name a directory this process can write to. First call wins.
72pub fn set_cache_dir(dir: std::path::PathBuf) {
73    let _ = CACHE_DIR.set(dir);
74}
75
76/// Same directory, for the backends.
77pub fn cache_dir_pub() -> std::path::PathBuf {
78    cache_dir()
79}
80
81fn cache_dir() -> std::path::PathBuf {
82    if let Some(d) = CACHE_DIR.get() {
83        return d.clone();
84    }
85    match std::env::var_os("TMPDIR") {
86        Some(t) => std::path::PathBuf::from(t),
87        None => std::env::temp_dir(),
88    }
89}
90
91/// Where decided verdicts are remembered between runs. `CMF_PROBE_CACHE`
92/// overrides the path; `0` disables the cache entirely.
93fn probe_cache_path() -> Option<std::path::PathBuf> {
94    match std::env::var("CMF_PROBE_CACHE") {
95        Ok(v) if v == "0" => None,
96        Ok(v) => Some(std::path::PathBuf::from(v)),
97        Err(_) => Some(cache_dir().join("cortiq-gpu-probe.tsv")),
98    }
99}
100
101/// One line per decided class: `version \t device \t class \t winner`.
102/// A different engine build or a different device simply does not match,
103/// so a stale file is inert rather than wrong.
104fn probe_cache_key_named(class: &str) -> String {
105    format!(
106        "{}\t{}\t{}",
107        env!("CARGO_PKG_VERSION"),
108        device_label(),
109        class
110    )
111}
112
113const CLASS_NAMES: [&str; 7] = [
114    "ffn",
115    "matvec",
116    "matmat",
117    "qkv-batch",
118    "matmat-wide",
119    "lm-head",
120    "gemm-nt",
121];
122
123/// Adopt every verdict this device already reached in an earlier run.
124///
125/// Probing is not cheap and it is not free of consequences: on a
126/// Snapdragon 778G the three deciding classes took **three minutes of
127/// wall clock** before the first token, every process, and in the phone
128/// app that was the whole first answer — 209.6 s for 25 tokens against
129/// 10.5 s on the CPU path. The verdict itself was the same every time.
130/// Paying to rediscover it is the defect; the answer is to write it down.
131fn probe_cache_load() {
132    static ONCE: std::sync::Once = std::sync::Once::new();
133    ONCE.call_once(|| {
134        let Some(path) = probe_cache_path() else {
135            return;
136        };
137        // Unit tests share this process and its default cache path; a
138        // verdict left by an earlier run would decide a class before the
139        // arbitration tests get to watch it alternate. Tests that mean to
140        // exercise the cache point `CMF_PROBE_CACHE` at their own file.
141        if cfg!(test) && std::env::var("CMF_PROBE_CACHE").is_err() {
142            return;
143        }
144        let Ok(text) = std::fs::read_to_string(&path) else {
145            return;
146        };
147        probe_cache_adopt(&text);
148    });
149}
150
151/// Apply verdicts from a cache file's text. Split out from the file
152/// reading so the adoption rule — including which lines must be IGNORED
153/// — is testable without a filesystem.
154fn probe_cache_adopt(text: &str) {
155    for line in text.lines() {
156        let Some((key, verdict)) = line.rsplit_once('\t') else {
157            continue;
158        };
159        let winner = match verdict.trim() {
160            "gpu" => 1u8,
161            "cpu" => 2u8,
162            _ => continue,
163        };
164        for (i, name) in CLASS_NAMES.iter().enumerate() {
165            if probe_cache_key_named(name) == key {
166                let _ = PROBES[i].state.compare_exchange(
167                    0,
168                    winner,
169                    Ordering::Relaxed,
170                    Ordering::Relaxed,
171                );
172                tracing::debug!("gpu probe [{name}]: remembered → {verdict}");
173            }
174        }
175    }
176}
177
178/// Remember a verdict for the next run. Best-effort: a read-only cache
179/// directory costs a re-probe, never a failure.
180fn probe_cache_store(c: OpClass, winner: u8) {
181    let Some(path) = probe_cache_path() else {
182        return;
183    };
184    let line = format!(
185        "{}\t{}\n",
186        probe_cache_key_named(CLASS_NAMES[c as usize]),
187        if winner == 1 { "gpu" } else { "cpu" }
188    );
189    use std::io::Write;
190    if let Ok(mut f) = std::fs::OpenOptions::new()
191        .create(true)
192        .append(true)
193        .open(&path)
194    {
195        let _ = f.write_all(line.as_bytes());
196    }
197}
198
199/// Backends: note a one-off cost (weight upload, buffer-cache fill) so
200/// the probe discards this sample.
201/// Every buffer creation anywhere bumps this; the graph's bind-group
202/// cache treats any cold event as total invalidation — a stale bind
203/// group is silent corruption, a cleared cache is one re-encoded token.
204pub fn cold_epoch() -> u64 {
205    COLD_EPOCH.load(std::sync::atomic::Ordering::Relaxed)
206}
207static COLD_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
208
209pub(crate) fn probe_note_cold() {
210    COLD_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
211    PROBE_COLD.with(|c| c.set(true));
212}
213
214/// Peek the cold flag without consuming it (`probe_record` consumes).
215/// Contention heuristics use this: a slow COLD op is a one-off build
216/// cost, not evidence the device is busy.
217pub(crate) fn probe_was_cold() -> bool {
218    PROBE_COLD.with(|c| c.get())
219}
220
221/// Pipeline: mark the current layer (or −1 outside layers) for layer-split.
222pub fn set_layer(l: i64) {
223    CUR_LAYER.with(|c| c.set(l));
224}
225
226/// The layer `set_layer` last marked on this thread (−1 outside layers).
227pub fn cur_layer() -> i64 {
228    CUR_LAYER.with(|c| c.get())
229}
230
231/// Parse `CMF_GPU_LAYERS` («0-19», «0,2,4», «0-9,30-39») once.
232/// None = no restriction (all layers on GPU). Garbage → also no restriction.
233fn layer_ranges() -> &'static Option<Vec<(i64, i64)>> {
234    static R: OnceLock<Option<Vec<(i64, i64)>>> = OnceLock::new();
235    R.get_or_init(|| {
236        let s = std::env::var("CMF_GPU_LAYERS").ok()?;
237        let mut v = Vec::new();
238        for part in s.split(',') {
239            let part = part.trim();
240            match part.split_once('-') {
241                Some((a, b)) => v.push((a.trim().parse().ok()?, b.trim().parse().ok()?)),
242                None => {
243                    let x: i64 = part.parse().ok()?;
244                    v.push((x, x));
245                }
246            }
247        }
248        Some(v)
249    })
250}
251
252fn layer_allowed() -> bool {
253    match layer_ranges() {
254        None => true,
255        Some(ranges) => {
256            let cur = CUR_LAYER.with(|c| c.get());
257            cur < 0 || ranges.iter().any(|(a, b)| cur >= *a && cur <= *b)
258        }
259    }
260}
261
262/// GPU allowed FOR THE CURRENT LAYER: backend is initialized AND the layer
263/// falls within `CMF_GPU_LAYERS` (GPU/CPU layer-split) AND we are not
264/// inside a `cpu_scope`. Op gates call this.
265pub fn enabled_here() -> bool {
266    !CPU_ONLY.with(|c| c.get()) && enabled() && layer_allowed()
267}
268
269// ── Runtime GPU-vs-CPU probe ────────────────────────────────────────────
270// CMF_GPU=1 does not TRUST that the device wins — it MEASURES. For each
271// op class the first calls alternate arms: GPU timed vs pure-CPU timed
272// (under cpu_scope). Cold GPU calls (weight upload / cache fill) are
273// discarded; after PROBE_SAMPLES clean samples per arm the faster arm is
274// chosen for the rest of the process. Rationale: submit+poll latency
275// differs by an order of magnitude across driver stacks (Metal/PCIe
276// ~3-4 ms, Vulkan/4090 ~0.3 ms) — a static threshold cannot know whether
277// per-op offload pays off HERE. CMF_GPU_PROBE=0 → always trust the GPU.
278
279/// GPU-eligible op classes, each with an independent probe.
280#[derive(Clone, Copy)]
281pub enum OpClass {
282    /// Whole FFN chain in one submission (dense / MoE block).
283    Ffn = 0,
284    /// Large hybrid CPU∥GPU matvec (lm_head class).
285    Matvec = 1,
286    /// Prefill GEMM (matmat).
287    Matmat = 2,
288    /// Batched matvecs of one input (QKV).
289    Batch = 3,
290    /// Prefill GEMM at image-diffusion widths (b ≥ 128). Probed apart
291    /// from `Matmat`: one imagegen process runs BOTH populations
292    /// (prompt encode b≈40 where the GPU wins big, DiT b≥256 where
293    /// the CPU AMX arm is competitive) — a single shared verdict locks
294    /// the wrong arm for whichever population samples second.
295    MatmatWide = 4,
296    /// The lm_head itself, apart from the merely-large matvecs. Same
297    /// reasoning as `MatmatWide`, and DeepSeek-V4 is where it bit: its
298    /// attention projections are 37M weights and its head is 529M, so
299    /// the projections' verdict — CPU, honestly measured at 0.19 ms —
300    /// decided for a matvec fourteen times their size that took 11 ms
301    /// a token on the host.
302    MatvecHead = 5,
303    /// The blocked f32 GEMM (`fcd_ops::gemm_nt`): attention's QKᵀ and
304    /// AV, and the VAE decoders' projections. It used to take every job
305    /// over 4 M MACs on sight, with no CPU arm to lose to — which on
306    /// the MiniMax-H3 video decoder was three times SLOWER than the
307    /// host it displaced. Its population is per-head slices, nothing
308    /// like the weight GEMMs above, so it probes on its own.
309    GemmNt = 6,
310}
311
312/// Which probe a large matvec belongs to. The head is an order of
313/// magnitude bigger than anything else that reaches this gate, and the
314/// two populations do not have the same answer.
315pub fn matvec_class(rows: usize, cols: usize) -> OpClass {
316    if rows * cols >= 67_108_864 {
317        OpClass::MatvecHead
318    } else {
319        OpClass::Matvec
320    }
321}
322
323/// Probe verdict for one call.
324pub enum ProbeArm {
325    /// Run the GPU path (during probing: timed, recorded).
326    Gpu,
327    /// Probing: run the CPU path under `cpu_scope`, timed, recorded.
328    CpuTimed,
329    /// Decided: CPU won — run the CPU path (under `cpu_scope`).
330    Cpu,
331}
332
333/// Clean samples per arm before a class decides.
334const PROBE_SAMPLES: u32 = 6;
335
336/// Device samples discarded before any count — see `Probe::gpu_burn`.
337const PROBE_WARMUP: u32 = 1;
338
339struct Probe {
340    /// 0 = probing, 1 = GPU won, 2 = CPU won.
341    state: AtomicU8,
342    flip: AtomicU32,
343    gpu_ns: AtomicU64,
344    gpu_n: AtomicU32,
345    /// GPU samples still to discard as warm-up.
346    ///
347    /// The cold flag catches buffer and weight uploads, but a compute
348    /// pipeline is compiled on first use and not every creation site
349    /// raises it — the wgpu path has 21 pipeline creations against 12
350    /// cold notes. One uncaught shader compile is enough to lose a
351    /// class for the whole process: `gemm-nt` on an A100 was recorded at
352    /// 117.01 ms against the host's 3.19 and sent to the CPU, which
353    /// parked a 27B bake on 2.6 cores with the card idle. The decision
354    /// already uses each arm's BEST sample, so discarding the first
355    /// GPU sample costs one extra round trip and removes the whole
356    /// class of first-call artefacts.
357    gpu_burn: AtomicU32,
358    cpu_ns: AtomicU64,
359    cpu_n: AtomicU32,
360    /// Best (minimum) sample per arm. The DECISION compares these:
361    /// means are poisoned by one-off cold costs the cold-flag cannot
362    /// see — e.g. the CPU arm's first mmap-cold expert matvec page
363    /// faults its weights in and reads 3× its steady state, which
364    /// locked the GPU arm on a 35B MoE at a 4× real-world loss. The
365    /// minimum is each arm's honest steady-state pace.
366    gpu_min: AtomicU64,
367    cpu_min: AtomicU64,
368}
369
370impl Probe {
371    const fn new() -> Self {
372        Self {
373            state: AtomicU8::new(0),
374            flip: AtomicU32::new(0),
375            gpu_ns: AtomicU64::new(0),
376            gpu_n: AtomicU32::new(0),
377            gpu_burn: AtomicU32::new(PROBE_WARMUP),
378            cpu_ns: AtomicU64::new(0),
379            cpu_n: AtomicU32::new(0),
380            gpu_min: AtomicU64::new(u64::MAX),
381            cpu_min: AtomicU64::new(u64::MAX),
382        }
383    }
384}
385
386static PROBES: [Probe; 7] = [
387    Probe::new(),
388    Probe::new(),
389    Probe::new(),
390    Probe::new(),
391    Probe::new(),
392    Probe::new(),
393    Probe::new(),
394];
395
396/// A caller that knows its loop is long, uniform and warm can say so: the
397/// probe times ops in isolation and alternates arms to do it, which reads a
398/// sustained diffusion step as slower on the device than it is. Measured on
399/// an M4 at 672 video tokens: the probe picked the CPU at 1.25 ms against
400/// 0.88 ms per op, and the loop it picked for ran 23.9 s a step against the
401/// device's 19.7 s.
402static TRUST_GPU: AtomicBool = AtomicBool::new(false);
403
404/// Take the probe out of the loop until the guard drops.
405pub fn trust_gpu() -> GpuTrust {
406    let was = TRUST_GPU.swap(true, Ordering::Relaxed);
407    GpuTrust(was)
408}
409
410pub struct GpuTrust(bool);
411
412impl Drop for GpuTrust {
413    fn drop(&mut self) {
414        TRUST_GPU.store(self.0, Ordering::Relaxed);
415    }
416}
417
418fn probe_on_for(c: OpClass) -> bool {
419    // The trust is only for the *wide* class. A sustained diffusion step is
420    // where the probe reads a warm device as cold; the narrow batches inside
421    // the same loop — an audio stream of fifty-one tokens against the same
422    // weights — are small enough that submit latency can genuinely beat the
423    // arithmetic, and there the probe is right and should keep deciding.
424    if TRUST_GPU.load(Ordering::Relaxed) && matches!(c, OpClass::MatmatWide | OpClass::Ffn) {
425        return false;
426    }
427    probe_on()
428}
429
430fn probe_on() -> bool {
431    static ON: OnceLock<bool> = OnceLock::new();
432    *ON.get_or_init(|| {
433        std::env::var("CMF_GPU_PROBE")
434            .map(|v| v != "0" && v != "off")
435            .unwrap_or(true)
436    })
437}
438
439/// q1 ops on the native Metal backend skip the probe entirely: the CPU
440/// q1 kernel is load-port-bound, the GPU one wins warm — and probe
441/// alternation itself cools the device between samples (measured: block
442/// times 5.8 ms warm vs 8.8 ms mixed). Other backends keep probing.
443pub fn q1_force() -> bool {
444    #[cfg(target_os = "macos")]
445    {
446        backend() == Backend::Metal
447    }
448    #[cfg(not(target_os = "macos"))]
449    {
450        false
451    }
452}
453
454/// Should a FUSED whole-block path trust the device instead of asking
455/// the per-op probe? True on native Metal and on discrete wgpu adapters.
456///
457/// The probe answers "is one wide matmat faster on the GPU", and for the
458/// DiT on Metal that is a coin flip — measured 2.62 ms GPU vs 2.56 ms
459/// CPU, a 2% spread that lands on either arm run to run. But the fused
460/// block's advantage is not per-op speed, it is that the hidden state,
461/// the packs and the attention panels never leave the device: end to end
462/// the whole-block path renders a 512² Lumina step in ~5.4 s against
463/// ~8.4 s when the probe happens to pick the CPU. Gating a fusion win on
464/// a per-op tie made every second render half-speed at random.
465///
466/// On a discrete card the verdict is never in doubt — an RTX 3090 against
467/// a 256-core EPYC measured 11.5 ms vs 31 ms per wide op, four runs out
468/// of four — so the probe's sampling phase is pure cost: it alone was 10%
469/// of a 512² render (74.3 s against 66.9 s with the probe off). Integrated
470/// and mobile adapters keep probing; there the submit latency is real and
471/// can genuinely lose.
472pub fn fused_block_trusted() -> bool {
473    #[cfg(target_os = "macos")]
474    if backend() == Backend::Metal {
475        return true;
476    }
477    wgpu_graph_default()
478}
479
480/// Which arm should this GPU-eligible call take? Consult AFTER the
481/// eligibility gates (`enabled_here` / `min_rows`) so only real
482/// candidates alternate.
483/// While a class is still probing, a call whose weights are NOT yet on
484/// the card should take the GPU arm anyway: the upload is work the next
485/// step needs regardless, and the sample it produces is discarded as
486/// cold — so handing that call to the CPU arm buys nothing and costs a
487/// host GEMM. Measured on a diffusion stack, where every layer is
488/// touched once per step and therefore EVERY first-step GPU sample is
489/// cold: one projection drew the CPU arm for the whole first step, 9.8 s
490/// against the 2.8 s it costs once the weights are warm.
491pub fn weight_is_resident(model: &Arc<CmfModel>, idx: usize) -> bool {
492    #[cfg(all(feature = "gpu", not(target_os = "macos")))]
493    {
494        return crate::gpu_wgpu::weight_is_resident(model, idx);
495    }
496    #[cfg(not(all(feature = "gpu", not(target_os = "macos"))))]
497    {
498        let _ = (model, idx);
499        true
500    }
501}
502
503pub fn probe_arm_cold_prefers_gpu(c: OpClass, weights_resident: bool) -> ProbeArm {
504    if !weights_resident && probe_deciding(c) {
505        return ProbeArm::Gpu;
506    }
507    probe_arm(c)
508}
509
510pub fn probe_arm(c: OpClass) -> ProbeArm {
511    // Every arbitrated call starts with a clean cold flag: both the
512    // sample discard in `probe_record` and the contention kill-switch
513    // read it AFTER the op, so a stale note from a previous call on
514    // this thread must not leak in.
515    PROBE_COLD.with(|f| f.set(false));
516    if !probe_on_for(c) {
517        return ProbeArm::Gpu;
518    }
519    probe_cache_load();
520    let p = &PROBES[c as usize];
521    match p.state.load(Ordering::Relaxed) {
522        1 => ProbeArm::Gpu,
523        2 => ProbeArm::Cpu,
524        _ => {
525            if p.flip.fetch_add(1, Ordering::Relaxed) % 2 == 0 {
526                ProbeArm::Gpu
527            } else {
528                ProbeArm::CpuTimed
529            }
530        }
531    }
532}
533
534/// Record a timed arm sample; on the `PROBE_SAMPLES`-th clean sample of
535/// BOTH arms the class decides for the rest of the process.
536pub fn probe_record(c: OpClass, gpu: bool, dur: std::time::Duration) {
537    probe_record_into(&PROBES[c as usize], CLASS_NAMES[c as usize], Some(c), gpu, dur)
538}
539
540/// The body of `probe_record` over ONE probe, so the decision can be
541/// driven in a test without touching the process-wide array.
542fn probe_record_into(
543    p: &Probe,
544    class_name: &str,
545    cache: Option<OpClass>,
546    gpu: bool,
547    dur: std::time::Duration,
548) {
549    if p.state.load(Ordering::Relaxed) != 0 {
550        return;
551    }
552    if gpu && PROBE_COLD.with(|f| f.replace(false)) {
553        return; // one-off cost in this call — not a steady-state sample
554    }
555    if gpu {
556        // Load-then-store rather than fetch_sub: a blind decrement at
557        // zero wraps a u32 to its maximum and mutes the arm forever.
558        // A benign race here burns one extra sample, which is free.
559        let left = p.gpu_burn.load(Ordering::Relaxed);
560        if left > 0 {
561            p.gpu_burn.store(left - 1, Ordering::Relaxed);
562            return; // warm-up: the first device sample builds its pipeline
563        }
564    }
565    let ns = dur.as_nanos().min(u64::MAX as u128) as u64;
566    if gpu {
567        p.gpu_ns.fetch_add(ns, Ordering::Relaxed);
568        p.gpu_n.fetch_add(1, Ordering::Relaxed);
569        p.gpu_min.fetch_min(ns, Ordering::Relaxed);
570    } else {
571        p.cpu_ns.fetch_add(ns, Ordering::Relaxed);
572        p.cpu_n.fetch_add(1, Ordering::Relaxed);
573        p.cpu_min.fetch_min(ns, Ordering::Relaxed);
574    }
575    let (gn, cn) = (
576        p.gpu_n.load(Ordering::Relaxed),
577        p.cpu_n.load(Ordering::Relaxed),
578    );
579    if gn >= 2 && cn >= 2 {
580        // Decide on each arm's BEST sample — the steady-state pace.
581        // Means carry one-off cold costs (mmap page-in on the CPU arm)
582        // that the cold-flag machinery cannot see.
583        let g = p.gpu_min.load(Ordering::Relaxed) as f64;
584        let cp = p.cpu_min.load(Ordering::Relaxed) as f64;
585        // Early verdict on a ≥2× gap — no reason to keep feeding the
586        // losing arm; close races take the full sample count. It was 3×,
587        // and the cost of that half-octave was measured: a DiT whose
588        // wide GEMMs run 11.4 ms on the device against 32.2 on the host
589        // (2.8×) kept ALTERNATING through the whole diffusion stack, and
590        // because the alternation counter is shared per class in call
591        // order, one projection drew the CPU arm every single time — 9.9
592        // seconds a step on a kernel that needs 0.4. Both arms are
593        // compared on their BEST sample, so a 2× gap is not noise.
594        if (gn < PROBE_SAMPLES || cn < PROBE_SAMPLES) && g < cp * 2.0 && cp < g * 2.0 {
595            return;
596        }
597        let winner = if g <= cp { 1 } else { 2 };
598        if p.state
599            .compare_exchange(0, winner, Ordering::Relaxed, Ordering::Relaxed)
600            .is_ok()
601        {
602            tracing::info!(
603                "gpu probe [{}]: gpu {:.2} ms vs cpu {:.2} ms per op → {}",
604                class_name,
605                g / 1e6,
606                cp / 1e6,
607                if winner == 1 { "gpu" } else { "cpu" },
608            );
609            if let Some(c) = cache {
610                probe_cache_store(c, winner);
611            }
612        }
613    }
614}
615
616/// Is the class still collecting samples? (Call sites use this to route
617/// cold-weight calls away from the GPU arm during probing.)
618pub fn probe_deciding(c: OpClass) -> bool {
619    probe_on_for(c) && PROBES[c as usize].state.load(Ordering::Relaxed) == 0
620}
621
622/// Probing helper: true — tensor `idx`'s quant weights are ALREADY
623/// device-resident (a clean GPU sample is possible now); false — they
624/// were not (the upload starts within the VRAM budget, so a later call
625/// finds them warm) or the tensor cannot go to the GPU at all. Keeps the
626/// probe from billing a full cold dispatch+readback to a sample it will
627/// discard anyway. The verdict needs only a couple of warm tensors, so
628/// probe-driven uploads are capped — the losing-GPU machine should not
629/// pay for uploading the whole layer stack it will never use; if the GPU
630/// wins, the rest uploads lazily on demand, in the same first-touch order.
631#[allow(unused_variables)]
632pub fn q8_resident_or_upload(model: &Arc<CmfModel>, idx: usize) -> bool {
633    static PROBE_UPLOADS: AtomicU32 = AtomicU32::new(0);
634    let may_upload = PROBE_UPLOADS.load(Ordering::Relaxed) < 4;
635    let resident = match backend() {
636        #[cfg(target_os = "macos")]
637        Backend::Metal => crate::gpu_metal::q8_resident_or_upload(model, idx, may_upload),
638        #[cfg(feature = "gpu")]
639        Backend::Wgpu => crate::gpu_wgpu::q8_resident_or_upload(model, idx, may_upload),
640        Backend::None => false,
641    };
642    if !resident && may_upload {
643        PROBE_UPLOADS.fetch_add(1, Ordering::Relaxed);
644    }
645    resident
646}
647
648/// Test hook: reset all probes to the undecided state.
649#[cfg(test)]
650pub(crate) fn probe_reset() {
651    for p in &PROBES {
652        p.state.store(0, Ordering::Relaxed);
653        p.flip.store(0, Ordering::Relaxed);
654        p.gpu_ns.store(0, Ordering::Relaxed);
655        p.gpu_n.store(0, Ordering::Relaxed);
656        p.cpu_ns.store(0, Ordering::Relaxed);
657        p.cpu_n.store(0, Ordering::Relaxed);
658    }
659}
660
661#[cfg(test)]
662mod probe_tests {
663    use super::*;
664    use std::time::Duration;
665
666    // One test fn: PROBES is process-global and probe_reset touches all
667    // classes — parallel test threads would race.
668    #[test]
669    fn probe_alternates_discards_cold_and_decides() {
670        probe_reset();
671        // Probing: arms alternate.
672        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::Gpu));
673        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::CpuTimed));
674
675        // A cold GPU sample (upload noted) must be discarded: feed a
676        // catastrophic cold sample, then clean fast-GPU samples — GPU
677        // wins only if the cold one did not count.
678        probe_note_cold();
679        probe_record(OpClass::Ffn, true, Duration::from_secs(1000));
680        for _ in 0..PROBE_SAMPLES {
681            probe_record(OpClass::Ffn, true, Duration::from_millis(1));
682            probe_record(OpClass::Ffn, false, Duration::from_millis(4));
683        }
684        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::Gpu));
685
686        // The reverse: a class where the CPU arm is faster decides CPU.
687        for _ in 0..PROBE_SAMPLES {
688            probe_record(OpClass::Matmat, true, Duration::from_millis(4));
689            probe_record(OpClass::Matmat, false, Duration::from_millis(1));
690        }
691        assert!(matches!(probe_arm(OpClass::Matmat), ProbeArm::Cpu));
692
693        // cpu_scope: gates off inside, restored after.
694        cpu_scope(|| CPU_ONLY.with(|c| assert!(c.get())));
695        CPU_ONLY.with(|c| assert!(!c.get()));
696        cpu_scope(|| {
697            cpu_scope(|| CPU_ONLY.with(|c| assert!(c.get())));
698            CPU_ONLY.with(|c| assert!(c.get()));
699        });
700        let _ = std::panic::catch_unwind(|| cpu_scope(|| panic!("scope test")));
701        CPU_ONLY.with(|c| assert!(!c.get()));
702        probe_reset();
703    }
704
705    #[test]
706    fn a_remembered_verdict_is_adopted_and_a_stranger_is_not() {
707        // Probing is not free: on a Snapdragon 778G the deciding classes
708        // cost minutes of wall clock before the first token, every
709        // process, and reached the same verdict every time. The cache
710        // exists so that price is paid once.
711        //
712        // The key is built from THIS process's device, never a name this
713        // test sets: `probe_set_device` is first-writer-wins and on a Mac
714        // the Metal backend may already have named the silicon before the
715        // tests run — which is exactly how this test failed on CI while
716        // passing locally. GemmNt on purpose: the arbitration test never
717        // touches it, and both run in one process.
718        let mine = probe_cache_key_named("gemm-nt");
719        let state = || {
720            PROBES[OpClass::GemmNt as usize]
721                .state
722                .load(Ordering::Relaxed)
723        };
724
725        // Another device's verdict is not mine, whatever it claims.
726        probe_cache_adopt("SomeOtherGPU/Vulkan\tgemm-nt\tgpu\n");
727        assert_eq!(state(), 0);
728        // Neither is one from another build of this engine.
729        let older = mine.replacen(env!("CARGO_PKG_VERSION"), "0.0.0-old", 1);
730        assert_ne!(older, mine);
731        probe_cache_adopt(&format!("{older}\tgpu\n"));
732        assert_eq!(state(), 0);
733        // Mine is.
734        probe_cache_adopt(&format!("{mine}\tcpu\n"));
735        assert_eq!(state(), 2);
736
737        PROBES[OpClass::GemmNt as usize]
738            .state
739            .store(0, Ordering::Relaxed);
740    }
741}
742
743/// Default row threshold: the GPU takes only larger matrices (lm_head
744/// class). Below it, the dispatch/readback cost does not pay off on unified memory.
745pub const GPU_MIN_ROWS: usize = 65_536;
746
747/// Effective threshold: `CMF_GPU_MIN_ROWS` overrides. Defaults differ
748/// by device class: on a DISCRETE card VRAM bandwidth pays off even for
749/// FFN/QKV-class matrices (4096), on unified memory only lm_head-class
750/// is worth the dispatch/readback (65536). Field case behind this: a
751/// 35B model on an RTX 4090 saw ~0 offload because every layer matrix
752/// sat below the old universal 65536.
753pub fn min_rows() -> usize {
754    if let Some(v) = std::env::var("CMF_GPU_MIN_ROWS")
755        .ok()
756        .and_then(|v| v.parse().ok())
757    {
758        return v;
759    }
760    if discrete() { 4096 } else { GPU_MIN_ROWS }
761}
762
763/// Is the active backend a discrete card (PCIe VRAM)?
764pub fn discrete() -> bool {
765    match backend() {
766        #[cfg(feature = "gpu")]
767        Backend::Wgpu => crate::gpu_wgpu::is_discrete(),
768        #[cfg(target_os = "macos")]
769        Backend::Metal => false, // UMA by the init() guard
770        Backend::None => false,
771    }
772}
773
774/// A single MoE-FFN job (an expert with its own weight), executed in one
775/// submission: (rows, cols, idx, row_scale) for gate/up/down + prescaled
776/// inputs + the down θ-field + the blending weight.
777pub struct MoeJob<'a> {
778    pub gate: (usize, usize, usize, &'a [f32]),
779    pub up: (usize, usize, usize, &'a [f32]),
780    pub down: (usize, usize, usize, &'a [f32]),
781    pub xs_gate: Vec<f32>,
782    pub xs_up: Vec<f32>,
783    pub down_col: &'a [f32],
784    pub w: f32,
785    /// q1 trio: scales live inside the 6-byte tiles (row_scale slices
786    /// empty, xs raw f32). Backends without a q1 kernel refuse the job.
787    pub q1: bool,
788    /// q4_tiled trio: scales inside the 18-byte tiles (row_scale
789    /// slices empty, xs raw f32) — the MoE-hybrid coder class.
790    pub q4t: bool,
791    /// q4tp trio: same raw-xs contract, 16-byte nibble stride and the scale
792    /// on a per-row ladder. Without this the experts of a q4tp MoE model fall
793    /// to the CPU while every other dtype rides the device.
794    pub q4tp: bool,
795    /// Mixed 2-bit profile: gate/up are q2tp (8-byte chunks, zero rung),
796    /// down stays q4tp. Set together with `q4tp`; a backend without the
797    /// 2-bit kernel must refuse the whole job.
798    pub gu_q2: bool,
799    /// The reference's `swiglu_limit`; 0 disables the clamp. A backend that
800    /// cannot apply it must REFUSE the job rather than drop it silently —
801    /// the difference only shows on saturating activations, which is the
802    /// hardest kind of divergence to notice.
803    pub swiglu_limit: f32,
804}
805
806/// A single independent batch matvec (GDN projections of one input).
807pub struct BatchJob<'a> {
808    pub idx: usize,
809    pub rows: usize,
810    pub cols: usize,
811    pub row_scale: &'a [f32],
812    pub xs: Vec<f32>,
813    /// Weight layout. Was a bare `q1: bool`, which could only ever spell two
814    /// of the four and silently sent everything else back to the CPU — the
815    /// GDN projections of a q4t/q4tp model never reached the device at all.
816    pub layout: BatchLayout,
817}
818
819/// Which kernel a batched matvec needs. q8 carries row scales in a side
820/// buffer; the rest embed them in the payload and differ in stride.
821#[derive(Clone, Copy, PartialEq, Eq, Debug)]
822pub enum BatchLayout {
823    Q8,
824    Q1,
825    Q4t,
826    Q4tp,
827}
828
829#[derive(Clone, Copy, PartialEq, Eq)]
830enum Backend {
831    None,
832    #[cfg(target_os = "macos")]
833    Metal,
834    #[cfg(feature = "gpu")]
835    Wgpu,
836}
837
838fn backend() -> Backend {
839    #[cfg(feature = "gpu")]
840    if crate::gpu_wgpu::selected() {
841        return if crate::gpu_wgpu::enabled() {
842            Backend::Wgpu
843        } else {
844            Backend::None
845        };
846    }
847    #[cfg(target_os = "macos")]
848    if crate::gpu_metal::enabled() {
849        return Backend::Metal;
850    }
851    Backend::None
852}
853
854/// GPU enabled and initialized on the selected backend?
855/// Whether THIS build can bring a GPU up on THIS device: a compiled-in
856/// backend plus a live adapter. The mobile FFI exposes it so an app can
857/// tell "GPU off" from "GPU impossible" (a CPU-only .so ships no
858/// backend at all). Cached after the first call.
859pub fn backend_available() -> bool {
860    #[cfg(target_os = "macos")]
861    {
862        // The Metal path is always compiled on macOS.
863        true
864    }
865    #[cfg(all(feature = "gpu", not(target_os = "macos")))]
866    {
867        static AVAIL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
868        *AVAIL.get_or_init(crate::gpu_wgpu::adapter_probe)
869    }
870    #[cfg(all(not(feature = "gpu"), not(target_os = "macos")))]
871    {
872        false
873    }
874}
875
876/// A process-wide, phase-scoped GPU gate. `cpu_scope` is thread-local and
877/// the pool's workers do not inherit it, so a caller that wants a whole
878/// *phase* off the device — a prompt encoder whose weights live in a part of
879/// the file the hot loop never touches, on a machine that cannot keep both
880/// wired — has to say so globally.
881static GPU_PAUSED: AtomicBool = AtomicBool::new(false);
882
883/// Park the device for every thread until the returned guard drops.
884pub fn pause_gpu() -> GpuPause {
885    GPU_PAUSED.store(true, Ordering::Relaxed);
886    GpuPause(())
887}
888
889pub struct GpuPause(());
890
891impl Drop for GpuPause {
892    fn drop(&mut self) {
893        GPU_PAUSED.store(false, Ordering::Relaxed);
894    }
895}
896
897pub fn enabled() -> bool {
898    !GPU_PAUSED.load(Ordering::Relaxed) && backend() != Backend::None
899}
900
901/// Default-on condition for the wgpu whole-token graph: the wgpu
902/// backend on a DISCRETE adapter. NOT plain `enabled()` (macOS/Metal
903/// must not pay a per-token layer scan for a graph its backend
904/// refuses), and NOT integrated adapters: the graph's ~300 barriered
905/// dispatches per token are cheap on desktop immediate-mode GPUs but
906/// tiled mobile GPUs (Adreno/Mali) drain the pipeline at every barrier
907/// — field report: 0.2 tok/s on-graph vs 15 tok/s on the CPU. On
908/// integrated adapters the per-op probe path arbitrates each op class
909/// against the CPU instead; CMF_GPU_WGPU_GRAPH=1 still forces the
910/// graph anywhere.
911/// Is the wgpu backend active at all (any adapter)? Eligibility gate
912/// for the whole-token graph — whether it actually RUNS is decided by
913/// `wgpu_graph_default` (trusted on discrete) or the generation race.
914pub fn wgpu_active() -> bool {
915    #[cfg(feature = "gpu")]
916    {
917        matches!(backend(), Backend::Wgpu)
918    }
919    #[cfg(not(feature = "gpu"))]
920    {
921        false
922    }
923}
924
925/// Which GPU this thread's engine calls address. Multi-card hosts hold
926/// one wgpu context PER card (weights, KV mirrors and scratch live
927/// inside a context, so per-device contexts give per-device caches for
928/// free); this thread-local says which one is current. Default: the
929/// process pin (CMF_GPU_ADAPTER) or 0 — so single-card runs behave
930/// exactly as they always have.
931pub fn default_device() -> usize {
932    static D: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
933    *D.get_or_init(|| {
934        std::env::var("CMF_GPU_ADAPTER")
935            .ok()
936            .and_then(|v| v.trim().parse::<usize>().ok())
937            .unwrap_or(0)
938    })
939}
940
941thread_local! {
942    static CUR_DEV: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
943}
944
945/// The device this thread is pinned to.
946pub fn current_device() -> usize {
947    CUR_DEV.with(|c| c.get()).unwrap_or_else(default_device)
948}
949
950/// Pin this thread to a device. Server slots call it once per request;
951/// the worker pool propagates it into its threads, so a dispatch begun
952/// on card 1 does not finish on card 0.
953pub fn set_current_device(i: usize) {
954    CUR_DEV.with(|c| c.set(Some(i)));
955}
956
957/// Run `f` with this thread pinned to `dev`, restoring the previous pin.
958pub fn with_device<R>(dev: usize, f: impl FnOnce() -> R) -> R {
959    let prev = CUR_DEV.with(|c| c.replace(Some(dev)));
960    let r = f();
961    CUR_DEV.with(|c| c.set(prev));
962    r
963}
964
965/// How many GPUs this process can address (wgpu adapter count; 1 on
966/// Metal, 0 without a backend).
967pub fn device_count() -> usize {
968    #[cfg(all(feature = "gpu", not(target_os = "macos")))]
969    {
970        return crate::gpu_wgpu::adapter_count();
971    }
972    #[cfg(not(all(feature = "gpu", not(target_os = "macos"))))]
973    {
974        usize::from(backend_available())
975    }
976}
977
978/// Weight budget of the current GPU in bytes; 0 when there is none and
979/// u64::MAX on unified memory (where the OS pages shared RAM and the
980/// question "does the model fit the card" has no separate answer).
981pub fn vram_budget() -> u64 {
982    #[cfg(all(feature = "gpu", not(target_os = "macos")))]
983    {
984        return crate::gpu_wgpu::device_vram_budget();
985    }
986    #[cfg(not(all(feature = "gpu", not(target_os = "macos"))))]
987    {
988        if backend_available() { u64::MAX } else { 0 }
989    }
990}
991
992/// Device weight bytes uploaded so far (wgpu; 0 on other backends).
993/// Steady-state windows must show a ZERO delta — growth mid-benchmark
994/// means eviction/re-upload and disqualifies the number.
995pub fn upload_bytes() -> u64 {
996    #[cfg(feature = "gpu")]
997    {
998        return crate::gpu_wgpu::UPLOAD_BYTES.load(std::sync::atomic::Ordering::Relaxed);
999    }
1000    #[cfg(not(feature = "gpu"))]
1001    0
1002}
1003
1004/// Which half of the run is asking.
1005///
1006/// The phase exists because the graph is plausibly two decisions, not
1007/// one — but on the hardware measured so far it is only ever a decode
1008/// decision. On an Adreno 642L with bonsai-1.7b, from identical clean
1009/// starts and two repeats each: decode 11.6 tok/s without it and 0.72
1010/// with, while prefill is 4.2 either way. A first reading of 3.4 -> 18.0
1011/// for prefill did not survive a controlled re-run — it was a dirty
1012/// probe cache between configurations, not the graph, and the prefill
1013/// route through the graph is GDN-only in the first place, which this
1014/// dense model never takes.
1015#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1016pub enum GraphPhase {
1017    Prefill,
1018    Decode,
1019}
1020
1021/// The one place that decides whether the whole-token graph runs.
1022///
1023/// `CMF_GPU_WGPU_GRAPH`: `0` off everywhere, `prefill` only for the
1024/// prompt, anything else on everywhere. Unset: desktop-class GPUs take
1025/// it for both phases; phone-class UMA takes it for PREFILL only, which
1026/// is the measurement above rather than a guess — the per-op path keeps
1027/// decode, where it is seventeen times better.
1028pub fn wgpu_graph_on(phase: GraphPhase) -> bool {
1029    match std::env::var("CMF_GPU_WGPU_GRAPH").ok().as_deref() {
1030        Some("0") => false,
1031        Some("prefill") => phase == GraphPhase::Prefill,
1032        Some(_) => true,
1033        None => {
1034            if wgpu_graph_default() {
1035                return true;
1036            }
1037            // Integrated/mobile keeps the per-op path for BOTH phases —
1038            // unchanged, because the measurement that would have bought
1039            // prefill a graph did not reproduce. `=prefill` is there for
1040            // the device where it does; the default does not guess.
1041            let _ = phase;
1042            false
1043        }
1044    }
1045}
1046
1047pub fn wgpu_graph_default() -> bool {
1048    #[cfg(feature = "gpu")]
1049    {
1050        // Discrete cards always; Apple-silicon UMA on macOS too — desktop
1051        // -class GPUs where the graph measured ~2x the CPU on the Qwen3.6
1052        // family (M4: 13.3 tok/s against 7.3). Phone-class UMA (Android/
1053        // iOS builds) keeps the per-op probe path: tiled mobile GPUs have
1054        // turned the ~300-dispatch graph into seconds per token.
1055        matches!(backend(), Backend::Wgpu)
1056            && (crate::gpu_wgpu::discrete_active()
1057                || (cfg!(target_os = "macos") && crate::gpu_wgpu::adapter_up()))
1058    }
1059    #[cfg(not(feature = "gpu"))]
1060    {
1061        false
1062    }
1063}
1064
1065/// q8_row/q8_2f matvec, rows [row0, row0+rows). `xs` — prescaled by the θ-field.
1066#[allow(clippy::too_many_arguments, unused_variables)]
1067pub fn q8_matvec_range(
1068    model: &Arc<CmfModel>,
1069    idx: usize,
1070    row0: usize,
1071    row_scale: &[f32],
1072    xs: &[f32],
1073    rows: usize,
1074    cols: usize,
1075    out: &mut [f32],
1076) -> bool {
1077    match backend() {
1078        #[cfg(target_os = "macos")]
1079        Backend::Metal => {
1080            crate::gpu_metal::q8_matvec_range(model, idx, row0, row_scale, xs, rows, cols, out)
1081        }
1082        #[cfg(feature = "gpu")]
1083        Backend::Wgpu => {
1084            crate::gpu_wgpu::q8_matvec_range(model, idx, row0, row_scale, xs, rows, cols, out)
1085        }
1086        Backend::None => false,
1087    }
1088}
1089
1090/// GEMM of a prefill batch: `pre` — prescaled inputs row-major [b, cols],
1091/// out — row-major [b, rows].
1092#[allow(clippy::too_many_arguments, unused_variables)]
1093/// The two-field int8 GEMM with the column field left for the device.
1094/// wgpu only — Metal's int8 kernel takes a pre-scaled activation, so the
1095/// caller keeps that path when this returns `false`.
1096#[allow(clippy::too_many_arguments)]
1097pub fn q8_matmat_2f(
1098    model: &Arc<CmfModel>,
1099    idx: usize,
1100    row_scale: &[f32],
1101    col_field: &[f32],
1102    xs: &[f32],
1103    b: usize,
1104    rows: usize,
1105    cols: usize,
1106    out: &mut [f32],
1107) -> bool {
1108    #[allow(unreachable_patterns)]
1109    match backend() {
1110        #[cfg(feature = "gpu")]
1111        Backend::Wgpu => crate::gpu_wgpu::q8_matmat_2f(
1112            model, idx, row_scale, col_field, xs, b, rows, cols, out,
1113        ),
1114        _ => false,
1115    }
1116}
1117
1118pub fn q8_matmat(
1119    model: &Arc<CmfModel>,
1120    idx: usize,
1121    row_scale: &[f32],
1122    pre: &[f32],
1123    b: usize,
1124    rows: usize,
1125    cols: usize,
1126    out: &mut [f32],
1127) -> bool {
1128    match backend() {
1129        #[cfg(target_os = "macos")]
1130        Backend::Metal => {
1131            crate::gpu_metal::q8_matmat(model, idx, row_scale, pre, b, rows, cols, out)
1132        }
1133        #[cfg(feature = "gpu")]
1134        Backend::Wgpu => crate::gpu_wgpu::q8_matmat(model, idx, row_scale, pre, b, rows, cols, out),
1135        Backend::None => false,
1136    }
1137}
1138
1139/// q1 matvec: raw f32 activations, tile-embedded scales. Metal only
1140/// for now (wgpu q1 WGSL is queued); false = CPU fallback.
1141#[allow(unused_variables)]
1142pub fn q1_matvec(
1143    model: &Arc<CmfModel>,
1144    idx: usize,
1145    xs: &[f32],
1146    rows: usize,
1147    cols: usize,
1148    out: &mut [f32],
1149) -> bool {
1150    match backend() {
1151        #[cfg(target_os = "macos")]
1152        Backend::Metal => crate::gpu_metal::q1_matvec(model, idx, xs, rows, cols, out),
1153        #[cfg(feature = "gpu")]
1154        Backend::Wgpu => crate::gpu_wgpu::q1_matvec(model, idx, xs, rows, cols, out),
1155        Backend::None => false,
1156    }
1157}
1158
1159/// Whole attention sub-block on the wgpu token graph (drop-in for
1160/// `qwen_attention`): normed hidden in, O-projection out, resident device
1161/// K/V mirror. false = refusal / not the wgpu backend → CPU path.
1162#[allow(clippy::too_many_arguments)]
1163pub fn attn_dropin(
1164    model: &Arc<CmfModel>,
1165    kv_id: u64,
1166    layer: usize,
1167    normed: &[f32],
1168    wq_idx: usize,
1169    wk_idx: usize,
1170    wv_idx: usize,
1171    wo_idx: usize,
1172    q_norm: Option<&[f32]>,
1173    k_norm: Option<&[f32]>,
1174    invf: &[f32],
1175    nh: usize,
1176    nkv: usize,
1177    hd: usize,
1178    rd: usize,
1179    hidden: usize,
1180    pos: usize,
1181    cap: usize,
1182    gemma: bool,
1183    eps: f32,
1184    cpu_k: &[Vec<f32>],
1185    cpu_v: &[Vec<f32>],
1186    out: &mut [f32],
1187) -> bool {
1188    match backend() {
1189        #[cfg(feature = "gpu")]
1190        Backend::Wgpu => crate::gpu_wgpu::attn_dropin_gpu(
1191            model, kv_id, layer, normed, wq_idx, wk_idx, wv_idx, wo_idx, q_norm, k_norm, invf, nh,
1192            nkv, hd, rd, hidden, pos, cap, gemma, eps, cpu_k, cpu_v, out,
1193        ),
1194        #[allow(unused_variables)]
1195        _ => false,
1196    }
1197}
1198
1199/// One weight in the whole-token graph: tensor idx + a codec tag (0=q8_row,
1200/// 1=q1, 2=q4_tiled, 3=q1t, 4=f32) + per-row scales (q8_row only) + the raw f32
1201/// data (kind 4 only — small unquantized projections like GDN in_proj_a/b).
1202pub struct GraphW<'a> {
1203    pub idx: usize,
1204    pub kind: u8,
1205    pub row_scale: &'a [f32],
1206    pub data: &'a [f32],
1207}
1208
1209/// A layer's token-mixing op: standard attention or a GDN (linear-attention)
1210/// block. The surrounding norms + SwiGLU FFN are common to both.
1211pub enum GraphAttn<'a> {
1212    Full {
1213        wq: GraphW<'a>,
1214        wk: GraphW<'a>,
1215        wv: GraphW<'a>,
1216        wo: GraphW<'a>,
1217        q_norm: Option<&'a [f32]>,
1218        k_norm: Option<&'a [f32]>,
1219        /// (bq, bk, bv) attention biases (Qwen2). None ⇒ no bias.
1220        bias: Option<(&'a [f32], &'a [f32], &'a [f32])>,
1221        /// Qwen3.5 gated attention: wq emits 2·nh·hd (q||gate per head), the
1222        /// attention output is scaled by sigmoid(gate) before the O projection.
1223        output_gate: bool,
1224        cpu_k: &'a [Vec<f32>],
1225        cpu_v: &'a [Vec<f32>],
1226    },
1227    Gdn {
1228        qkv: GraphW<'a>,
1229        z: GraphW<'a>,
1230        a: GraphW<'a>,
1231        b: GraphW<'a>,
1232        out: GraphW<'a>,
1233        conv1d: &'a [f32],
1234        a_log: &'a [f32],
1235        dt_bias: &'a [f32],
1236        norm: &'a [f32],
1237        nv: usize,
1238        nk: usize,
1239        dk: usize,
1240        dv: usize,
1241        kk: usize,
1242        /// CPU recurrent state `[ring (kk-1)·cdim | S nv·dk·dv]` — seeds the
1243        /// device mirror when prefill ran on the host (o1 collection, CPU
1244        /// fallback): a zero-initialized device state at decode is exactly
1245        /// the "coherent but contextless" garble.
1246        cpu_state: &'a [f32],
1247    },
1248}
1249
1250/// Per-layer weights for the whole-token wgpu graph.
1251pub struct GraphLayer<'a> {
1252    pub input_norm: &'a [f32],
1253    pub attn: GraphAttn<'a>,
1254    pub post_norm: &'a [f32],
1255    pub ffn: GraphFfn<'a>,
1256}
1257
1258/// The FFN of one graph layer: a dense SwiGLU trio, or a routed MoE —
1259/// router + top-k selection + all selected experts run ON DEVICE (the
1260/// routing decision depends on the resident hidden state, so a CPU
1261/// round-trip per layer would forfeit the one-submit design).
1262pub enum GraphFfn<'a> {
1263    Dense {
1264        gate: GraphW<'a>,
1265        up: GraphW<'a>,
1266        down: GraphW<'a>,
1267    },
1268    Moe {
1269        /// Router logits weight (f32, kind 4) `[n_exp, hidden]`.
1270        router: GraphW<'a>,
1271        /// Shared-expert sigmoid gate (f32) `[1, hidden]`.
1272        shared_gate: GraphW<'a>,
1273        /// Per-expert q4_tiled directory indices `(gate, up, down)`;
1274        /// the SHARED expert rides as the LAST entry — the select
1275        /// kernel pins it with the sigmoid weight.
1276        experts: Vec<(usize, usize, usize)>,
1277        /// Routed experts (shared excluded).
1278        n_exp: usize,
1279        top_k: usize,
1280        inter: usize,
1281        norm_topk: bool,
1282        /// Expert weight layout, uniform across the layer: `false` =
1283        /// q4_tiled (18 B tiles, inline f16 scale), `true` = q4tp
1284        /// (16 B nibbles + a per-row ladder plane). The two differ only
1285        /// in where the scale comes from, so they share every kernel
1286        /// but the weight-staging block.
1287        q4tp: bool,
1288        /// `true` = the gate/up experts are `q2tp` (2-bit plane) while
1289        /// `down` stays q4tp — the mixed profile a 2-bit-class checkpoint
1290        /// converts into. Only meaningful with `q4tp: true`.
1291        gu_q2: bool,
1292    },
1293}
1294
1295/// Whole-token decode graph on wgpu: the entire layer stack in ONE submit,
1296/// hidden resident, one readback. Updates `h` in place. false = refusal.
1297/// `loop_norm_at`: virtual layer indices after which `final_norm` is applied
1298/// (Looped Transformer mid-stack norm). Empty for standard models.
1299#[allow(clippy::too_many_arguments)]
1300pub fn forward_token_graph(
1301    model: &Arc<CmfModel>,
1302    kv_id: u64,
1303    layers: &[GraphLayer],
1304    // Per-layer sealed o1 (Nystrom) state; Some = replace this layer's
1305    // exact attention with the O(1) kernels. wgpu only.
1306    o1: &[Option<Vec<crate::nystrom::O1DeviceView<'_>>>],
1307    o1_epoch: u64,
1308    invf: &[f32],
1309    h: &mut [f32],
1310    nh: usize,
1311    nkv: usize,
1312    hd: usize,
1313    rd: usize,
1314    hidden: usize,
1315    inter: usize,
1316    position: usize,
1317    cap: usize,
1318    gemma: bool,
1319    eps: f32,
1320    lm_head: Option<(&GraphW, usize)>,
1321    final_norm: &[f32],
1322    logits: &mut Vec<f32>,
1323    loop_norm_at: &[usize],
1324    steps: usize,
1325    embed: Option<(&GraphW, usize, f32)>,
1326    ids_out: Option<&mut Vec<u32>>,
1327    // How many leading layers the graph ran (see the wgpu twin) — smaller
1328    // than layers.len() when the expert budget ended the device prefix.
1329    layers_run: Option<&mut usize>,
1330    // Absolute index of layers[0] in the model — the KV/state mirrors key
1331    // on it, so a layer SPAN (network split segment) shares mirrors with
1332    // a full-stack run instead of colliding at slot 0.
1333    layer_base: usize,
1334    // Read the final hidden back alongside the fused head's logits.
1335    hidden_too: bool,
1336) -> bool {
1337    match backend() {
1338        #[cfg(feature = "gpu")]
1339        Backend::Wgpu => crate::gpu_wgpu::forward_token_graph(
1340            model,
1341            kv_id,
1342            layers,
1343            o1,
1344            o1_epoch,
1345            invf,
1346            h,
1347            nh,
1348            nkv,
1349            hd,
1350            rd,
1351            hidden,
1352            inter,
1353            position,
1354            cap,
1355            gemma,
1356            eps,
1357            lm_head,
1358            final_norm,
1359            logits,
1360            loop_norm_at,
1361            steps,
1362            embed,
1363            ids_out,
1364            layers_run,
1365            layer_base,
1366            hidden_too,
1367        ),
1368        #[allow(unused_variables)]
1369        _ => {
1370            let _ = (
1371                lm_head,
1372                final_norm,
1373                logits,
1374                loop_norm_at,
1375                layers_run,
1376                layer_base,
1377                hidden_too,
1378            );
1379            false
1380        }
1381    }
1382}
1383
1384/// Speculative-verify tail for the batched graph: fold final-norm + lm_head
1385/// over every batch position and read all k logit rows back; the batch also
1386/// snapshots the GDN state per position for `gdn_spec_restore`.
1387pub struct SpecTail<'a> {
1388    pub lm: GraphW<'a>,
1389    pub lm_rows: usize,
1390    pub final_norm: &'a [f32],
1391    pub logits_out: &'a mut Vec<f32>,
1392}
1393
1394/// Batched prefill: k contiguous positions through the whole graph in one submit
1395/// (projections/FFN as GEMMs, attention/GDN looped over scratch). `h` is
1396/// [k·hidden] in/out; `positions` len k. wgpu only.
1397#[allow(clippy::too_many_arguments)]
1398pub fn forward_batch_graph(
1399    model: &Arc<CmfModel>,
1400    kv_id: u64,
1401    layers: &[GraphLayer],
1402    invf: &[f32],
1403    h: &mut [f32],
1404    nh: usize,
1405    nkv: usize,
1406    hd: usize,
1407    rd: usize,
1408    hidden: usize,
1409    inter: usize,
1410    positions: &[usize],
1411    cap: usize,
1412    gemma: bool,
1413    eps: f32,
1414    k: usize,
1415    spec: Option<SpecTail<'_>>,
1416) -> bool {
1417    match backend() {
1418        #[cfg(feature = "gpu")]
1419        Backend::Wgpu => crate::gpu_wgpu::forward_batch_graph(
1420            model, kv_id, layers, invf, h, nh, nkv, hd, rd, hidden, inter, positions, cap, gemma,
1421            eps, k, spec,
1422        ),
1423        #[allow(unreachable_patterns)]
1424        _ => {
1425            let _ = spec;
1426            false
1427        }
1428    }
1429}
1430
1431/// After a partial speculative acceptance: restore every GDN layer's device
1432/// state to the snapshot after batch position `slot`. wgpu only.
1433pub fn gdn_spec_restore(kv_id: u64, slot: usize) -> bool {
1434    #[cfg(feature = "gpu")]
1435    if backend() == Backend::Wgpu {
1436        return crate::gpu_wgpu::gdn_spec_restore(kv_id, slot);
1437    }
1438    #[allow(unreachable_code)]
1439    {
1440        let _ = (kv_id, slot);
1441        false
1442    }
1443}
1444
1445/// Drop the wgpu token graph's device K/V mirror for a pipeline.
1446pub fn graph_kv_reset(_kv_id: u64) {
1447    #[cfg(feature = "gpu")]
1448    if backend() == Backend::Wgpu {
1449        crate::gpu_wgpu::kv_mirror_reset(_kv_id);
1450    }
1451}
1452
1453/// Ternary (q1t) BASE matvec on the GPU — fills `out` with the base dot; the
1454/// caller adds the sparse overlay on the CPU. Metal only for now (wgpu q1t not
1455/// yet written → CPU fallback).
1456pub fn q1t_matvec(
1457    model: &Arc<CmfModel>,
1458    idx: usize,
1459    xs: &[f32],
1460    rows: usize,
1461    cols: usize,
1462    out: &mut [f32],
1463) -> bool {
1464    match backend() {
1465        #[cfg(target_os = "macos")]
1466        Backend::Metal => {
1467            if metal_q1t_enabled() {
1468                crate::gpu_metal::q1t_matvec(model, idx, xs, rows, cols, out)
1469            } else {
1470                false
1471            }
1472        }
1473        #[cfg(feature = "gpu")]
1474        Backend::Wgpu => crate::gpu_wgpu::q1t_matvec(model, idx, xs, rows, cols, out),
1475        Backend::None => false,
1476    }
1477}
1478
1479/// q4_block matvec on the GPU — wgpu only (Metal drives q4_block through the
1480/// whole-token graph, not a standalone matvec).
1481#[allow(unused_variables)]
1482pub fn q4b_matvec(
1483    model: &Arc<CmfModel>,
1484    idx: usize,
1485    xs: &[f32],
1486    rows: usize,
1487    cols: usize,
1488    out: &mut [f32],
1489) -> bool {
1490    match backend() {
1491        #[cfg(target_os = "macos")]
1492        Backend::Metal => false,
1493        #[cfg(feature = "gpu")]
1494        Backend::Wgpu => crate::gpu_wgpu::q4b_matvec(model, idx, xs, rows, cols, out),
1495        Backend::None => false,
1496    }
1497}
1498
1499/// q1t batched GEMM (prefill) — base + overlay on-device (Metal simdgroup or
1500/// wgpu register-blocked).
1501pub fn q1t_matmat(
1502    model: &Arc<CmfModel>,
1503    idx: usize,
1504    xs: &[f32],
1505    b: usize,
1506    rows: usize,
1507    cols: usize,
1508    out: &mut [f32],
1509) -> bool {
1510    match backend() {
1511        #[cfg(target_os = "macos")]
1512        // Batched prefill and single-token decode are both enabled. On the
1513        // real 14.8B Q1T model prefill PPL was within 0.3% of CPU (7.942 vs
1514        // 7.966), and the alignment-safe decode kernel reached 3.52e-6 max_rel.
1515        Backend::Metal => crate::gpu_metal::q1t_matmat(model, idx, xs, b, rows, cols, out),
1516        #[cfg(feature = "gpu")]
1517        Backend::Wgpu => crate::gpu_wgpu::q1t_matmat(model, idx, xs, b, rows, cols, out),
1518        Backend::None => false,
1519    }
1520}
1521
1522/// Native Metal Q1T switch. Enabled by default after the byte-packed Q1T
1523/// fields were changed to alignment-safe loads; keep an explicit emergency
1524/// fallback for device/driver diagnostics.
1525#[cfg(target_os = "macos")]
1526pub(crate) fn metal_q1t_enabled() -> bool {
1527    std::env::var("CMF_METAL_Q1T")
1528        .map(|v| v != "0" && !v.eq_ignore_ascii_case("off"))
1529        .unwrap_or(true)
1530}
1531
1532/// Batched q1 GEMM (prefill). wgpu only — Metal has its own block path.
1533pub fn q1_matmat(
1534    model: &Arc<CmfModel>,
1535    idx: usize,
1536    xs: &[f32],
1537    b: usize,
1538    rows: usize,
1539    cols: usize,
1540    out: &mut [f32],
1541) -> bool {
1542    match backend() {
1543        #[cfg(feature = "gpu")]
1544        Backend::Wgpu => crate::gpu_wgpu::q1_matmat(model, idx, xs, b, rows, cols, out),
1545        #[allow(unused_variables)]
1546        _ => false,
1547    }
1548}
1549
1550/// Contention kill for the wide imagegen GEMM/FFN paths: one grossly
1551/// slow op under a work-proportional budget (fair-device ops are
1552/// ≤~100 ms even at 1024px) means another process owns the device —
1553/// verdicts are per-process, so CPU for the rest of this one.
1554static MM_KILL: AtomicBool = AtomicBool::new(false);
1555pub(crate) fn mm_killed() -> bool {
1556    MM_KILL.load(Ordering::Relaxed)
1557}
1558pub(crate) fn mm_kill() {
1559    MM_KILL.store(true, Ordering::Relaxed);
1560}
1561
1562/// Consecutive over-budget ops. ONE slow op is not contention: on a
1563/// 24 GB Mac running the 25.7 GB fl2va file the first ops after the
1564/// prompt encode page their weights in from the SSD and take seconds —
1565/// a field report (hololabs, HF discussion #2) had to neuter the kill
1566/// to keep the denoise on the GPU, and then measured 48 s/step where the
1567/// CPU fallback took >60. Contention is persistent; a page-in is not.
1568static MM_STRIKES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1569const MM_STRIKES_TO_KILL: u32 = 3;
1570/// Whether the kill is armed at all. A one-shot phase whose slowness is
1571/// expected and not contention — the video prompt encoder streaming
1572/// 12 GB off the SSD on a 24 GB Mac (HF discussion #4: users had to
1573/// gut `mm_kill` to keep the denoise loop on the GPU) — disarms it and
1574/// re-arms it when the phase is over; strikes taken meanwhile are
1575/// forgotten.
1576static MM_ARMED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
1577
1578/// Disarm / re-arm the contention kill around a phase whose GEMMs are
1579/// slow for reasons that are not another process (see `MM_ARMED`).
1580pub fn mm_kill_arm(on: bool) {
1581    MM_ARMED.store(on, Ordering::Relaxed);
1582    if on {
1583        MM_STRIKES.store(0, Ordering::Relaxed);
1584    }
1585}
1586
1587/// The contention verdict for one wide op: `el` against its
1588/// work-proportional `budget`. `exempt` marks ops whose time is not
1589/// evidence — the cold probe, or a weight that was not resident before
1590/// the call and rode in with it. Kills after `MM_STRIKES_TO_KILL`
1591/// consecutive strikes; a within-budget op clears the count.
1592/// `CMF_MM_KILL=0` disables the kill entirely (the device is trusted).
1593pub(crate) fn mm_budget_check(
1594    what: &str,
1595    el: std::time::Duration,
1596    budget: std::time::Duration,
1597    exempt: bool,
1598) {
1599    if el <= budget {
1600        MM_STRIKES.store(0, Ordering::Relaxed);
1601        return;
1602    }
1603    if exempt || !MM_ARMED.load(Ordering::Relaxed) {
1604        return;
1605    }
1606    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1607    let on = *ON.get_or_init(|| std::env::var("CMF_MM_KILL").as_deref() != Ok("0"));
1608    let n = MM_STRIKES.fetch_add(1, Ordering::Relaxed) + 1;
1609    if !on {
1610        tracing::info!(
1611            "gpu {what} took {el:?} (budget {budget:?}) — over budget, CMF_MM_KILL=0 keeps the device"
1612        );
1613        return;
1614    }
1615    if n >= MM_STRIKES_TO_KILL {
1616        tracing::warn!(
1617            "gpu {what} took {el:?} (budget {budget:?}), {n} in a row — \
1618             device contended, CPU for the rest of the process (CMF_MM_KILL=0 to override)"
1619        );
1620        mm_kill();
1621    } else {
1622        tracing::info!(
1623            "gpu {what} took {el:?} (budget {budget:?}) — strike {n} of {MM_STRIKES_TO_KILL}"
1624        );
1625    }
1626}
1627
1628/// Fused DiT SwiGLU FFN on the device: g=X·W1ᵀ, u=X·W3ᵀ, silu(g)·u,
1629/// Causal chunk attention on the device: `b` queries against `s0 + b`
1630/// cached keys. wgpu only — Metal's chunk graph keeps attention inside
1631/// the resident block and never calls out.
1632#[allow(unused_variables, clippy::too_many_arguments)]
1633pub fn chunk_attend(
1634    q: &[f32],
1635    k: &[&[f32]],
1636    v: &[&[f32]],
1637    b: usize,
1638    s0: usize,
1639    nh: usize,
1640    nkv: usize,
1641    hd: usize,
1642    scale: f32,
1643    out: &mut [f32],
1644) -> bool {
1645    match backend() {
1646        #[cfg(feature = "gpu")]
1647        Backend::Wgpu => crate::gpu_wgpu::chunk_attend(q, k, v, b, s0, nh, nkv, hd, scale, out),
1648        #[allow(unreachable_patterns)]
1649        _ => false,
1650    }
1651}
1652
1653/// Fused QKV projection: one upload of the normed chunk, three GEMMs,
1654/// one readback of Q|K|V back to back. Metal has no twin yet — its
1655/// chunk graph keeps the whole layer resident and never surfaces QKV.
1656#[allow(unused_variables, clippy::too_many_arguments)]
1657pub fn q4t_qkv(
1658    model: &Arc<CmfModel>,
1659    wq: usize,
1660    wk: usize,
1661    wv: usize,
1662    xs: &[f32],
1663    b: usize,
1664    cols: usize,
1665    rq: usize,
1666    rk: usize,
1667    rv: usize,
1668    out: &mut [f32],
1669) -> bool {
1670    match backend() {
1671        #[cfg(feature = "gpu")]
1672        Backend::Wgpu => crate::gpu_wgpu::q4t_qkv(model, wq, wk, wv, xs, b, cols, rq, rk, rv, out),
1673        #[allow(unreachable_patterns)]
1674        _ => false,
1675    }
1676}
1677
1678/// y=·W2ᵀ — one command buffer, only X and Y cross the CPU boundary.
1679#[allow(unused_variables, clippy::too_many_arguments)]
1680/// SwiGLU FFN with a row-packed [gate|up] fc1 (MiniMax-H3's DiT), run
1681/// end to end on the device. wgpu only: Metal keeps the host loop until
1682/// its own packed kernel exists.
1683#[allow(clippy::too_many_arguments, unused_variables)]
1684pub fn q4tp_ffn_packed(
1685    model: &Arc<CmfModel>,
1686    w1: usize,
1687    w2: usize,
1688    xs: &[f32],
1689    b: usize,
1690    hidden: usize,
1691    inter: usize,
1692    bias: Option<&[f32]>,
1693    out: &mut [f32],
1694) -> bool {
1695    match backend() {
1696        #[cfg(feature = "gpu")]
1697        Backend::Wgpu => {
1698            crate::gpu_wgpu::ffn_packed(model, w1, w2, xs, b, hidden, inter, bias, out)
1699        }
1700        #[allow(unreachable_patterns)]
1701        _ => false,
1702    }
1703}
1704
1705pub fn q4tp_ffn(
1706    model: &Arc<CmfModel>,
1707    w1: usize,
1708    w3: usize,
1709    w2: usize,
1710    xs: &[f32],
1711    b: usize,
1712    hidden: usize,
1713    inter: usize,
1714    out: &mut [f32],
1715) -> bool {
1716    match backend() {
1717        #[cfg(target_os = "macos")]
1718        Backend::Metal => crate::gpu_metal::q4tp_ffn(model, w1, w3, w2, xs, b, hidden, inter, out),
1719        #[cfg(feature = "gpu")]
1720        Backend::Wgpu => crate::gpu_wgpu::q4tp_ffn(model, w1, w3, w2, xs, b, hidden, inter, out),
1721        #[allow(unreachable_patterns)]
1722        _ => false,
1723    }
1724}
1725
1726pub fn q4t_ffn(
1727    model: &Arc<CmfModel>,
1728    w1: usize,
1729    w3: usize,
1730    w2: usize,
1731    xs: &[f32],
1732    b: usize,
1733    hidden: usize,
1734    inter: usize,
1735    out: &mut [f32],
1736) -> bool {
1737    match backend() {
1738        #[cfg(target_os = "macos")]
1739        Backend::Metal => crate::gpu_metal::q4t_ffn(model, w1, w3, w2, xs, b, hidden, inter, out),
1740        #[cfg(feature = "gpu")]
1741        Backend::Wgpu => crate::gpu_wgpu::q4t_ffn(model, w1, w3, w2, xs, b, hidden, inter, out),
1742        #[allow(unreachable_patterns)]
1743        _ => false,
1744    }
1745}
1746
1747/// One whole modulated DiT block for `dit_block`: geometry, norm
1748/// weights, AdaLN scale/gate vectors (gates pre-tanh'd), a per-token
1749/// f32 RoPE cos/sin table, and the directory indices of the seven
1750/// q4t projections. `x` is in-out `[n, hidden]`.
1751pub struct DitBlockArgs<'a> {
1752    pub n: usize,
1753    pub hidden: usize,
1754    pub inter: usize,
1755    pub nh: usize,
1756    pub nkv: usize,
1757    pub hd: usize,
1758    pub eps: f32,
1759    pub rope_cos: &'a [f32],
1760    pub rope_sin: &'a [f32],
1761    pub norm1: &'a [f32],
1762    pub norm2: &'a [f32],
1763    pub ffn_norm1: &'a [f32],
1764    pub ffn_norm2: &'a [f32],
1765    pub norm_q: &'a [f32],
1766    pub norm_k: &'a [f32],
1767    pub s_msa: &'a [f32],
1768    pub gate_msa: &'a [f32],
1769    pub s_mlp: &'a [f32],
1770    pub gate_mlp: &'a [f32],
1771    pub wq: usize,
1772    pub wk: usize,
1773    pub wv: usize,
1774    pub wo: usize,
1775    pub w1: usize,
1776    pub w3: usize,
1777    pub w2: usize,
1778    /// The projections' layout: q4tp (ladder scales) vs plain q4_tiled.
1779    /// The recommended Lumina file is q4tp, and a backend that only
1780    /// knows q4t must decline rather than decode with the wrong reader.
1781    pub q4tp: bool,
1782    /// The hidden state is already on the device from the previous block,
1783    /// so `x` need not be uploaded.
1784    pub resident_in: bool,
1785    /// Leave the result on the device instead of reading it back. The DiT
1786    /// loop does not touch `x` between blocks, so 27 of every 28 readbacks
1787    /// were moving 19 MB across PCIe and stalling on it for nothing.
1788    pub resident_out: bool,
1789}
1790
1791/// Can the selected backend keep the DiT's hidden state on the device
1792/// between blocks? Only the wgpu whole-block path; the Metal entry takes
1793/// and returns host memory every call.
1794pub fn dit_chain_supported() -> bool {
1795    #[cfg(feature = "gpu")]
1796    {
1797        return matches!(backend(), Backend::Wgpu) && fused_dit_block_available();
1798    }
1799    #[allow(unreachable_code)]
1800    false
1801}
1802
1803/// Pull the resident hidden state back to the host. For the caller that
1804/// chained blocks and then hit one the device declined.
1805pub fn dit_state_fetch(_x: &mut [f32]) -> bool {
1806    #[cfg(feature = "gpu")]
1807    {
1808        if matches!(backend(), Backend::Wgpu) {
1809            return crate::gpu_wgpu::dit_state_fetch(_x);
1810        }
1811    }
1812    false
1813}
1814
1815/// One whole modulated DiT block on the device — norms, qkv, RoPE,
1816/// attention, residuals and the SwiGLU FFN in a single command
1817/// buffer; only `x` crosses the CPU boundary (in and out).
1818#[allow(unused_variables)]
1819/// The DiT's three projections in one submission (wgpu only; the
1820/// Metal path fuses the whole block instead). False = the caller keeps
1821/// its three separate calls.
1822#[allow(unused_variables, clippy::too_many_arguments)]
1823pub fn dit_qkv(
1824    model: &Arc<CmfModel>,
1825    wq: usize,
1826    wk: usize,
1827    wv: usize,
1828    xs: &[f32],
1829    b: usize,
1830    hidden: usize,
1831    qrows: usize,
1832    kvrows: usize,
1833    q_out: &mut [f32],
1834    k_out: &mut [f32],
1835    v_out: &mut [f32],
1836) -> bool {
1837    match backend() {
1838        #[cfg(feature = "gpu")]
1839        Backend::Wgpu => crate::gpu_wgpu::q4tp_qkv(
1840            model, wq, wk, wv, xs, b, hidden, qrows, kvrows, q_out, k_out, v_out,
1841        ),
1842        #[allow(unreachable_patterns)]
1843        _ => false,
1844    }
1845}
1846
1847/// Is a FUSED whole-block device path on offer? The batched-CFG shape
1848/// (two sequences in one tall batch) and the fused block (one sequence,
1849/// one command buffer) are alternatives, and the caller picks.
1850pub fn fused_dit_block_available() -> bool {
1851    #[cfg(target_os = "macos")]
1852    {
1853        matches!(backend(), Backend::Metal) && fused_block_trusted()
1854    }
1855    #[cfg(not(target_os = "macos"))]
1856    {
1857        false
1858    }
1859}
1860
1861pub fn dit_block(model: &Arc<CmfModel>, a: &DitBlockArgs, x: &mut [f32]) -> bool {
1862    dit_block_seg(model, a, &[a.n], x)
1863}
1864
1865/// The same block over a CONCATENATION of independent sequences:
1866/// attention per segment, everything position-wise batched. wgpu only —
1867/// the Metal path takes the single-sequence entry above.
1868pub fn dit_block_seg(
1869    model: &Arc<CmfModel>,
1870    a: &DitBlockArgs,
1871    segs: &[usize],
1872    x: &mut [f32],
1873) -> bool {
1874    match backend() {
1875        #[cfg(target_os = "macos")]
1876        Backend::Metal if segs.len() <= 1 => crate::gpu_metal::dit_block(model, a, x),
1877        // The wgpu whole-block path. What it buys is host round trips —
1878        // six a block become one — so it defaults ON where those cost
1879        // real time (a discrete card across PCIe) and OFF on unified
1880        // memory, where the per-op path shares the same pages and the
1881        // fusion measured slightly slower on an M4. `CMF_DIT_FUSED=1`
1882        // forces it anywhere, `=0` forbids it.
1883        #[cfg(feature = "gpu")]
1884        Backend::Wgpu
1885            if match std::env::var("CMF_DIT_FUSED").ok().as_deref() {
1886                Some("0") => false,
1887                Some(_) => true,
1888                None => crate::gpu_wgpu::discrete_active(),
1889            } =>
1890        {
1891            crate::gpu_wgpu::dit_block_seg(model, a, segs, x)
1892        }
1893        #[allow(unreachable_patterns)]
1894        _ => false,
1895    }
1896}
1897
1898/// One VAE resnet block for `vae_resnet`: norm/conv weights and the
1899/// channel/shape geometry. `shortcut` is the 1×1 projection (w, b, k)
1900/// when in/out channels differ.
1901pub struct VaeResnetArgs<'a> {
1902    pub groups: usize,
1903    pub ic: usize,
1904    pub oc: usize,
1905    pub h: usize,
1906    pub w: usize,
1907    pub n1w: &'a [f32],
1908    pub n1b: &'a [f32],
1909    pub c1w: &'a [f32],
1910    pub c1b: &'a [f32],
1911    pub c1k: usize,
1912    pub n2w: &'a [f32],
1913    pub n2b: &'a [f32],
1914    pub c2w: &'a [f32],
1915    pub c2b: &'a [f32],
1916    pub c2k: usize,
1917    pub shortcut: Option<(&'a [f32], &'a [f32], usize)>,
1918}
1919
1920/// One whole VAE resnet block on the device (norm+silu → conv ×2 →
1921/// shortcut → add, one command buffer).
1922#[allow(unused_variables)]
1923pub fn vae_resnet(a: &VaeResnetArgs, x: &[f32], out: &mut [f32]) -> bool {
1924    match backend() {
1925        #[cfg(target_os = "macos")]
1926        Backend::Metal => crate::gpu_metal::vae_resnet(a, x, out),
1927        _ => false,
1928    }
1929}
1930
1931/// Nearest-2× upsample fused with the following conv — the small
1932/// pre-upsample image is what crosses the CPU boundary.
1933#[allow(unused_variables, clippy::too_many_arguments)]
1934pub fn vae_upsample_conv(
1935    w: &[f32],
1936    bias: &[f32],
1937    x: &[f32],
1938    ic: usize,
1939    oc: usize,
1940    h: usize,
1941    w_img: usize,
1942    k: usize,
1943    out: &mut [f32],
1944) -> bool {
1945    match backend() {
1946        #[cfg(target_os = "macos")]
1947        Backend::Metal => crate::gpu_metal::vae_upsample_conv(w, bias, x, ic, oc, h, w_img, k, out),
1948        #[cfg(feature = "gpu")]
1949        Backend::Wgpu => crate::gpu_wgpu::vae_upsample_conv(w, bias, x, ic, oc, h, w_img, k, out),
1950        #[allow(unreachable_patterns)]
1951        _ => false,
1952    }
1953}
1954
1955/// VAE conv2d on the device (implicit GEMM — the CPU path pays for a
1956/// multi-GB im2col matrix at high resolutions).
1957#[allow(unused_variables, clippy::too_many_arguments)]
1958pub fn vae_conv2d(
1959    w: &[f32],
1960    bias: &[f32],
1961    x: &[f32],
1962    ic: usize,
1963    oc: usize,
1964    h: usize,
1965    w_img: usize,
1966    k: usize,
1967    out: &mut [f32],
1968) -> bool {
1969    match backend() {
1970        #[cfg(target_os = "macos")]
1971        Backend::Metal => crate::gpu_metal::vae_conv2d(w, bias, x, ic, oc, h, w_img, k, out),
1972        #[cfg(feature = "gpu")]
1973        Backend::Wgpu => crate::gpu_wgpu::vae_conv2d(w, bias, x, ic, oc, h, w_img, k, out),
1974        #[allow(unreachable_patterns)]
1975        _ => false,
1976    }
1977}
1978
1979/// DiT full bidirectional attention on the device (all heads:
1980/// scores GEMM → row softmax → P·V → panel unstack, one command
1981/// buffer). Head-major inputs; out is [n, nh·hd].
1982#[allow(unused_variables, clippy::too_many_arguments)]
1983/// Attention from an interleaved qkv panel, splitting into head-major
1984/// planes ON the device. wgpu only; `false` elsewhere so the caller
1985/// keeps its host repack.
1986#[allow(unused_variables)]
1987#[allow(clippy::too_many_arguments)]
1988/// qkv projection + attention with the panel never leaving the card.
1989/// wgpu only; `false` elsewhere and the caller keeps its host chain.
1990#[allow(clippy::too_many_arguments, unused_variables)]
1991pub fn dit_qkv_attention(
1992    model: &Arc<CmfModel>,
1993    qkv_idx: usize,
1994    xn: &[f32],
1995    n: usize,
1996    hidden: usize,
1997    nh: usize,
1998    hd: usize,
1999    scale: f32,
2000    nr: (&[f32], &[f32], &[f32], f32),
2001    out: &mut [f32],
2002) -> bool {
2003    match backend() {
2004        #[cfg(all(feature = "gpu", not(target_os = "macos")))]
2005        Backend::Wgpu => crate::gpu_wgpu::dit_qkv_attention(
2006            model, qkv_idx, xn, n, hidden, nh, hd, scale, nr, out,
2007        ),
2008        #[allow(unreachable_patterns)]
2009        _ => false,
2010    }
2011}
2012
2013/// The whole attention half of a DiT block on the card: qkv GEMM,
2014/// attention, output projection. Only `proj` comes home.
2015#[allow(clippy::too_many_arguments)]
2016pub fn dit_qkv_attn_out(
2017    model: &Arc<CmfModel>,
2018    qkv_idx: usize,
2019    out_idx: usize,
2020    xn: &[f32],
2021    n: usize,
2022    hidden: usize,
2023    nh: usize,
2024    hd: usize,
2025    scale: f32,
2026    nr: (&[f32], &[f32], &[f32], f32),
2027    proj: &mut [f32],
2028) -> bool {
2029    match backend() {
2030        #[cfg(all(feature = "gpu", not(target_os = "macos")))]
2031        Backend::Wgpu => crate::gpu_wgpu::dit_qkv_attn_out(
2032            model, qkv_idx, out_idx, xn, n, hidden, nh, hd, scale, nr, proj,
2033        ),
2034        #[allow(unreachable_patterns)]
2035        _ => false,
2036    }
2037}
2038
2039/// The VAE decoder's attention half on the card. Only `proj` returns.
2040#[allow(clippy::too_many_arguments)]
2041pub fn vae_qkv_attn_out(
2042    model: &Arc<CmfModel>,
2043    qkv_idx: usize,
2044    out_idx: usize,
2045    xn: &[f32],
2046    n: usize,
2047    dim: usize,
2048    nh: usize,
2049    hd: usize,
2050    scale: f32,
2051    angles: &[f32],
2052    eps: f32,
2053    qkv_bias: &[f32],
2054    proj: &mut [f32],
2055) -> bool {
2056    match backend() {
2057        #[cfg(all(feature = "gpu", not(target_os = "macos")))]
2058        Backend::Wgpu => crate::gpu_wgpu::vae_qkv_attn_out(
2059            model, qkv_idx, out_idx, xn, n, dim, nh, hd, scale, angles, eps, qkv_bias, proj,
2060        ),
2061        #[allow(unreachable_patterns)]
2062        _ => false,
2063    }
2064}
2065
2066#[allow(clippy::too_many_arguments)]
2067pub fn vae_attention_packed(
2068    qkv: &[f32],
2069    nh: usize,
2070    n: usize,
2071    hd: usize,
2072    scale: f32,
2073    angles: &[f32],
2074    eps: f32,
2075    out: &mut [f32],
2076) -> bool {
2077    vae_attention_packed_layout(qkv, nh, n, hd, scale, angles, eps, out, 1)
2078}
2079
2080#[allow(clippy::too_many_arguments)]
2081pub fn vae_attention_packed_layout(
2082    qkv: &[f32],
2083    nh: usize,
2084    n: usize,
2085    hd: usize,
2086    scale: f32,
2087    angles: &[f32],
2088    eps: f32,
2089    out: &mut [f32],
2090    layout: u32,
2091) -> bool {
2092    match backend() {
2093        #[cfg(all(feature = "gpu", not(target_os = "macos")))]
2094        Backend::Wgpu => crate::gpu_wgpu::vae_attention_packed_layout(
2095            qkv, nh, n, hd, scale, angles, eps, out, layout,
2096        ),
2097        #[allow(unreachable_patterns)]
2098        _ => false,
2099    }
2100}
2101
2102#[allow(clippy::too_many_arguments)]
2103pub fn dit_split_only(
2104    qkv: &[f32],
2105    nh: usize,
2106    n: usize,
2107    hd: usize,
2108    layout: u32,
2109    norm: Option<(&[f32], f32)>,
2110    out_q: &mut [f32],
2111) -> bool {
2112    match backend() {
2113        #[cfg(all(feature = "gpu", not(target_os = "macos")))]
2114        Backend::Wgpu => crate::gpu_wgpu::dit_split_only(qkv, nh, n, hd, layout, norm, out_q),
2115        #[allow(unreachable_patterns)]
2116        _ => false,
2117    }
2118}
2119
2120/// The backend's f32 NT GEMM: `y[n×m] = x[n×k] · wᵀ[m×k]`. Tensor
2121/// cores where the card has them. Refuses under `CMF_BAKE_GPU=0` or
2122/// strict f32, and for jobs below n·k·m = 4M, where the round trip
2123/// costs more than the arithmetic saves.
2124/// `gemm_nt_f32` whose `w` is known to change every call (an
2125/// accumulation over fresh activations, not a weight): it skips the
2126/// resident ledger and its per-call fingerprint of the whole operand.
2127pub fn gemm_nt_f32_transient(
2128    x: &[f32],
2129    w: &[f32],
2130    y: &mut [f32],
2131    n: usize,
2132    k: usize,
2133    m: usize,
2134) -> bool {
2135    match backend() {
2136        #[cfg(all(feature = "gpu", not(target_os = "macos")))]
2137        Backend::Wgpu => crate::gpu_wgpu::gemm_nt_f32_transient(x, w, y, n, k, m),
2138        #[allow(unreachable_patterns)]
2139        _ => false,
2140    }
2141}
2142
2143pub fn gemm_nt_f32(x: &[f32], w: &[f32], y: &mut [f32], n: usize, k: usize, m: usize) -> bool {
2144    match backend() {
2145        #[cfg(all(feature = "gpu", not(target_os = "macos")))]
2146        Backend::Wgpu => crate::gpu_wgpu::gemm_nt_f32(x, w, y, n, k, m),
2147        #[allow(unreachable_patterns)]
2148        _ => false,
2149    }
2150}
2151
2152/// Music-3's FFN chain resident on the device — two GEMMs and the GLU
2153/// between them with no host round trip. `false` = refused, host runs.
2154#[allow(clippy::too_many_arguments)]
2155pub fn music3_ffn(
2156    model: &std::sync::Arc<CmfModel>,
2157    idx_in: usize,
2158    idx_out: usize,
2159    h: &[f32],
2160    bias_in: &[f32],
2161    n: usize,
2162    hs: usize,
2163    inter: usize,
2164    out: &mut [f32],
2165) -> bool {
2166    match backend() {
2167        #[cfg(all(feature = "gpu", not(target_os = "macos")))]
2168        Backend::Wgpu => {
2169            crate::gpu_wgpu::music3_ffn(model, idx_in, idx_out, h, bias_in, n, hs, inter, out)
2170        }
2171        #[allow(unreachable_patterns)]
2172        _ => false,
2173    }
2174}
2175
2176/// A 1D convolution as a GEMM whose column matrix is expanded on the
2177/// device instead of being built, transposed and uploaded by the host.
2178/// `yt` comes back `[out_n x oc]`. `false` = refused, caller runs host.
2179#[allow(clippy::too_many_arguments)]
2180pub fn conv1d_gemm(
2181    x: &[f32],
2182    w: &[f32],
2183    ic: usize,
2184    oc: usize,
2185    n: usize,
2186    k: usize,
2187    pad: usize,
2188    dil: usize,
2189    out_n: usize,
2190    yt: &mut [f32],
2191) -> bool {
2192    match backend() {
2193        #[cfg(target_os = "macos")]
2194        Backend::Metal => crate::gpu_metal::conv1d_gemm(x, w, ic, oc, n, k, pad, dil, out_n, yt),
2195        #[cfg(all(feature = "gpu", not(target_os = "macos")))]
2196        Backend::Wgpu => crate::gpu_wgpu::conv1d_gemm(x, w, ic, oc, n, k, pad, dil, out_n, yt),
2197        #[allow(unreachable_patterns)]
2198        _ => false,
2199    }
2200}
2201
2202/// The convolution as a GEMM on the matrix units. `false` = refused.
2203#[allow(clippy::too_many_arguments)]
2204pub fn vae_conv2d_coop(
2205    w: &[f32],
2206    bias: Option<&[f32]>,
2207    x: &[f32],
2208    ic: usize,
2209    oc: usize,
2210    h: usize,
2211    wi: usize,
2212    k: usize,
2213    out: &mut [f32],
2214) -> bool {
2215    match backend() {
2216        #[cfg(all(feature = "gpu", not(target_os = "macos")))]
2217        Backend::Wgpu => crate::gpu_wgpu::vae_conv2d_coop(w, bias, x, ic, oc, h, wi, k, out),
2218        #[allow(unreachable_patterns)]
2219        _ => false,
2220    }
2221}
2222
2223pub fn dit_attention_packed(
2224    qkv: &[f32],
2225    nh: usize,
2226    n: usize,
2227    hd: usize,
2228    scale: f32,
2229    // (rope angles, q norm weights, k norm weights, eps) when the device
2230    // should apply qk-norm and RoPE itself; None when the host already did.
2231    nr: Option<(&[f32], &[f32], &[f32], f32)>,
2232    out: &mut [f32],
2233) -> bool {
2234    match backend() {
2235        // wgpu carries the only implementation, and it is not
2236        // platform-specific: `CMF_GPU=wgpu` on macOS runs it over Metal
2237        // like anywhere else. It used to be compiled out here on macOS,
2238        // which made the call a silent `false` — and the caller's
2239        // `assert!` turned that refusal into a panic on every
2240        // `cortiq animate` this platform ever ran.
2241        #[cfg(feature = "gpu")]
2242        Backend::Wgpu => crate::gpu_wgpu::dit_attention_packed(qkv, nh, n, hd, scale, nr, out),
2243        #[allow(unreachable_patterns)]
2244        _ => false,
2245    }
2246}
2247
2248/// Whether `dit_attention_packed` has an implementation on the backend
2249/// that is actually selected.
2250///
2251/// The caller has to know BEFORE it skips the host qk-norm: deferring
2252/// the norm to a device that then refuses leaves q/k unnormalized with
2253/// no way back. Native Metal has no packed kernel, so on macOS this is
2254/// false unless `CMF_GPU=wgpu` picked the other backend.
2255pub fn dit_attention_packed_available() -> bool {
2256    #[allow(unreachable_patterns)]
2257    match backend() {
2258        #[cfg(feature = "gpu")]
2259        Backend::Wgpu => crate::gpu_wgpu::dit_attention_packed_ready(),
2260        _ => false,
2261    }
2262}
2263
2264pub fn dit_attention(
2265    qh: &[f32],
2266    kh: &[f32],
2267    vh: &[f32],
2268    nh: usize,
2269    nkv: usize,
2270    n: usize,
2271    hd: usize,
2272    scale: f32,
2273    out: &mut [f32],
2274) -> bool {
2275    match backend() {
2276        #[cfg(target_os = "macos")]
2277        Backend::Metal => crate::gpu_metal::dit_attention(qh, kh, vh, nh, nkv, n, hd, scale, out),
2278        #[cfg(feature = "gpu")]
2279        Backend::Wgpu => crate::gpu_wgpu::dit_attention(qh, kh, vh, nh, nkv, n, hd, scale, out),
2280        #[allow(unreachable_patterns)]
2281        _ => false,
2282    }
2283}
2284
2285/// Batched q4t GEMM on the device (imagegen DiT prefill shapes).
2286/// Metal: q4t_mul_mm decodes the mmap-resident tiles inside the
2287/// GEMM's K loop. wgpu (Vulkan/DX12 → NVIDIA/AMD/Intel/Adreno/Mali):
2288/// the register-blocked WGSL twin, weights cached in VRAM.
2289#[allow(unused_variables)]
2290pub fn q4tp_matmat(
2291    model: &Arc<CmfModel>,
2292    idx: usize,
2293    xs: &[f32],
2294    b: usize,
2295    rows: usize,
2296    cols: usize,
2297    out: &mut [f32],
2298) -> bool {
2299    match backend() {
2300        #[cfg(target_os = "macos")]
2301        Backend::Metal => crate::gpu_metal::q4tp_matmat(model, idx, xs, b, rows, cols, out),
2302        #[cfg(feature = "gpu")]
2303        Backend::Wgpu => crate::gpu_wgpu::q4tp_matmat(model, idx, xs, b, rows, cols, out),
2304        #[allow(unreachable_patterns)]
2305        _ => false,
2306    }
2307}
2308
2309/// The same over a two-bit weight plane. Metal has no q2tp kernel, so
2310/// there it declines and the host takes it.
2311pub fn q2tp_matmat(
2312    model: &Arc<CmfModel>,
2313    idx: usize,
2314    xs: &[f32],
2315    b: usize,
2316    rows: usize,
2317    cols: usize,
2318    out: &mut [f32],
2319) -> bool {
2320    match backend() {
2321        #[cfg(feature = "gpu")]
2322        Backend::Wgpu => crate::gpu_wgpu::q2tp_matmat(model, idx, xs, b, rows, cols, out),
2323        #[allow(unreachable_patterns)]
2324        _ => false,
2325    }
2326}
2327
2328/// Single-token q4tp matvec on the device — the lm_head class. Through the
2329/// DEDICATED matvec kernel: the batched GEMM at b=1 measured 11.73 ms
2330/// against the host's 9.51 on the release head, so the route that was
2331/// supposed to save eleven milliseconds a token lost its own probe instead.
2332pub fn q4tp_matvec(
2333    model: &Arc<CmfModel>,
2334    idx: usize,
2335    xs: &[f32],
2336    rows: usize,
2337    cols: usize,
2338    out: &mut [f32],
2339) -> bool {
2340    match backend() {
2341        #[cfg(target_os = "macos")]
2342        Backend::Metal => crate::gpu_metal::q4tp_matvec_for_test(model, idx, xs, rows, cols, out),
2343        #[cfg(feature = "gpu")]
2344        Backend::Wgpu => crate::gpu_wgpu::q4tp_matvec(model, idx, xs, rows, cols, out),
2345        #[allow(unreachable_patterns)]
2346        _ => false,
2347    }
2348}
2349
2350/// Single-token q4_tiled matvec on the device — the lm_head class (a
2351/// q4t checkpoint's head is its biggest host matvec, exactly like the
2352/// q4tp twin above). wgpu holds q4t_mv pipelines only inside the graph
2353/// encoder — the standalone arm stays an honest refusal until a
2354/// discrete-GPU q4t model reaches the bench.
2355pub fn q4t_matvec(
2356    model: &Arc<CmfModel>,
2357    idx: usize,
2358    xs: &[f32],
2359    rows: usize,
2360    cols: usize,
2361    out: &mut [f32],
2362) -> bool {
2363    match backend() {
2364        #[cfg(target_os = "macos")]
2365        Backend::Metal => crate::gpu_metal::q4t_matvec_for_test(model, idx, xs, rows, cols, out),
2366        #[allow(unreachable_patterns)]
2367        _ => false,
2368    }
2369}
2370
2371pub fn q4t_matmat(
2372    model: &Arc<CmfModel>,
2373    idx: usize,
2374    xs: &[f32],
2375    b: usize,
2376    rows: usize,
2377    cols: usize,
2378    out: &mut [f32],
2379) -> bool {
2380    match backend() {
2381        #[cfg(target_os = "macos")]
2382        Backend::Metal => crate::gpu_metal::q4t_matmat(model, idx, xs, b, rows, cols, out),
2383        #[cfg(feature = "gpu")]
2384        Backend::Wgpu => crate::gpu_wgpu::q4t_matmat(model, idx, xs, b, rows, cols, out),
2385        #[allow(unreachable_patterns)]
2386        _ => false,
2387    }
2388}
2389
2390/// Whole-block token-graph types re-exported from the Metal backend.
2391#[cfg(target_os = "macos")]
2392pub use crate::gpu_metal::{
2393    AttnDeviceParams, AttnGpuLayer, GdnGpuCfg, GdnGpuLayer, GpuMoe, GraphDims, MetalFfn,
2394    O1AttnParams, TokenGraph, kv_mirror_drop, kv_mirror_read_last, kv_mirror_take_imp,
2395};
2396
2397/// A BLOCK of consecutive q1 GDN layers in one submission (Metal only).
2398#[cfg(target_os = "macos")]
2399pub fn gdn_block(
2400    model: &Arc<CmfModel>,
2401    layers: &[GdnGpuLayer],
2402    states: &mut [&mut [f32]],
2403    cfg: &GdnGpuCfg,
2404    h: &mut [f32],
2405) -> bool {
2406    match backend() {
2407        Backend::Metal => crate::gpu_metal::gdn_block(model, layers, states, cfg, h),
2408        _ => false,
2409    }
2410}
2411
2412/// A layer's MoE-FFN in one submission (amortizing the dispatch cost).
2413#[allow(unused_variables)]
2414pub fn moe_block(model: &Arc<CmfModel>, jobs: &[MoeJob], out: &mut [f32]) -> bool {
2415    match backend() {
2416        #[cfg(target_os = "macos")]
2417        Backend::Metal => crate::gpu_metal::moe_block(model, jobs, out),
2418        #[cfg(feature = "gpu")]
2419        Backend::Wgpu => crate::gpu_wgpu::moe_block(model, jobs, out),
2420        Backend::None => false,
2421    }
2422}
2423
2424/// Independent matvecs of one input in a single submission (GDN projections).
2425#[allow(unused_variables)]
2426pub fn matvec_batch(model: &Arc<CmfModel>, jobs: &[BatchJob], out: &mut [&mut [f32]]) -> bool {
2427    match backend() {
2428        #[cfg(target_os = "macos")]
2429        Backend::Metal => crate::gpu_metal::matvec_batch(model, jobs, out),
2430        #[cfg(feature = "gpu")]
2431        Backend::Wgpu => crate::gpu_wgpu::matvec_batch(model, jobs, out),
2432        Backend::None => false,
2433    }
2434}
2435
2436// ── Whole-token wgpu graph race (generation granularity) ─────────────
2437// On integrated/mobile adapters the graph is neither trusted nor banned
2438// a priori — it RACES the normal path: generations alternate arms (the
2439// normal path first — known-good UX — then the graph), per-token wall
2440// times accumulate per arm, and once both arms have enough steady
2441// samples the faster one wins for the process. Arm switches happen ONLY
2442// at generation boundaries (`kv_cache.clear()` resets state), so the
2443// device KV mirror and the CPU cache never diverge mid-sequence. The
2444// single exception is the first-token bail: the very first decode token
2445// of a graph generation may be discarded and recomputed on the CPU
2446// path (the prompt KV is CPU-owned at that point, so this is safe) —
2447// a tiled mobile GPU that drains its pipeline at every barrier turns
2448// the ~300-dispatch graph into seconds per token (field report: 0.2
2449// tok/s vs 15 on the CPU), and one token is all it takes to see that.
2450static GRAPH_RACE_STATE: AtomicU8 = AtomicU8::new(0); // 0 racing, 1 graph won, 2 normal won
2451static GRAPH_RACE_FLIP: AtomicU32 = AtomicU32::new(0);
2452static GRAPH_RACE_ARM_GRAPH: AtomicU8 = AtomicU8::new(0); // this generation's arm
2453static GRAPH_RACE_TOK: AtomicU32 = AtomicU32::new(0); // token index within the generation
2454static GRAPH_NS: [AtomicU64; 2] = [AtomicU64::new(0), AtomicU64::new(0)]; // [normal, graph]
2455static GRAPH_N: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
2456
2457/// Steady per-token samples per arm before the race decides.
2458const GRAPH_RACE_SAMPLES: u32 = 4;
2459
2460/// Called at every generation start (fresh KV). Applies a pending
2461/// verdict and picks this generation's arm while racing.
2462/// A graph that cannot be built for THIS model will never build: the
2463/// refusal is a property of the weights, not of the moment. Retrying it
2464/// per token is not free — the builder walks every layer and asks each
2465/// tensor for a graph view before giving up at layer 0 — and on an
2466/// Adreno 642L that retry cost 3x: forcing the graph on a model it
2467/// refuses measured 0.3 tok/s against 0.905 for the per-op path it falls
2468/// back to. Remembered once, the fallback runs at its own speed.
2469static GRAPH_UNSUPPORTED: AtomicBool = AtomicBool::new(false);
2470
2471/// The builder refused for a STRUCTURAL reason — an unsupported weight
2472/// or layer kind. Callers must NOT report the transient refusals (an
2473/// unsealed o1 state during prefill, a softcap): those clear on their
2474/// own and marking them would disable the graph for good.
2475pub fn graph_mark_unsupported() {
2476    if !GRAPH_UNSUPPORTED.swap(true, Ordering::Relaxed) {
2477        tracing::info!("wgpu token graph: unsupported for this model — not retrying");
2478    }
2479}
2480
2481pub fn graph_unsupported() -> bool {
2482    GRAPH_UNSUPPORTED.load(Ordering::Relaxed)
2483}
2484
2485/// A different model in the same process starts with a clean slate.
2486pub fn graph_unsupported_reset() {
2487    GRAPH_UNSUPPORTED.store(false, Ordering::Relaxed);
2488}
2489
2490pub fn graph_race_begin_generation() {
2491    // One generation has now compiled whatever this model needs; keep it
2492    // for the next process. Once per run: the blob does not grow after
2493    // the pipelines exist, and the write is megabytes against the ~200 s
2494    // of compiling it saves on the device that needed this.
2495    #[cfg(feature = "gpu")]
2496    {
2497        // Save once, at the start of the SECOND generation: the first
2498        // has dispatched, so there is something to keep, and nothing is
2499        // saved before any work (the driver compiles at first use, not
2500        // at pipeline creation — the context comes up in 1.5 s while the
2501        // compiling costs minutes).
2502        //
2503        // Flushing again on 4, 8, 16 … was tried on the theory that a
2504        // chat turn compiles shapes the first one did not. It buys
2505        // nothing: a fresh app process still spent 49.0 s, then 58.7,
2506        // then 61.3 on its first answer with the backoff in place. One
2507        // flush it is.
2508        static FLUSHED: std::sync::Once = std::sync::Once::new();
2509        static FIRST: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
2510        if FIRST.swap(false, Ordering::Relaxed) {
2511            // Nothing dispatched yet.
2512        } else {
2513            FLUSHED.call_once(crate::gpu_wgpu::pipeline_cache_flush);
2514        }
2515    }
2516    GRAPH_RACE_TOK.store(0, Ordering::Relaxed);
2517    if GRAPH_RACE_STATE.load(Ordering::Relaxed) != 0 {
2518        return;
2519    }
2520    let (gn, cn) = (
2521        GRAPH_N[1].load(Ordering::Relaxed),
2522        GRAPH_N[0].load(Ordering::Relaxed),
2523    );
2524    if gn >= GRAPH_RACE_SAMPLES && cn >= GRAPH_RACE_SAMPLES {
2525        let g_avg = GRAPH_NS[1].load(Ordering::Relaxed) / gn as u64;
2526        let c_avg = GRAPH_NS[0].load(Ordering::Relaxed) / cn as u64;
2527        let verdict = if g_avg < c_avg { 1 } else { 2 };
2528        GRAPH_RACE_STATE.store(verdict, Ordering::Relaxed);
2529        tracing::info!(
2530            "wgpu graph race: graph {:.2} ms/tok vs normal {:.2} ms/tok -> {}",
2531            g_avg as f64 / 1e6,
2532            c_avg as f64 / 1e6,
2533            if verdict == 1 { "graph" } else { "normal path" }
2534        );
2535        return;
2536    }
2537    let flip = GRAPH_RACE_FLIP.fetch_add(1, Ordering::Relaxed);
2538    GRAPH_RACE_ARM_GRAPH.store((flip % 2 == 1) as u8, Ordering::Relaxed);
2539}
2540
2541/// Should this decode token try the graph? `trusted` (discrete adapter,
2542/// explicit env, or a GDN hybrid whose state lives on the device) skips
2543/// the race entirely.
2544pub fn graph_race_use_graph(trusted: bool) -> bool {
2545    if trusted {
2546        return true;
2547    }
2548    match GRAPH_RACE_STATE.load(Ordering::Relaxed) {
2549        1 => true,
2550        2 => false,
2551        _ => GRAPH_RACE_ARM_GRAPH.load(Ordering::Relaxed) == 1,
2552    }
2553}
2554
2555/// First decode token of a racing graph generation: hopeless already?
2556/// (>4x the normal path's per-token average AND over a second.) Settles
2557/// the race immediately; the caller discards the graph result and
2558/// recomputes this token on the normal path.
2559pub fn graph_race_first_token_hopeless(dur: std::time::Duration) -> bool {
2560    if GRAPH_RACE_STATE.load(Ordering::Relaxed) != 0 {
2561        return false;
2562    }
2563    let first = GRAPH_RACE_TOK.load(Ordering::Relaxed) == 0;
2564    let cn = GRAPH_N[0].load(Ordering::Relaxed);
2565    if !first || cn == 0 {
2566        return false;
2567    }
2568    let c_avg = GRAPH_NS[0].load(Ordering::Relaxed) / cn as u64;
2569    let ns = dur.as_nanos() as u64;
2570    if ns > 1_000_000_000 && ns > 4 * c_avg {
2571        GRAPH_RACE_STATE.store(2, Ordering::Relaxed);
2572        tracing::info!(
2573            "wgpu graph race: first graph token {:.0} ms vs normal {:.2} ms/tok — hopeless, normal path wins",
2574            ns as f64 / 1e6,
2575            c_avg as f64 / 1e6
2576        );
2577        return true;
2578    }
2579    false
2580}
2581
2582/// Record one decode-token wall time for the racing arm. The first
2583/// token of each generation is discarded (KV-mirror upload / cold
2584/// caches on the graph arm; cold mmap on the normal arm).
2585pub fn graph_race_record(used_graph: bool, dur: std::time::Duration) {
2586    if GRAPH_RACE_STATE.load(Ordering::Relaxed) != 0 {
2587        return;
2588    }
2589    let tok = GRAPH_RACE_TOK.fetch_add(1, Ordering::Relaxed);
2590    if tok == 0 {
2591        return;
2592    }
2593    let i = used_graph as usize;
2594    GRAPH_NS[i].fetch_add(dur.as_nanos() as u64, Ordering::Relaxed);
2595    GRAPH_N[i].fetch_add(1, Ordering::Relaxed);
2596}
2597
2598/// Bounded-cost content fingerprint for the backends' pointer-keyed device
2599/// caches: FNV over the whole slice up to 4 KiB, over 64 spread 64-byte
2600/// windows (plus the length) above. An address-keyed hit must also prove
2601/// the bytes are still the ones it uploaded — the allocator reuses heap
2602/// and mmap addresses freely, so a reloaded model or a re-dequantized
2603/// layer lands where the old bytes were — and sampling keeps that proof at
2604/// ~a microsecond even for a 126 MB matrix. Real replacements (another
2605/// model's tensor, an Adam-updated master) differ densely, so a 4 KiB
2606/// spread cannot miss them.
2607pub(crate) fn fp_bytes(data: &[u8]) -> u64 {
2608    #[inline]
2609    fn fnv(mut h: u64, bytes: &[u8]) -> u64 {
2610        let (chunks, tail) = bytes.split_at(bytes.len() & !7);
2611        for c in chunks.chunks_exact(8) {
2612            h ^= u64::from_le_bytes(c.try_into().unwrap());
2613            h = h.wrapping_mul(0x100_0000_01b3);
2614        }
2615        for &b in tail {
2616            h ^= b as u64;
2617            h = h.wrapping_mul(0x100_0000_01b3);
2618        }
2619        h
2620    }
2621    let mut h = 0xcbf2_9ce4_8422_2325u64 ^ (data.len() as u64);
2622    if data.len() <= 4096 {
2623        return fnv(h, data);
2624    }
2625    let step = (data.len() - 64) / 63;
2626    for i in 0..64 {
2627        h = fnv(h, &data[i * step..i * step + 64]);
2628    }
2629    h
2630}
2631
2632/// `fp_bytes` over an f32 slice without a bytemuck dependency (the Metal
2633/// backend builds with no GPU feature flags).
2634pub(crate) fn fp_f32(data: &[f32]) -> u64 {
2635    let bytes = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, data.len() * 4) };
2636    fp_bytes(bytes)
2637}
2638
2639#[cfg(test)]
2640mod fp_tests {
2641    use super::fp_bytes;
2642
2643    /// The pointer-keyed caches survive on `fp_bytes` telling two different
2644    /// tensors apart at a reused address. Its sampling must therefore see a
2645    /// change ANYWHERE — head, tail, and the stretches between windows are
2646    /// the places a cheaper hash would go blind.
2647    #[test]
2648    fn fp_bytes_sees_a_change_anywhere_in_a_sampled_slice() {
2649        let n = 1 << 20; // 1 MiB — far above the 4 KiB full-hash threshold
2650        let base: Vec<u8> = (0..n).map(|i| (i * 31 + 7) as u8).collect();
2651        let h0 = fp_bytes(&base);
2652        assert_eq!(h0, fp_bytes(&base), "fingerprint must be deterministic");
2653        // A DENSE change (every requantized/redequantized tensor is one)
2654        // must flip the fingerprint no matter how the windows fall.
2655        let mut dense = base.clone();
2656        for b in dense.iter_mut() {
2657            *b = b.wrapping_add(1);
2658        }
2659        assert_ne!(
2660            h0,
2661            fp_bytes(&dense),
2662            "a fully different tensor slipped through"
2663        );
2664        // Length participates: the same prefix at a shorter length is a
2665        // different key AND a different fingerprint.
2666        assert_ne!(h0, fp_bytes(&base[..n - 64]));
2667        // Below the threshold the hash is exact: a single flipped byte in
2668        // a norm-sized vector must be seen.
2669        let mut small = vec![3u8; 4096];
2670        let hs = fp_bytes(&small);
2671        small[2048] ^= 1;
2672        assert_ne!(hs, fp_bytes(&small), "full hash missed a one-byte change");
2673        // And the sampled windows land within bounds on awkward sizes.
2674        for n in [4097usize, 5000, 64 * 64, 1 << 16] {
2675            let v = vec![9u8; n];
2676            let _ = fp_bytes(&v); // must not panic on window math
2677        }
2678    }
2679}
2680
2681/// Hand the card back after a bake: drop its resident weights, planes and
2682/// pools so the ordinary engine (the runtime gate, a serve that follows)
2683/// starts from a clean budget. No-op off the wgpu backend.
2684pub fn bake_release() {
2685    #[cfg(feature = "gpu")]
2686    crate::gpu_wgpu::bake_release();
2687}
2688
2689/// Strict-f32 for the bake's GEMMs (phase A mask training): the mask
2690/// selects neurons by a gradient signal, and f16 operand rounding on
2691/// that signal closes the wrong ones. No-op off the wgpu backend.
2692pub fn bake_precision_strict(on: bool) {
2693    #[cfg(feature = "gpu")]
2694    crate::gpu_wgpu::bake_precision_strict(on);
2695    #[cfg(not(feature = "gpu"))]
2696    let _ = on;
2697}
2698
2699/// CMF_GRAPH_HOSTPROF=1: how a graph token's wall splits between the
2700/// host encoding the command stream and the tail the GPU still owes
2701/// after encode. Fifteen GPU-side suspects measured null while the
2702/// bench counted 17.7k allocations a token — this is the instrument
2703/// that says whether the thief was on the host all along.
2704pub fn hostprof_encode_done(t0: std::time::Instant) {
2705    use std::sync::atomic::{AtomicU64, Ordering};
2706    static ENC: AtomicU64 = AtomicU64::new(0);
2707    static N: AtomicU64 = AtomicU64::new(0);
2708    if std::env::var("CMF_GRAPH_HOSTPROF").as_deref() != Ok("1") {
2709        return;
2710    }
2711    ENC.fetch_add(t0.elapsed().as_nanos() as u64, Ordering::Relaxed);
2712    let n = N.fetch_add(1, Ordering::Relaxed) + 1;
2713    if n % 100 == 0 {
2714        eprintln!(
2715            "hostprof: encode {:.2} ms/token over {n} tokens",
2716            ENC.load(Ordering::Relaxed) as f64 / n as f64 / 1e6
2717        );
2718    }
2719}
2720
2721pub fn hostprof_total(t0: std::time::Instant) {
2722    use std::sync::atomic::{AtomicU64, Ordering};
2723    static TOT: AtomicU64 = AtomicU64::new(0);
2724    static N: AtomicU64 = AtomicU64::new(0);
2725    if std::env::var("CMF_GRAPH_HOSTPROF").as_deref() != Ok("1") {
2726        return;
2727    }
2728    TOT.fetch_add(t0.elapsed().as_nanos() as u64, Ordering::Relaxed);
2729    let n = N.fetch_add(1, Ordering::Relaxed) + 1;
2730    if n % 100 == 0 {
2731        eprintln!(
2732            "hostprof: total {:.2} ms/token over {n} tokens",
2733            TOT.load(Ordering::Relaxed) as f64 / n as f64 / 1e6
2734        );
2735    }
2736}
2737
2738/// Per-stage host-encode accumulator for the Metal token loop
2739/// (CMF_GRAPH_HOSTPROF=1). Stage 0 = GDN-run encode; everything else
2740/// falls out by subtraction from hostprof's encode total.
2741pub fn stageprof(stage: u32, dt: std::time::Duration) {
2742    use std::sync::atomic::{AtomicU64, Ordering};
2743    static NS: [AtomicU64; 4] = [
2744        AtomicU64::new(0),
2745        AtomicU64::new(0),
2746        AtomicU64::new(0),
2747        AtomicU64::new(0),
2748    ];
2749    static N: AtomicU64 = AtomicU64::new(0);
2750    if std::env::var("CMF_GRAPH_HOSTPROF").as_deref() != Ok("1") {
2751        return;
2752    }
2753    NS[stage as usize % 4].fetch_add(dt.as_nanos() as u64, Ordering::Relaxed);
2754    if stage == 1 {
2755        let n = N.fetch_add(1, Ordering::Relaxed) + 1;
2756        if n % 200 == 0 {
2757            eprintln!(
2758                "stageprof: planning {:.2} ms/tok | gdn-item {:.2} ms/tok | attn-item {:.2} ms/tok ({n} tok)",
2759                NS[1].load(Ordering::Relaxed) as f64 / n as f64 / 1e6,
2760                NS[2].load(Ordering::Relaxed) as f64 / n as f64 / 1e6,
2761                NS[3].load(Ordering::Relaxed) as f64 / n as f64 / 1e6
2762            );
2763        }
2764    }
2765}
2766
2767/// Active weight bytes dispatched so far (Metal decode path); 0 where
2768/// the backend does not count. The honest floor's numerator.
2769pub fn weight_bytes_dispatched() -> u64 {
2770    let mut total = 0u64;
2771    #[cfg(target_os = "macos")]
2772    {
2773        total += crate::gpu_metal::WEIGHT_BYTES.load(std::sync::atomic::Ordering::Relaxed);
2774    }
2775    #[cfg(feature = "gpu")]
2776    {
2777        total += crate::gpu_wgpu::WEIGHT_BYTES.load(std::sync::atomic::Ordering::Relaxed);
2778    }
2779    total
2780}
2781
2782/// The per-stage split of `weight_bytes_dispatched`:
2783/// [misc, dense-ffn, moe, attn, gdn, head].
2784pub fn weight_bytes_by() -> [u64; 6] {
2785    #[cfg(target_os = "macos")]
2786    {
2787        let mut o = [0u64; 6];
2788        for (i, a) in crate::gpu_metal::WEIGHT_BYTES_BY.iter().enumerate() {
2789            o[i] = a.load(std::sync::atomic::Ordering::Relaxed);
2790        }
2791        return o;
2792    }
2793    #[allow(unreachable_code)]
2794    [0; 6]
2795}
2796
2797#[cfg(test)]
2798mod probe_warmup_tests {
2799    use super::*;
2800    use std::time::Duration;
2801
2802    fn ms(v: f64) -> Duration {
2803        Duration::from_nanos((v * 1e6) as u64)
2804    }
2805
2806    /// The bug this pins, measured on an A100: the first device call for
2807    /// a class compiles its pipeline, was timed at 117.01 ms against the
2808    /// host's 3.19, and sent `gemm-nt` to the CPU for the whole process —
2809    /// which ran a 27B bake on 2.6 cores with the card idle.
2810    #[test]
2811    fn one_cold_first_sample_does_not_lose_the_class() {
2812        let p = Probe::new();
2813        // First device sample is the pipeline build. Then the truth.
2814        probe_record_into(&p, "gemm-nt", None, true, ms(117.01));
2815        probe_record_into(&p, "gemm-nt", None, true, ms(1.1));
2816        probe_record_into(&p, "gemm-nt", None, true, ms(1.0));
2817        probe_record_into(&p, "gemm-nt", None, false, ms(3.19));
2818        probe_record_into(&p, "gemm-nt", None, false, ms(3.20));
2819        assert_eq!(
2820            p.state.load(Ordering::Relaxed),
2821            1,
2822            "the device is 3x faster once warm and must win"
2823        );
2824    }
2825
2826    /// The warm-up must not become a way to never decide, and must not
2827    /// underflow: a blind decrement at zero wraps a u32 to its maximum
2828    /// and mutes the arm for the life of the process.
2829    #[test]
2830    fn the_warmup_is_spent_once_and_never_underflows() {
2831        let p = Probe::new();
2832        for _ in 0..8 {
2833            probe_record_into(&p, "matmat", None, true, ms(10.0));
2834        }
2835        assert_eq!(p.gpu_burn.load(Ordering::Relaxed), 0, "spent, not wrapped");
2836        assert_eq!(
2837            p.gpu_n.load(Ordering::Relaxed),
2838            7,
2839            "one sample burned, the rest counted"
2840        );
2841    }
2842
2843    /// A genuinely slower device still loses — the warm-up removes an
2844    /// artefact, it does not put a thumb on the scale.
2845    #[test]
2846    fn a_slow_device_still_loses_after_the_warmup() {
2847        let p = Probe::new();
2848        for _ in 0..4 {
2849            probe_record_into(&p, "matvec", None, true, ms(40.0));
2850        }
2851        for _ in 0..4 {
2852            probe_record_into(&p, "matvec", None, false, ms(2.0));
2853        }
2854        assert_eq!(p.state.load(Ordering::Relaxed), 2, "host wins on merit");
2855    }
2856}