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