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