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
18/// Packed, f32-only Embryo model consumed by the resident Vulkan graph.
19///
20/// This is deliberately a separate representation from `CmfModel`: the
21/// Embryo graph owns one contiguous device copy and never dereferences the
22/// mmap or asks the ordinary per-op residency path for a tensor. `meta`
23/// contains byte-free element offsets into `weights`; `UINT_MAX` marks an
24/// absent optional matrix. The builder lives in `pipeline.rs`, where the
25/// architecture-specific weight types are visible.
26pub struct EmbryoGraphModel {
27 pub id: u64,
28 pub hidden: usize,
29 pub intermediate: usize,
30 pub vocab: usize,
31 pub layers: usize,
32 pub phase_heads: usize,
33 pub nphase: usize,
34 pub phase_dv: usize,
35 pub anchor_q_heads: usize,
36 pub anchor_kv_heads: usize,
37 pub anchor_head_dim: usize,
38 pub rotary_dim: usize,
39 pub max_seq: usize,
40 pub cluster_count: usize,
41 pub cluster_size: usize,
42 pub phase_state_len: usize,
43 pub state_stride: usize,
44 pub kv_stride: usize,
45 pub norm_gemma: bool,
46 pub phase_mass: f32,
47 pub weights: Vec<f32>,
48 pub meta: Vec<u32>,
49 pub lm_head: Vec<f32>,
50 pub clusters: Vec<f32>,
51 pub final_norm: Vec<f32>,
52 pub inv_freq: Vec<f32>,
53 /// Natively bounded anchors (`swa_sink_v1`): every anchor layer owns a
54 /// ring of `anchor_window` raw keys/values instead of a `max_seq` KV
55 /// plane, so `kv_stride` is the ring size, `kv_layers` counts the
56 /// anchors, and no position cap applies (the operator has none).
57 pub bounded: bool,
58 /// Layers that own a KV/ring slot of `kv_stride` f32 (all layers for
59 /// the legacy full anchor — offsets are `layer·kv_stride` — or the
60 /// bounded anchors only).
61 pub kv_layers: usize,
62 /// Recurrent (phase or GDN) layers — the state buffer holds one
63 /// `state_stride` per such layer; anchors own none.
64 pub state_layers: usize,
65 pub anchor_window: usize,
66 pub anchor_sink: usize,
67 /// vmf_phase mixer layers (kind 0/1) in the stack.
68 pub phase_layers: usize,
69 /// GatedDeltaNet mixer layers (kind 4) in the stack; their geometry
70 /// is `gdn_heads` (nv), `gdn_k_heads` (nk), `gdn_dk`, `gdn_dv`,
71 /// `gdn_kk` (conv taps) — header words 24..29 of `meta`.
72 pub gdn_layers: usize,
73 pub gdn_heads: usize,
74 pub gdn_k_heads: usize,
75 pub gdn_dk: usize,
76 pub gdn_dv: usize,
77 pub gdn_kk: usize,
78}
79
80impl EmbryoGraphModel {
81 /// Fused q/k/v projection width of the GDN mixer (`2·nk·dk + nv·dv`).
82 pub fn gdn_c_dim(&self) -> usize {
83 2 * self.gdn_k_heads * self.gdn_dk + self.gdn_heads * self.gdn_dv
84 }
85}
86
87/// Words in the resident graph's `meta` header before the per-layer
88/// records (64 words each). Shared by the host packer and the encoder.
89pub const EMBRYO_META_HEADER: usize = 32;
90
91/// Positions one chunked-prefill submit of the resident Embryo graph
92/// covers (the shader's `CMAX`; the chunk window of the scratch buffer is
93/// sized by it).
94pub const EMBRYO_CHUNK_MAX: usize = 64;
95
96/// `n` contiguous prompt positions (`rows` = n × hidden embeddings from
97/// `position`) through the resident Embryo graph in one submit; logits of
98/// the last position. False = refused before any device work (the caller
99/// keeps the per-position path), or the sequence went cold.
100pub fn forward_embryo_graph_chunk(
101 model: &Arc<EmbryoGraphModel>,
102 kv_id: u64,
103 rows: &[f32],
104 position: usize,
105 n: usize,
106 logits: &mut Vec<f32>,
107) -> bool {
108 #[cfg(feature = "gpu")]
109 if backend() == Backend::Wgpu {
110 return crate::gpu_wgpu::forward_embryo_graph_chunk(model, kv_id, rows, position, n, logits);
111 }
112 let _ = (model, kv_id, rows, position, n, logits);
113 false
114}
115
116/// Bytes the resident Embryo graph holds on the device for a sequence:
117/// `(recurrent state, anchor KV/ring)`. None when the sequence has no
118/// device image (host path, or not started yet). This is the measured
119/// long-context claim of the resident path — a bounded genome reports
120/// the same two numbers at every context depth.
121pub fn embryo_device_state_bytes(kv_id: u64) -> Option<(u64, u64)> {
122 #[cfg(feature = "gpu")]
123 if backend() == Backend::Wgpu {
124 return crate::gpu_wgpu::embryo_device_state_bytes(kv_id);
125 }
126 let _ = kv_id;
127 None
128}
129
130/// Does this directory entry count as WEIGHT in the device placement
131/// heuristics (layer prefix, live-weight budget, `serve` replicas vs
132/// split)? On a genome file only the trunk does: skill tensors replace
133/// trunk tensors of the same shape inside their own lane, so counting
134/// them would give the backbone of F1 another GPU prefix than F0's
135/// (NF-7). Other files keep the historical whole-directory count.
136pub fn counts_as_placement_weight(model: &CmfModel, name: &str) -> bool {
137 model.header.genome.is_none() || cortiq_core::knowledge::is_trunk_tensor(name)
138}
139
140/// Weight bytes the placement heuristics budget for `model`: the trunk's
141/// payload on a genome file ([`counts_as_placement_weight`]), the whole
142/// mapped file otherwise (the historical measure).
143pub fn placement_weight_bytes(model: &CmfModel) -> u64 {
144 if model.header.genome.is_none() {
145 return model.primary_bytes().len() as u64;
146 }
147 model
148 .tensors
149 .iter()
150 .filter(|t| counts_as_placement_weight(model, &t.name))
151 .map(|t| t.nbytes)
152 .sum()
153}
154
155/// Next position of the resident Embryo sequence `kv_id` (`Some(n)` = the
156/// device holds positions `0..n` of it), `None` when the device holds no
157/// initialized image of that sequence — the host owns it (or none began).
158pub fn embryo_device_next_position(kv_id: u64) -> Option<usize> {
159 #[cfg(feature = "gpu")]
160 if backend() == Backend::Wgpu {
161 return crate::gpu_wgpu::embryo_device_next_position(kv_id);
162 }
163 let _ = kv_id;
164 None
165}
166
167thread_local! {
168 /// Index of the current forward layer (−1 = outside a numbered layer:
169 /// lm_head/embed — always allowed). The pipeline sets it before
170 /// each layer so that the GPU/CPU layer-split works.
171 static CUR_LAYER: Cell<i64> = const { Cell::new(-1) };
172 /// Inside `cpu_scope` every GPU gate reports disabled: the timed CPU
173 /// arm of a probe (and a class that lost its probe) must run PURE
174 /// CPU, or inner per-op hooks would re-enter the GPU and poison the
175 /// comparison.
176 static CPU_ONLY: Cell<bool> = const { Cell::new(false) };
177 /// "This op paid a one-off cost" (weight upload / first pipeline
178 /// build): backends set it, `probe_record` discards the sample so
179 /// only steady-state timings compete.
180 static PROBE_COLD: Cell<bool> = const { Cell::new(false) };
181}
182
183/// RAII form of `cpu_scope`, used when a device-prefix graph hands a whole
184/// remainder of the forward pass to the host. Without a guard around that
185/// tail, its ordinary per-op hooks re-entered the GPU and streamed the rest of
186/// an over-size model through the residency arena, defeating the prefix's VRAM
187/// bound at the driver-allocation level.
188pub struct CpuScopeGuard(bool);
189
190impl Drop for CpuScopeGuard {
191 fn drop(&mut self) {
192 CPU_ONLY.with(|c| c.set(self.0));
193 }
194}
195
196pub fn enter_cpu_scope() -> CpuScopeGuard {
197 let previous = CPU_ONLY.with(|c| c.replace(true));
198 CpuScopeGuard(previous)
199}
200
201/// Temporarily turn the CPU-only gate back ON inside a held
202/// [`enter_cpu_scope`] (the host tail holds one for its per-op layers).
203/// The pooled tail's own device frames are explicitly device-owned — the
204/// same escape hatch the MiniCPM prepared frame documents — and must not be
205/// declined by the surrounding scope. Restores the previous flag on drop.
206pub fn gpu_scope<R>(f: impl FnOnce() -> R) -> R {
207 struct Restore(bool);
208 impl Drop for Restore {
209 fn drop(&mut self) {
210 CPU_ONLY.with(|c| c.set(self.0));
211 }
212 }
213 let prev = CPU_ONLY.with(|c| c.replace(false));
214 let restore = Restore(prev);
215 let out = f();
216 drop(restore);
217 out
218}
219
220/// Run `f` with the GPU gates off on this thread (pure-CPU arm).
221pub fn cpu_scope<R>(f: impl FnOnce() -> R) -> R {
222 let _restore = enter_cpu_scope();
223 f()
224}
225
226/// Capture CPU-only placement before dispatching whole operators to workers.
227/// `cpu_scope` is thread-local, while a MoE panel worker calls QTensor again;
228/// without inheritance, which expert happened to land on the caller changed
229/// its precision/backend from run to run.
230pub(crate) fn inherit_cpu_scope() -> impl Fn() -> Option<CpuScopeGuard> + Copy {
231 let on = CPU_ONLY.get();
232 move || on.then(enter_cpu_scope)
233}
234
235/// Backends: name the device once at init. The probe cache is keyed by
236/// it, because a verdict is a property of THIS silicon and nothing else.
237/// First writer wins: a process runs one backend, and on the rare host
238/// where two initialize, the one that came up first is the one in use.
239pub fn probe_set_device(label: &str) {
240 let _ = DEVICE_LABEL.set(label.to_string());
241}
242
243fn device_label() -> &'static str {
244 DEVICE_LABEL.get().map(String::as_str).unwrap_or("unknown")
245}
246
247static DEVICE_LABEL: std::sync::OnceLock<String> = std::sync::OnceLock::new();
248
249/// Somewhere this process may write small caches.
250///
251/// `std::env::temp_dir()` is NOT that place on Android: with no `TMPDIR`
252/// it answers `/tmp`, which does not exist in an app sandbox, and every
253/// write fails silently — measured, after the pipeline cache appeared to
254/// work in a shell (where `TMPDIR=/data/local/tmp`) and did nothing at
255/// all in the app. The loader points this at the model's own directory,
256/// which is somewhere the caller already writes.
257static CACHE_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
258
259/// Loader: name a directory this process can write to. First call wins.
260pub fn set_cache_dir(dir: std::path::PathBuf) {
261 let _ = CACHE_DIR.set(dir);
262}
263
264/// Same directory, for the backends.
265pub fn cache_dir_pub() -> std::path::PathBuf {
266 cache_dir()
267}
268
269fn cache_dir() -> std::path::PathBuf {
270 if let Some(d) = CACHE_DIR.get() {
271 return d.clone();
272 }
273 match std::env::var_os("TMPDIR") {
274 Some(t) => std::path::PathBuf::from(t),
275 None => std::env::temp_dir(),
276 }
277}
278
279/// Where decided verdicts are remembered between runs. `CMF_PROBE_CACHE`
280/// overrides the path; `0` disables the cache entirely.
281fn probe_cache_path() -> Option<std::path::PathBuf> {
282 match std::env::var("CMF_PROBE_CACHE") {
283 Ok(v) if v == "0" => None,
284 Ok(v) => Some(std::path::PathBuf::from(v)),
285 Err(_) => Some(cache_dir().join("cortiq-gpu-probe.tsv")),
286 }
287}
288
289/// One line per decided class: `version \t device \t class \t winner`.
290/// A different engine build or a different device simply does not match,
291/// so a stale file is inert rather than wrong.
292fn probe_cache_key_named(class: &str) -> String {
293 format!(
294 "{}\t{}\t{}",
295 env!("CARGO_PKG_VERSION"),
296 device_label(),
297 class
298 )
299}
300
301const CLASS_NAMES: [&str; 8] = [
302 "ffn",
303 "matvec",
304 "matmat",
305 "qkv-batch",
306 "matmat-wide",
307 "lm-head",
308 "gemm-nt",
309 // MoE has a different residency policy from a dense FFN. In
310 // particular, an out-of-core Metal MoE block can become viable after a
311 // dense `ffn` probe has (correctly) decided that ordinary per-op FFNs
312 // lose. Never let that remembered verdict suppress the separate path.
313 "moe",
314];
315
316/// Adopt every verdict this device already reached in an earlier run.
317///
318/// Probing is not cheap and it is not free of consequences: on a
319/// Snapdragon 778G the three deciding classes took **three minutes of
320/// wall clock** before the first token, every process, and in the phone
321/// app that was the whole first answer — 209.6 s for 25 tokens against
322/// 10.5 s on the CPU path. The verdict itself was the same every time.
323/// Paying to rediscover it is the defect; the answer is to write it down.
324fn probe_cache_load() {
325 static ONCE: std::sync::Once = std::sync::Once::new();
326 ONCE.call_once(|| {
327 let Some(path) = probe_cache_path() else {
328 return;
329 };
330 // Unit tests share this process and its default cache path; a
331 // verdict left by an earlier run would decide a class before the
332 // arbitration tests get to watch it alternate. Tests that mean to
333 // exercise the cache point `CMF_PROBE_CACHE` at their own file.
334 if cfg!(test) && std::env::var("CMF_PROBE_CACHE").is_err() {
335 return;
336 }
337 let Ok(text) = std::fs::read_to_string(&path) else {
338 return;
339 };
340 probe_cache_adopt(&text);
341 });
342}
343
344/// Apply verdicts from a cache file's text. Split out from the file
345/// reading so the adoption rule — including which lines must be IGNORED
346/// — is testable without a filesystem.
347fn probe_cache_adopt(text: &str) {
348 for line in text.lines() {
349 let Some((key, verdict)) = line.rsplit_once('\t') else {
350 continue;
351 };
352 let winner = match verdict.trim() {
353 "gpu" => 1u8,
354 "cpu" => 2u8,
355 _ => continue,
356 };
357 for (i, name) in CLASS_NAMES.iter().enumerate() {
358 if probe_cache_key_named(name) == key {
359 let _ = PROBES[i].state.compare_exchange(
360 0,
361 winner,
362 Ordering::Relaxed,
363 Ordering::Relaxed,
364 );
365 tracing::debug!("gpu probe [{name}]: remembered → {verdict}");
366 }
367 }
368 }
369}
370
371/// Remember a verdict for the next run. Best-effort: a read-only cache
372/// directory costs a re-probe, never a failure.
373fn probe_cache_store(c: OpClass, winner: u8) {
374 let Some(path) = probe_cache_path() else {
375 return;
376 };
377 let line = format!(
378 "{}\t{}\n",
379 probe_cache_key_named(CLASS_NAMES[c as usize]),
380 if winner == 1 { "gpu" } else { "cpu" }
381 );
382 use std::io::Write;
383 if let Ok(mut f) = std::fs::OpenOptions::new()
384 .create(true)
385 .append(true)
386 .open(&path)
387 {
388 let _ = f.write_all(line.as_bytes());
389 }
390}
391
392/// Backends: note a one-off cost (weight upload, buffer-cache fill) so
393/// the probe discards this sample.
394/// Every buffer creation anywhere bumps this; the graph's bind-group
395/// cache treats any cold event as total invalidation — a stale bind
396/// group is silent corruption, a cleared cache is one re-encoded token.
397pub fn cold_epoch() -> u64 {
398 COLD_EPOCH.load(std::sync::atomic::Ordering::Relaxed)
399}
400static COLD_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
401
402pub(crate) fn probe_note_cold() {
403 COLD_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
404 PROBE_COLD.with(|c| c.set(true));
405}
406
407/// Peek the cold flag without consuming it (`probe_record` consumes).
408/// Contention heuristics use this: a slow COLD op is a one-off build
409/// cost, not evidence the device is busy.
410pub(crate) fn probe_was_cold() -> bool {
411 PROBE_COLD.with(|c| c.get())
412}
413
414/// Pipeline: mark the current layer (or −1 outside layers) for layer-split.
415pub fn set_layer(l: i64) {
416 CUR_LAYER.with(|c| c.set(l));
417}
418
419/// The layer `set_layer` last marked on this thread (−1 outside layers).
420pub fn cur_layer() -> i64 {
421 CUR_LAYER.with(|c| c.get())
422}
423
424/// Capacity-derived layer prefix for per-op walks. The explicit
425/// `CMF_GPU_LAYERS` override is handled by the backend and takes precedence.
426pub fn automatic_layer_prefix(
427 model: &Arc<CmfModel>,
428 num_layers: usize,
429 physical_layers: usize,
430) -> Option<usize> {
431 match backend() {
432 #[cfg(feature = "gpu")]
433 Backend::Wgpu => {
434 crate::gpu_wgpu::automatic_layer_prefix(model, num_layers, physical_layers)
435 }
436 _ => None,
437 }
438}
439
440/// Parse `CMF_GPU_LAYERS` («0-19», «0,2,4», «0-9,30-39») once.
441/// None = no restriction (all layers on GPU). Garbage → also no restriction.
442fn layer_ranges() -> &'static Option<Vec<(i64, i64)>> {
443 static R: OnceLock<Option<Vec<(i64, i64)>>> = OnceLock::new();
444 R.get_or_init(|| {
445 let s = std::env::var("CMF_GPU_LAYERS").ok()?;
446 let mut v = Vec::new();
447 for part in s.split(',') {
448 let part = part.trim();
449 match part.split_once('-') {
450 Some((a, b)) => v.push((a.trim().parse().ok()?, b.trim().parse().ok()?)),
451 None => {
452 let x: i64 = part.parse().ok()?;
453 v.push((x, x));
454 }
455 }
456 }
457 Some(v)
458 })
459}
460
461fn layer_allowed() -> bool {
462 match layer_ranges() {
463 None => true,
464 Some(ranges) => {
465 let cur = CUR_LAYER.with(|c| c.get());
466 cur < 0 || ranges.iter().any(|(a, b)| cur >= *a && cur <= *b)
467 }
468 }
469}
470
471/// GPU allowed FOR THE CURRENT LAYER: backend is initialized AND the layer
472/// falls within `CMF_GPU_LAYERS` (GPU/CPU layer-split) AND we are not
473/// inside a `cpu_scope`. Op gates call this.
474pub fn enabled_here() -> bool {
475 !CPU_ONLY.with(|c| c.get()) && enabled() && layer_allowed()
476}
477
478/// Descriptor-aware q2tp Vulkan kernels are kept behind an explicit opt-in
479/// until the Prism full-graph/resident-weight path has a coherent generation
480/// gate. `CMF_GPU=1` alone must not silently turn synchronous per-op
481/// readbacks into the default model path; callers and validation tests can
482/// request the measured kernels with `CMF_Q2TP_GPU=1`.
483pub fn q2tp_gpu_opt_in() -> bool {
484 std::env::var("CMF_Q2TP_GPU").as_deref() == Ok("1")
485}
486
487// ── Runtime GPU-vs-CPU probe ────────────────────────────────────────────
488// CMF_GPU=1 does not TRUST that the device wins — it MEASURES. For each
489// op class the first calls alternate arms: GPU timed vs pure-CPU timed
490// (under cpu_scope). Cold GPU calls (weight upload / cache fill) are
491// discarded; after PROBE_SAMPLES clean samples per arm the faster arm is
492// chosen for the rest of the process. Rationale: submit+poll latency
493// differs by an order of magnitude across driver stacks (Metal/PCIe
494// ~3-4 ms, Vulkan/4090 ~0.3 ms) — a static threshold cannot know whether
495// per-op offload pays off HERE. CMF_GPU_PROBE=0 → always trust the GPU.
496
497/// GPU-eligible op classes, each with an independent probe.
498#[derive(Clone, Copy)]
499pub enum OpClass {
500 /// Whole FFN chain in one submission (dense / MoE block).
501 Ffn = 0,
502 /// Large hybrid CPU∥GPU matvec (lm_head class).
503 Matvec = 1,
504 /// Prefill GEMM (matmat).
505 Matmat = 2,
506 /// Batched matvecs of one input (QKV).
507 Batch = 3,
508 /// Prefill GEMM at image-diffusion widths (b ≥ 128). Probed apart
509 /// from `Matmat`: one imagegen process runs BOTH populations
510 /// (prompt encode b≈40 where the GPU wins big, DiT b≥256 where
511 /// the CPU AMX arm is competitive) — a single shared verdict locks
512 /// the wrong arm for whichever population samples second.
513 MatmatWide = 4,
514 /// The lm_head itself, apart from the merely-large matvecs. Same
515 /// reasoning as `MatmatWide`, and DeepSeek-V4 is where it bit: its
516 /// attention projections are 37M weights and its head is 529M, so
517 /// the projections' verdict — CPU, honestly measured at 0.19 ms —
518 /// decided for a matvec fourteen times their size that took 11 ms
519 /// a token on the host.
520 MatvecHead = 5,
521 /// The blocked f32 GEMM (`fcd_ops::gemm_nt`): attention's QKᵀ and
522 /// AV, and the VAE decoders' projections. It used to take every job
523 /// over 4 M MACs on sight, with no CPU arm to lose to — which on
524 /// the MiniMax-H3 video decoder was three times SLOWER than the
525 /// host it displaced. Its population is per-head slices, nothing
526 /// like the weight GEMMs above, so it probes on its own.
527 GemmNt = 6,
528 /// A host-routed MoE block. Kept separate from [`Self::Ffn`] because
529 /// its selected-weight pager has different memory and latency costs.
530 Moe = 7,
531}
532
533/// Which probe a large matvec belongs to. The head is an order of
534/// magnitude bigger than anything else that reaches this gate, and the
535/// two populations do not have the same answer.
536pub fn matvec_class(rows: usize, cols: usize) -> OpClass {
537 if rows * cols >= 67_108_864 {
538 OpClass::MatvecHead
539 } else {
540 OpClass::Matvec
541 }
542}
543
544/// Probe verdict for one call.
545pub enum ProbeArm {
546 /// Run the GPU path (during probing: timed, recorded).
547 Gpu,
548 /// Probing: run the CPU path under `cpu_scope`, timed, recorded.
549 CpuTimed,
550 /// Decided: CPU won — run the CPU path (under `cpu_scope`).
551 Cpu,
552}
553
554/// Clean samples per arm before a class decides.
555const PROBE_SAMPLES: u32 = 6;
556
557/// Declines before a class gives the work to the host for good. High
558/// enough that a transient refusal — an unsealed state during prefill, a
559/// shape the kernel skips this once — cannot settle the question.
560const PROBE_DECLINE_LIMIT: u32 = 16;
561
562/// Device samples discarded before any count — see `Probe::gpu_burn`.
563const PROBE_WARMUP: u32 = 1;
564
565struct Probe {
566 /// 0 = probing, 1 = GPU won, 2 = CPU won.
567 state: AtomicU8,
568 flip: AtomicU32,
569 gpu_ns: AtomicU64,
570 gpu_n: AtomicU32,
571 /// Times the device arm was chosen and the device DECLINED.
572 ///
573 /// A decline carries no timing, so nothing is recorded — and a class
574 /// whose device path always refuses therefore never reaches a
575 /// verdict, alternates arms forever, and pays a failed device
576 /// attempt on half of every token's calls. Measured on an M4 with
577 /// LFM2.5-2.6B: `ffn` was still undecided after 9000 calls, and a
578 /// token cost 83.55 ms against 41.85 with the device off — twice the
579 /// price for work the host did anyway.
580 declines: AtomicU32,
581 /// GPU samples still to discard as warm-up.
582 ///
583 /// The cold flag catches buffer and weight uploads, but a compute
584 /// pipeline is compiled on first use and not every creation site
585 /// raises it — the wgpu path has 21 pipeline creations against 12
586 /// cold notes. One uncaught shader compile is enough to lose a
587 /// class for the whole process: `gemm-nt` on an A100 was recorded at
588 /// 117.01 ms against the host's 3.19 and sent to the CPU, which
589 /// parked a 27B bake on 2.6 cores with the card idle. The decision
590 /// already uses each arm's BEST sample, so discarding the first
591 /// GPU sample costs one extra round trip and removes the whole
592 /// class of first-call artefacts.
593 gpu_burn: AtomicU32,
594 cpu_ns: AtomicU64,
595 cpu_n: AtomicU32,
596 /// Best (minimum) sample per arm. The DECISION compares these:
597 /// means are poisoned by one-off cold costs the cold-flag cannot
598 /// see — e.g. the CPU arm's first mmap-cold expert matvec page
599 /// faults its weights in and reads 3× its steady state, which
600 /// locked the GPU arm on a 35B MoE at a 4× real-world loss. The
601 /// minimum is each arm's honest steady-state pace.
602 gpu_min: AtomicU64,
603 cpu_min: AtomicU64,
604}
605
606impl Probe {
607 const fn new() -> Self {
608 Self {
609 state: AtomicU8::new(0),
610 flip: AtomicU32::new(0),
611 gpu_ns: AtomicU64::new(0),
612 gpu_n: AtomicU32::new(0),
613 declines: AtomicU32::new(0),
614 gpu_burn: AtomicU32::new(PROBE_WARMUP),
615 cpu_ns: AtomicU64::new(0),
616 cpu_n: AtomicU32::new(0),
617 gpu_min: AtomicU64::new(u64::MAX),
618 cpu_min: AtomicU64::new(u64::MAX),
619 }
620 }
621}
622
623static PROBES: [Probe; 8] = [
624 Probe::new(),
625 Probe::new(),
626 Probe::new(),
627 Probe::new(),
628 Probe::new(),
629 Probe::new(),
630 Probe::new(),
631 Probe::new(),
632];
633
634/// A caller that knows its loop is long, uniform and warm can say so: the
635/// probe times ops in isolation and alternates arms to do it, which reads a
636/// sustained diffusion step as slower on the device than it is. Measured on
637/// an M4 at 672 video tokens: the probe picked the CPU at 1.25 ms against
638/// 0.88 ms per op, and the loop it picked for ran 23.9 s a step against the
639/// device's 19.7 s.
640static TRUST_GPU: AtomicBool = AtomicBool::new(false);
641
642/// Take the probe out of the loop until the guard drops.
643pub fn trust_gpu() -> GpuTrust {
644 let was = TRUST_GPU.swap(true, Ordering::Relaxed);
645 GpuTrust(was)
646}
647
648pub struct GpuTrust(bool);
649
650impl Drop for GpuTrust {
651 fn drop(&mut self) {
652 TRUST_GPU.store(self.0, Ordering::Relaxed);
653 }
654}
655
656fn probe_on_for(c: OpClass) -> bool {
657 // The trust is only for the *wide* class. A sustained diffusion step is
658 // where the probe reads a warm device as cold; the narrow batches inside
659 // the same loop — an audio stream of fifty-one tokens against the same
660 // weights — are small enough that submit latency can genuinely beat the
661 // arithmetic, and there the probe is right and should keep deciding.
662 if TRUST_GPU.load(Ordering::Relaxed) && matches!(c, OpClass::MatmatWide | OpClass::Ffn) {
663 return false;
664 }
665 probe_on()
666}
667
668/// Is the per-op GPU/CPU probe enabled (`CMF_GPU_PROBE`, default on)?
669/// The native Metal decode route never consults it — `q1_force` routes
670/// the token graph to the device outright — so it is reported, not used.
671pub fn probe_enabled() -> bool {
672 probe_on()
673}
674
675fn probe_on() -> bool {
676 static ON: OnceLock<bool> = OnceLock::new();
677 *ON.get_or_init(|| {
678 std::env::var("CMF_GPU_PROBE")
679 .map(|v| v != "0" && v != "off")
680 .unwrap_or(true)
681 })
682}
683
684/// q1 ops on the native Metal backend skip the probe entirely: the CPU
685/// q1 kernel is load-port-bound, the GPU one wins warm — and probe
686/// alternation itself cools the device between samples (measured: block
687/// times 5.8 ms warm vs 8.8 ms mixed). Other backends keep probing.
688pub fn q1_force() -> bool {
689 #[cfg(target_os = "macos")]
690 {
691 backend() == Backend::Metal
692 }
693 #[cfg(not(target_os = "macos"))]
694 {
695 false
696 }
697}
698
699/// Should a FUSED whole-block path trust the device instead of asking
700/// the per-op probe? True on native Metal and on discrete wgpu adapters.
701///
702/// The probe answers "is one wide matmat faster on the GPU", and for the
703/// DiT on Metal that is a coin flip — measured 2.62 ms GPU vs 2.56 ms
704/// CPU, a 2% spread that lands on either arm run to run. But the fused
705/// block's advantage is not per-op speed, it is that the hidden state,
706/// the packs and the attention panels never leave the device: end to end
707/// the whole-block path renders a 512² Lumina step in ~5.4 s against
708/// ~8.4 s when the probe happens to pick the CPU. Gating a fusion win on
709/// a per-op tie made every second render half-speed at random.
710///
711/// On a discrete card the verdict is never in doubt — an RTX 3090 against
712/// a 256-core EPYC measured 11.5 ms vs 31 ms per wide op, four runs out
713/// of four — so the probe's sampling phase is pure cost: it alone was 10%
714/// of a 512² render (74.3 s against 66.9 s with the probe off). Integrated
715/// and mobile adapters keep probing; there the submit latency is real and
716/// can genuinely lose.
717pub fn fused_block_trusted() -> bool {
718 #[cfg(target_os = "macos")]
719 if backend() == Backend::Metal {
720 return true;
721 }
722 wgpu_graph_default()
723}
724
725/// Which arm should this GPU-eligible call take? Consult AFTER the
726/// eligibility gates (`enabled_here` / `min_rows`) so only real
727/// candidates alternate.
728/// While a class is still probing, a call whose weights are NOT yet on
729/// the card should take the GPU arm anyway: the upload is work the next
730/// step needs regardless, and the sample it produces is discarded as
731/// cold — so handing that call to the CPU arm buys nothing and costs a
732/// host GEMM. Measured on a diffusion stack, where every layer is
733/// touched once per step and therefore EVERY first-step GPU sample is
734/// cold: one projection drew the CPU arm for the whole first step, 9.8 s
735/// against the 2.8 s it costs once the weights are warm.
736pub fn weight_is_resident(model: &Arc<CmfModel>, idx: usize) -> bool {
737 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
738 {
739 return crate::gpu_wgpu::weight_is_resident(model, idx);
740 }
741 #[cfg(not(all(feature = "gpu", not(target_os = "macos"))))]
742 {
743 let _ = (model, idx);
744 true
745 }
746}
747
748pub fn probe_arm_cold_prefers_gpu(c: OpClass, weights_resident: bool) -> ProbeArm {
749 if !weights_resident && probe_deciding(c) {
750 return ProbeArm::Gpu;
751 }
752 probe_arm(c)
753}
754
755pub fn probe_arm(c: OpClass) -> ProbeArm {
756 // Every arbitrated call starts with a clean cold flag: both the
757 // sample discard in `probe_record` and the contention kill-switch
758 // read it AFTER the op, so a stale note from a previous call on
759 // this thread must not leak in.
760 PROBE_COLD.with(|f| f.set(false));
761 if !probe_on_for(c) {
762 return ProbeArm::Gpu;
763 }
764 probe_cache_load();
765 let p = &PROBES[c as usize];
766 match p.state.load(Ordering::Relaxed) {
767 1 => ProbeArm::Gpu,
768 2 => ProbeArm::Cpu,
769 _ => {
770 if p.flip.fetch_add(1, Ordering::Relaxed) % 2 == 0 {
771 ProbeArm::Gpu
772 } else {
773 ProbeArm::CpuTimed
774 }
775 }
776 }
777}
778
779/// The device arm was chosen and the device refused the work, so there
780/// is no time to record. Callers that fall through to the host MUST say
781/// so here, or the class can never decide.
782pub fn probe_note_decline(c: OpClass) {
783 let p = &PROBES[c as usize];
784 if p.state.load(Ordering::Relaxed) != 0 {
785 return;
786 }
787 let n = p.declines.fetch_add(1, Ordering::Relaxed) + 1;
788 if n >= PROBE_DECLINE_LIMIT
789 && p.state
790 .compare_exchange(0, 2, Ordering::Relaxed, Ordering::Relaxed)
791 .is_ok()
792 {
793 tracing::info!(
794 "gpu probe [{}]: device declined {n} times → cpu",
795 CLASS_NAMES[c as usize]
796 );
797 }
798}
799
800/// Record a timed arm sample; on the `PROBE_SAMPLES`-th clean sample of
801/// BOTH arms the class decides for the rest of the process.
802pub fn probe_record(c: OpClass, gpu: bool, dur: std::time::Duration) {
803 probe_record_into(
804 &PROBES[c as usize],
805 CLASS_NAMES[c as usize],
806 Some(c),
807 gpu,
808 dur,
809 )
810}
811
812/// The body of `probe_record` over ONE probe, so the decision can be
813/// driven in a test without touching the process-wide array.
814fn probe_record_into(
815 p: &Probe,
816 class_name: &str,
817 cache: Option<OpClass>,
818 gpu: bool,
819 dur: std::time::Duration,
820) {
821 if p.state.load(Ordering::Relaxed) != 0 {
822 return;
823 }
824 if gpu && PROBE_COLD.with(|f| f.replace(false)) {
825 return; // one-off cost in this call — not a steady-state sample
826 }
827 if gpu {
828 // Load-then-store rather than fetch_sub: a blind decrement at
829 // zero wraps a u32 to its maximum and mutes the arm forever.
830 // A benign race here burns one extra sample, which is free.
831 let left = p.gpu_burn.load(Ordering::Relaxed);
832 if left > 0 {
833 p.gpu_burn.store(left - 1, Ordering::Relaxed);
834 return; // warm-up: the first device sample builds its pipeline
835 }
836 }
837 let ns = dur.as_nanos().min(u64::MAX as u128) as u64;
838 if gpu {
839 p.gpu_ns.fetch_add(ns, Ordering::Relaxed);
840 p.gpu_n.fetch_add(1, Ordering::Relaxed);
841 p.gpu_min.fetch_min(ns, Ordering::Relaxed);
842 } else {
843 p.cpu_ns.fetch_add(ns, Ordering::Relaxed);
844 p.cpu_n.fetch_add(1, Ordering::Relaxed);
845 p.cpu_min.fetch_min(ns, Ordering::Relaxed);
846 }
847 let (gn, cn) = (
848 p.gpu_n.load(Ordering::Relaxed),
849 p.cpu_n.load(Ordering::Relaxed),
850 );
851 if gn >= 2 && cn >= 2 {
852 // Decide on each arm's BEST sample — the steady-state pace.
853 // Means carry one-off cold costs (mmap page-in on the CPU arm)
854 // that the cold-flag machinery cannot see.
855 let g = p.gpu_min.load(Ordering::Relaxed) as f64;
856 let cp = p.cpu_min.load(Ordering::Relaxed) as f64;
857 // Early verdict on a ≥2× gap — no reason to keep feeding the
858 // losing arm; close races take the full sample count. It was 3×,
859 // and the cost of that half-octave was measured: a DiT whose
860 // wide GEMMs run 11.4 ms on the device against 32.2 on the host
861 // (2.8×) kept ALTERNATING through the whole diffusion stack, and
862 // because the alternation counter is shared per class in call
863 // order, one projection drew the CPU arm every single time — 9.9
864 // seconds a step on a kernel that needs 0.4. Both arms are
865 // compared on their BEST sample, so a 2× gap is not noise.
866 if (gn < PROBE_SAMPLES || cn < PROBE_SAMPLES) && g < cp * 2.0 && cp < g * 2.0 {
867 return;
868 }
869 let winner = if g <= cp { 1 } else { 2 };
870 if p.state
871 .compare_exchange(0, winner, Ordering::Relaxed, Ordering::Relaxed)
872 .is_ok()
873 {
874 tracing::info!(
875 "gpu probe [{}]: gpu {:.2} ms vs cpu {:.2} ms per op → {}",
876 class_name,
877 g / 1e6,
878 cp / 1e6,
879 if winner == 1 { "gpu" } else { "cpu" },
880 );
881 if let Some(c) = cache {
882 probe_cache_store(c, winner);
883 }
884 }
885 }
886}
887
888/// Is the class still collecting samples? (Call sites use this to route
889/// cold-weight calls away from the GPU arm during probing.)
890pub fn probe_deciding(c: OpClass) -> bool {
891 probe_on_for(c) && PROBES[c as usize].state.load(Ordering::Relaxed) == 0
892}
893
894/// Probing helper: true — tensor `idx`'s quant weights are ALREADY
895/// device-resident (a clean GPU sample is possible now); false — they
896/// were not (the upload starts within the VRAM budget, so a later call
897/// finds them warm) or the tensor cannot go to the GPU at all. Keeps the
898/// probe from billing a full cold dispatch+readback to a sample it will
899/// discard anyway. The verdict needs only a couple of warm tensors, so
900/// probe-driven uploads are capped — the losing-GPU machine should not
901/// pay for uploading the whole layer stack it will never use; if the GPU
902/// wins, the rest uploads lazily on demand, in the same first-touch order.
903#[allow(unused_variables)]
904pub fn q8_resident_or_upload(model: &Arc<CmfModel>, idx: usize) -> bool {
905 static PROBE_UPLOADS: AtomicU32 = AtomicU32::new(0);
906 let may_upload = PROBE_UPLOADS.load(Ordering::Relaxed) < 4;
907 let resident = match backend() {
908 #[cfg(target_os = "macos")]
909 Backend::Metal => crate::gpu_metal::q8_resident_or_upload(model, idx, may_upload),
910 #[cfg(feature = "gpu")]
911 Backend::Wgpu => crate::gpu_wgpu::q8_resident_or_upload(model, idx, may_upload),
912 Backend::None => false,
913 };
914 if !resident && may_upload {
915 PROBE_UPLOADS.fetch_add(1, Ordering::Relaxed);
916 }
917 resident
918}
919
920/// Test hook: reset all probes to the undecided state.
921#[cfg(test)]
922pub(crate) fn probe_reset() {
923 for p in &PROBES {
924 p.state.store(0, Ordering::Relaxed);
925 p.flip.store(0, Ordering::Relaxed);
926 p.gpu_ns.store(0, Ordering::Relaxed);
927 p.gpu_n.store(0, Ordering::Relaxed);
928 p.cpu_ns.store(0, Ordering::Relaxed);
929 p.cpu_n.store(0, Ordering::Relaxed);
930 }
931}
932
933/// The probe table is process-global by design, while these unit tests reset
934/// and seed selected entries to exercise arbitration. Keep only those tests
935/// out of each other's way; production callers still probe concurrently.
936#[cfg(test)]
937static PROBE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
938
939#[cfg(test)]
940fn probe_test_guard() -> std::sync::MutexGuard<'static, ()> {
941 PROBE_TEST_LOCK
942 .lock()
943 .unwrap_or_else(std::sync::PoisonError::into_inner)
944}
945
946#[cfg(test)]
947mod probe_tests {
948 use super::*;
949 use std::time::Duration;
950
951 #[test]
952 fn cpu_only_whole_operator_dispatch_inherits_and_restores_scope() {
953 let pool = crate::pool::Pool::with_spin(3, 0);
954 cpu_scope(|| {
955 let inherit = inherit_cpu_scope();
956 pool.run_rows(64, &|_, _| {
957 let _guard = inherit();
958 assert!(CPU_ONLY.get());
959 });
960 });
961 pool.run_rows(64, &|_, _| assert!(!CPU_ONLY.get()));
962 }
963
964 // One test fn: PROBES is process-global and probe_reset touches all
965 // classes — parallel test threads would race.
966 #[test]
967 fn probe_alternates_discards_cold_and_decides() {
968 let _probe_guard = probe_test_guard();
969 probe_reset();
970 // Probing: arms alternate.
971 assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::Gpu));
972 assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::CpuTimed));
973
974 // A cold GPU sample (upload noted) must be discarded: feed a
975 // catastrophic cold sample, then clean fast-GPU samples — GPU
976 // wins only if the cold one did not count.
977 probe_note_cold();
978 probe_record(OpClass::Ffn, true, Duration::from_secs(1000));
979 for _ in 0..PROBE_SAMPLES {
980 probe_record(OpClass::Ffn, true, Duration::from_millis(1));
981 probe_record(OpClass::Ffn, false, Duration::from_millis(4));
982 }
983 assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::Gpu));
984
985 // The reverse: a class where the CPU arm is faster decides CPU.
986 for _ in 0..PROBE_SAMPLES {
987 probe_record(OpClass::Matmat, true, Duration::from_millis(4));
988 probe_record(OpClass::Matmat, false, Duration::from_millis(1));
989 }
990 assert!(matches!(probe_arm(OpClass::Matmat), ProbeArm::Cpu));
991
992 // cpu_scope: gates off inside, restored after.
993 cpu_scope(|| CPU_ONLY.with(|c| assert!(c.get())));
994 CPU_ONLY.with(|c| assert!(!c.get()));
995 cpu_scope(|| {
996 cpu_scope(|| CPU_ONLY.with(|c| assert!(c.get())));
997 CPU_ONLY.with(|c| assert!(c.get()));
998 });
999 let _ = std::panic::catch_unwind(|| cpu_scope(|| panic!("scope test")));
1000 CPU_ONLY.with(|c| assert!(!c.get()));
1001 probe_reset();
1002 }
1003
1004 #[test]
1005 fn a_remembered_verdict_is_adopted_and_a_stranger_is_not() {
1006 let _probe_guard = probe_test_guard();
1007 // Probing is not free: on a Snapdragon 778G the deciding classes
1008 // cost minutes of wall clock before the first token, every
1009 // process, and reached the same verdict every time. The cache
1010 // exists so that price is paid once.
1011 //
1012 // The key is built from THIS process's device, never a name this
1013 // test sets: `probe_set_device` is first-writer-wins and on a Mac
1014 // the Metal backend may already have named the silicon before the
1015 // tests run — which is exactly how this test failed on CI while
1016 // passing locally. GemmNt on purpose: the arbitration test never
1017 // touches it, and both run in one process.
1018 let mine = probe_cache_key_named("gemm-nt");
1019 let state = || {
1020 PROBES[OpClass::GemmNt as usize]
1021 .state
1022 .load(Ordering::Relaxed)
1023 };
1024
1025 // Another device's verdict is not mine, whatever it claims.
1026 probe_cache_adopt("SomeOtherGPU/Vulkan\tgemm-nt\tgpu\n");
1027 assert_eq!(state(), 0);
1028 // Neither is one from another build of this engine.
1029 let older = mine.replacen(env!("CARGO_PKG_VERSION"), "0.0.0-old", 1);
1030 assert_ne!(older, mine);
1031 probe_cache_adopt(&format!("{older}\tgpu\n"));
1032 assert_eq!(state(), 0);
1033 // Mine is.
1034 probe_cache_adopt(&format!("{mine}\tcpu\n"));
1035 assert_eq!(state(), 2);
1036
1037 PROBES[OpClass::GemmNt as usize]
1038 .state
1039 .store(0, Ordering::Relaxed);
1040 }
1041}
1042
1043/// Default row threshold: the GPU takes only larger matrices (lm_head
1044/// class). Below it, the dispatch/readback cost does not pay off on unified memory.
1045pub const GPU_MIN_ROWS: usize = 65_536;
1046
1047/// Effective threshold: `CMF_GPU_MIN_ROWS` overrides. Defaults differ
1048/// by device class: on a DISCRETE card VRAM bandwidth pays off even for
1049/// FFN/QKV-class matrices (4096), on unified memory only lm_head-class
1050/// is worth the dispatch/readback (65536). Field case behind this: a
1051/// 35B model on an RTX 4090 saw ~0 offload because every layer matrix
1052/// sat below the old universal 65536.
1053pub fn min_rows() -> usize {
1054 if let Some(v) = std::env::var("CMF_GPU_MIN_ROWS")
1055 .ok()
1056 .and_then(|v| v.parse().ok())
1057 {
1058 return v;
1059 }
1060 if discrete() { 4096 } else { GPU_MIN_ROWS }
1061}
1062
1063/// Is the active backend a discrete card (PCIe VRAM)?
1064pub fn discrete() -> bool {
1065 match backend() {
1066 #[cfg(feature = "gpu")]
1067 Backend::Wgpu => crate::gpu_wgpu::is_discrete(),
1068 #[cfg(target_os = "macos")]
1069 Backend::Metal => false, // UMA by the init() guard
1070 Backend::None => false,
1071 }
1072}
1073
1074/// A single MoE-FFN job (an expert with its own weight), executed in one
1075/// submission: (rows, cols, idx, row_scale) for gate/up/down + prescaled
1076/// inputs + the down column scale + the blending weight.
1077pub struct MoeJob<'a> {
1078 pub gate: (usize, usize, usize, &'a [f32]),
1079 pub up: (usize, usize, usize, &'a [f32]),
1080 pub down: (usize, usize, usize, &'a [f32]),
1081 pub xs_gate: Vec<f32>,
1082 pub xs_up: Vec<f32>,
1083 pub down_col: &'a [f32],
1084 pub w: f32,
1085 /// q1 trio: scales live inside the 6-byte tiles (row_scale slices
1086 /// empty, xs raw f32). Backends without a q1 kernel refuse the job.
1087 pub q1: bool,
1088 /// q4_tiled trio: scales inside the 18-byte tiles (row_scale
1089 /// slices empty, xs raw f32) — the MoE-hybrid coder class.
1090 pub q4t: bool,
1091 /// q4tp trio: same raw-xs contract, 16-byte nibble stride and the scale
1092 /// on a per-row ladder. Without this the experts of a q4tp MoE model fall
1093 /// to the CPU while every other dtype rides the device.
1094 pub q4tp: bool,
1095 /// Mixed 2-bit profile: gate/up are q2tp (8-byte chunks, zero rung),
1096 /// down stays q4tp. Set together with `q4tp`; a backend without the
1097 /// 2-bit kernel must refuse the whole job.
1098 pub gu_q2: bool,
1099 /// The reference's `swiglu_limit`; 0 disables the clamp. A backend that
1100 /// cannot apply it must REFUSE the job rather than drop it silently —
1101 /// the difference only shows on saturating activations, which is the
1102 /// hardest kind of divergence to notice.
1103 pub swiglu_limit: f32,
1104 /// `GraphAct::code` of act(gate)·up: 0 SiLU, 1 exact GELU, 2 tanh-GELU
1105 /// (a Gemma dense FFN riding the single-job block), or 3 MiniMax M3's
1106 /// frozen SwiGLU-OAI contract. Code 3 is valid only with its exact 7.0
1107 /// `swiglu_limit`; a backend without the complete activation must refuse
1108 /// the job rather than reinterpret it as SiLU.
1109 pub act: u32,
1110}
1111
1112/// A single independent batch matvec (GDN projections of one input).
1113pub struct BatchJob<'a> {
1114 pub idx: usize,
1115 pub rows: usize,
1116 pub cols: usize,
1117 pub row_scale: &'a [f32],
1118 pub xs: Vec<f32>,
1119 /// Weight layout. Was a bare `q1: bool`, which could only ever spell two
1120 /// of the four and silently sent everything else back to the CPU — the
1121 /// GDN projections of a q4t/q4tp model never reached the device at all.
1122 pub layout: BatchLayout,
1123}
1124
1125/// Which kernel a batched matvec needs. q8 carries row scales in a side
1126/// buffer; the rest embed them in the payload and differ in stride.
1127#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1128pub enum BatchLayout {
1129 Q8,
1130 Q1,
1131 /// q1g: sign + scale planes, one scale per 128 (q1's raw-xs contract).
1132 Q1g,
1133 Q4t,
1134 Q4tp,
1135}
1136
1137#[derive(Clone, Copy, PartialEq, Eq)]
1138enum Backend {
1139 None,
1140 #[cfg(target_os = "macos")]
1141 Metal,
1142 #[cfg(feature = "gpu")]
1143 Wgpu,
1144}
1145
1146fn backend() -> Backend {
1147 #[cfg(feature = "gpu")]
1148 if crate::gpu_wgpu::selected() {
1149 return if crate::gpu_wgpu::enabled() {
1150 Backend::Wgpu
1151 } else {
1152 Backend::None
1153 };
1154 }
1155 #[cfg(target_os = "macos")]
1156 if crate::gpu_metal::enabled() {
1157 return Backend::Metal;
1158 }
1159 Backend::None
1160}
1161
1162/// GPU enabled and initialized on the selected backend?
1163/// Whether THIS build can bring a GPU up on THIS device: a compiled-in
1164/// backend plus a live adapter. The mobile FFI exposes it so an app can
1165/// tell "GPU off" from "GPU impossible" (a CPU-only .so ships no
1166/// backend at all). Cached after the first call.
1167pub fn backend_available() -> bool {
1168 #[cfg(target_os = "macos")]
1169 {
1170 // The Metal path is always compiled on macOS.
1171 true
1172 }
1173 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
1174 {
1175 static AVAIL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1176 *AVAIL.get_or_init(crate::gpu_wgpu::adapter_probe)
1177 }
1178 #[cfg(all(not(feature = "gpu"), not(target_os = "macos")))]
1179 {
1180 false
1181 }
1182}
1183
1184/// A process-wide, phase-scoped GPU gate. `cpu_scope` is thread-local and
1185/// the pool's workers do not inherit it, so a caller that wants a whole
1186/// *phase* off the device — a prompt encoder whose weights live in a part of
1187/// the file the hot loop never touches, on a machine that cannot keep both
1188/// wired — has to say so globally.
1189static GPU_PAUSED: PauseGate = PauseGate::new();
1190
1191/// The pause as a count of live guards, not a flag: with a flag, the first
1192/// guard to drop lifted every other holder's pause too — two overlapping
1193/// phases, or two tests of one binary (a bit-exact CPU reference whose
1194/// neighbour's guard drops mid-forward), put the device back early.
1195struct PauseGate(std::sync::atomic::AtomicUsize);
1196
1197impl PauseGate {
1198 const fn new() -> Self {
1199 Self(std::sync::atomic::AtomicUsize::new(0))
1200 }
1201
1202 fn enter(&self) {
1203 self.0.fetch_add(1, Ordering::Relaxed);
1204 }
1205
1206 fn leave(&self) {
1207 self.0.fetch_sub(1, Ordering::Relaxed);
1208 }
1209
1210 fn paused(&self) -> bool {
1211 self.0.load(Ordering::Relaxed) != 0
1212 }
1213}
1214
1215/// Park the device for every thread until the returned guard drops (and
1216/// every other live guard with it).
1217pub fn pause_gpu() -> GpuPause {
1218 GPU_PAUSED.enter();
1219 GpuPause(())
1220}
1221
1222pub struct GpuPause(());
1223
1224impl Drop for GpuPause {
1225 fn drop(&mut self) {
1226 GPU_PAUSED.leave();
1227 }
1228}
1229
1230pub fn enabled() -> bool {
1231 !GPU_PAUSED.paused() && backend() != Backend::None
1232}
1233
1234#[cfg(test)]
1235mod pause_gate_tests {
1236 use super::PauseGate;
1237
1238 /// Overlapping holders: the pause lasts until the last one leaves (a
1239 /// local gate, so the process-wide one other tests see is untouched).
1240 #[test]
1241 fn pause_holds_until_the_last_guard() {
1242 let g = PauseGate::new();
1243 assert!(!g.paused());
1244 g.enter();
1245 g.enter();
1246 g.leave();
1247 assert!(g.paused(), "one holder left, the other's pause was lifted");
1248 g.leave();
1249 assert!(!g.paused());
1250 }
1251}
1252
1253/// Default-on condition for the wgpu whole-token graph: the wgpu
1254/// backend on a DISCRETE adapter. NOT plain `enabled()` (macOS/Metal
1255/// must not pay a per-token layer scan for a graph its backend
1256/// refuses), and NOT integrated adapters: the graph's ~300 barriered
1257/// dispatches per token are cheap on desktop immediate-mode GPUs but
1258/// tiled mobile GPUs (Adreno/Mali) drain the pipeline at every barrier
1259/// — field report: 0.2 tok/s on-graph vs 15 tok/s on the CPU. On
1260/// integrated adapters the per-op probe path arbitrates each op class
1261/// against the CPU instead; CMF_GPU_WGPU_GRAPH=1 still forces the
1262/// graph anywhere.
1263/// Is the wgpu backend active at all (any adapter)? Eligibility gate
1264/// for the whole-token graph — whether it actually RUNS is decided by
1265/// `wgpu_graph_default` (trusted on discrete) or the generation race.
1266pub fn wgpu_active() -> bool {
1267 #[cfg(feature = "gpu")]
1268 {
1269 matches!(backend(), Backend::Wgpu)
1270 }
1271 #[cfg(not(feature = "gpu"))]
1272 {
1273 false
1274 }
1275}
1276
1277/// Which GPU this thread's engine calls address. Multi-card hosts hold
1278/// one wgpu context PER card (weights, KV mirrors and scratch live
1279/// inside a context, so per-device contexts give per-device caches for
1280/// free); this thread-local says which one is current. Default: the
1281/// process pin (CMF_GPU_ADAPTER) or 0 — so single-card runs behave
1282/// exactly as they always have.
1283pub fn default_device() -> usize {
1284 static D: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
1285 *D.get_or_init(|| {
1286 std::env::var("CMF_GPU_ADAPTER")
1287 .ok()
1288 .and_then(|v| v.trim().parse::<usize>().ok())
1289 .unwrap_or(0)
1290 })
1291}
1292
1293thread_local! {
1294 static CUR_DEV: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
1295}
1296
1297/// The device this thread is pinned to.
1298pub fn current_device() -> usize {
1299 CUR_DEV.with(|c| c.get()).unwrap_or_else(default_device)
1300}
1301
1302/// Pin this thread to a device. Server slots call it once per request;
1303/// the worker pool propagates it into its threads, so a dispatch begun
1304/// on card 1 does not finish on card 0.
1305pub fn set_current_device(i: usize) {
1306 CUR_DEV.with(|c| c.set(Some(i)));
1307}
1308
1309/// Run `f` with this thread pinned to `dev`, restoring the previous pin.
1310pub fn with_device<R>(dev: usize, f: impl FnOnce() -> R) -> R {
1311 let prev = CUR_DEV.with(|c| c.replace(Some(dev)));
1312 let r = f();
1313 CUR_DEV.with(|c| c.set(prev));
1314 r
1315}
1316
1317/// How many GPUs this process can address (wgpu adapter count; 1 on
1318/// Metal, 0 without a backend).
1319pub fn device_count() -> usize {
1320 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
1321 {
1322 return crate::gpu_wgpu::adapter_count();
1323 }
1324 #[cfg(not(all(feature = "gpu", not(target_os = "macos"))))]
1325 {
1326 usize::from(backend_available())
1327 }
1328}
1329
1330/// Weight budget of the current GPU in bytes; 0 when there is none and
1331/// u64::MAX on unified memory (where the OS pages shared RAM and the
1332/// question "does the model fit the card" has no separate answer).
1333pub fn vram_budget() -> u64 {
1334 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
1335 {
1336 return crate::gpu_wgpu::device_vram_budget();
1337 }
1338 #[cfg(not(all(feature = "gpu", not(target_os = "macos"))))]
1339 {
1340 if backend_available() { u64::MAX } else { 0 }
1341 }
1342}
1343
1344/// Bytes currently accounted as resident weight buffers on the active wgpu
1345/// adapter. This is the logical device-local weight set; physical driver
1346/// allocations are reported separately by the platform tools.
1347pub fn resident_bytes() -> u64 {
1348 #[cfg(feature = "gpu")]
1349 {
1350 if backend() == Backend::Wgpu {
1351 return crate::gpu_wgpu::resident_bytes();
1352 }
1353 }
1354 0
1355}
1356
1357/// Sealed O(1) device mirror count and logical bytes for one pipeline id.
1358/// Zero is returned when wgpu is unavailable or the sequence has not reached
1359/// an O(1) seal yet.
1360pub fn o1_device_stats(kv_id: u64) -> (usize, u64) {
1361 #[cfg(feature = "gpu")]
1362 {
1363 if backend() == Backend::Wgpu {
1364 return crate::gpu_wgpu::o1_device_stats(kv_id);
1365 }
1366 }
1367 let _ = kv_id;
1368 (0, 0)
1369}
1370
1371/// Device weight bytes uploaded so far (wgpu; 0 on other backends).
1372/// Steady-state windows must show a ZERO delta — growth mid-benchmark
1373/// means eviction/re-upload and disqualifies the number.
1374pub fn upload_bytes() -> u64 {
1375 #[cfg(feature = "gpu")]
1376 {
1377 return crate::gpu_wgpu::UPLOAD_BYTES.load(std::sync::atomic::Ordering::Relaxed);
1378 }
1379 #[cfg(not(feature = "gpu"))]
1380 0
1381}
1382
1383/// Measure a transient host-to-device upload when the wgpu backend is
1384/// compiled in. CPU-only builds keep the benchmark command available and
1385/// report no device measurement instead of referring to the gated module.
1386pub fn upload_bandwidth_probe(block: usize, rounds: usize) -> Option<f64> {
1387 #[cfg(feature = "gpu")]
1388 {
1389 return crate::gpu_wgpu::upload_bandwidth_probe(block, rounds);
1390 }
1391 let _ = (block, rounds);
1392 None
1393}
1394
1395/// Which half of the run is asking.
1396///
1397/// The phase exists because the graph is plausibly two decisions, not
1398/// one — but on the hardware measured so far it is only ever a decode
1399/// decision. On an Adreno 642L with bonsai-1.7b, from identical clean
1400/// starts and two repeats each: decode 11.6 tok/s without it and 0.72
1401/// with, while prefill is 4.2 either way. A first reading of 3.4 -> 18.0
1402/// for prefill did not survive a controlled re-run — it was a dirty
1403/// probe cache between configurations, not the graph, and the prefill
1404/// route through the graph is GDN-only in the first place, which this
1405/// dense model never takes.
1406#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1407pub enum GraphPhase {
1408 Prefill,
1409 Decode,
1410}
1411
1412/// The one place that decides whether the whole-token graph runs.
1413///
1414/// `CMF_GPU_WGPU_GRAPH`: `0` off everywhere, `prefill` only for the
1415/// prompt, anything else on everywhere. Unset: desktop-class GPUs take
1416/// it for both phases; phone-class UMA takes it for PREFILL only, which
1417/// is the measurement above rather than a guess — the per-op path keeps
1418/// decode, where it is seventeen times better.
1419pub fn wgpu_graph_on(phase: GraphPhase) -> bool {
1420 match std::env::var("CMF_GPU_WGPU_GRAPH").ok().as_deref() {
1421 Some("0") => false,
1422 Some("prefill") => phase == GraphPhase::Prefill,
1423 Some(_) => true,
1424 None => {
1425 if wgpu_graph_default() {
1426 return true;
1427 }
1428 // Integrated/mobile keeps the per-op path for BOTH phases —
1429 // unchanged, because the measurement that would have bought
1430 // prefill a graph did not reproduce. `=prefill` is there for
1431 // the device where it does; the default does not guess.
1432 let _ = phase;
1433 false
1434 }
1435 }
1436}
1437
1438pub fn wgpu_graph_default() -> bool {
1439 #[cfg(feature = "gpu")]
1440 {
1441 // Discrete cards always; Apple-silicon UMA on macOS too — desktop
1442 // -class GPUs where the graph measured ~2x the CPU on the Qwen3.6
1443 // family (M4: 13.3 tok/s against 7.3). Phone-class UMA (Android/
1444 // iOS builds) keeps the per-op probe path: tiled mobile GPUs have
1445 // turned the ~300-dispatch graph into seconds per token.
1446 matches!(backend(), Backend::Wgpu)
1447 && (crate::gpu_wgpu::discrete_active()
1448 || (cfg!(target_os = "macos") && crate::gpu_wgpu::adapter_up()))
1449 }
1450 #[cfg(not(feature = "gpu"))]
1451 {
1452 false
1453 }
1454}
1455
1456/// q8_row/q8_2f matvec, rows [row0, row0+rows). `xs` — prescaled by the column scale.
1457#[allow(clippy::too_many_arguments, unused_variables)]
1458pub fn q8_matvec_range(
1459 model: &Arc<CmfModel>,
1460 idx: usize,
1461 row0: usize,
1462 row_scale: &[f32],
1463 xs: &[f32],
1464 rows: usize,
1465 cols: usize,
1466 out: &mut [f32],
1467) -> bool {
1468 match backend() {
1469 #[cfg(target_os = "macos")]
1470 Backend::Metal => {
1471 crate::gpu_metal::q8_matvec_range(model, idx, row0, row_scale, xs, rows, cols, out)
1472 }
1473 #[cfg(feature = "gpu")]
1474 Backend::Wgpu => {
1475 crate::gpu_wgpu::q8_matvec_range(model, idx, row0, row_scale, xs, rows, cols, out)
1476 }
1477 Backend::None => false,
1478 }
1479}
1480
1481/// Decode-exact short q8_2f panel for the banked MiMo head.
1482pub(crate) fn q82_short_rows(
1483 model: &Arc<CmfModel>,
1484 idx: usize,
1485 xs: &[f32],
1486 b: usize,
1487 rows: usize,
1488 cols: usize,
1489 out: &mut [f32],
1490) -> bool {
1491 #[cfg(feature = "gpu")]
1492 if enabled_here() && backend() == Backend::Wgpu {
1493 return crate::gpu_wgpu::q82_short_rows(model, idx, xs, b, rows, cols, out);
1494 }
1495 let _ = (model, idx, xs, b, rows, cols, out);
1496 false
1497}
1498
1499/// GEMM of a prefill batch: `pre` — prescaled inputs row-major [b, cols],
1500/// out — row-major [b, rows].
1501#[allow(clippy::too_many_arguments, unused_variables)]
1502/// The two-field int8 GEMM with the column field left for the device.
1503/// wgpu only — Metal's int8 kernel takes a pre-scaled activation, so the
1504/// caller keeps that path when this returns `false`.
1505#[allow(clippy::too_many_arguments)]
1506pub fn q8_matmat_2f(
1507 model: &Arc<CmfModel>,
1508 idx: usize,
1509 row_scale: &[f32],
1510 col_field: &[f32],
1511 xs: &[f32],
1512 b: usize,
1513 rows: usize,
1514 cols: usize,
1515 out: &mut [f32],
1516) -> bool {
1517 #[allow(unreachable_patterns)]
1518 match backend() {
1519 #[cfg(feature = "gpu")]
1520 Backend::Wgpu => {
1521 crate::gpu_wgpu::q8_matmat_2f(model, idx, row_scale, col_field, xs, b, rows, cols, out)
1522 }
1523 _ => false,
1524 }
1525}
1526
1527pub fn q8_matmat(
1528 model: &Arc<CmfModel>,
1529 idx: usize,
1530 row_scale: &[f32],
1531 pre: &[f32],
1532 b: usize,
1533 rows: usize,
1534 cols: usize,
1535 out: &mut [f32],
1536) -> bool {
1537 match backend() {
1538 #[cfg(target_os = "macos")]
1539 Backend::Metal => {
1540 crate::gpu_metal::q8_matmat(model, idx, row_scale, pre, b, rows, cols, out)
1541 }
1542 #[cfg(feature = "gpu")]
1543 Backend::Wgpu => crate::gpu_wgpu::q8_matmat(model, idx, row_scale, pre, b, rows, cols, out),
1544 Backend::None => false,
1545 }
1546}
1547
1548/// q1 matvec: raw f32 activations, tile-embedded scales. Metal only
1549/// for now (wgpu q1 WGSL is queued); false = CPU fallback.
1550#[allow(unused_variables)]
1551pub fn q1_matvec(
1552 model: &Arc<CmfModel>,
1553 idx: usize,
1554 xs: &[f32],
1555 rows: usize,
1556 cols: usize,
1557 out: &mut [f32],
1558) -> bool {
1559 match backend() {
1560 #[cfg(target_os = "macos")]
1561 Backend::Metal => crate::gpu_metal::q1_matvec(model, idx, xs, rows, cols, out),
1562 #[cfg(feature = "gpu")]
1563 Backend::Wgpu => crate::gpu_wgpu::q1_matvec(model, idx, xs, rows, cols, out),
1564 Backend::None => false,
1565 }
1566}
1567
1568/// q1g matvec (sign + scale planes, raw f32 activations); the q1 kernel's
1569/// walk and sums, so a uniform-scale repack matches `q1_matvec` bit for
1570/// bit. false = CPU fallback.
1571#[allow(unused_variables)]
1572pub fn q1g_matvec(
1573 model: &Arc<CmfModel>,
1574 idx: usize,
1575 xs: &[f32],
1576 rows: usize,
1577 cols: usize,
1578 out: &mut [f32],
1579) -> bool {
1580 match backend() {
1581 #[cfg(target_os = "macos")]
1582 Backend::Metal => crate::gpu_metal::q1g_matvec(model, idx, xs, rows, cols, out),
1583 #[cfg(feature = "gpu")]
1584 Backend::Wgpu => crate::gpu_wgpu::q1g_matvec(model, idx, xs, rows, cols, out),
1585 Backend::None => false,
1586 }
1587}
1588
1589/// Whole attention sub-block on the wgpu token graph (drop-in for
1590/// `qwen_attention`): normed hidden in, O-projection out, resident device
1591/// K/V mirror. false = refusal / not the wgpu backend → CPU path.
1592#[allow(clippy::too_many_arguments)]
1593pub fn attn_dropin(
1594 model: &Arc<CmfModel>,
1595 kv_id: u64,
1596 layer: usize,
1597 normed: &[f32],
1598 wq_idx: usize,
1599 wk_idx: usize,
1600 wv_idx: usize,
1601 wo_idx: usize,
1602 q_norm: Option<&[f32]>,
1603 k_norm: Option<&[f32]>,
1604 late_qk_norm: bool,
1605 invf: &[f32],
1606 nh: usize,
1607 nkv: usize,
1608 hd: usize,
1609 rd: usize,
1610 hidden: usize,
1611 pos: usize,
1612 cap: usize,
1613 gemma: bool,
1614 eps: f32,
1615 cpu_k: &[Vec<f32>],
1616 cpu_v: &[Vec<f32>],
1617 out: &mut [f32],
1618) -> bool {
1619 match backend() {
1620 #[cfg(feature = "gpu")]
1621 Backend::Wgpu => crate::gpu_wgpu::attn_dropin_gpu(
1622 model, kv_id, layer, normed, wq_idx, wk_idx, wv_idx, wo_idx, q_norm, k_norm,
1623 late_qk_norm, invf, nh, nkv, hd, rd, hidden, pos, cap, gemma, eps, cpu_k, cpu_v, out,
1624 ),
1625 #[allow(unused_variables)]
1626 _ => false,
1627 }
1628}
1629
1630/// Descriptor operation attached to a graph weight. `None` is the default
1631/// for ordinary CMF files; Prism weights are admitted only when the token
1632/// graph carries this explicit transform contract.
1633#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1634pub enum GraphPrismOp {
1635 None,
1636 Forward,
1637 InverseEmbedding,
1638}
1639
1640/// Graph kind of a binary low-rank (`blr`, dtype 19) weight: two stage
1641/// dispatches (V, U) over the resident payload instead of one matvec. Only
1642/// the wgpu token graph builder asks for it (`QTensor::graph_weight_or_blr`).
1643pub const GRAPH_KIND_BLR: u8 = 11;
1644
1645/// One weight in the whole-token graph: tensor idx + a codec tag (0=q8_row,
1646/// 1=q1, 2=q4_tiled, 3=q1t, 4=f32, … 11=blr) + per-row scales (q8_row only) + the raw f32
1647/// data (kind 4 only — small unquantized projections like GDN in_proj_a/b).
1648pub struct GraphW<'a> {
1649 pub idx: usize,
1650 pub kind: u8,
1651 pub row_scale: &'a [f32],
1652 pub data: &'a [f32],
1653 pub prism: GraphPrismOp,
1654 pub affine: bool,
1655}
1656
1657/// A layer's token-mixing op: standard attention or a GDN (linear-attention)
1658/// block. The surrounding norms + SwiGLU FFN are common to both.
1659pub enum GraphAttn<'a> {
1660 Full {
1661 wq: GraphW<'a>,
1662 wk: GraphW<'a>,
1663 wv: GraphW<'a>,
1664 wo: GraphW<'a>,
1665 q_norm: Option<&'a [f32]>,
1666 k_norm: Option<&'a [f32]>,
1667 /// HunYuan dense: q/k norm after RoPE (rope-kernel flag bit 32).
1668 late_qk_norm: bool,
1669 /// (bq, bk, bv) attention biases (Qwen2). None ⇒ no bias.
1670 bias: Option<(&'a [f32], &'a [f32], &'a [f32])>,
1671 /// Qwen3.5 gated attention: wq emits 2·nh·hd (q||gate per head), the
1672 /// attention output is scaled by sigmoid(gate) before the O projection.
1673 output_gate: bool,
1674 cpu_k: &'a [Vec<f32>],
1675 cpu_v: &'a [Vec<f32>],
1676 /// Absolute position of `cpu_k`/`cpu_v` row 0
1677 /// (`LayerKvCache::base`): a sliding layer that keeps only its
1678 /// tail stores rows from there on. 0 for every untrimmed layer.
1679 cpu_base: usize,
1680 /// This layer's own attention geometry, when the model's layers do
1681 /// not share one (MiMo-V2: 4/8 KV heads, 128-wide V under 192-wide
1682 /// heads, sliding windows with learned sinks, two RoPE tables).
1683 /// None = the call-wide (nkv, hd, rd, invf), V as wide as K, full
1684 /// context and a plain softmax — the historical contract, whose
1685 /// kernels and dispatch are untouched.
1686 geom: Option<GraphAttnGeom<'a>>,
1687 /// Spark-X2.5 head-wise output gate: `self_attn.g_proj`
1688 /// `[num_heads, hidden]` against the attention's normed input; head
1689 /// h's output is scaled by sigmoid(g[h]) before the O projection.
1690 /// None = no such gate (every other model).
1691 head_gate: Option<GraphW<'a>>,
1692 },
1693 Gdn {
1694 qkv: GraphW<'a>,
1695 z: GraphW<'a>,
1696 a: GraphW<'a>,
1697 b: GraphW<'a>,
1698 out: GraphW<'a>,
1699 conv1d: &'a [f32],
1700 a_log: &'a [f32],
1701 dt_bias: &'a [f32],
1702 norm: &'a [f32],
1703 nv: usize,
1704 nk: usize,
1705 dk: usize,
1706 dv: usize,
1707 kk: usize,
1708 /// CPU recurrent state `[ring (kk-1)·cdim | S nv·dk·dv]` — seeds the
1709 /// device mirror when prefill ran on the host (o1 collection, CPU
1710 /// fallback): a zero-initialized device state at decode is exactly
1711 /// the "coherent but contextless" garble.
1712 cpu_state: &'a [f32],
1713 },
1714 /// LFM2 gated short convolution: a fused (B, C, x) projection, a
1715 /// depthwise causal conv over a (kernel−1)-deep per-channel ring,
1716 /// C-gating, and an output projection. This mixer is what most of an
1717 /// LFM2 stack is (22 of the 2.6B's 30 layers), and before it had a
1718 /// graph arm the whole model fell to the per-op path — ~100 submits
1719 /// a token, 22 tok/s on an A100 for a 1.4 GB file.
1720 ShortConv {
1721 /// [3·hidden, hidden] fused input projection.
1722 inp: GraphW<'a>,
1723 /// [hidden, hidden] output projection.
1724 out: GraphW<'a>,
1725 /// [hidden · kernel] depthwise taps, `[channel][tap]`, tap
1726 /// kernel−1 multiplying the current position.
1727 taps: &'a [f32],
1728 kernel: usize,
1729 /// CPU conv ring `[channel][kernel−1]`, slot 0 newest — seeds
1730 /// the device mirror when prefill ran on the host, which for
1731 /// this mixer is always (the batch graph declines it).
1732 cpu_state: &'a [f32],
1733 },
1734}
1735
1736/// Per-layer attention geometry for the wgpu graphs (see
1737/// `GraphAttn::Full::geom`). The layer's CPU cache keeps K rows `hd` wide
1738/// and V rows zero-padded to `hd`; the device mirror stores V `dv` wide,
1739/// and a windowed layer keeps a ring of the last positions only.
1740#[derive(Clone, Copy)]
1741pub struct GraphAttnGeom<'a> {
1742 /// KV heads of this layer (divides the Q heads).
1743 pub nkv: usize,
1744 /// Q/K head width of this layer. The call-wide `hd` for every model
1745 /// but Gemma-4, whose global layers are 512 wide over 256-wide sliding
1746 /// ones (`<= 512`, a multiple of 4).
1747 pub hd: usize,
1748 /// V head width, `4 <= dv <= hd`, a multiple of 4.
1749 pub dv: usize,
1750 /// Rotary width of this layer (NeoX half-split over `[0, rd)`).
1751 pub rd: usize,
1752 /// This layer's RoPE inverse frequencies (`rd / 2` of them).
1753 pub invf: &'a [f32],
1754 /// YaRN attention-amplitude factor applied after the rotary pair
1755 /// transform. `1.0` is ordinary RoPE; Mellum's global layers use
1756 /// `1.277258872…`. This is deliberately per-layer: local and global
1757 /// layers may have different RoPE tables and factors.
1758 pub rope_scale: f32,
1759 /// Positions a query sees, its own included (MiMo-V2 SWA: 128);
1760 /// None = the whole context.
1761 pub window: Option<usize>,
1762 /// Learned per-Q-head sink logits (gpt-oss / MiMo-V2): they join the
1763 /// softmax max and denominator and carry no value row.
1764 pub sink: Option<&'a [f32]>,
1765 /// Gemma-4's scale-less V norm: every V head is RMS-normalized (no
1766 /// weight) before it is cached, as `attention::vnorm_head` does.
1767 pub v_norm: bool,
1768}
1769
1770/// Activation of a graph layer's dense FFN, `act(gate)·up`. The code is
1771/// the word the kernels read (`silu_mul_pre`'s `_c`, the fused gate+up
1772/// kernels' `_p1`): 0 keeps the historical SiLU expression untouched.
1773#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1774pub enum GraphAct {
1775 Silu,
1776 /// Exact erf GELU (HF `gelu`), erf by Abramowitz–Stegun 7.1.26 — the
1777 /// formula `inference::erf_f32` uses on the host.
1778 GeluErf,
1779 /// tanh-approximated GELU (HF `gelu_pytorch_tanh`, the Gemma family):
1780 /// `inference::gelu_tanh` term for term.
1781 GeluTanh,
1782 /// MiniMax M3's checked SwiGLU-OAI contract. A backend may admit this
1783 /// only through an exact matching kernel; generic SiLU must never be used
1784 /// as a substitute.
1785 SwiGluOaiM3,
1786}
1787
1788impl GraphAct {
1789 pub fn code(self) -> u32 {
1790 match self {
1791 Self::Silu => 0,
1792 Self::GeluErf => 1,
1793 Self::GeluTanh => 2,
1794 Self::SwiGluOaiM3 => 3,
1795 }
1796 }
1797}
1798
1799/// Per-layer weights for the whole-token wgpu graph.
1800pub struct GraphLayer<'a> {
1801 pub input_norm: &'a [f32],
1802 pub attn: GraphAttn<'a>,
1803 pub post_norm: &'a [f32],
1804 pub ffn: GraphFfn<'a>,
1805 /// Gemma sandwich norms: the token mixer's output and the FFN's output
1806 /// are RMS-normed with these weights before their residual adds.
1807 pub attn_out_norm: Option<&'a [f32]>,
1808 pub ffn_out_norm: Option<&'a [f32]>,
1809 /// Gemma-4 `layer_scalar`: the residual stream is multiplied by it at
1810 /// the end of the layer.
1811 pub layer_scale: Option<f32>,
1812}
1813
1814/// A SwiGLU shared MoE expert whose intermediate width or packed layout does
1815/// not match the routed expert bank. Most MoE checkpoints put the shared
1816/// expert in the same packed bank as the routed experts, but Flint keeps its
1817/// 1024-wide shared expert beside 512-wide routed experts. Keeping this
1818/// descriptor separate lets the graph run the existing routed-bank kernels
1819/// and add the shared expert through the ordinary dense q4tp path, rather
1820/// than silently falling all the way back to per-op execution.
1821pub struct GraphMoeDetachedShared<'a> {
1822 pub gate: GraphW<'a>,
1823 pub up: GraphW<'a>,
1824 pub down: GraphW<'a>,
1825 pub width: usize,
1826}
1827
1828/// The FFN of one graph layer: a dense SwiGLU trio, or a routed MoE —
1829/// router + top-k selection + all selected experts run ON DEVICE (the
1830/// routing decision depends on the resident hidden state, so a CPU
1831/// round-trip per layer would forfeit the one-submit design).
1832pub enum GraphFfn<'a> {
1833 /// A singleton attention-only batch graph. Returns the post-attention
1834 /// residual, allowing a dynamic expert bank to own the FFN separately.
1835 AttentionOnly,
1836 Dense {
1837 gate: GraphW<'a>,
1838 up: GraphW<'a>,
1839 down: GraphW<'a>,
1840 /// act(gate)·up. The builders refuse an activation with no arm here
1841 /// (before this field the graph computed SiLU for any dense FFN).
1842 act: GraphAct,
1843 },
1844 Moe {
1845 /// Router logits weight (f32, kind 4) `[n_exp, hidden]`.
1846 router: GraphW<'a>,
1847 /// Shared-expert sigmoid gate (f32) `[1, hidden]`.
1848 shared_gate: GraphW<'a>,
1849 /// Per-expert q4_tiled directory indices `(gate, up, down)`;
1850 /// the SHARED expert rides as the LAST entry — the select
1851 /// kernel pins it with the sigmoid weight.
1852 experts: Vec<(usize, usize, usize)>,
1853 /// Routed experts (shared excluded).
1854 n_exp: usize,
1855 top_k: usize,
1856 inter: usize,
1857 /// Exact gate/up activation used by every uniformly packed routed
1858 /// expert (and its in-bank shared expert, if any). The graph builder
1859 /// verifies uniformity before encoding one kernel contract for the
1860 /// layer.
1861 act: GraphAct,
1862 norm_topk: bool,
1863 /// Expert weight layout, uniform across the layer: `false` =
1864 /// q4_tiled (18 B tiles, inline f16 scale), `true` = q4tp
1865 /// (16 B nibbles + a per-row ladder plane). The two differ only
1866 /// in where the scale comes from, so they share every kernel
1867 /// but the weight-staging block.
1868 q4tp: bool,
1869 /// `true` = the gate/up experts are `q2tp` (2-bit plane) while
1870 /// `down` stays q4tp — the mixed profile a 2-bit-class checkpoint
1871 /// converts into. Only meaningful with `q4tp: true`.
1872 gu_q2: bool,
1873 /// `true` = every expert's gate, up AND down is q8_2f (int8 body,
1874 /// f16 row scales, f16 column field; Mellum2.1's quality file).
1875 /// `q4tp` and `gu_q2` are false then. Its own kernels: each
1876 /// expert's input is multiplied by THAT expert's column field.
1877 q82: bool,
1878 /// LFM2-MoE / DeepSeek-V3 `noaux_tc` routing: per-expert sigmoid
1879 /// scores instead of a softmax. `sigmoid_denom_eps` carries the
1880 /// source architecture's selected-score renormalization floor.
1881 sigmoid: bool,
1882 sigmoid_denom_eps: f32,
1883 /// Per-expert SELECTION bias: added to the score for the top-k
1884 /// choice only — the mixing weights stay unbiased (noaux_tc).
1885 bias: Option<&'a [f32]>,
1886 /// Whether a shared expert rides as the last `experts` entry.
1887 /// LFM2-MoE has none; the select kernel then leaves slot `top_k`
1888 /// unwritten and the expert loop runs `top_k` slots, not +1.
1889 has_shared: bool,
1890 /// A separately evaluated, ungated shared SwiGLU expert. It is
1891 /// deliberately outside `experts`: those tensors cannot share the
1892 /// routed bank's stride when their intermediate width differs.
1893 detached_shared: Option<GraphMoeDetachedShared<'a>>,
1894 /// The shared expert carries a sigmoid gate (Qwen2/3-MoE). `false`
1895 /// with `has_shared`: the shared expert enters with weight 1
1896 /// (DeepSeek-V3 / HunYuan hy_v3) and `shared_gate` is a stand-in
1897 /// the select kernels ignore.
1898 shared_gated: bool,
1899 /// Multiplier on the routed mixing weights after the optional
1900 /// renormalization (`routed_scaling_factor`); 1.0 = none.
1901 route_scale: f32,
1902 },
1903}
1904
1905/// Outcome of one whole-token graph attempt. A failed attempt after sealed
1906/// O(1) state was admitted must not fall through to the stale CPU state.
1907#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1908pub enum TokenGraphOutcome {
1909 /// No command was committed; the caller may use its ordinary path.
1910 Declined,
1911 /// The graph completed and its hidden/logits output is valid.
1912 Completed,
1913 /// Sealed O(1) state was admitted and a later graph operation failed.
1914 Failed,
1915}
1916
1917/// Whole-token decode graph on wgpu: the entire layer stack in ONE submit,
1918/// hidden resident, one readback. Updates `h` in place.
1919/// `loop_norm_at`: virtual layer indices after which `final_norm` is applied
1920/// (Looped Transformer mid-stack norm). Empty for standard models.
1921#[allow(clippy::too_many_arguments)]
1922pub fn forward_token_graph(
1923 model: &Arc<CmfModel>,
1924 kv_id: u64,
1925 layers: &[GraphLayer],
1926 // Per-layer sealed o1 (Nystrom) state; Some = replace this layer's
1927 // exact attention with the O(1) kernels. wgpu only.
1928 o1: &[Option<Vec<crate::nystrom::O1DeviceView<'_>>>],
1929 o1_epoch: u64,
1930 invf: &[f32],
1931 h: &mut [f32],
1932 nh: usize,
1933 nkv: usize,
1934 hd: usize,
1935 attn_scale: f32,
1936 rd: usize,
1937 hidden: usize,
1938 inter: usize,
1939 position: usize,
1940 cap: usize,
1941 gemma: bool,
1942 eps: f32,
1943 lm_head: Option<(&GraphW, usize)>,
1944 final_norm: &[f32],
1945 logits: &mut Vec<f32>,
1946 loop_norm_at: &[usize],
1947 steps: usize,
1948 embed: Option<(&GraphW, usize, f32)>,
1949 ids_out: Option<&mut Vec<u32>>,
1950 // How many leading layers the graph ran (see the wgpu twin) — smaller
1951 // than layers.len() when the expert budget ended the device prefix.
1952 layers_run: Option<&mut usize>,
1953 // Absolute index of layers[0] in the model — the KV/state mirrors key
1954 // on it, so a layer SPAN (network split segment) shares mirrors with
1955 // a full-stack run instead of colliding at slot 0.
1956 layer_base: usize,
1957 // Read the final hidden back alongside the fused head's logits.
1958 hidden_too: bool,
1959) -> TokenGraphOutcome {
1960 match backend() {
1961 #[cfg(feature = "gpu")]
1962 Backend::Wgpu => crate::gpu_wgpu::forward_token_graph(
1963 model,
1964 kv_id,
1965 layers,
1966 o1,
1967 o1_epoch,
1968 invf,
1969 h,
1970 nh,
1971 nkv,
1972 hd,
1973 attn_scale,
1974 rd,
1975 hidden,
1976 inter,
1977 position,
1978 cap,
1979 gemma,
1980 eps,
1981 lm_head,
1982 final_norm,
1983 logits,
1984 loop_norm_at,
1985 steps,
1986 embed,
1987 ids_out,
1988 layers_run,
1989 layer_base,
1990 hidden_too,
1991 ),
1992 #[allow(unused_variables)]
1993 _ => {
1994 let _ = (
1995 attn_scale,
1996 lm_head,
1997 final_norm,
1998 logits,
1999 loop_norm_at,
2000 layers_run,
2001 layer_base,
2002 hidden_too,
2003 );
2004 TokenGraphOutcome::Declined
2005 }
2006 }
2007}
2008
2009/// Whole-token resident graph for the Embryo working model. Unlike the
2010/// generic graph this path accepts a packed f32 model and keeps both phase
2011/// recurrent state and the anchor KV cache on the device. `false` is an
2012/// honest capability refusal; callers must retain the CPU executor.
2013pub fn forward_embryo_graph(
2014 model: &Arc<EmbryoGraphModel>,
2015 kv_id: u64,
2016 hidden: &[f32],
2017 position: usize,
2018 logits: &mut Vec<f32>,
2019) -> bool {
2020 #[cfg(feature = "gpu")]
2021 if backend() == Backend::Wgpu {
2022 return crate::gpu_wgpu::forward_embryo_graph(model, kv_id, hidden, position, logits);
2023 }
2024 let _ = (model, kv_id, hidden, position, logits);
2025 false
2026}
2027
2028/// Speculative-verify tail for the batched graph: fold final-norm + lm_head
2029/// over every batch position and read all k logit rows back; the batch also
2030/// snapshots the GDN state per position for `gdn_spec_restore`.
2031#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2032pub enum BatchGraphOutcome {
2033 /// The graph declined before mutating persistent device state. Callers may
2034 /// safely use the existing per-position path.
2035 Declined,
2036 /// The complete batch committed and its readback succeeded.
2037 Completed,
2038 /// A batch that had admitted sealed O(1) state failed after admission.
2039 /// Falling back to CPU would mix two state machines, so the caller must
2040 /// abort and clear the sequence instead.
2041 Failed,
2042}
2043
2044pub struct SpecTail<'a> {
2045 pub lm: GraphW<'a>,
2046 pub lm_rows: usize,
2047 pub final_norm: &'a [f32],
2048 pub logits_out: &'a mut Vec<f32>,
2049}
2050
2051/// Batched prefill: k contiguous positions through the whole graph in one submit
2052/// (projections/FFN as GEMMs, attention/GDN looped over scratch). `h` is
2053/// [k·hidden] in/out; `positions` len k. wgpu only.
2054#[allow(clippy::too_many_arguments)]
2055pub fn forward_batch_graph(
2056 model: &Arc<CmfModel>,
2057 kv_id: u64,
2058 layers: &[GraphLayer],
2059 invf: &[f32],
2060 h: &mut [f32],
2061 nh: usize,
2062 nkv: usize,
2063 hd: usize,
2064 rd: usize,
2065 hidden: usize,
2066 inter: usize,
2067 positions: &[usize],
2068 cap: usize,
2069 gemma: bool,
2070 eps: f32,
2071 attn_scale: f32,
2072 k: usize,
2073 // Per-layer sealed O(1) device views. An empty slice means the ordinary
2074 // exact-KV path; otherwise it must have one entry per graph layer.
2075 o1: &[Option<Vec<crate::nystrom::O1DeviceView<'_>>>],
2076 o1_epoch: u64,
2077 spec: Option<SpecTail<'_>>,
2078 // Device-prefix mode (plain prefill only): Some = when the whole stack
2079 // does not fit the weight budget, run the leading layers that do — the
2080 // same prefix rule as the token graph — leave the boundary hidden in
2081 // `h` and report the count here; the caller runs the rest on the host.
2082 // None = all layers or a decline, as before.
2083 layers_run: Option<&mut usize>,
2084) -> BatchGraphOutcome {
2085 forward_batch_graph_at(model, kv_id, 0, layers, invf, h, nh, nkv, hd, rd, hidden, inter, positions, cap, gemma, eps, attn_scale, k, o1, o1_epoch, spec, layers_run)
2086}
2087
2088thread_local! {
2089 static MIMO_ATTN_SCRATCH: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
2090}
2091
2092pub(crate) fn mimo_attention_scratch_enabled() -> bool {
2093 MIMO_ATTN_SCRATCH.with(std::cell::Cell::get)
2094}
2095
2096/// Scoped diagnostic A/B switch; unlike process environment mutations it
2097/// cannot race a model's background workers. Restores on panic as well.
2098#[doc(hidden)]
2099pub fn mimo_attention_scratch_scope<R>(enabled: bool, f: impl FnOnce() -> R) -> R {
2100 struct Restore(bool);
2101 impl Drop for Restore {
2102 fn drop(&mut self) {
2103 MIMO_ATTN_SCRATCH.with(|v| v.set(self.0));
2104 }
2105 }
2106 let _restore = Restore(MIMO_ATTN_SCRATCH.with(|v| v.replace(enabled)));
2107 f()
2108}
2109
2110/// Gemma-4's prompt on the batched wgpu graph (`CMF_G4_BATCH=0` keeps the
2111/// chunked host prefill it used before).
2112pub fn g4_batch_on() -> bool {
2113 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2114 *ON.get_or_init(|| std::env::var("CMF_G4_BATCH").as_deref() != Ok("0"))
2115}
2116
2117/// q8_2f MoE experts on the wgpu token and batch graphs (`CMF_MOE_Q82=0`
2118/// keeps them off the graphs: every op on its own, the 0.8.15 route).
2119pub(crate) fn moe_q82_graph_on() -> bool {
2120 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2121 *ON.get_or_init(|| std::env::var("CMF_MOE_Q82").as_deref() != Ok("0"))
2122}
2123
2124thread_local! {
2125 static MIMO_Q8_SHORT: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
2126}
2127
2128pub(crate) fn mimo_q8_short_enabled() -> bool {
2129 MIMO_Q8_SHORT.with(std::cell::Cell::get)
2130}
2131
2132/// Diagnostic A/B switch for row-exact short q8 graph kernels.
2133#[doc(hidden)]
2134pub fn mimo_q8_short_scope<R>(enabled: bool, f: impl FnOnce() -> R) -> R {
2135 struct Restore(bool);
2136 impl Drop for Restore {
2137 fn drop(&mut self) {
2138 MIMO_Q8_SHORT.with(|v| v.set(self.0));
2139 }
2140 }
2141 let _restore = Restore(MIMO_Q8_SHORT.with(|v| v.replace(enabled)));
2142 f()
2143}
2144
2145/// Batched graph over a span whose first absolute layer is `layer_base`.
2146#[allow(clippy::too_many_arguments)]
2147pub fn forward_batch_graph_at(
2148 model: &Arc<CmfModel>,
2149 kv_id: u64,
2150 layer_base: usize,
2151 layers: &[GraphLayer],
2152 invf: &[f32],
2153 h: &mut [f32],
2154 nh: usize,
2155 nkv: usize,
2156 hd: usize,
2157 rd: usize,
2158 hidden: usize,
2159 inter: usize,
2160 positions: &[usize],
2161 cap: usize,
2162 gemma: bool,
2163 eps: f32,
2164 attn_scale: f32,
2165 k: usize,
2166 // Per-layer sealed O(1) device views. An empty slice means the ordinary
2167 // exact-KV path; otherwise it must have one entry per graph layer.
2168 o1: &[Option<Vec<crate::nystrom::O1DeviceView<'_>>>],
2169 o1_epoch: u64,
2170 spec: Option<SpecTail<'_>>,
2171 // Device-prefix mode (plain prefill only): Some = when the whole stack
2172 // does not fit the weight budget, run the leading layers that do — the
2173 // same prefix rule as the token graph — leave the boundary hidden in
2174 // `h` and report the count here; the caller runs the rest on the host.
2175 // None = all layers or a decline, as before.
2176 layers_run: Option<&mut usize>,
2177) -> BatchGraphOutcome {
2178 match backend() {
2179 #[cfg(feature = "gpu")]
2180 Backend::Wgpu => crate::gpu_wgpu::forward_batch_graph_at(
2181 model, kv_id, layer_base, layers, invf, h, nh, nkv, hd, rd, hidden, inter, positions,
2182 cap, gemma, eps, attn_scale, k, o1, o1_epoch, spec, layers_run,
2183 ),
2184 #[allow(unreachable_patterns)]
2185 _ => {
2186 let _ = (o1, o1_epoch, spec, layers_run);
2187 BatchGraphOutcome::Declined
2188 }
2189 }
2190}
2191
2192/// After a partial speculative acceptance: restore every GDN layer's device
2193/// state to the snapshot after batch position `slot`. `base_pos` is the
2194/// absolute position of the first verify row and `expected_layers` makes the
2195/// restore all-or-nothing across the model's recurrent layers. wgpu only.
2196pub fn gdn_spec_restore(kv_id: u64, slot: usize, base_pos: usize, expected_layers: usize) -> bool {
2197 #[cfg(feature = "gpu")]
2198 if backend() == Backend::Wgpu {
2199 return crate::gpu_wgpu::gdn_spec_restore(kv_id, slot, base_pos, expected_layers);
2200 }
2201 #[allow(unreachable_code)]
2202 {
2203 let _ = (kv_id, slot, base_pos, expected_layers);
2204 false
2205 }
2206}
2207
2208/// Re-point one exact-attention device mirror after a speculative round has
2209/// discarded unaccepted rows. The rows beyond `stored` remain allocated and
2210/// are overwritten by the next append; only the logical cursor moves. This
2211/// is the wgpu twin of Metal's existing mirror cursor helper and keeps the
2212/// MTP graph's speculative/device cache coherent with its real anchor.
2213pub fn graph_kv_set_stored(kv_id: u64, layer: usize, stored: usize) -> bool {
2214 #[cfg(feature = "gpu")]
2215 if backend() == Backend::Wgpu {
2216 return crate::gpu_wgpu::kv_mirror_set_stored(kv_id, layer, stored);
2217 }
2218 #[cfg(target_os = "macos")]
2219 if backend() == Backend::Metal {
2220 crate::gpu_metal::kv_mirror_set_stored(kv_id, layer, stored);
2221 return true;
2222 }
2223 false
2224}
2225
2226/// Rows the wgpu token graph's exact-attention mirror holds for one layer
2227/// (None: no wgpu mirror). Metal keeps its owner cache current per token
2228/// and reports None here.
2229pub fn graph_kv_stored(_kv_id: u64, _layer: usize) -> Option<usize> {
2230 #[cfg(feature = "gpu")]
2231 if backend() == Backend::Wgpu {
2232 return crate::gpu_wgpu::kv_mirror_stored(_kv_id, _layer);
2233 }
2234 None
2235}
2236
2237/// Does the wgpu token graph hold a device-resident recurrent state for
2238/// this layer (one the host `linear_state` has not seen)?
2239pub fn graph_state_resident(_kv_id: u64, _layer: usize) -> bool {
2240 #[cfg(feature = "gpu")]
2241 if backend() == Backend::Wgpu {
2242 return crate::gpu_wgpu::graph_state_resident(_kv_id, _layer);
2243 }
2244 false
2245}
2246
2247/// Copy rows back from the wgpu token graph's K/V mirrors in one submit:
2248/// for each `(layer, from, to)` the K and V rows `[from..to)`, position-major
2249/// (`[(to − from) × nkv × hd]` each).
2250pub fn graph_kv_read_rows(
2251 _kv_id: u64,
2252 _reqs: &[(usize, usize, usize)],
2253 _nkv: usize,
2254 _hd: usize,
2255) -> Option<Vec<(Vec<f32>, Vec<f32>)>> {
2256 #[cfg(feature = "gpu")]
2257 if backend() == Backend::Wgpu {
2258 return crate::gpu_wgpu::kv_mirror_read_rows(_kv_id, _reqs, _nkv, _hd);
2259 }
2260 None
2261}
2262
2263/// Rows `[from, to)` of one wgpu exact-attention mirror in the host
2264/// cache's layout (V zero-padded to `hd`), whatever the mirror's geometry
2265/// (narrow V, a sliding layer's ring). The third value is the first
2266/// position actually read: a ring returns zeros below it. None: no wgpu
2267/// mirror holding those rows at (nkv, hd).
2268pub fn graph_kv_pull_host(
2269 _kv_id: u64,
2270 _layer: usize,
2271 _from: usize,
2272 _to: usize,
2273 _nkv: usize,
2274 _hd: usize,
2275) -> Option<(Vec<f32>, Vec<f32>, usize)> {
2276 #[cfg(feature = "gpu")]
2277 if backend() == Backend::Wgpu {
2278 return crate::gpu_wgpu::kv_mirror_pull_host(_kv_id, _layer, _from, _to, _nkv, _hd);
2279 }
2280 None
2281}
2282
2283/// Drop the wgpu token graph's device K/V mirror for a pipeline.
2284pub fn graph_kv_reset(_kv_id: u64) {
2285 #[cfg(feature = "gpu")]
2286 if backend() == Backend::Wgpu {
2287 crate::gpu_wgpu::kv_mirror_reset(_kv_id);
2288 crate::gpu_wgpu::embryo_graph_reset(_kv_id);
2289 }
2290}
2291
2292/// Ternary (q1t) BASE matvec on the GPU — fills `out` with the base dot; the
2293/// caller adds the sparse overlay on the CPU. Metal only for now (wgpu q1t not
2294/// yet written → CPU fallback).
2295pub fn q1t_matvec(
2296 model: &Arc<CmfModel>,
2297 idx: usize,
2298 xs: &[f32],
2299 rows: usize,
2300 cols: usize,
2301 out: &mut [f32],
2302) -> bool {
2303 match backend() {
2304 #[cfg(target_os = "macos")]
2305 Backend::Metal => {
2306 if metal_q1t_enabled() {
2307 crate::gpu_metal::q1t_matvec(model, idx, xs, rows, cols, out)
2308 } else {
2309 false
2310 }
2311 }
2312 #[cfg(feature = "gpu")]
2313 Backend::Wgpu => crate::gpu_wgpu::q1t_matvec(model, idx, xs, rows, cols, out),
2314 Backend::None => false,
2315 }
2316}
2317
2318/// q4_block matvec on the GPU — wgpu only (Metal drives q4_block through the
2319/// whole-token graph, not a standalone matvec).
2320#[allow(unused_variables)]
2321pub fn q4b_matvec(
2322 model: &Arc<CmfModel>,
2323 idx: usize,
2324 xs: &[f32],
2325 rows: usize,
2326 cols: usize,
2327 out: &mut [f32],
2328) -> bool {
2329 match backend() {
2330 #[cfg(target_os = "macos")]
2331 Backend::Metal => false,
2332 #[cfg(feature = "gpu")]
2333 Backend::Wgpu => crate::gpu_wgpu::q4b_matvec(model, idx, xs, rows, cols, out),
2334 Backend::None => false,
2335 }
2336}
2337
2338/// q1t batched GEMM (prefill) — base + overlay on-device (Metal simdgroup or
2339/// wgpu register-blocked).
2340pub fn q1t_matmat(
2341 model: &Arc<CmfModel>,
2342 idx: usize,
2343 xs: &[f32],
2344 b: usize,
2345 rows: usize,
2346 cols: usize,
2347 out: &mut [f32],
2348) -> bool {
2349 match backend() {
2350 #[cfg(target_os = "macos")]
2351 // Batched prefill and single-token decode are both enabled. On the
2352 // real 14.8B Q1T model prefill PPL was within 0.3% of CPU (7.942 vs
2353 // 7.966), and the alignment-safe decode kernel reached 3.52e-6 max_rel.
2354 Backend::Metal => crate::gpu_metal::q1t_matmat(model, idx, xs, b, rows, cols, out),
2355 #[cfg(feature = "gpu")]
2356 Backend::Wgpu => crate::gpu_wgpu::q1t_matmat(model, idx, xs, b, rows, cols, out),
2357 Backend::None => false,
2358 }
2359}
2360
2361/// Native Metal Q1T switch. Enabled by default after the byte-packed Q1T
2362/// fields were changed to alignment-safe loads; keep an explicit emergency
2363/// fallback for device/driver diagnostics.
2364#[cfg(target_os = "macos")]
2365pub(crate) fn metal_q1t_enabled() -> bool {
2366 std::env::var("CMF_METAL_Q1T")
2367 .map(|v| v != "0" && !v.eq_ignore_ascii_case("off"))
2368 .unwrap_or(true)
2369}
2370
2371/// Batched q1 GEMM (prefill). wgpu only — Metal has its own block path.
2372pub fn q1_matmat(
2373 model: &Arc<CmfModel>,
2374 idx: usize,
2375 xs: &[f32],
2376 b: usize,
2377 rows: usize,
2378 cols: usize,
2379 out: &mut [f32],
2380) -> bool {
2381 match backend() {
2382 #[cfg(feature = "gpu")]
2383 Backend::Wgpu => crate::gpu_wgpu::q1_matmat(model, idx, xs, b, rows, cols, out),
2384 #[allow(unused_variables)]
2385 _ => false,
2386 }
2387}
2388
2389/// Batched q1g GEMM (prefill). wgpu only, like `q1_matmat`.
2390pub fn q1g_matmat(
2391 model: &Arc<CmfModel>,
2392 idx: usize,
2393 xs: &[f32],
2394 b: usize,
2395 rows: usize,
2396 cols: usize,
2397 out: &mut [f32],
2398) -> bool {
2399 match backend() {
2400 #[cfg(feature = "gpu")]
2401 Backend::Wgpu => crate::gpu_wgpu::q1g_matmat(model, idx, xs, b, rows, cols, out),
2402 #[allow(unused_variables)]
2403 _ => false,
2404 }
2405}
2406
2407/// Contention kill for the wide imagegen GEMM/FFN paths: one grossly
2408/// slow op under a work-proportional budget (fair-device ops are
2409/// ≤~100 ms even at 1024px) means another process owns the device —
2410/// verdicts are per-process, so CPU for the rest of this one.
2411static MM_KILL: AtomicBool = AtomicBool::new(false);
2412pub(crate) fn mm_killed() -> bool {
2413 MM_KILL.load(Ordering::Relaxed)
2414}
2415pub(crate) fn mm_kill() {
2416 MM_KILL.store(true, Ordering::Relaxed);
2417}
2418
2419/// Consecutive over-budget ops. ONE slow op is not contention: on a
2420/// 24 GB Mac running the 25.7 GB fl2va file the first ops after the
2421/// prompt encode page their weights in from the SSD and take seconds —
2422/// a field report (hololabs, HF discussion #2) had to neuter the kill
2423/// to keep the denoise on the GPU, and then measured 48 s/step where the
2424/// CPU fallback took >60. Contention is persistent; a page-in is not.
2425static MM_STRIKES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
2426const MM_STRIKES_TO_KILL: u32 = 3;
2427/// Whether the kill is armed at all. A one-shot phase whose slowness is
2428/// expected and not contention — the video prompt encoder streaming
2429/// 12 GB off the SSD on a 24 GB Mac (HF discussion #4: users had to
2430/// gut `mm_kill` to keep the denoise loop on the GPU) — disarms it and
2431/// re-arms it when the phase is over; strikes taken meanwhile are
2432/// forgotten.
2433static MM_ARMED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
2434
2435/// Disarm / re-arm the contention kill around a phase whose GEMMs are
2436/// slow for reasons that are not another process (see `MM_ARMED`).
2437pub fn mm_kill_arm(on: bool) {
2438 MM_ARMED.store(on, Ordering::Relaxed);
2439 if on {
2440 MM_STRIKES.store(0, Ordering::Relaxed);
2441 }
2442}
2443
2444/// The contention verdict for one wide op: `el` against its
2445/// work-proportional `budget`. `exempt` marks ops whose time is not
2446/// evidence — the cold probe, or a weight that was not resident before
2447/// the call and rode in with it. Kills after `MM_STRIKES_TO_KILL`
2448/// consecutive strikes; a within-budget op clears the count.
2449/// `CMF_MM_KILL=0` disables the kill entirely (the device is trusted).
2450pub(crate) fn mm_budget_check(
2451 what: &str,
2452 el: std::time::Duration,
2453 budget: std::time::Duration,
2454 exempt: bool,
2455) {
2456 if el <= budget {
2457 MM_STRIKES.store(0, Ordering::Relaxed);
2458 return;
2459 }
2460 if exempt || !MM_ARMED.load(Ordering::Relaxed) {
2461 return;
2462 }
2463 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2464 let on = *ON.get_or_init(|| std::env::var("CMF_MM_KILL").as_deref() != Ok("0"));
2465 let n = MM_STRIKES.fetch_add(1, Ordering::Relaxed) + 1;
2466 if !on {
2467 tracing::info!(
2468 "gpu {what} took {el:?} (budget {budget:?}) — over budget, CMF_MM_KILL=0 keeps the device"
2469 );
2470 return;
2471 }
2472 if n >= MM_STRIKES_TO_KILL {
2473 tracing::warn!(
2474 "gpu {what} took {el:?} (budget {budget:?}), {n} in a row — \
2475 device contended, CPU for the rest of the process (CMF_MM_KILL=0 to override)"
2476 );
2477 mm_kill();
2478 } else {
2479 tracing::info!(
2480 "gpu {what} took {el:?} (budget {budget:?}) — strike {n} of {MM_STRIKES_TO_KILL}"
2481 );
2482 }
2483}
2484
2485/// Fused DiT SwiGLU FFN on the device: g=X·W1ᵀ, u=X·W3ᵀ, silu(g)·u,
2486/// Causal chunk attention on the device: `b` queries against `s0 + b`
2487/// cached keys. wgpu only — Metal's chunk graph keeps attention inside
2488/// the resident block and never calls out.
2489#[allow(unused_variables, clippy::too_many_arguments)]
2490pub fn chunk_attend(
2491 q: &[f32],
2492 k: &[&[f32]],
2493 v: &[&[f32]],
2494 b: usize,
2495 s0: usize,
2496 nh: usize,
2497 nkv: usize,
2498 hd: usize,
2499 scale: f32,
2500 out: &mut [f32],
2501) -> bool {
2502 match backend() {
2503 #[cfg(feature = "gpu")]
2504 Backend::Wgpu => crate::gpu_wgpu::chunk_attend(q, k, v, b, s0, nh, nkv, hd, scale, out),
2505 #[allow(unreachable_patterns)]
2506 _ => false,
2507 }
2508}
2509
2510/// `chunk_attend` under a sliding window (`window` keys, the query's own
2511/// included). wgpu only; see `chunk_attend_windowed`.
2512#[allow(unused_variables, clippy::too_many_arguments)]
2513pub fn chunk_attend_win(
2514 q: &[f32],
2515 k: &[&[f32]],
2516 v: &[&[f32]],
2517 b: usize,
2518 s0: usize,
2519 nh: usize,
2520 nkv: usize,
2521 hd: usize,
2522 scale: f32,
2523 window: usize,
2524 out: &mut [f32],
2525) -> bool {
2526 match backend() {
2527 #[cfg(feature = "gpu")]
2528 Backend::Wgpu => {
2529 crate::gpu_wgpu::chunk_attend_win(q, k, v, b, s0, nh, nkv, hd, scale, window, out)
2530 }
2531 #[allow(unreachable_patterns)]
2532 _ => false,
2533 }
2534}
2535
2536/// Several projections of one host activation, read back in one trip:
2537/// `out` = [P0 | P1 | …], each `idxs` entry (tensor, rows). wgpu only.
2538#[allow(unused_variables)]
2539pub fn gemm_many_keep(
2540 model: &Arc<CmfModel>,
2541 idxs: &[(usize, usize)],
2542 xs: &[f32],
2543 b: usize,
2544 cols: usize,
2545 out: &mut [f32],
2546) -> bool {
2547 match backend() {
2548 #[cfg(feature = "gpu")]
2549 Backend::Wgpu => crate::gpu_wgpu::gemm_many_keep(model, idxs, xs, b, cols, out),
2550 #[allow(unreachable_patterns)]
2551 _ => false,
2552 }
2553}
2554
2555thread_local! {
2556 /// Set by the pipeline around a layer of Spark-X2.5's batched prefill:
2557 /// its int8 matrix-unit GEMMs may take the `zi_mm` tile, and the
2558 /// projections that read one activation share its upload (wgpu).
2559 static PREFILL_FAST_GEMM: Cell<bool> = const { Cell::new(false) };
2560}
2561
2562/// Restores the previous flag on drop.
2563pub struct PrefillFastGemmGuard(bool);
2564
2565impl Drop for PrefillFastGemmGuard {
2566 fn drop(&mut self) {
2567 PREFILL_FAST_GEMM.with(|c| c.set(self.0));
2568 }
2569}
2570
2571/// Let this thread's int8 matrix-unit GEMMs take the `zi_mm` tile for the
2572/// guard's lifetime (bit-identical to the 64x64 kernel; see
2573/// `gpu_wgpu::zi_gemm_route`).
2574pub fn enter_prefill_fast_gemm() -> PrefillFastGemmGuard {
2575 PrefillFastGemmGuard(PREFILL_FAST_GEMM.with(|c| c.replace(true)))
2576}
2577
2578/// Whether `enter_prefill_fast_gemm` is in force on this thread.
2579pub fn prefill_fast_gemm() -> bool {
2580 PREFILL_FAST_GEMM.with(|c| c.get())
2581}
2582
2583/// The largest finite f16. An operand the matrix-unit attention casts to
2584/// f16 must stay within it, or the cast overflows to inf.
2585pub const F16_MAX: f32 = 65504.0;
2586
2587/// max |x| over `xs`, or +inf when an entry is inf or NaN: the value the
2588/// f16 operand guards compare with `F16_MAX`. Sixteen independent lanes
2589/// and a flag, so it vectorizes (a single `fold` chain does not).
2590pub fn abs_max_or_inf(xs: &[f32]) -> f32 {
2591 let mut acc = [0f32; 16];
2592 let mut bad = [false; 16];
2593 let mut it = xs.chunks_exact(16);
2594 for c in &mut it {
2595 for ((a, f), &v) in acc.iter_mut().zip(bad.iter_mut()).zip(c) {
2596 let v = v.abs();
2597 // NaN compares false both ways: flagged, never taken.
2598 *f |= !(v < f32::INFINITY);
2599 *a = if v > *a { v } else { *a };
2600 }
2601 }
2602 let mut m = 0f32;
2603 for &v in it.remainder() {
2604 let v = v.abs();
2605 if !(v < f32::INFINITY) {
2606 return f32::INFINITY;
2607 }
2608 m = m.max(v);
2609 }
2610 if bad.iter().any(|&f| f) {
2611 return f32::INFINITY;
2612 }
2613 acc.iter().fold(m, |m, &a| m.max(a))
2614}
2615
2616/// The decode graph's device K/V mirror a prefill chunk of one layer also
2617/// writes to (wgpu): `kv_id` is the pipeline's graph id, `layer` the
2618/// virtual layer index, `limit` the cache's `max_seq_len` — the same key
2619/// and capacity ceiling the token graph uses, so the mirror the prefill
2620/// fills is the one the first decode token reads.
2621#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2622pub struct PrefillMirror {
2623 pub kv_id: u64,
2624 pub layer: usize,
2625 pub limit: usize,
2626}
2627
2628thread_local! {
2629 /// Set by the pipeline around one layer's batched attention (see
2630 /// `enter_prefill_mirror`); every other caller of the batched
2631 /// attention (MTP heads, tests) sees None and keeps the host upload.
2632 static PREFILL_MIRROR: Cell<Option<PrefillMirror>> = const { Cell::new(None) };
2633}
2634
2635/// Restores the previous target on drop.
2636pub struct PrefillMirrorGuard(Option<PrefillMirror>);
2637
2638impl Drop for PrefillMirrorGuard {
2639 fn drop(&mut self) {
2640 PREFILL_MIRROR.with(|c| c.set(self.0));
2641 }
2642}
2643
2644/// Name the device mirror the current layer's prefill chunk appends to
2645/// and attends against, for the guard's lifetime.
2646pub fn enter_prefill_mirror(t: PrefillMirror) -> PrefillMirrorGuard {
2647 PrefillMirrorGuard(PREFILL_MIRROR.with(|c| c.replace(Some(t))))
2648}
2649
2650/// The mirror `enter_prefill_mirror` named on this thread, if any.
2651pub fn prefill_mirror() -> Option<PrefillMirror> {
2652 PREFILL_MIRROR.with(|c| c.get())
2653}
2654
2655/// `chunk_attend_win` against the decode graph's device mirror instead of
2656/// an upload of the whole prefix: the host rows the mirror lacks (the
2657/// chunk's own `b`, or more after a host-only chunk) are appended first,
2658/// then the attention binds the mirror rows in place. `s0` is the chunk's
2659/// first position and `base` the position of host row 0
2660/// (`LayerKvCache::base`: past 0 on a trimmed sliding tail). `ring` is the
2661/// layer's window as the mirror geometry (Some on every sliding layer),
2662/// `window` the window the softmax masks with (0 while it masks nothing).
2663/// `operand_max` = max |x| over `q` and every K/V row the attend reads
2664/// (`abs_max_or_inf`; +inf when unknown): past `F16_MAX` the f32 kernels
2665/// run instead of the matrix units. wgpu only; false = nothing attended
2666/// (the caller uploads as before).
2667#[allow(unused_variables, clippy::too_many_arguments)]
2668pub fn chunk_attend_mirror(
2669 t: PrefillMirror,
2670 cpu_k: &[Vec<f32>],
2671 cpu_v: &[Vec<f32>],
2672 base: usize,
2673 q: &[f32],
2674 b: usize,
2675 s0: usize,
2676 nh: usize,
2677 nkv: usize,
2678 hd: usize,
2679 scale: f32,
2680 ring: Option<usize>,
2681 window: usize,
2682 operand_max: f32,
2683 out: &mut [f32],
2684) -> bool {
2685 match backend() {
2686 #[cfg(feature = "gpu")]
2687 // QKᵀ and P·V on the matrix units where the device has them (f16
2688 // operands, f32 accumulators — not bit-identical to the f32
2689 // kernels: Spark-X2.5 4B q8_2f wiki ppl 10.677 -> 10.672);
2690 // `CMF_PREFILL_ATTN_COOP=0` keeps the f32 ones (A/B).
2691 Backend::Wgpu => crate::gpu_wgpu::chunk_attend_mirror(
2692 t.kv_id,
2693 t.layer,
2694 t.limit,
2695 cpu_k,
2696 cpu_v,
2697 base,
2698 q,
2699 b,
2700 s0,
2701 nh,
2702 nkv,
2703 hd,
2704 scale,
2705 ring,
2706 window,
2707 std::env::var("CMF_PREFILL_ATTN_COOP").as_deref() != Ok("0"),
2708 operand_max,
2709 out,
2710 ),
2711 #[allow(unreachable_patterns)]
2712 _ => false,
2713 }
2714}
2715
2716/// `chunk_attend_mirror`, then the head gate (`gains`, b × nh, computed on
2717/// the host) and the O projection `wo` on the card: the attention output
2718/// never comes home. `out` = [b][hidden]. The card scans the O operand
2719/// itself, which is the host path's product bit for bit only while max
2720/// |gated output| <= 1000: the caller vouches for that (Spark-X2.5's
2721/// prefill, max |v| <= 990). wgpu only; false = nothing written to `out`
2722/// (the caller attends and projects as before).
2723#[allow(unused_variables, clippy::too_many_arguments)]
2724pub fn chunk_attend_mirror_wo(
2725 t: PrefillMirror,
2726 cpu_k: &[Vec<f32>],
2727 cpu_v: &[Vec<f32>],
2728 base: usize,
2729 q: &[f32],
2730 b: usize,
2731 s0: usize,
2732 nh: usize,
2733 nkv: usize,
2734 hd: usize,
2735 scale: f32,
2736 ring: Option<usize>,
2737 window: usize,
2738 operand_max: f32,
2739 gains: Option<&[f32]>,
2740 wo: (&Arc<CmfModel>, usize),
2741 hidden: usize,
2742 out: &mut [f32],
2743) -> bool {
2744 match backend() {
2745 #[cfg(feature = "gpu")]
2746 Backend::Wgpu => crate::gpu_wgpu::chunk_attend_mirror_wo(
2747 t.kv_id,
2748 t.layer,
2749 t.limit,
2750 cpu_k,
2751 cpu_v,
2752 base,
2753 q,
2754 b,
2755 s0,
2756 nh,
2757 nkv,
2758 hd,
2759 scale,
2760 ring,
2761 window,
2762 std::env::var("CMF_PREFILL_ATTN_COOP").as_deref() != Ok("0"),
2763 operand_max,
2764 gains,
2765 wo,
2766 hidden,
2767 out,
2768 ),
2769 #[allow(unreachable_patterns)]
2770 _ => false,
2771 }
2772}
2773
2774/// Whether the active backend's chunk attend can apply a sliding window.
2775pub fn chunk_attend_windowed() -> bool {
2776 match backend() {
2777 #[cfg(feature = "gpu")]
2778 Backend::Wgpu => true,
2779 #[allow(unreachable_patterns)]
2780 _ => false,
2781 }
2782}
2783
2784/// Fused QKV projection: one upload of the normed chunk, three GEMMs,
2785/// one readback of Q|K|V back to back. Metal has no twin yet — its
2786/// chunk graph keeps the whole layer resident and never surfaces QKV.
2787#[allow(unused_variables, clippy::too_many_arguments)]
2788pub fn q4t_qkv(
2789 model: &Arc<CmfModel>,
2790 wq: usize,
2791 wk: usize,
2792 wv: usize,
2793 xs: &[f32],
2794 b: usize,
2795 cols: usize,
2796 rq: usize,
2797 rk: usize,
2798 rv: usize,
2799 out: &mut [f32],
2800) -> bool {
2801 match backend() {
2802 #[cfg(feature = "gpu")]
2803 Backend::Wgpu => crate::gpu_wgpu::q4t_qkv(model, wq, wk, wv, xs, b, cols, rq, rk, rv, out),
2804 #[allow(unreachable_patterns)]
2805 _ => false,
2806 }
2807}
2808
2809/// y=·W2ᵀ — one command buffer, only X and Y cross the CPU boundary.
2810#[allow(unused_variables, clippy::too_many_arguments)]
2811/// SwiGLU FFN with a row-packed [gate|up] fc1 (MiniMax-H3's DiT), run
2812/// end to end on the device. wgpu only: Metal keeps the host loop until
2813/// its own packed kernel exists.
2814#[allow(clippy::too_many_arguments, unused_variables)]
2815pub fn q4tp_ffn_packed(
2816 model: &Arc<CmfModel>,
2817 w1: usize,
2818 w2: usize,
2819 xs: &[f32],
2820 b: usize,
2821 hidden: usize,
2822 inter: usize,
2823 bias: Option<&[f32]>,
2824 out: &mut [f32],
2825) -> bool {
2826 match backend() {
2827 #[cfg(feature = "gpu")]
2828 Backend::Wgpu => {
2829 crate::gpu_wgpu::ffn_packed(model, w1, w2, xs, b, hidden, inter, bias, out)
2830 }
2831 #[allow(unreachable_patterns)]
2832 _ => false,
2833 }
2834}
2835
2836pub fn q4tp_ffn(
2837 model: &Arc<CmfModel>,
2838 w1: usize,
2839 w3: usize,
2840 w2: usize,
2841 xs: &[f32],
2842 b: usize,
2843 hidden: usize,
2844 inter: usize,
2845 out: &mut [f32],
2846) -> bool {
2847 match backend() {
2848 #[cfg(target_os = "macos")]
2849 Backend::Metal => crate::gpu_metal::q4tp_ffn(model, w1, w3, w2, xs, b, hidden, inter, out),
2850 #[cfg(feature = "gpu")]
2851 Backend::Wgpu => crate::gpu_wgpu::q4tp_ffn(model, w1, w3, w2, xs, b, hidden, inter, out),
2852 #[allow(unreachable_patterns)]
2853 _ => false,
2854 }
2855}
2856
2857/// `q4tp_ffn` (`q4tp` = true) / `q4t_ffn` with the activation named:
2858/// SiLU takes the plain entry points (every backend), the exact GELU of
2859/// Spark-X2.5 has a wgpu arm only — other backends decline it.
2860#[allow(clippy::too_many_arguments, unused_variables)]
2861pub fn q4_ffn_act(
2862 model: &Arc<CmfModel>,
2863 w1: usize,
2864 w3: usize,
2865 w2: usize,
2866 xs: &[f32],
2867 b: usize,
2868 hidden: usize,
2869 inter: usize,
2870 q4tp: bool,
2871 act: GraphAct,
2872 out: &mut [f32],
2873) -> bool {
2874 match act {
2875 GraphAct::Silu if q4tp => q4tp_ffn(model, w1, w3, w2, xs, b, hidden, inter, out),
2876 GraphAct::Silu => q4t_ffn(model, w1, w3, w2, xs, b, hidden, inter, out),
2877 // The graph FFN's M3 OAI contract is WGPU-only. Metal reaches its
2878 // separate direct-MoE kernel through `moe_block`; falling through to
2879 // q4tp_ffn here would silently turn it into ordinary SiLU.
2880 GraphAct::GeluErf | GraphAct::GeluTanh | GraphAct::SwiGluOaiM3 => match backend() {
2881 #[cfg(feature = "gpu")]
2882 Backend::Wgpu => crate::gpu_wgpu::q4_ffn_act(
2883 model,
2884 w1,
2885 w3,
2886 w2,
2887 xs,
2888 b,
2889 hidden,
2890 inter,
2891 q4tp,
2892 act.code(),
2893 out,
2894 ),
2895 #[allow(unreachable_patterns)]
2896 _ => false,
2897 },
2898 }
2899}
2900
2901/// The LLM prefill's dense FFN for separate gate/up/down weights in any
2902/// codec with a device GEMM (int8, q4tp, or a mix of them): the panels
2903/// stay on the card between the projections. wgpu only; other backends
2904/// decline and the caller keeps its per-GEMM path.
2905#[allow(clippy::too_many_arguments, unused_variables)]
2906pub fn ffn_act_keep(
2907 model: &Arc<CmfModel>,
2908 w1: usize,
2909 w3: usize,
2910 w2: usize,
2911 xs: &[f32],
2912 b: usize,
2913 hidden: usize,
2914 inter: usize,
2915 act: GraphAct,
2916 out: &mut [f32],
2917) -> bool {
2918 match backend() {
2919 #[cfg(feature = "gpu")]
2920 Backend::Wgpu => crate::gpu_wgpu::ffn_act_keep(
2921 model,
2922 w1,
2923 w3,
2924 w2,
2925 xs,
2926 b,
2927 hidden,
2928 inter,
2929 act.code(),
2930 out,
2931 ),
2932 #[allow(unreachable_patterns)]
2933 _ => false,
2934 }
2935}
2936
2937/// Qwen Image's exact two-projection tanh-GELU FFN. The WGPU arm keeps the
2938/// intermediate on the device; other backends decline so the caller retains
2939/// its bounded CPU path. `bias_in` is applied before GELU and `bias_out`
2940/// after the second projection, matching the official transformer.
2941#[allow(clippy::too_many_arguments, unused_variables)]
2942pub fn q4tp_gelu_ffn(
2943 model: &Arc<CmfModel>,
2944 w_in: usize,
2945 w_out: usize,
2946 xs: &[f32],
2947 b: usize,
2948 hidden: usize,
2949 inter: usize,
2950 bias_in: &[f32],
2951 bias_out: &[f32],
2952 out: &mut [f32],
2953) -> bool {
2954 match backend() {
2955 #[cfg(feature = "gpu")]
2956 Backend::Wgpu => crate::gpu_wgpu::q4tp_gelu_ffn(
2957 model, w_in, w_out, xs, b, hidden, inter, bias_in, bias_out, out,
2958 ),
2959 #[allow(unreachable_patterns)]
2960 _ => false,
2961 }
2962}
2963
2964pub fn q4t_ffn(
2965 model: &Arc<CmfModel>,
2966 w1: usize,
2967 w3: usize,
2968 w2: usize,
2969 xs: &[f32],
2970 b: usize,
2971 hidden: usize,
2972 inter: usize,
2973 out: &mut [f32],
2974) -> bool {
2975 match backend() {
2976 #[cfg(target_os = "macos")]
2977 Backend::Metal => crate::gpu_metal::q4t_ffn(model, w1, w3, w2, xs, b, hidden, inter, out),
2978 #[cfg(feature = "gpu")]
2979 Backend::Wgpu => crate::gpu_wgpu::q4t_ffn(model, w1, w3, w2, xs, b, hidden, inter, out),
2980 #[allow(unreachable_patterns)]
2981 _ => false,
2982 }
2983}
2984
2985/// One whole modulated DiT block for `dit_block`: geometry, norm
2986/// weights, AdaLN scale/gate vectors (gates pre-tanh'd), a per-token
2987/// f32 RoPE cos/sin table, and the directory indices of the seven
2988/// q4t projections. `x` is in-out `[n, hidden]`.
2989pub struct DitBlockArgs<'a> {
2990 pub n: usize,
2991 pub hidden: usize,
2992 pub inter: usize,
2993 pub nh: usize,
2994 pub nkv: usize,
2995 pub hd: usize,
2996 pub eps: f32,
2997 pub rope_cos: &'a [f32],
2998 pub rope_sin: &'a [f32],
2999 pub norm1: &'a [f32],
3000 pub norm2: &'a [f32],
3001 pub ffn_norm1: &'a [f32],
3002 pub ffn_norm2: &'a [f32],
3003 pub norm_q: &'a [f32],
3004 pub norm_k: &'a [f32],
3005 pub s_msa: &'a [f32],
3006 pub gate_msa: &'a [f32],
3007 pub s_mlp: &'a [f32],
3008 pub gate_mlp: &'a [f32],
3009 pub wq: usize,
3010 pub wk: usize,
3011 pub wv: usize,
3012 pub wo: usize,
3013 pub w1: usize,
3014 pub w3: usize,
3015 pub w2: usize,
3016 /// The projections' layout: q4tp (ladder scales) vs plain q4_tiled.
3017 /// The recommended Lumina file is q4tp, and a backend that only
3018 /// knows q4t must decline rather than decode with the wrong reader.
3019 pub q4tp: bool,
3020 /// The hidden state is already on the device from the previous block,
3021 /// so `x` need not be uploaded.
3022 pub resident_in: bool,
3023 /// Leave the result on the device instead of reading it back. The DiT
3024 /// loop does not touch `x` between blocks, so 27 of every 28 readbacks
3025 /// were moving 19 MB across PCIe and stalling on it for nothing.
3026 pub resident_out: bool,
3027}
3028
3029/// Can the selected backend keep the DiT's hidden state on the device
3030/// between blocks? Only the wgpu whole-block path; the Metal entry takes
3031/// and returns host memory every call.
3032pub fn dit_chain_supported() -> bool {
3033 #[cfg(feature = "gpu")]
3034 {
3035 return matches!(backend(), Backend::Wgpu) && fused_dit_block_available();
3036 }
3037 #[allow(unreachable_code)]
3038 false
3039}
3040
3041/// Pull the resident hidden state back to the host. For the caller that
3042/// chained blocks and then hit one the device declined.
3043pub fn dit_state_fetch(_x: &mut [f32]) -> bool {
3044 #[cfg(feature = "gpu")]
3045 {
3046 if matches!(backend(), Backend::Wgpu) {
3047 return crate::gpu_wgpu::dit_state_fetch(_x);
3048 }
3049 }
3050 false
3051}
3052
3053/// One whole modulated DiT block on the device — norms, qkv, RoPE,
3054/// attention, residuals and the SwiGLU FFN in a single command
3055/// buffer; only `x` crosses the CPU boundary (in and out).
3056#[allow(unused_variables)]
3057/// The DiT's three projections in one submission (wgpu only; the
3058/// Metal path fuses the whole block instead). False = the caller keeps
3059/// its three separate calls.
3060#[allow(unused_variables, clippy::too_many_arguments)]
3061pub fn dit_qkv(
3062 model: &Arc<CmfModel>,
3063 wq: usize,
3064 wk: usize,
3065 wv: usize,
3066 xs: &[f32],
3067 b: usize,
3068 hidden: usize,
3069 qrows: usize,
3070 kvrows: usize,
3071 q_out: &mut [f32],
3072 k_out: &mut [f32],
3073 v_out: &mut [f32],
3074) -> bool {
3075 match backend() {
3076 #[cfg(feature = "gpu")]
3077 Backend::Wgpu => crate::gpu_wgpu::q4tp_qkv(
3078 model, wq, wk, wv, xs, b, hidden, qrows, kvrows, q_out, k_out, v_out,
3079 ),
3080 #[allow(unreachable_patterns)]
3081 _ => false,
3082 }
3083}
3084
3085/// The Qwen Image double-stream attention half. The WGPU implementation
3086/// keeps the six Q/K/V projections, the stream join, qk-norm/RoPE, joint
3087/// attention, and both output projections on the device; the caller only
3088/// supplies the two normalized streams and receives the two projected
3089/// streams. A backend or codec that cannot satisfy the full contract
3090/// returns `false` before changing either output, so the native host path
3091/// remains the portable fallback.
3092pub struct QwenImageAttentionArgs<'a> {
3093 pub image: &'a [f32],
3094 pub text: &'a [f32],
3095 pub image_tokens: usize,
3096 pub text_tokens: usize,
3097 pub heads: usize,
3098 pub head_dim: usize,
3099 pub image_q: usize,
3100 pub image_k: usize,
3101 pub image_v: usize,
3102 pub text_q: usize,
3103 pub text_k: usize,
3104 pub text_v: usize,
3105 pub image_out: usize,
3106 pub text_out: usize,
3107 pub image_q_norm: &'a [f32],
3108 pub image_k_norm: &'a [f32],
3109 pub text_q_norm: &'a [f32],
3110 pub text_k_norm: &'a [f32],
3111 pub image_cos: &'a [f32],
3112 pub image_sin: &'a [f32],
3113 pub text_cos: &'a [f32],
3114 pub text_sin: &'a [f32],
3115 pub image_q_bias: &'a [f32],
3116 pub image_k_bias: &'a [f32],
3117 pub image_v_bias: &'a [f32],
3118 pub text_q_bias: &'a [f32],
3119 pub text_k_bias: &'a [f32],
3120 pub text_v_bias: &'a [f32],
3121 pub image_out_bias: &'a [f32],
3122 pub text_out_bias: &'a [f32],
3123 pub image_proj: &'a mut [f32],
3124 pub text_proj: &'a mut [f32],
3125}
3126
3127/// The per-layer controls and Q4TP directory indices used by the native
3128/// Qwen block. Keeping this descriptor separate from the stream buffers
3129/// lets a whole transformer forward reuse one explicit device state without
3130/// a global scratch slot or a hidden context label.
3131#[allow(clippy::too_many_fields)]
3132pub struct QwenImageChainBlock<'a> {
3133 pub image_mod: &'a [f32],
3134 pub text_mod: &'a [f32],
3135 pub image_q: usize,
3136 pub image_k: usize,
3137 pub image_v: usize,
3138 pub text_q: usize,
3139 pub text_k: usize,
3140 pub text_v: usize,
3141 pub image_out: usize,
3142 pub text_out: usize,
3143 pub image_q_norm: &'a [f32],
3144 pub image_k_norm: &'a [f32],
3145 pub text_q_norm: &'a [f32],
3146 pub text_k_norm: &'a [f32],
3147 pub image_q_bias: &'a [f32],
3148 pub image_k_bias: &'a [f32],
3149 pub image_v_bias: &'a [f32],
3150 pub text_q_bias: &'a [f32],
3151 pub text_k_bias: &'a [f32],
3152 pub text_v_bias: &'a [f32],
3153 pub image_out_bias: &'a [f32],
3154 pub text_out_bias: &'a [f32],
3155 pub image_attn_gate: &'a [f32],
3156 pub text_attn_gate: &'a [f32],
3157 pub image_mlp_in: usize,
3158 pub image_mlp_out: usize,
3159 pub text_mlp_in: usize,
3160 pub text_mlp_out: usize,
3161 pub image_mlp_in_bias: &'a [f32],
3162 pub image_mlp_out_bias: &'a [f32],
3163 pub text_mlp_in_bias: &'a [f32],
3164 pub text_mlp_out_bias: &'a [f32],
3165}
3166
3167/// Complete Qwen Image transformer block contract. The first norm/mod
3168/// panels are supplied by the native caller; the WGPU arm keeps both streams
3169/// resident through QKV, QK/RoPE, joint attention, output projections, both
3170/// gated residuals, and the exact tanh-GELU MLPs. A backend that cannot
3171/// satisfy the whole graph returns `false` without changing either output.
3172#[allow(clippy::too_many_fields)]
3173pub struct QwenImageBlockArgs<'a> {
3174 /// Raw stream state is read for the first gated residual and overwritten
3175 /// with the block's final state after the one readback.
3176 pub image: &'a mut [f32],
3177 pub text: &'a mut [f32],
3178 pub image_norm: &'a [f32],
3179 pub text_norm: &'a [f32],
3180 pub image_tokens: usize,
3181 pub text_tokens: usize,
3182 pub heads: usize,
3183 pub head_dim: usize,
3184 pub image_cos: &'a [f32],
3185 pub image_sin: &'a [f32],
3186 pub text_cos: &'a [f32],
3187 pub text_sin: &'a [f32],
3188 pub image_q: usize,
3189 pub image_k: usize,
3190 pub image_v: usize,
3191 pub text_q: usize,
3192 pub text_k: usize,
3193 pub text_v: usize,
3194 pub image_out: usize,
3195 pub text_out: usize,
3196 pub image_q_norm: &'a [f32],
3197 pub image_k_norm: &'a [f32],
3198 pub text_q_norm: &'a [f32],
3199 pub text_k_norm: &'a [f32],
3200 pub image_q_bias: &'a [f32],
3201 pub image_k_bias: &'a [f32],
3202 pub image_v_bias: &'a [f32],
3203 pub text_q_bias: &'a [f32],
3204 pub text_k_bias: &'a [f32],
3205 pub text_v_bias: &'a [f32],
3206 pub image_out_bias: &'a [f32],
3207 pub text_out_bias: &'a [f32],
3208 pub image_attn_gate: &'a [f32],
3209 pub text_attn_gate: &'a [f32],
3210 pub image_mlp_in: usize,
3211 pub image_mlp_out: usize,
3212 pub text_mlp_in: usize,
3213 pub text_mlp_out: usize,
3214 pub image_mlp_in_bias: &'a [f32],
3215 pub image_mlp_out_bias: &'a [f32],
3216 pub text_mlp_in_bias: &'a [f32],
3217 pub text_mlp_out_bias: &'a [f32],
3218 pub image_mlp_mod: &'a [f32],
3219 pub text_mlp_mod: &'a [f32],
3220 pub image_mlp_gate: &'a [f32],
3221 pub text_mlp_gate: &'a [f32],
3222}
3223
3224/// Explicit whole-forward Qwen state contract. The WGPU backend uploads the
3225/// two initial streams once, encodes a bounded number of complete blocks per
3226/// submission, and reads the final state once. `blocks` is immutable for the
3227/// call, while the two stream slices receive only the final readback.
3228pub struct QwenImageChainArgs<'a> {
3229 pub image: &'a mut [f32],
3230 pub text: &'a mut [f32],
3231 pub image_tokens: usize,
3232 pub text_tokens: usize,
3233 pub heads: usize,
3234 pub head_dim: usize,
3235 pub image_cos: &'a [f32],
3236 pub image_sin: &'a [f32],
3237 pub text_cos: &'a [f32],
3238 pub text_sin: &'a [f32],
3239 pub blocks: &'a [QwenImageChainBlock<'a>],
3240}
3241
3242#[allow(unused_variables)]
3243pub fn qwen_image_attention(
3244 model: &Arc<CmfModel>,
3245 args: &mut QwenImageAttentionArgs<'_>,
3246) -> bool {
3247 match backend() {
3248 #[cfg(feature = "gpu")]
3249 Backend::Wgpu => crate::gpu_wgpu::qwen_image_attention(model, args),
3250 #[allow(unreachable_patterns)]
3251 _ => false,
3252 }
3253}
3254
3255#[allow(unused_variables)]
3256pub fn qwen_image_block(model: &Arc<CmfModel>, args: &mut QwenImageBlockArgs<'_>) -> bool {
3257 match backend() {
3258 #[cfg(feature = "gpu")]
3259 Backend::Wgpu => crate::gpu_wgpu::qwen_image_block(model, args),
3260 #[allow(unreachable_patterns)]
3261 _ => false,
3262 }
3263}
3264
3265/// Keep all Qwen transformer blocks on the selected WGPU device, with only
3266/// bounded chunk submissions and one final readback. Other backends decline
3267/// so the native caller can use its exact portable block loop.
3268#[allow(unused_variables)]
3269pub fn qwen_image_chain(model: &Arc<CmfModel>, args: &mut QwenImageChainArgs<'_>) -> bool {
3270 match backend() {
3271 #[cfg(feature = "gpu")]
3272 Backend::Wgpu => crate::gpu_wgpu::qwen_image_chain(model, args),
3273 #[allow(unreachable_patterns)]
3274 _ => false,
3275 }
3276}
3277
3278/// The Qwen Image second sub-block on WGPU: affine-free LayerNorm,
3279/// shift/scale modulation, Q4TP input projection, exact tanh-GELU, output
3280/// projection, bias and gated residual. `data` is updated in place after a
3281/// single final readback. Backends/codecs that cannot keep this chain on the
3282/// device return `false` before changing `data`, leaving the caller's
3283/// portable per-op path intact.
3284#[allow(unused_variables, clippy::too_many_arguments)]
3285pub fn qwen_image_mlp_inplace(
3286 model: &Arc<CmfModel>,
3287 w_in: usize,
3288 w_out: usize,
3289 data: &mut [f32],
3290 batch: usize,
3291 hidden: usize,
3292 inter: usize,
3293 bias_in: &[f32],
3294 bias_out: &[f32],
3295 modulation: &[f32],
3296 gate: &[f32],
3297) -> bool {
3298 match backend() {
3299 #[cfg(feature = "gpu")]
3300 Backend::Wgpu => crate::gpu_wgpu::qwen_image_mlp_inplace(
3301 model,
3302 w_in,
3303 w_out,
3304 data,
3305 batch,
3306 hidden,
3307 inter,
3308 bias_in,
3309 bias_out,
3310 modulation,
3311 gate,
3312 ),
3313 #[allow(unreachable_patterns)]
3314 _ => false,
3315 }
3316}
3317
3318/// Is a FUSED whole-block device path on offer? The batched-CFG shape
3319/// (two sequences in one tall batch) and the fused block (one sequence,
3320/// one command buffer) are alternatives, and the caller picks.
3321pub fn fused_dit_block_available() -> bool {
3322 #[cfg(target_os = "macos")]
3323 {
3324 matches!(backend(), Backend::Metal) && fused_block_trusted()
3325 }
3326 #[cfg(not(target_os = "macos"))]
3327 {
3328 false
3329 }
3330}
3331
3332pub fn dit_block(model: &Arc<CmfModel>, a: &DitBlockArgs, x: &mut [f32]) -> bool {
3333 dit_block_seg(model, a, &[a.n], x)
3334}
3335
3336/// The same block over a CONCATENATION of independent sequences:
3337/// attention per segment, everything position-wise batched. wgpu only —
3338/// the Metal path takes the single-sequence entry above.
3339pub fn dit_block_seg(
3340 model: &Arc<CmfModel>,
3341 a: &DitBlockArgs,
3342 segs: &[usize],
3343 x: &mut [f32],
3344) -> bool {
3345 match backend() {
3346 #[cfg(target_os = "macos")]
3347 Backend::Metal if segs.len() <= 1 => crate::gpu_metal::dit_block(model, a, x),
3348 // The wgpu whole-block path. What it buys is host round trips —
3349 // six a block become one — so it defaults ON where those cost
3350 // real time (a discrete card across PCIe) and OFF on unified
3351 // memory, where the per-op path shares the same pages and the
3352 // fusion measured slightly slower on an M4. `CMF_DIT_FUSED=1`
3353 // forces it anywhere, `=0` forbids it.
3354 #[cfg(feature = "gpu")]
3355 Backend::Wgpu
3356 if match std::env::var("CMF_DIT_FUSED").ok().as_deref() {
3357 Some("0") => false,
3358 Some(_) => true,
3359 None => crate::gpu_wgpu::discrete_active(),
3360 } =>
3361 {
3362 crate::gpu_wgpu::dit_block_seg(model, a, segs, x)
3363 }
3364 #[allow(unreachable_patterns)]
3365 _ => false,
3366 }
3367}
3368
3369/// One VAE resnet block for `vae_resnet`: norm/conv weights and the
3370/// channel/shape geometry. `shortcut` is the 1×1 projection (w, b, k)
3371/// when in/out channels differ.
3372pub struct VaeResnetArgs<'a> {
3373 pub groups: usize,
3374 pub ic: usize,
3375 pub oc: usize,
3376 pub h: usize,
3377 pub w: usize,
3378 pub n1w: &'a [f32],
3379 pub n1b: &'a [f32],
3380 pub c1w: &'a [f32],
3381 pub c1b: &'a [f32],
3382 pub c1k: usize,
3383 pub n2w: &'a [f32],
3384 pub n2b: &'a [f32],
3385 pub c2w: &'a [f32],
3386 pub c2b: &'a [f32],
3387 pub c2k: usize,
3388 pub shortcut: Option<(&'a [f32], &'a [f32], usize)>,
3389}
3390
3391/// One whole VAE resnet block on the device (norm+silu → conv ×2 →
3392/// shortcut → add, one command buffer).
3393#[allow(unused_variables)]
3394pub fn vae_resnet(a: &VaeResnetArgs, x: &[f32], out: &mut [f32]) -> bool {
3395 match backend() {
3396 #[cfg(target_os = "macos")]
3397 Backend::Metal => crate::gpu_metal::vae_resnet(a, x, out),
3398 _ => false,
3399 }
3400}
3401
3402/// Nearest-2× upsample fused with the following conv — the small
3403/// pre-upsample image is what crosses the CPU boundary.
3404#[allow(unused_variables, clippy::too_many_arguments)]
3405pub fn vae_upsample_conv(
3406 w: &[f32],
3407 bias: &[f32],
3408 x: &[f32],
3409 ic: usize,
3410 oc: usize,
3411 h: usize,
3412 w_img: usize,
3413 k: usize,
3414 out: &mut [f32],
3415) -> bool {
3416 match backend() {
3417 #[cfg(target_os = "macos")]
3418 Backend::Metal => crate::gpu_metal::vae_upsample_conv(w, bias, x, ic, oc, h, w_img, k, out),
3419 #[cfg(feature = "gpu")]
3420 Backend::Wgpu => crate::gpu_wgpu::vae_upsample_conv(w, bias, x, ic, oc, h, w_img, k, out),
3421 #[allow(unreachable_patterns)]
3422 _ => false,
3423 }
3424}
3425
3426/// VAE conv2d on the device (implicit GEMM — the CPU path pays for a
3427/// multi-GB im2col matrix at high resolutions).
3428#[allow(unused_variables, clippy::too_many_arguments)]
3429pub fn vae_conv2d(
3430 w: &[f32],
3431 bias: &[f32],
3432 x: &[f32],
3433 ic: usize,
3434 oc: usize,
3435 h: usize,
3436 w_img: usize,
3437 k: usize,
3438 out: &mut [f32],
3439) -> bool {
3440 match backend() {
3441 #[cfg(target_os = "macos")]
3442 Backend::Metal => crate::gpu_metal::vae_conv2d(w, bias, x, ic, oc, h, w_img, k, out),
3443 #[cfg(feature = "gpu")]
3444 Backend::Wgpu => crate::gpu_wgpu::vae_conv2d(w, bias, x, ic, oc, h, w_img, k, out),
3445 #[allow(unreachable_patterns)]
3446 _ => false,
3447 }
3448}
3449
3450/// DiT full bidirectional attention on the device (all heads:
3451/// scores GEMM → row softmax → P·V → panel unstack, one command
3452/// buffer). Head-major inputs; out is [n, nh·hd].
3453#[allow(unused_variables, clippy::too_many_arguments)]
3454/// Attention from an interleaved qkv panel, splitting into head-major
3455/// planes ON the device. wgpu only; `false` elsewhere so the caller
3456/// keeps its host repack.
3457#[allow(unused_variables)]
3458#[allow(clippy::too_many_arguments)]
3459/// qkv projection + attention with the panel never leaving the card.
3460/// wgpu only; `false` elsewhere and the caller keeps its host chain.
3461#[allow(clippy::too_many_arguments, unused_variables)]
3462pub fn dit_qkv_attention(
3463 model: &Arc<CmfModel>,
3464 qkv_idx: usize,
3465 xn: &[f32],
3466 n: usize,
3467 hidden: usize,
3468 nh: usize,
3469 hd: usize,
3470 scale: f32,
3471 nr: (&[f32], &[f32], &[f32], f32),
3472 out: &mut [f32],
3473) -> bool {
3474 match backend() {
3475 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
3476 Backend::Wgpu => crate::gpu_wgpu::dit_qkv_attention(
3477 model, qkv_idx, xn, n, hidden, nh, hd, scale, nr, out,
3478 ),
3479 #[allow(unreachable_patterns)]
3480 _ => false,
3481 }
3482}
3483
3484/// The whole attention half of a DiT block on the card: qkv GEMM,
3485/// attention, output projection. Only `proj` comes home.
3486#[allow(clippy::too_many_arguments)]
3487pub fn dit_qkv_attn_out(
3488 model: &Arc<CmfModel>,
3489 qkv_idx: usize,
3490 out_idx: usize,
3491 xn: &[f32],
3492 n: usize,
3493 hidden: usize,
3494 nh: usize,
3495 hd: usize,
3496 scale: f32,
3497 nr: (&[f32], &[f32], &[f32], f32),
3498 proj: &mut [f32],
3499) -> bool {
3500 match backend() {
3501 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
3502 Backend::Wgpu => crate::gpu_wgpu::dit_qkv_attn_out(
3503 model, qkv_idx, out_idx, xn, n, hidden, nh, hd, scale, nr, proj,
3504 ),
3505 #[allow(unreachable_patterns)]
3506 _ => false,
3507 }
3508}
3509
3510/// The VAE decoder's attention half on the card. Only `proj` returns.
3511#[allow(clippy::too_many_arguments)]
3512pub fn vae_qkv_attn_out(
3513 model: &Arc<CmfModel>,
3514 qkv_idx: usize,
3515 out_idx: usize,
3516 xn: &[f32],
3517 n: usize,
3518 dim: usize,
3519 nh: usize,
3520 hd: usize,
3521 scale: f32,
3522 angles: &[f32],
3523 eps: f32,
3524 qkv_bias: &[f32],
3525 proj: &mut [f32],
3526) -> bool {
3527 match backend() {
3528 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
3529 Backend::Wgpu => crate::gpu_wgpu::vae_qkv_attn_out(
3530 model, qkv_idx, out_idx, xn, n, dim, nh, hd, scale, angles, eps, qkv_bias, proj,
3531 ),
3532 #[allow(unreachable_patterns)]
3533 _ => false,
3534 }
3535}
3536
3537#[allow(clippy::too_many_arguments)]
3538pub fn vae_attention_packed(
3539 qkv: &[f32],
3540 nh: usize,
3541 n: usize,
3542 hd: usize,
3543 scale: f32,
3544 angles: &[f32],
3545 eps: f32,
3546 out: &mut [f32],
3547) -> bool {
3548 vae_attention_packed_layout(qkv, nh, n, hd, scale, angles, eps, out, 1)
3549}
3550
3551#[allow(clippy::too_many_arguments)]
3552pub fn vae_attention_packed_layout(
3553 qkv: &[f32],
3554 nh: usize,
3555 n: usize,
3556 hd: usize,
3557 scale: f32,
3558 angles: &[f32],
3559 eps: f32,
3560 out: &mut [f32],
3561 layout: u32,
3562) -> bool {
3563 match backend() {
3564 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
3565 Backend::Wgpu => crate::gpu_wgpu::vae_attention_packed_layout(
3566 qkv, nh, n, hd, scale, angles, eps, out, layout,
3567 ),
3568 #[allow(unreachable_patterns)]
3569 _ => false,
3570 }
3571}
3572
3573#[allow(clippy::too_many_arguments)]
3574pub fn dit_split_only(
3575 qkv: &[f32],
3576 nh: usize,
3577 n: usize,
3578 hd: usize,
3579 layout: u32,
3580 norm: Option<(&[f32], f32)>,
3581 out_q: &mut [f32],
3582) -> bool {
3583 match backend() {
3584 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
3585 Backend::Wgpu => crate::gpu_wgpu::dit_split_only(qkv, nh, n, hd, layout, norm, out_q),
3586 #[allow(unreachable_patterns)]
3587 _ => false,
3588 }
3589}
3590
3591/// The backend's f32 NT GEMM: `y[n×m] = x[n×k] · wᵀ[m×k]`. Tensor
3592/// cores where the card has them. Refuses under `CMF_BAKE_GPU=0` or
3593/// strict f32, and for jobs below n·k·m = 4M, where the round trip
3594/// costs more than the arithmetic saves.
3595/// `gemm_nt_f32` whose `w` is known to change every call (an
3596/// accumulation over fresh activations, not a weight): it skips the
3597/// resident ledger and its per-call fingerprint of the whole operand.
3598pub fn gemm_nt_f32_transient(
3599 x: &[f32],
3600 w: &[f32],
3601 y: &mut [f32],
3602 n: usize,
3603 k: usize,
3604 m: usize,
3605) -> bool {
3606 match backend() {
3607 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
3608 Backend::Wgpu => crate::gpu_wgpu::gemm_nt_f32_transient(x, w, y, n, k, m),
3609 #[allow(unreachable_patterns)]
3610 _ => false,
3611 }
3612}
3613
3614pub fn gemm_nt_f32(x: &[f32], w: &[f32], y: &mut [f32], n: usize, k: usize, m: usize) -> bool {
3615 match backend() {
3616 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
3617 Backend::Wgpu => crate::gpu_wgpu::gemm_nt_f32(x, w, y, n, k, m),
3618 #[allow(unreachable_patterns)]
3619 _ => false,
3620 }
3621}
3622
3623/// Music-3's FFN chain resident on the device — two GEMMs and the GLU
3624/// between them with no host round trip. `false` = refused, host runs.
3625#[allow(clippy::too_many_arguments)]
3626pub fn music3_ffn(
3627 model: &std::sync::Arc<CmfModel>,
3628 idx_in: usize,
3629 idx_out: usize,
3630 h: &[f32],
3631 bias_in: &[f32],
3632 n: usize,
3633 hs: usize,
3634 inter: usize,
3635 out: &mut [f32],
3636) -> bool {
3637 match backend() {
3638 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
3639 Backend::Wgpu => {
3640 crate::gpu_wgpu::music3_ffn(model, idx_in, idx_out, h, bias_in, n, hs, inter, out)
3641 }
3642 #[allow(unreachable_patterns)]
3643 _ => false,
3644 }
3645}
3646
3647/// A 1D convolution as a GEMM whose column matrix is expanded on the
3648/// device instead of being built, transposed and uploaded by the host.
3649/// `yt` comes back `[out_n x oc]`. `false` = refused, caller runs host.
3650#[allow(clippy::too_many_arguments)]
3651pub fn conv1d_gemm(
3652 x: &[f32],
3653 w: &[f32],
3654 ic: usize,
3655 oc: usize,
3656 n: usize,
3657 k: usize,
3658 pad: usize,
3659 dil: usize,
3660 out_n: usize,
3661 yt: &mut [f32],
3662) -> bool {
3663 match backend() {
3664 #[cfg(target_os = "macos")]
3665 Backend::Metal => crate::gpu_metal::conv1d_gemm(x, w, ic, oc, n, k, pad, dil, out_n, yt),
3666 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
3667 Backend::Wgpu => crate::gpu_wgpu::conv1d_gemm(x, w, ic, oc, n, k, pad, dil, out_n, yt),
3668 #[allow(unreachable_patterns)]
3669 _ => false,
3670 }
3671}
3672
3673/// The convolution as a GEMM on the matrix units. `false` = refused.
3674#[allow(clippy::too_many_arguments)]
3675pub fn vae_conv2d_coop(
3676 w: &[f32],
3677 bias: Option<&[f32]>,
3678 x: &[f32],
3679 ic: usize,
3680 oc: usize,
3681 h: usize,
3682 wi: usize,
3683 k: usize,
3684 out: &mut [f32],
3685) -> bool {
3686 match backend() {
3687 #[cfg(all(feature = "gpu", not(target_os = "macos")))]
3688 Backend::Wgpu => crate::gpu_wgpu::vae_conv2d_coop(w, bias, x, ic, oc, h, wi, k, out),
3689 #[allow(unreachable_patterns)]
3690 _ => false,
3691 }
3692}
3693
3694pub fn dit_attention_packed(
3695 qkv: &[f32],
3696 nh: usize,
3697 n: usize,
3698 hd: usize,
3699 scale: f32,
3700 // (rope angles, q norm weights, k norm weights, eps) when the device
3701 // should apply qk-norm and RoPE itself; None when the host already did.
3702 nr: Option<(&[f32], &[f32], &[f32], f32)>,
3703 out: &mut [f32],
3704) -> bool {
3705 match backend() {
3706 // wgpu carries the only implementation, and it is not
3707 // platform-specific: `CMF_GPU=wgpu` on macOS runs it over Metal
3708 // like anywhere else. It used to be compiled out here on macOS,
3709 // which made the call a silent `false` — and the caller's
3710 // `assert!` turned that refusal into a panic on every
3711 // `cortiq animate` this platform ever ran.
3712 #[cfg(feature = "gpu")]
3713 Backend::Wgpu => crate::gpu_wgpu::dit_attention_packed(qkv, nh, n, hd, scale, nr, out),
3714 #[allow(unreachable_patterns)]
3715 _ => false,
3716 }
3717}
3718
3719/// Whether `dit_attention_packed` has an implementation on the backend
3720/// that is actually selected.
3721///
3722/// The caller has to know BEFORE it skips the host qk-norm: deferring
3723/// the norm to a device that then refuses leaves q/k unnormalized with
3724/// no way back. Native Metal has no packed kernel, so on macOS this is
3725/// false unless `CMF_GPU=wgpu` picked the other backend.
3726pub fn dit_attention_packed_available() -> bool {
3727 #[allow(unreachable_patterns)]
3728 match backend() {
3729 #[cfg(feature = "gpu")]
3730 Backend::Wgpu => crate::gpu_wgpu::dit_attention_packed_ready(),
3731 _ => false,
3732 }
3733}
3734
3735pub fn dit_attention(
3736 qh: &[f32],
3737 kh: &[f32],
3738 vh: &[f32],
3739 nh: usize,
3740 nkv: usize,
3741 n: usize,
3742 hd: usize,
3743 scale: f32,
3744 out: &mut [f32],
3745) -> bool {
3746 match backend() {
3747 #[cfg(target_os = "macos")]
3748 Backend::Metal => crate::gpu_metal::dit_attention(qh, kh, vh, nh, nkv, n, hd, scale, out),
3749 #[cfg(feature = "gpu")]
3750 Backend::Wgpu => crate::gpu_wgpu::dit_attention(qh, kh, vh, nh, nkv, n, hd, scale, out),
3751 #[allow(unreachable_patterns)]
3752 _ => false,
3753 }
3754}
3755
3756/// GPTQ calibration: a zeroed `cols × cols` Hessian accumulator on the
3757/// device (wgpu only), or None — the caller keeps the host fold then.
3758pub fn gram_alloc(cols: usize) -> Option<u64> {
3759 #[cfg(feature = "gpu")]
3760 if backend() == Backend::Wgpu {
3761 return crate::gpu_wgpu::gram::alloc(cols);
3762 }
3763 let _ = cols;
3764 None
3765}
3766
3767/// `H[id] += XᵀX` over `b` rows of `xs` (`[b·cols]`).
3768pub fn gram_add(id: u64, xs: &[f32], b: usize) -> bool {
3769 #[cfg(feature = "gpu")]
3770 if backend() == Backend::Wgpu {
3771 return crate::gpu_wgpu::gram::add(id, xs, b);
3772 }
3773 let _ = (id, xs, b);
3774 false
3775}
3776
3777/// `H[id] += XᵀX` over `b` rows of a device-resident f32 buffer.
3778#[cfg(feature = "gpu")]
3779pub fn gram_add_dev(id: u64, x: &wgpu::Buffer, b: usize) -> bool {
3780 if backend() == Backend::Wgpu {
3781 return crate::gpu_wgpu::gram::add_dev(id, x, b);
3782 }
3783 false
3784}
3785
3786/// The accumulated Hessian as a full symmetric f64 matrix (frees it).
3787pub fn gram_take(id: u64) -> Option<Vec<f64>> {
3788 #[cfg(feature = "gpu")]
3789 if backend() == Backend::Wgpu {
3790 return crate::gpu_wgpu::gram::take(id);
3791 }
3792 let _ = id;
3793 None
3794}
3795
3796/// GPTQ fold of the device Hessian `h_id` on the device: the upper Cholesky
3797/// factor of `(H + λ·mean(diag)·I)⁻¹` stays there under the returned id and
3798/// the Hessian is freed. None (no wgpu device, it does not fit, it does not
3799/// factor): the Hessian is left for `gram_take`.
3800pub fn gptq_fold_dev(h_id: u64, n: usize, lambda: f64) -> Option<u64> {
3801 #[cfg(feature = "gpu")]
3802 if backend() == Backend::Wgpu {
3803 return crate::gpu_wgpu::gptq_dev::fold(h_id, n, lambda);
3804 }
3805 let _ = (h_id, n, lambda);
3806 None
3807}
3808
3809/// (dead channels, raw H diagonal, damping used) of a device fold.
3810pub fn gptq_fold_info(id: u64) -> Option<(Vec<bool>, Vec<f32>, f64)> {
3811 #[cfg(feature = "gpu")]
3812 if backend() == Backend::Wgpu {
3813 return crate::gpu_wgpu::gptq_dev::info(id);
3814 }
3815 let _ = id;
3816 None
3817}
3818
3819/// A device fold's operator read back (n×n f32, upper).
3820pub fn gptq_fold_read(id: u64) -> Option<Vec<f32>> {
3821 #[cfg(feature = "gpu")]
3822 if backend() == Backend::Wgpu {
3823 return crate::gpu_wgpu::gptq_dev::read_u(id);
3824 }
3825 let _ = id;
3826 None
3827}
3828
3829/// Free a device fold.
3830pub fn gptq_fold_free(id: u64) {
3831 #[cfg(feature = "gpu")]
3832 if backend() == Backend::Wgpu {
3833 crate::gpu_wgpu::gptq_dev::free(id);
3834 }
3835 let _ = id;
3836}
3837
3838/// The q4tp GPTQ sweep on the device (see `gpu_wgpu::gptq_dev::sweep_q4tp`).
3839#[allow(clippy::too_many_arguments)]
3840pub fn gptq_sweep_q4tp_dev(
3841 id: u64,
3842 w: &[f32],
3843 rows: usize,
3844 tab: &[f32],
3845 lost: &[f32],
3846 nib: &mut [u8],
3847 codes: &mut [u32],
3848) -> bool {
3849 #[cfg(feature = "gpu")]
3850 if backend() == Backend::Wgpu {
3851 return crate::gpu_wgpu::gptq_dev::sweep_q4tp(id, w, rows, tab, lost, nib, codes);
3852 }
3853 let _ = (id, w, rows, tab, lost, nib, codes);
3854 false
3855}
3856
3857/// The q2tp GPTQ sweep ("placed" rule) on the device.
3858pub fn gptq_sweep_q2tp_dev(
3859 id: u64,
3860 w: &[f32],
3861 rows: usize,
3862 tab: &[f32],
3863 hw: &[f32],
3864 chunks: &mut [u8],
3865 codes: &mut [u32],
3866) -> bool {
3867 #[cfg(feature = "gpu")]
3868 if backend() == Backend::Wgpu {
3869 return crate::gpu_wgpu::gptq_dev::sweep_q2tp(id, w, rows, tab, hw, chunks, codes);
3870 }
3871 let _ = (id, w, rows, tab, hw, chunks, codes);
3872 false
3873}
3874
3875/// A device Hessian read back as a full symmetric f64 matrix, left on the
3876/// device.
3877pub fn gram_read(id: u64) -> Option<Vec<f64>> {
3878 #[cfg(feature = "gpu")]
3879 if backend() == Backend::Wgpu {
3880 return crate::gpu_wgpu::gram::read(id);
3881 }
3882 let _ = id;
3883 None
3884}
3885
3886/// Free every device Hessian (an aborted calibration pass).
3887pub fn gram_clear() {
3888 #[cfg(feature = "gpu")]
3889 if backend() == Backend::Wgpu {
3890 crate::gpu_wgpu::gram::clear();
3891 }
3892}
3893
3894/// Batched q4t GEMM on the device (imagegen DiT prefill shapes).
3895/// Metal: q4t_mul_mm decodes the mmap-resident tiles inside the
3896/// GEMM's K loop. wgpu (Vulkan/DX12 → NVIDIA/AMD/Intel/Adreno/Mali):
3897/// the register-blocked WGSL twin, weights cached in VRAM.
3898#[allow(unused_variables)]
3899pub fn q4tp_matmat(
3900 model: &Arc<CmfModel>,
3901 idx: usize,
3902 xs: &[f32],
3903 b: usize,
3904 rows: usize,
3905 cols: usize,
3906 out: &mut [f32],
3907) -> bool {
3908 match backend() {
3909 #[cfg(target_os = "macos")]
3910 Backend::Metal => crate::gpu_metal::q4tp_matmat(model, idx, xs, b, rows, cols, out),
3911 #[cfg(feature = "gpu")]
3912 Backend::Wgpu => crate::gpu_wgpu::q4tp_matmat(model, idx, xs, b, rows, cols, out),
3913 #[allow(unreachable_patterns)]
3914 _ => false,
3915 }
3916}
3917
3918/// The same over a two-bit weight plane. Native Metal uses the dedicated
3919/// q2tp tile; unsupported shapes return false and preserve the host fallback.
3920pub fn q2tp_matmat(
3921 model: &Arc<CmfModel>,
3922 idx: usize,
3923 xs: &[f32],
3924 b: usize,
3925 rows: usize,
3926 cols: usize,
3927 out: &mut [f32],
3928) -> bool {
3929 match backend() {
3930 #[cfg(target_os = "macos")]
3931 Backend::Metal => crate::gpu_metal::q2tp_matmat(model, idx, xs, b, rows, cols, out),
3932 #[cfg(feature = "gpu")]
3933 Backend::Wgpu => crate::gpu_wgpu::q2tp_matmat(model, idx, xs, b, rows, cols, out),
3934 #[allow(unreachable_patterns)]
3935 _ => false,
3936 }
3937}
3938
3939/// Descriptor-aware q2tp GEMM. The affine center is selected only for a
3940/// validated q2tp_affine target; the raw dtype16 payload remains unchanged.
3941pub fn q2tp_affine_matmat(
3942 model: &Arc<CmfModel>,
3943 idx: usize,
3944 xs: &[f32],
3945 b: usize,
3946 rows: usize,
3947 cols: usize,
3948 out: &mut [f32],
3949) -> bool {
3950 match backend() {
3951 #[cfg(target_os = "macos")]
3952 Backend::Metal => crate::gpu_metal::q2tp_affine_matmat(model, idx, xs, b, rows, cols, out),
3953 #[cfg(feature = "gpu")]
3954 Backend::Wgpu => crate::gpu_wgpu::q2tp_affine_matmat(model, idx, xs, b, rows, cols, out),
3955 #[allow(unreachable_patterns)]
3956 _ => false,
3957 }
3958}
3959
3960/// Binary low-rank (`blr`) product on the device: `out[b·rows] = xs[b·cols] ·
3961/// Wᵀ` through the two ±1 stages (V then U). `b = 1` is the decode GEMV.
3962/// False = not taken (no wgpu backend — Metal has no blr kernel yet — or a
3963/// shape outside the kernel's contract): the caller runs the CPU kernels.
3964pub fn blr_matmat(t: &crate::blr::BlrTensor, xs: &[f32], b: usize, out: &mut [f32]) -> bool {
3965 match backend() {
3966 #[cfg(feature = "gpu")]
3967 Backend::Wgpu => crate::gpu_wgpu::blr_matmat(t, xs, b, out),
3968 #[allow(unreachable_patterns)]
3969 _ => {
3970 let _ = (t, xs, b, out);
3971 false
3972 }
3973 }
3974}
3975
3976/// `mxfp4` matvec on the device: the wgpu kernel (`gpu_wgpu::mxfp4`).
3977/// Native Metal has no mxfp4 kernel and answers false, as does any shape or
3978/// budget the device path refuses — the CPU kernels run then.
3979pub fn mxfp4_matvec(
3980 model: &Arc<CmfModel>,
3981 idx: usize,
3982 xs: &[f32],
3983 rows: usize,
3984 cols: usize,
3985 out: &mut [f32],
3986) -> bool {
3987 mxfp4_matmat(model, idx, xs, 1, rows, cols, out)
3988}
3989
3990/// Batched `mxfp4` matvec on the device (`xs` `[b, cols]` → `out`
3991/// `[b, rows]`, up to eight tokens per workgroup). false = CPU fallback.
3992pub fn mxfp4_matmat(
3993 model: &Arc<CmfModel>,
3994 idx: usize,
3995 xs: &[f32],
3996 b: usize,
3997 rows: usize,
3998 cols: usize,
3999 out: &mut [f32],
4000) -> bool {
4001 match backend() {
4002 #[cfg(feature = "gpu")]
4003 Backend::Wgpu => crate::gpu_wgpu::mxfp4::matmat(model, idx, xs, b, rows, cols, out),
4004 #[allow(unreachable_patterns)]
4005 _ => {
4006 let _ = (model, idx, xs, b, rows, cols, out);
4007 false
4008 }
4009 }
4010}
4011
4012/// Single-token q2tp matvec through the ordinary (center=1.5) WGSL kernel.
4013pub fn q2tp_matvec(
4014 model: &Arc<CmfModel>,
4015 idx: usize,
4016 xs: &[f32],
4017 rows: usize,
4018 cols: usize,
4019 out: &mut [f32],
4020) -> bool {
4021 match backend() {
4022 #[cfg(target_os = "macos")]
4023 Backend::Metal => crate::gpu_metal::q2tp_matvec(model, idx, xs, rows, cols, out),
4024 #[cfg(feature = "gpu")]
4025 Backend::Wgpu => crate::gpu_wgpu::q2tp_matvec(model, idx, xs, rows, cols, out),
4026 #[allow(unreachable_patterns)]
4027 _ => false,
4028 }
4029}
4030
4031/// Single-token q2tp matvec with the explicit affine center=1 descriptor
4032/// operator. This is kept separate from ordinary q2tp to make accidental
4033/// center changes impossible at a call site.
4034pub fn q2tp_affine_matvec(
4035 model: &Arc<CmfModel>,
4036 idx: usize,
4037 xs: &[f32],
4038 rows: usize,
4039 cols: usize,
4040 out: &mut [f32],
4041) -> bool {
4042 match backend() {
4043 #[cfg(target_os = "macos")]
4044 Backend::Metal => crate::gpu_metal::q2tp_affine_matvec(model, idx, xs, rows, cols, out),
4045 #[cfg(feature = "gpu")]
4046 Backend::Wgpu => crate::gpu_wgpu::q2tp_affine_matvec(model, idx, xs, rows, cols, out),
4047 #[allow(unreachable_patterns)]
4048 _ => false,
4049 }
4050}
4051
4052/// Single-token q4tp matvec on the device — the lm_head class. Through the
4053/// DEDICATED matvec kernel: the batched GEMM at b=1 measured 11.73 ms
4054/// against the host's 9.51 on the release head, so the route that was
4055/// supposed to save eleven milliseconds a token lost its own probe instead.
4056pub fn q4tp_matvec(
4057 model: &Arc<CmfModel>,
4058 idx: usize,
4059 xs: &[f32],
4060 rows: usize,
4061 cols: usize,
4062 out: &mut [f32],
4063) -> bool {
4064 match backend() {
4065 #[cfg(target_os = "macos")]
4066 Backend::Metal => crate::gpu_metal::q4tp_matvec_for_test(model, idx, xs, rows, cols, out),
4067 #[cfg(feature = "gpu")]
4068 Backend::Wgpu => crate::gpu_wgpu::q4tp_matvec(model, idx, xs, rows, cols, out),
4069 #[allow(unreachable_patterns)]
4070 _ => false,
4071 }
4072}
4073
4074/// Single-token q4_tiled matvec on the device — the lm_head class (a
4075/// q4t checkpoint's head is its biggest host matvec, exactly like the
4076/// q4tp twin above). wgpu holds q4t_mv pipelines only inside the graph
4077/// encoder — the standalone arm stays an honest refusal until a
4078/// discrete-GPU q4t model reaches the bench.
4079pub fn q4t_matvec(
4080 model: &Arc<CmfModel>,
4081 idx: usize,
4082 xs: &[f32],
4083 rows: usize,
4084 cols: usize,
4085 out: &mut [f32],
4086) -> bool {
4087 match backend() {
4088 #[cfg(target_os = "macos")]
4089 Backend::Metal => crate::gpu_metal::q4t_matvec_for_test(model, idx, xs, rows, cols, out),
4090 #[allow(unreachable_patterns)]
4091 _ => false,
4092 }
4093}
4094
4095pub fn q4t_matmat(
4096 model: &Arc<CmfModel>,
4097 idx: usize,
4098 xs: &[f32],
4099 b: usize,
4100 rows: usize,
4101 cols: usize,
4102 out: &mut [f32],
4103) -> bool {
4104 match backend() {
4105 #[cfg(target_os = "macos")]
4106 Backend::Metal => crate::gpu_metal::q4t_matmat(model, idx, xs, b, rows, cols, out),
4107 #[cfg(feature = "gpu")]
4108 Backend::Wgpu => crate::gpu_wgpu::q4t_matmat(model, idx, xs, b, rows, cols, out),
4109 #[allow(unreachable_patterns)]
4110 _ => false,
4111 }
4112}
4113
4114/// Whole-block token-graph types re-exported from the Metal backend.
4115#[cfg(target_os = "macos")]
4116pub use crate::gpu_metal::{
4117 AttnDeviceParams, AttnGpuLayer, GdnGpuCfg, GdnGpuLayer, GpuFlintMoe, GpuMoe, GraphDims,
4118 MetalFfn, O1AttnParams, TokenGraph, kv_mirror_drop, kv_mirror_read_last, kv_mirror_take_imp,
4119};
4120
4121/// A BLOCK of consecutive q1 GDN layers in one submission (Metal only).
4122#[cfg(target_os = "macos")]
4123pub fn gdn_block(
4124 model: &Arc<CmfModel>,
4125 layers: &[GdnGpuLayer],
4126 states: &mut [&mut [f32]],
4127 cfg: &GdnGpuCfg,
4128 h: &mut [f32],
4129) -> bool {
4130 match backend() {
4131 Backend::Metal => crate::gpu_metal::gdn_block(model, layers, states, cfg, h),
4132 _ => false,
4133 }
4134}
4135
4136/// A layer's MoE-FFN in one submission (amortizing the dispatch cost).
4137#[allow(unused_variables)]
4138pub fn moe_block(model: &Arc<CmfModel>, jobs: &[MoeJob], out: &mut [f32]) -> bool {
4139 match backend() {
4140 // Each backend validates its own complete activation contract. In
4141 // particular Metal admits M3 code 3 only through its dedicated OAI
4142 // kernels and rejects every mixed/nearby variant; filtering here
4143 // would make that safe path unreachable.
4144 #[cfg(target_os = "macos")]
4145 Backend::Metal => crate::gpu_metal::moe_block(model, jobs, out),
4146 #[cfg(feature = "gpu")]
4147 Backend::Wgpu => crate::gpu_wgpu::moe_block(model, jobs, out),
4148 Backend::None => false,
4149 }
4150}
4151
4152/// Independent matvecs of one input in a single submission (GDN projections).
4153#[allow(unused_variables)]
4154pub fn matvec_batch(model: &Arc<CmfModel>, jobs: &[BatchJob], out: &mut [&mut [f32]]) -> bool {
4155 match backend() {
4156 #[cfg(target_os = "macos")]
4157 Backend::Metal => crate::gpu_metal::matvec_batch(model, jobs, out),
4158 #[cfg(feature = "gpu")]
4159 Backend::Wgpu => crate::gpu_wgpu::matvec_batch(model, jobs, out),
4160 Backend::None => false,
4161 }
4162}
4163
4164// ── Whole-token wgpu graph race (generation granularity) ─────────────
4165// On integrated/mobile adapters the graph is neither trusted nor banned
4166// a priori — it RACES the normal path: generations alternate arms (the
4167// normal path first — known-good UX — then the graph), per-token wall
4168// times accumulate per arm, and once both arms have enough steady
4169// samples the faster one wins for the process. Arm switches happen ONLY
4170// at generation boundaries (`kv_cache.clear()` resets state), so the
4171// device KV mirror and the CPU cache never diverge mid-sequence. The
4172// single exception is the first-token bail: the very first decode token
4173// of a graph generation may be discarded and recomputed on the CPU
4174// path (the prompt KV is CPU-owned at that point, so this is safe) —
4175// a tiled mobile GPU that drains its pipeline at every barrier turns
4176// the ~300-dispatch graph into seconds per token (field report: 0.2
4177// tok/s vs 15 on the CPU), and one token is all it takes to see that.
4178static GRAPH_RACE_STATE: AtomicU8 = AtomicU8::new(0); // 0 racing, 1 graph won, 2 normal won
4179static GRAPH_RACE_FLIP: AtomicU32 = AtomicU32::new(0);
4180static GRAPH_RACE_ARM_GRAPH: AtomicU8 = AtomicU8::new(0); // this generation's arm
4181static GRAPH_RACE_TOK: AtomicU32 = AtomicU32::new(0); // token index within the generation
4182static GRAPH_NS: [AtomicU64; 2] = [AtomicU64::new(0), AtomicU64::new(0)]; // [normal, graph]
4183static GRAPH_N: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
4184
4185/// Steady per-token samples per arm before the race decides.
4186const GRAPH_RACE_SAMPLES: u32 = 4;
4187
4188// A graph that cannot be built for THIS model will never build: the
4189// refusal is a property of the weights, not of the moment. Retrying it
4190// per token is not free — the builder walks every layer and asks each
4191// tensor for a graph view before giving up at layer 0 — and on an
4192// Adreno 642L that retry cost 3x: forcing the graph on a model it
4193// refuses measured 0.3 tok/s against 0.905 for the per-op path it falls
4194// back to. Remembered once, the fallback runs at its own speed.
4195//
4196// The verdict is kept PER PIPELINE (`Pipeline::graph_refused` /
4197// `mark_graph_refused`), not in a process-wide flag: several pipelines
4198// of one file share a process in `serve` (backbone slots + skill lanes
4199// loaded mid-traffic), and a refusal in one lane — or the reset a new
4200// lane used to issue — must not move another lane's running sequence
4201// between the device and the host (R4/NF-2). Callers must NOT report
4202// transient refusals (an unsealed o1 state during prefill, a softcap).
4203
4204/// Called at every generation start (fresh KV). Applies a pending
4205/// verdict and picks this generation's arm while racing.
4206pub fn graph_race_begin_generation() {
4207 // One generation has now compiled whatever this model needs; keep it
4208 // for the next process. Once per run: the blob does not grow after
4209 // the pipelines exist, and the write is megabytes against the ~200 s
4210 // of compiling it saves on the device that needed this.
4211 #[cfg(feature = "gpu")]
4212 {
4213 // Save once, at the start of the SECOND generation: the first
4214 // has dispatched, so there is something to keep, and nothing is
4215 // saved before any work (the driver compiles at first use, not
4216 // at pipeline creation — the context comes up in 1.5 s while the
4217 // compiling costs minutes).
4218 //
4219 // Flushing again on 4, 8, 16 … was tried on the theory that a
4220 // chat turn compiles shapes the first one did not. It buys
4221 // nothing: a fresh app process still spent 49.0 s, then 58.7,
4222 // then 61.3 on its first answer with the backoff in place. One
4223 // flush it is.
4224 static FLUSHED: std::sync::Once = std::sync::Once::new();
4225 static FIRST: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
4226 if FIRST.swap(false, Ordering::Relaxed) {
4227 // Nothing dispatched yet.
4228 } else {
4229 FLUSHED.call_once(crate::gpu_wgpu::pipeline_cache_flush);
4230 }
4231 }
4232 GRAPH_RACE_TOK.store(0, Ordering::Relaxed);
4233 if GRAPH_RACE_STATE.load(Ordering::Relaxed) != 0 {
4234 return;
4235 }
4236 let (gn, cn) = (
4237 GRAPH_N[1].load(Ordering::Relaxed),
4238 GRAPH_N[0].load(Ordering::Relaxed),
4239 );
4240 if gn >= GRAPH_RACE_SAMPLES && cn >= GRAPH_RACE_SAMPLES {
4241 let g_avg = GRAPH_NS[1].load(Ordering::Relaxed) / gn as u64;
4242 let c_avg = GRAPH_NS[0].load(Ordering::Relaxed) / cn as u64;
4243 let verdict = if g_avg < c_avg { 1 } else { 2 };
4244 GRAPH_RACE_STATE.store(verdict, Ordering::Relaxed);
4245 tracing::info!(
4246 "wgpu graph race: graph {:.2} ms/tok vs normal {:.2} ms/tok -> {}",
4247 g_avg as f64 / 1e6,
4248 c_avg as f64 / 1e6,
4249 if verdict == 1 { "graph" } else { "normal path" }
4250 );
4251 return;
4252 }
4253 let flip = GRAPH_RACE_FLIP.fetch_add(1, Ordering::Relaxed);
4254 GRAPH_RACE_ARM_GRAPH.store((flip % 2 == 1) as u8, Ordering::Relaxed);
4255}
4256
4257/// Should this decode token try the graph? `trusted` (discrete adapter,
4258/// explicit env, or a GDN hybrid whose state lives on the device) skips
4259/// the race entirely.
4260pub fn graph_race_use_graph(trusted: bool) -> bool {
4261 if trusted {
4262 return true;
4263 }
4264 match GRAPH_RACE_STATE.load(Ordering::Relaxed) {
4265 1 => true,
4266 2 => false,
4267 _ => GRAPH_RACE_ARM_GRAPH.load(Ordering::Relaxed) == 1,
4268 }
4269}
4270
4271/// First decode token of a racing graph generation: hopeless already?
4272/// (>4x the normal path's per-token average AND over a second.) Settles
4273/// the race immediately; the caller discards the graph result and
4274/// recomputes this token on the normal path.
4275pub fn graph_race_first_token_hopeless(dur: std::time::Duration) -> bool {
4276 if GRAPH_RACE_STATE.load(Ordering::Relaxed) != 0 {
4277 return false;
4278 }
4279 let first = GRAPH_RACE_TOK.load(Ordering::Relaxed) == 0;
4280 let cn = GRAPH_N[0].load(Ordering::Relaxed);
4281 if !first || cn == 0 {
4282 return false;
4283 }
4284 let c_avg = GRAPH_NS[0].load(Ordering::Relaxed) / cn as u64;
4285 let ns = dur.as_nanos() as u64;
4286 if ns > 1_000_000_000 && ns > 4 * c_avg {
4287 GRAPH_RACE_STATE.store(2, Ordering::Relaxed);
4288 tracing::info!(
4289 "wgpu graph race: first graph token {:.0} ms vs normal {:.2} ms/tok — hopeless, normal path wins",
4290 ns as f64 / 1e6,
4291 c_avg as f64 / 1e6
4292 );
4293 return true;
4294 }
4295 false
4296}
4297
4298/// Record one decode-token wall time for the racing arm. The first
4299/// token of each generation is discarded (KV-mirror upload / cold
4300/// caches on the graph arm; cold mmap on the normal arm).
4301pub fn graph_race_record(used_graph: bool, dur: std::time::Duration) {
4302 if GRAPH_RACE_STATE.load(Ordering::Relaxed) != 0 {
4303 return;
4304 }
4305 let tok = GRAPH_RACE_TOK.fetch_add(1, Ordering::Relaxed);
4306 if tok == 0 {
4307 return;
4308 }
4309 let i = used_graph as usize;
4310 GRAPH_NS[i].fetch_add(dur.as_nanos() as u64, Ordering::Relaxed);
4311 GRAPH_N[i].fetch_add(1, Ordering::Relaxed);
4312}
4313
4314/// Bounded-cost content fingerprint for the backends' pointer-keyed device
4315/// caches: FNV over the whole slice up to 4 KiB, over 64 spread 64-byte
4316/// windows (plus the length) above. An address-keyed hit must also prove
4317/// the bytes are still the ones it uploaded — the allocator reuses heap
4318/// and mmap addresses freely, so a reloaded model or a re-dequantized
4319/// layer lands where the old bytes were — and sampling keeps that proof at
4320/// ~a microsecond even for a 126 MB matrix. Real replacements (another
4321/// model's tensor, an Adam-updated master) differ densely, so a 4 KiB
4322/// spread cannot miss them.
4323pub(crate) fn fp_bytes(data: &[u8]) -> u64 {
4324 #[inline]
4325 fn fnv(mut h: u64, bytes: &[u8]) -> u64 {
4326 let (chunks, tail) = bytes.split_at(bytes.len() & !7);
4327 for c in chunks.chunks_exact(8) {
4328 h ^= u64::from_le_bytes(c.try_into().unwrap());
4329 h = h.wrapping_mul(0x100_0000_01b3);
4330 }
4331 for &b in tail {
4332 h ^= b as u64;
4333 h = h.wrapping_mul(0x100_0000_01b3);
4334 }
4335 h
4336 }
4337 let mut h = 0xcbf2_9ce4_8422_2325u64 ^ (data.len() as u64);
4338 if data.len() <= 4096 {
4339 return fnv(h, data);
4340 }
4341 let step = (data.len() - 64) / 63;
4342 for i in 0..64 {
4343 h = fnv(h, &data[i * step..i * step + 64]);
4344 }
4345 h
4346}
4347
4348/// `fp_bytes` over an f32 slice without a bytemuck dependency (the Metal
4349/// backend builds with no GPU feature flags).
4350pub(crate) fn fp_f32(data: &[f32]) -> u64 {
4351 let bytes = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, data.len() * 4) };
4352 fp_bytes(bytes)
4353}
4354
4355#[cfg(test)]
4356mod fp_tests {
4357 use super::fp_bytes;
4358
4359 /// The pointer-keyed caches survive on `fp_bytes` telling two different
4360 /// tensors apart at a reused address. Its sampling must therefore see a
4361 /// change ANYWHERE — head, tail, and the stretches between windows are
4362 /// the places a cheaper hash would go blind.
4363 #[test]
4364 fn fp_bytes_sees_a_change_anywhere_in_a_sampled_slice() {
4365 let n = 1 << 20; // 1 MiB — far above the 4 KiB full-hash threshold
4366 let base: Vec<u8> = (0..n).map(|i| (i * 31 + 7) as u8).collect();
4367 let h0 = fp_bytes(&base);
4368 assert_eq!(h0, fp_bytes(&base), "fingerprint must be deterministic");
4369 // A DENSE change (every requantized/redequantized tensor is one)
4370 // must flip the fingerprint no matter how the windows fall.
4371 let mut dense = base.clone();
4372 for b in dense.iter_mut() {
4373 *b = b.wrapping_add(1);
4374 }
4375 assert_ne!(
4376 h0,
4377 fp_bytes(&dense),
4378 "a fully different tensor slipped through"
4379 );
4380 // Length participates: the same prefix at a shorter length is a
4381 // different key AND a different fingerprint.
4382 assert_ne!(h0, fp_bytes(&base[..n - 64]));
4383 // Below the threshold the hash is exact: a single flipped byte in
4384 // a norm-sized vector must be seen.
4385 let mut small = vec![3u8; 4096];
4386 let hs = fp_bytes(&small);
4387 small[2048] ^= 1;
4388 assert_ne!(hs, fp_bytes(&small), "full hash missed a one-byte change");
4389 // And the sampled windows land within bounds on awkward sizes.
4390 for n in [4097usize, 5000, 64 * 64, 1 << 16] {
4391 let v = vec![9u8; n];
4392 let _ = fp_bytes(&v); // must not panic on window math
4393 }
4394 }
4395}
4396
4397/// Hand the card back after a bake: drop its resident weights, planes and
4398/// pools so the ordinary engine (the runtime gate, a serve that follows)
4399/// starts from a clean budget. No-op off the wgpu backend.
4400pub fn bake_release() {
4401 #[cfg(feature = "gpu")]
4402 crate::gpu_wgpu::bake_release();
4403}
4404
4405/// Strict-f32 for the bake's GEMMs (phase A mask training): the mask
4406/// selects neurons by a gradient signal, and f16 operand rounding on
4407/// that signal closes the wrong ones. No-op off the wgpu backend.
4408pub fn bake_precision_strict(on: bool) {
4409 #[cfg(feature = "gpu")]
4410 crate::gpu_wgpu::bake_precision_strict(on);
4411 #[cfg(not(feature = "gpu"))]
4412 let _ = on;
4413}
4414
4415/// CMF_GRAPH_HOSTPROF=1: how a graph token's wall splits between the
4416/// host encoding the command stream and the tail the GPU still owes
4417/// after encode. Fifteen GPU-side suspects measured null while the
4418/// bench counted 17.7k allocations a token — this is the instrument
4419/// that says whether the thief was on the host all along.
4420pub fn hostprof_encode_done(t0: std::time::Instant) {
4421 use std::sync::atomic::{AtomicU64, Ordering};
4422 static ENC: AtomicU64 = AtomicU64::new(0);
4423 static N: AtomicU64 = AtomicU64::new(0);
4424 if std::env::var("CMF_GRAPH_HOSTPROF").as_deref() != Ok("1") {
4425 return;
4426 }
4427 ENC.fetch_add(t0.elapsed().as_nanos() as u64, Ordering::Relaxed);
4428 let n = N.fetch_add(1, Ordering::Relaxed) + 1;
4429 if n % 100 == 0 {
4430 eprintln!(
4431 "hostprof: encode {:.2} ms/token over {n} tokens",
4432 ENC.load(Ordering::Relaxed) as f64 / n as f64 / 1e6
4433 );
4434 }
4435}
4436
4437pub fn hostprof_total(t0: std::time::Instant) {
4438 use std::sync::atomic::{AtomicU64, Ordering};
4439 static TOT: AtomicU64 = AtomicU64::new(0);
4440 static N: AtomicU64 = AtomicU64::new(0);
4441 if std::env::var("CMF_GRAPH_HOSTPROF").as_deref() != Ok("1") {
4442 return;
4443 }
4444 TOT.fetch_add(t0.elapsed().as_nanos() as u64, Ordering::Relaxed);
4445 let n = N.fetch_add(1, Ordering::Relaxed) + 1;
4446 if n % 100 == 0 {
4447 eprintln!(
4448 "hostprof: total {:.2} ms/token over {n} tokens",
4449 TOT.load(Ordering::Relaxed) as f64 / n as f64 / 1e6
4450 );
4451 }
4452}
4453
4454/// Per-stage host-encode accumulator for the Metal token loop
4455/// (CMF_GRAPH_HOSTPROF=1). Stage 0 = GDN-run encode; everything else
4456/// falls out by subtraction from hostprof's encode total.
4457pub fn stageprof(stage: u32, dt: std::time::Duration) {
4458 use std::sync::atomic::{AtomicU64, Ordering};
4459 static NS: [AtomicU64; 4] = [
4460 AtomicU64::new(0),
4461 AtomicU64::new(0),
4462 AtomicU64::new(0),
4463 AtomicU64::new(0),
4464 ];
4465 static N: AtomicU64 = AtomicU64::new(0);
4466 if std::env::var("CMF_GRAPH_HOSTPROF").as_deref() != Ok("1") {
4467 return;
4468 }
4469 NS[stage as usize % 4].fetch_add(dt.as_nanos() as u64, Ordering::Relaxed);
4470 if stage == 1 {
4471 let n = N.fetch_add(1, Ordering::Relaxed) + 1;
4472 if n % 200 == 0 {
4473 eprintln!(
4474 "stageprof: planning {:.2} ms/tok | gdn-item {:.2} ms/tok | attn-item {:.2} ms/tok ({n} tok)",
4475 NS[1].load(Ordering::Relaxed) as f64 / n as f64 / 1e6,
4476 NS[2].load(Ordering::Relaxed) as f64 / n as f64 / 1e6,
4477 NS[3].load(Ordering::Relaxed) as f64 / n as f64 / 1e6
4478 );
4479 }
4480 }
4481}
4482
4483/// Active weight bytes dispatched so far (Metal decode path); 0 where
4484/// the backend does not count. The honest floor's numerator.
4485pub fn weight_bytes_dispatched() -> u64 {
4486 let mut total = 0u64;
4487 #[cfg(target_os = "macos")]
4488 {
4489 total += crate::gpu_metal::WEIGHT_BYTES.load(std::sync::atomic::Ordering::Relaxed);
4490 }
4491 #[cfg(feature = "gpu")]
4492 {
4493 total += crate::gpu_wgpu::WEIGHT_BYTES.load(std::sync::atomic::Ordering::Relaxed);
4494 }
4495 total
4496}
4497
4498/// The per-stage split of `weight_bytes_dispatched`:
4499/// [misc, dense-ffn, moe, attn, gdn, head].
4500pub fn weight_bytes_by() -> [u64; 6] {
4501 #[cfg(target_os = "macos")]
4502 {
4503 let mut o = [0u64; 6];
4504 for (i, a) in crate::gpu_metal::WEIGHT_BYTES_BY.iter().enumerate() {
4505 o[i] = a.load(std::sync::atomic::Ordering::Relaxed);
4506 }
4507 return o;
4508 }
4509 #[allow(unreachable_code)]
4510 [0; 6]
4511}
4512
4513#[cfg(test)]
4514mod f16_guard_tests {
4515 use super::*;
4516
4517 /// The f16 guard's maximum: the largest magnitude through the lanes and
4518 /// the remainder, +inf for an inf or a NaN anywhere.
4519 #[test]
4520 fn abs_max_or_inf_is_the_magnitude_maximum() {
4521 let mut xs: Vec<f32> = (0..37).map(|i| (i as f32 - 18.0) * 0.5).collect();
4522 assert_eq!(abs_max_or_inf(&xs), 9.0);
4523 xs[36] = -70000.0; // the remainder
4524 assert_eq!(abs_max_or_inf(&xs), 70000.0);
4525 xs[3] = f32::NAN; // a lane
4526 assert_eq!(abs_max_or_inf(&xs), f32::INFINITY);
4527 xs[3] = 0.0;
4528 xs[35] = f32::NEG_INFINITY;
4529 assert_eq!(abs_max_or_inf(&xs), f32::INFINITY);
4530 assert_eq!(abs_max_or_inf(&[]), 0.0);
4531 assert_eq!(abs_max_or_inf(&[-0.0, -1e-30]), 1e-30);
4532 }
4533}
4534
4535#[cfg(test)]
4536mod probe_warmup_tests {
4537 use super::*;
4538 use std::time::Duration;
4539
4540 fn ms(v: f64) -> Duration {
4541 Duration::from_nanos((v * 1e6) as u64)
4542 }
4543
4544 /// The bug this pins, measured on an A100: the first device call for
4545 /// a class compiles its pipeline, was timed at 117.01 ms against the
4546 /// host's 3.19, and sent `gemm-nt` to the CPU for the whole process —
4547 /// which ran a 27B bake on 2.6 cores with the card idle.
4548 #[test]
4549 fn one_cold_first_sample_does_not_lose_the_class() {
4550 let p = Probe::new();
4551 // First device sample is the pipeline build. Then the truth.
4552 probe_record_into(&p, "gemm-nt", None, true, ms(117.01));
4553 probe_record_into(&p, "gemm-nt", None, true, ms(1.1));
4554 probe_record_into(&p, "gemm-nt", None, true, ms(1.0));
4555 probe_record_into(&p, "gemm-nt", None, false, ms(3.19));
4556 probe_record_into(&p, "gemm-nt", None, false, ms(3.20));
4557 assert_eq!(
4558 p.state.load(Ordering::Relaxed),
4559 1,
4560 "the device is 3x faster once warm and must win"
4561 );
4562 }
4563
4564 /// The warm-up must not become a way to never decide, and must not
4565 /// underflow: a blind decrement at zero wraps a u32 to its maximum
4566 /// and mutes the arm for the life of the process.
4567 #[test]
4568 fn the_warmup_is_spent_once_and_never_underflows() {
4569 let p = Probe::new();
4570 for _ in 0..8 {
4571 probe_record_into(&p, "matmat", None, true, ms(10.0));
4572 }
4573 assert_eq!(p.gpu_burn.load(Ordering::Relaxed), 0, "spent, not wrapped");
4574 assert_eq!(
4575 p.gpu_n.load(Ordering::Relaxed),
4576 7,
4577 "one sample burned, the rest counted"
4578 );
4579 }
4580
4581 /// A device path that always refuses records no timing, so without
4582 /// counting the refusals the class can never reach a verdict. On an
4583 /// M4 with LFM2.5-2.6B `ffn` was still undecided after 9000 calls,
4584 /// alternating arms and paying a failed device attempt on half of
4585 /// them.
4586 #[test]
4587 fn a_class_whose_device_always_declines_settles_on_the_host() {
4588 let _probe_guard = probe_test_guard();
4589 // A class no other test in this file touches: `probe_note_decline`
4590 // works on the process-wide probes by design, and the tests in
4591 // this binary share them.
4592 let c = OpClass::MatmatWide;
4593 let p = &PROBES[c as usize];
4594 p.state.store(0, Ordering::Relaxed);
4595 p.declines.store(0, Ordering::Relaxed);
4596 for _ in 0..(PROBE_DECLINE_LIMIT - 1) {
4597 probe_note_decline(c);
4598 }
4599 assert_eq!(
4600 p.state.load(Ordering::Relaxed),
4601 0,
4602 "one short of the limit is still a question, not an answer"
4603 );
4604 probe_note_decline(c);
4605 assert_eq!(p.state.load(Ordering::Relaxed), 2, "settled on the host");
4606 assert!(matches!(probe_arm(c), ProbeArm::Cpu));
4607 p.state.store(0, Ordering::Relaxed);
4608 p.declines.store(0, Ordering::Relaxed);
4609 }
4610
4611 /// A genuinely slower device still loses — the warm-up removes an
4612 /// artefact, it does not put a thumb on the scale.
4613 #[test]
4614 fn a_slow_device_still_loses_after_the_warmup() {
4615 let p = Probe::new();
4616 for _ in 0..4 {
4617 probe_record_into(&p, "matvec", None, true, ms(40.0));
4618 }
4619 for _ in 0..4 {
4620 probe_record_into(&p, "matvec", None, false, ms(2.0));
4621 }
4622 assert_eq!(p.state.load(Ordering::Relaxed), 2, "host wins on merit");
4623 }
4624}
4625
4626/// Scratch/weight lifetime for a synchronous image-pipeline stage. Declare
4627/// this before the stage model so the model drops before cache collection.
4628pub(crate) struct ImageStageGuard {
4629 #[cfg(target_os = "macos")]
4630 metal: Option<crate::gpu_metal::ImageStageGuard>,
4631 #[cfg(feature = "gpu")]
4632 wgpu: crate::gpu_wgpu::ImageStageGuard,
4633}
4634
4635pub(crate) fn image_stage_scope() -> ImageStageGuard {
4636 ImageStageGuard {
4637 #[cfg(target_os = "macos")]
4638 metal: if matches!(backend(), Backend::Metal) {
4639 Some(crate::gpu_metal::image_stage_scope())
4640 } else {
4641 None
4642 },
4643 #[cfg(feature = "gpu")]
4644 wgpu: crate::gpu_wgpu::image_stage_scope(),
4645 }
4646}
4647
4648impl ImageStageGuard {
4649 pub(crate) fn track_model(&mut self, uid: u64) {
4650 #[cfg(target_os = "macos")]
4651 if let Some(metal) = &mut self.metal {
4652 metal.track_model(uid);
4653 }
4654 #[cfg(feature = "gpu")]
4655 self.wgpu.track_model(uid);
4656 #[cfg(not(target_os = "macos"))]
4657 let _ = uid;
4658 }
4659}
4660
4661// ════════════════════════════════════════════════════════════════════
4662// Z-Image-Turbo device contract (WP0 scaffold, plan §2.1). APPEND-ONLY.
4663//
4664// Owner of the contract: the WP1 lead. The backends implement it in their
4665// own child modules — `gpu_wgpu/zimage.rs` (WP2) and `gpu_metal/zimage.rs`
4666// (WP3) — and never edit the parent files. New fields are added only as
4667// `Option<…>` with agreed semantics; existing fields never change meaning.
4668//
4669// Convention (the same as every `gpu::*` entry): `false` = "not handled",
4670// nothing observable was changed, and the caller runs the CPU path
4671// (`zimage::ZImageDit::step_cpu` etc.), which is the bit-level reference.
4672//
4673// Sequence order everywhere is diffusers' [img rows…, cap rows…], with
4674// padded lengths n_img_p = ceil32(n_img) and n_cap_p = ceil32(L).
4675// ════════════════════════════════════════════════════════════════════
4676
4677// ───────────────────── Qwen-Image-2.1 device contract ─────────────────────
4678
4679/// Geometry of the Qwen-Image-2.1 denoiser.
4680#[derive(Clone, Copy, Debug, PartialEq)]
4681pub struct Qi21Geom {
4682 pub hidden: usize,
4683 pub nh: usize,
4684 pub hd: usize,
4685 pub inter: usize,
4686 pub in_ch: usize,
4687 pub eps: f32,
4688}
4689
4690/// One block's weights: tensor indices of `to_q, to_k, to_v, to_out.0,
4691/// gate_layer, proj (up), out (down)` and the per-head q/k norm weights.
4692pub struct Qi21BlockRef<'a> {
4693 pub w: [usize; 7],
4694 pub norm_q: &'a [f32],
4695 pub norm_k: &'a [f32],
4696}
4697
4698/// Everything the device needs to build a program and run its prefix.
4699pub struct Qi21PrefillArgs<'a> {
4700 pub model: &'a Arc<CmfModel>,
4701 pub geom: Qi21Geom,
4702 pub blocks: &'a [Qi21BlockRef<'a>],
4703 /// `img_in` [hidden, 64] and `proj_out` [64, hidden], f32.
4704 pub img_in: &'a [f32],
4705 pub proj_out: &'a [f32],
4706 pub key: u64,
4707 /// Prefix rows after `txt_in` / `img_in`, `[lp, hidden]`.
4708 pub x: &'a [f32],
4709 pub lp: usize,
4710 /// RoPE cos/sin of the prefix rows and of the target rows, `[rows, 64]`.
4711 pub rope_p: (&'a [f32], &'a [f32]),
4712 pub rope_t: (&'a [f32], &'a [f32]),
4713 /// Keys each prefix row sees (a leading run; non-decreasing).
4714 pub vis: &'a [u32],
4715 /// The t = 0 modulation `[s1|g1|s2|g2]`.
4716 pub mods0: &'a [f32],
4717 /// Target rows.
4718 pub n: usize,
4719}
4720
4721/// Build the program `a.key` and run its prefix on the device.
4722#[allow(unused_variables)]
4723pub fn qi21_prefill(a: &Qi21PrefillArgs) -> bool {
4724 match backend() {
4725 #[cfg(target_os = "macos")]
4726 Backend::Metal => crate::gpu_metal::qi21::prefill(a),
4727 #[cfg(feature = "gpu")]
4728 Backend::Wgpu => crate::gpu_wgpu::qi21::prefill(a),
4729 #[allow(unreachable_patterns)]
4730 _ => false,
4731 }
4732}
4733
4734/// One denoiser call of program `key`: `xtok [n, 64]` → `out [n, 64]`.
4735#[allow(unused_variables)]
4736pub fn qi21_step(key: u64, xtok: &[f32], mods: &[f32], fs: &[f32], out: &mut [f32]) -> bool {
4737 match backend() {
4738 #[cfg(target_os = "macos")]
4739 Backend::Metal => crate::gpu_metal::qi21::step(key, xtok, mods, fs, out),
4740 #[cfg(feature = "gpu")]
4741 Backend::Wgpu => crate::gpu_wgpu::qi21::step(key, xtok, mods, fs, out),
4742 #[allow(unreachable_patterns)]
4743 _ => false,
4744 }
4745}
4746
4747/// Drop program `key`. The wgpu module is released directly (module-local
4748/// state only), so a release never brings a device up.
4749#[allow(unused_variables)]
4750pub fn qi21_release_key(key: u64) {
4751 #[cfg(target_os = "macos")]
4752 if matches!(backend(), Backend::Metal) {
4753 crate::gpu_metal::qi21::release_key(key);
4754 }
4755 #[cfg(feature = "gpu")]
4756 crate::gpu_wgpu::qi21::release_key(key);
4757}
4758
4759/// Drop the denoiser's device state (see `qi21_release_key`), and the
4760/// resident VAE decoder's weights.
4761pub fn qi21_release() {
4762 #[cfg(target_os = "macos")]
4763 if matches!(backend(), Backend::Metal) {
4764 crate::gpu_metal::qi21::release();
4765 }
4766 #[cfg(feature = "gpu")]
4767 {
4768 crate::gpu_wgpu::qi21::release();
4769 crate::gpu_wgpu::qi21_vae::release();
4770 }
4771}
4772
4773/// One conv of the Qwen-Image-2.1 VAE decoder: host f32 weights
4774/// `[co][ci][k][k]` and bias `[co]` (k = 1 or 3, stride 1, same padding).
4775#[derive(Clone, Copy)]
4776pub struct Qi21VaeConvRef<'a> {
4777 pub w: &'a [f32],
4778 pub b: &'a [f32],
4779 pub ci: usize,
4780 pub co: usize,
4781 pub k: usize,
4782}
4783
4784/// A residual block: `conv2(φ(conv1(φ(x)))) + x` (through the 1×1
4785/// shortcut when ci ≠ co), φ = RMS_norm over channels (x/‖x‖·√C·γ), SiLU.
4786pub struct Qi21VaeResRef<'a> {
4787 pub g1: &'a [f32],
4788 pub c1: Qi21VaeConvRef<'a>,
4789 pub g2: &'a [f32],
4790 pub c2: Qi21VaeConvRef<'a>,
4791 pub shortcut: Option<Qi21VaeConvRef<'a>>,
4792}
4793
4794/// An up block: its resnets, then (all but the last block) nearest-2× +
4795/// the 3×3 conv, plus the DupUp shortcut of the block's input (`in_dim` →
4796/// `out_dim` channels, temporal factor `ft`).
4797pub struct Qi21VaeUpRef<'a> {
4798 pub resnets: Vec<Qi21VaeResRef<'a>>,
4799 pub up: Option<(Qi21VaeConvRef<'a>, usize)>,
4800 pub in_dim: usize,
4801 pub out_dim: usize,
4802}
4803
4804/// The Qwen-Image-2.1 VAE decoder (`qwen_image21_vae.rs`) for a resident
4805/// device decode.
4806pub struct Qi21VaeDecodeArgs<'a> {
4807 /// Identity of the weights: a backend caches its planes by it.
4808 pub key: u64,
4809 pub post_quant: Qi21VaeConvRef<'a>,
4810 pub conv_in: Qi21VaeConvRef<'a>,
4811 pub mid_res: [Qi21VaeResRef<'a>; 2],
4812 /// The mid attention: RMS_norm γ, `to_qkv` (1×1, 3c outputs: q, k, v),
4813 /// `proj` (1×1); single head, residual.
4814 pub attn_gamma: &'a [f32],
4815 pub attn_qkv: Qi21VaeConvRef<'a>,
4816 pub attn_proj: Qi21VaeConvRef<'a>,
4817 pub ups: Vec<Qi21VaeUpRef<'a>>,
4818 pub norm_out: &'a [f32],
4819 pub conv_out: Qi21VaeConvRef<'a>,
4820}
4821
4822/// Resident VAE decode: `z` = raw latent planes `[z_dim, h·w]` → `out`
4823/// `[out_channels, 16h·16w]` (before the clamp). `false` = not handled,
4824/// `out` untouched: the per-conv path runs.
4825#[allow(unused_variables)]
4826pub fn qi21_vae_decode(a: &Qi21VaeDecodeArgs, z: &[f32], h: usize, w: usize, out: &mut [f32]) -> bool {
4827 match backend() {
4828 #[cfg(feature = "gpu")]
4829 Backend::Wgpu => crate::gpu_wgpu::qi21_vae::decode(a, z, h, w, out),
4830 #[allow(unreachable_patterns)]
4831 _ => false,
4832 }
4833}
4834
4835/// One Z-Image transformer block's device inputs (noise refiner, context
4836/// refiner or main layer — all share this shape). Weights are tensor
4837/// indices into `model.tensors` (diffusers names under `dit.`); the codec
4838/// is whatever the container holds (F16/Bf16/Q8Row/Q8_2f/Q4TiledP…), and a
4839/// backend that cannot expand a codec declines (returns `false`).
4840/// Norm vectors are f32 host slices that live as long as the caller's
4841/// `ZImageDit`; a backend may cache them by pointer (they do not change).
4842#[derive(Clone, Copy)]
4843pub struct ZBlockRef<'a> {
4844 /// `attention.to_q/to_k/to_v/to_out.0.weight`, each [hidden, hidden].
4845 pub wq: usize,
4846 pub wk: usize,
4847 pub wv: usize,
4848 pub wo: usize,
4849 /// `feed_forward.w1` (gate) / `w3` (up) [inter, hidden], `w2` (down)
4850 /// [hidden, inter]. FFN = w2(silu(w1·x) ⊙ w3·x).
4851 pub w1: usize,
4852 pub w3: usize,
4853 pub w2: usize,
4854 /// `attention_norm1` / `attention_norm2`, [hidden] (plain-w RMSNorm).
4855 pub norm1: &'a [f32],
4856 pub norm2: &'a [f32],
4857 /// `ffn_norm1` / `ffn_norm2`, [hidden].
4858 pub ffn_norm1: &'a [f32],
4859 pub ffn_norm2: &'a [f32],
4860 /// `attention.norm_q` / `norm_k`, [hd] (per-head RMSNorm before RoPE).
4861 pub norm_q: &'a [f32],
4862 pub norm_k: &'a [f32],
4863}
4864
4865/// Z-Image geometry. Turbo: hidden 3840, nh 30 (MHA, no GQA), hd 128,
4866/// inter 10240, eps 1e-5 (all RMSNorms incl. qk-norm), final_eps 1e-6
4867/// (the affine-free final LayerNorm), patch_dim 64 (2×2×16).
4868#[derive(Clone, Copy, Debug, PartialEq)]
4869pub struct ZGeom {
4870 pub hidden: usize,
4871 pub nh: usize,
4872 pub hd: usize,
4873 pub inter: usize,
4874 pub eps: f32,
4875 pub final_eps: f32,
4876 pub patch_dim: usize,
4877}
4878
4879/// Once per (prompt, resolution). The backend uploads/caches what it needs
4880/// keyed by `key`; weight planes are keyed by the MODEL (not by `key`) and
4881/// survive across prompts until `zimage_release`.
4882pub struct ZPrepareArgs<'a> {
4883 pub model: &'a Arc<CmfModel>,
4884 pub geom: ZGeom,
4885 /// Caller-chosen identity of this (prompt, resolution) state; every
4886 /// `ZStepArgs` of the same image carries the same key.
4887 pub key: u64,
4888 /// Image tokens (H/16 · W/16), padded count ceil32(n_img), caption
4889 /// padded count ceil32(L). S = n_img_p + n_cap_p.
4890 pub n_img: usize,
4891 pub n_img_p: usize,
4892 pub n_cap_p: usize,
4893 /// The patch grid (H/16, W/16); n_img = grid.0 · grid.1. Row-major
4894 /// token order `hp·grid.1 + wp`.
4895 pub grid: (usize, usize),
4896 /// [n_cap_p, hidden], ALREADY context-refined (host or device).
4897 pub cap: &'a [f32],
4898 /// Noise-refiner RoPE: [n_img_p · hd/2] cos, sin (complex-interleaved
4899 /// pairs, hd/2 angles per token).
4900 pub rope_img: (&'a [f32], &'a [f32]),
4901 /// Main-layer RoPE: [(n_img_p + n_cap_p) · hd/2], rows ordered [img, cap].
4902 pub rope_joint: (&'a [f32], &'a [f32]),
4903 /// `all_x_embedder.2-1.weight` [hidden, 64], `.bias` [hidden],
4904 /// `x_pad_token` [hidden] (replaces rows ≥ n_img after the embed).
4905 pub x_emb_w: &'a [f32],
4906 pub x_emb_b: &'a [f32],
4907 pub x_pad: &'a [f32],
4908 /// `all_final_layer.2-1.linear.weight` [64, hidden], `.bias` [64].
4909 pub final_w: &'a [f32],
4910 pub final_b: &'a [f32],
4911 /// 2 noise-refiner blocks (image rows only) and 30 main layers.
4912 pub noise_refiner: &'a [ZBlockRef<'a>],
4913 pub layers: &'a [ZBlockRef<'a>],
4914 /// OPTIONAL (backends may ignore): the modulation of EVERY step of this
4915 /// image, [steps][(2+30)·4·hidden] in the `ZStepArgs::mods` layout, and
4916 /// [steps][hidden] final scales, so a backend can upload them once per
4917 /// image and index them by `ZStepArgs::step`. `ZStepArgs::mods` is still
4918 /// always supplied and is authoritative.
4919 pub mods_all: Option<&'a [f32]>,
4920 pub final_scale_all: Option<&'a [f32]>,
4921 /// OPTIONAL (B2): the CFG negative item. When `Some`, the backend
4922 /// prepares ONE batch-2 program under `key` — item 0 is this prompt,
4923 /// item 1 the negative — and every `ZStepArgs` of that key must carry
4924 /// `out_neg`. A backend without batch 2 returns `false` (the caller
4925 /// then prepares the two items separately or runs the CPU path).
4926 pub neg: Option<ZNegArgs<'a>>,
4927}
4928
4929/// The negative (unconditional) item of a CFG pair: its own refined
4930/// caption, padded caption length and joint RoPE table (the image ids sit
4931/// at axis-0 position L_p+1, so both tables depend on the item's L_p).
4932pub struct ZNegArgs<'a> {
4933 /// [n_cap_p, hidden], context-refined.
4934 pub cap: &'a [f32],
4935 pub n_cap_p: usize,
4936 /// [n_img_p · hd/2] cos, sin (noise refiner) of this item.
4937 pub rope_img: (&'a [f32], &'a [f32]),
4938 /// [(n_img_p + n_cap_p) · hd/2] cos, sin, rows [img, cap].
4939 pub rope_joint: (&'a [f32], &'a [f32]),
4940}
4941
4942/// Once per denoising step.
4943pub struct ZStepArgs<'a> {
4944 /// The `ZPrepareArgs::key` this step belongs to. A key the backend has
4945 /// not prepared → `false`.
4946 pub key: u64,
4947 /// Step index into the schedule (0..steps); selects the row of
4948 /// `ZPrepareArgs::mods_all` when a backend uses it.
4949 pub step: usize,
4950 /// [n_img_p, 64] patchified latent, inner order (dy·2+dx)·16+c. Rows
4951 /// ≥ n_img are copies of the last row; the backend replaces them with
4952 /// `x_pad` after the embed.
4953 pub x_tok: &'a [f32],
4954 /// Per block (noise_refiner then layers) the RAW chunks
4955 /// [scale_msa, gate_msa, scale_mlp, gate_mlp] of Linear(temb) (no SiLU
4956 /// before it), [(2+30)·4·hidden]. The backend applies (1+s) and tanh(g).
4957 pub mods: &'a [f32],
4958 /// [hidden] = 1 + Linear(SiLU(temb)) — already includes the +1.
4959 pub final_scale: &'a [f32],
4960 /// [n_img, 64]: the model output v (before the pipeline's negation),
4961 /// image rows only, patchified order.
4962 pub out: &'a mut [f32],
4963 /// [n_img, 64]: the negative item's v — required (and only valid) for
4964 /// a key prepared with `ZPrepareArgs::neg`. Both items see `x_tok`.
4965 pub out_neg: Option<&'a mut [f32]>,
4966}
4967
4968/// Prepare the per-(prompt, resolution) device state. Backends: wgpu →
4969/// `gpu_wgpu::zimage::prepare` (WP2), Metal → `gpu_metal::zimage::prepare`
4970/// (WP3).
4971#[allow(unused_variables)]
4972pub fn zimage_prepare(a: &ZPrepareArgs) -> bool {
4973 match backend() {
4974 #[cfg(target_os = "macos")]
4975 Backend::Metal => crate::gpu_metal::zimage::prepare(a),
4976 #[cfg(feature = "gpu")]
4977 Backend::Wgpu => crate::gpu_wgpu::zimage::prepare(a),
4978 #[allow(unreachable_patterns)]
4979 _ => false,
4980 }
4981}
4982
4983/// One full DiT forward on the device: x_embed → pad rows → noise refiner
4984/// ×2 → concat [img, cap] → 30 layers → final LayerNorm·scale → Linear →
4985/// image rows into `a.out`.
4986#[allow(unused_variables)]
4987pub fn zimage_step(a: &mut ZStepArgs) -> bool {
4988 match backend() {
4989 #[cfg(target_os = "macos")]
4990 Backend::Metal => crate::gpu_metal::zimage::step(a),
4991 #[cfg(feature = "gpu")]
4992 Backend::Wgpu => crate::gpu_wgpu::zimage::step(a),
4993 #[allow(unreachable_patterns)]
4994 _ => false,
4995 }
4996}
4997
4998/// OPTIONAL (B2): build the backend's weight planes for the per-step blocks
4999/// and the context refiner ahead of `zimage_prepare`, so the caller can
5000/// overlap the upload with the (CPU) text encoder. `false` = not done;
5001/// `zimage_prepare` builds whatever is missing either way.
5002#[allow(unused_variables)]
5003pub fn zimage_preload(
5004 model: &Arc<CmfModel>,
5005 geom: &ZGeom,
5006 noise_refiner: &[ZBlockRef],
5007 layers: &[ZBlockRef],
5008 context_refiner: &[ZBlockRef],
5009) -> bool {
5010 match backend() {
5011 #[cfg(target_os = "macos")]
5012 Backend::Metal => {
5013 crate::gpu_metal::zimage::preload(model, geom, noise_refiner, layers, context_refiner)
5014 }
5015 #[cfg(feature = "gpu")]
5016 Backend::Wgpu => crate::gpu_wgpu::zimage::preload(model, geom, noise_refiner, layers, context_refiner),
5017 #[allow(unreachable_patterns)]
5018 _ => false,
5019 }
5020}
5021
5022/// Persist the driver's compiled pipelines after a Z-Image generation (the
5023/// chain's kernels are built at first use, after the context came up), so
5024/// the next process skips the compile. Best-effort, no-op off wgpu.
5025pub fn zimage_flush_pipelines() {
5026 #[cfg(feature = "gpu")]
5027 if matches!(backend(), Backend::Wgpu) {
5028 crate::gpu_wgpu::pipeline_cache_flush();
5029 }
5030}
5031
5032/// OPTIONAL (B2): bring the device up and compile the Z-Image kernels, on
5033/// a helper thread at the start of a generation (the context and the
5034/// compiles cost ~1 s cold, beside the host-side loading). `false` = no
5035/// device path here.
5036pub fn zimage_warmup() -> bool {
5037 match backend() {
5038 #[cfg(target_os = "macos")]
5039 Backend::Metal => crate::gpu_metal::zimage::warmup(),
5040 #[cfg(feature = "gpu")]
5041 Backend::Wgpu => crate::gpu_wgpu::zimage::warmup(),
5042 #[allow(unreachable_patterns)]
5043 _ => false,
5044 }
5045}
5046
5047/// OPTIONAL (B2): upload the resident VAE's weights and compile its
5048/// kernels ahead of `vae_decode_chain` (the caller runs it on a helper
5049/// thread while the DiT steps keep the device busy). `false` = not done.
5050#[allow(unused_variables)]
5051pub fn vae_prewarm(a: &crate::vae::VaeChainArgs) -> bool {
5052 match backend() {
5053 #[cfg(target_os = "macos")]
5054 Backend::Metal => crate::gpu_metal::zimage::vae_prewarm(a),
5055 #[cfg(feature = "gpu")]
5056 Backend::Wgpu => crate::gpu_wgpu::zimage::vae_prewarm(a),
5057 #[allow(unreachable_patterns)]
5058 _ => false,
5059 }
5060}
5061
5062/// Drop the Z-Image DiT device state (planes, prepared programs) but keep
5063/// the VAE chain (B2: the generator frees the DiT before decoding).
5064pub fn zimage_release_dit() {
5065 #[cfg(target_os = "macos")]
5066 crate::gpu_metal::zimage::release_dit();
5067 #[cfg(feature = "gpu")]
5068 crate::gpu_wgpu::zimage::release_dit();
5069}
5070
5071/// Drop every Z-Image device resource (planes, prepared states, VAE chain
5072/// buffers): stage change or process end. Calls each compiled backend's
5073/// release directly, without `backend()`, so it never brings a device up;
5074/// the child modules' `release` must touch module-local state only.
5075pub fn zimage_release() {
5076 #[cfg(target_os = "macos")]
5077 crate::gpu_metal::zimage::release();
5078 #[cfg(feature = "gpu")]
5079 crate::gpu_wgpu::zimage::release();
5080}
5081
5082/// Optional device context refiner: the same block math with scale = 0 and
5083/// gate = 1 (unmodulated): x += norm2(attn(norm1(x))); x += ffn_norm2(ffn(
5084/// ffn_norm1(x))). `cap` is [n_cap_p, hidden] in/out (the cap_embedder
5085/// output with pad rows already = cap_pad_token); `rope_cap` is
5086/// [n_cap_p · hd/2] cos, sin. `false` = untouched, run the CPU refiner.
5087#[allow(unused_variables)]
5088pub fn zimage_refine_caption(
5089 model: &Arc<CmfModel>,
5090 geom: &ZGeom,
5091 blocks: &[ZBlockRef],
5092 rope_cap: (&[f32], &[f32]),
5093 cap: &mut [f32],
5094) -> bool {
5095 match backend() {
5096 #[cfg(target_os = "macos")]
5097 Backend::Metal => {
5098 crate::gpu_metal::zimage::refine_caption(model, geom, blocks, rope_cap, cap)
5099 }
5100 #[cfg(feature = "gpu")]
5101 Backend::Wgpu => {
5102 crate::gpu_wgpu::zimage::refine_caption(model, geom, blocks, rope_cap, cap)
5103 }
5104 #[allow(unreachable_patterns)]
5105 _ => false,
5106 }
5107}
5108
5109/// Resident Flux-VAE decode (the whole decoder on the device, one latent
5110/// upload, one RGB readback). `a` comes from `VaeDecoder::chain_args()`.
5111/// `z` is [latent_channels, h, w] ALREADY de-normalised
5112/// (z/scaling_factor + shift_factor — the conv_in input); `out` is
5113/// [3, 8h, 8w], the raw decoder output (≈[-1, 1], before x/2+0.5).
5114#[allow(unused_variables)]
5115pub fn vae_decode_chain(
5116 a: &crate::vae::VaeChainArgs,
5117 z: &[f32],
5118 h: usize,
5119 w: usize,
5120 out: &mut [f32],
5121) -> bool {
5122 match backend() {
5123 #[cfg(target_os = "macos")]
5124 Backend::Metal => crate::gpu_metal::zimage::vae_decode_chain(a, z, h, w, out),
5125 #[cfg(feature = "gpu")]
5126 Backend::Wgpu => crate::gpu_wgpu::zimage::vae_decode_chain(a, z, h, w, out),
5127 #[allow(unreachable_patterns)]
5128 _ => false,
5129 }
5130}