Skip to main content

cortiq_engine/
gpu.rs

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