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