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