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