Skip to main content

cortiq_engine/
gpu.rs

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