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