Skip to main content

cortiq_engine/
gpu.rs

1//! Facade for GPU backends: a single call entry point for qtensor/pipeline/
2//! linear_core. Job types and the threshold are canonical HERE; behind the
3//! facade dispatch goes to a platform backend:
4//!   - `gpu_metal` (Apple Silicon, unified memory + no-copy buffers);
5//!   - `gpu_wgpu` (C1: Vulkan/DX12/Metal — NVIDIA/Radeon/Intel/Apple,
6//!     weights resident in VRAM), available under `--features gpu`.
7//!
8//! Runtime selection via `CMF_GPU`: `1` — native Metal (macOS) or wgpu
9//! (other OSes); `wgpu` — force wgpu (including for the local
10//! Metal-via-wgpu parity test). Any backend refusal — `false` and the honest
11//! CPU path, no partial results.
12
13use cortiq_core::CmfModel;
14use std::cell::Cell;
15use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, 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/// Run `f` with the GPU gates off on this thread (pure-CPU arm).
35pub fn cpu_scope<R>(f: impl FnOnce() -> R) -> R {
36    CPU_ONLY.with(|c| c.set(true));
37    let r = f();
38    CPU_ONLY.with(|c| c.set(false));
39    r
40}
41
42/// Backends: note a one-off cost (weight upload, buffer-cache fill) so
43/// the probe discards this sample.
44pub(crate) fn probe_note_cold() {
45    PROBE_COLD.with(|c| c.set(true));
46}
47
48/// Pipeline: mark the current layer (or −1 outside layers) for layer-split.
49pub fn set_layer(l: i64) {
50    CUR_LAYER.with(|c| c.set(l));
51}
52
53/// Parse `CMF_GPU_LAYERS` («0-19», «0,2,4», «0-9,30-39») once.
54/// None = no restriction (all layers on GPU). Garbage → also no restriction.
55fn layer_ranges() -> &'static Option<Vec<(i64, i64)>> {
56    static R: OnceLock<Option<Vec<(i64, i64)>>> = OnceLock::new();
57    R.get_or_init(|| {
58        let s = std::env::var("CMF_GPU_LAYERS").ok()?;
59        let mut v = Vec::new();
60        for part in s.split(',') {
61            let part = part.trim();
62            match part.split_once('-') {
63                Some((a, b)) => v.push((a.trim().parse().ok()?, b.trim().parse().ok()?)),
64                None => {
65                    let x: i64 = part.parse().ok()?;
66                    v.push((x, x));
67                }
68            }
69        }
70        Some(v)
71    })
72}
73
74fn layer_allowed() -> bool {
75    match layer_ranges() {
76        None => true,
77        Some(ranges) => {
78            let cur = CUR_LAYER.with(|c| c.get());
79            cur < 0 || ranges.iter().any(|(a, b)| cur >= *a && cur <= *b)
80        }
81    }
82}
83
84/// GPU allowed FOR THE CURRENT LAYER: backend is initialized AND the layer
85/// falls within `CMF_GPU_LAYERS` (GPU/CPU layer-split) AND we are not
86/// inside a `cpu_scope`. Op gates call this.
87pub fn enabled_here() -> bool {
88    !CPU_ONLY.with(|c| c.get()) && enabled() && layer_allowed()
89}
90
91// ── Runtime GPU-vs-CPU probe ────────────────────────────────────────────
92// CMF_GPU=1 does not TRUST that the device wins — it MEASURES. For each
93// op class the first calls alternate arms: GPU timed vs pure-CPU timed
94// (under cpu_scope). Cold GPU calls (weight upload / cache fill) are
95// discarded; after PROBE_SAMPLES clean samples per arm the faster arm is
96// chosen for the rest of the process. Rationale: submit+poll latency
97// differs by an order of magnitude across driver stacks (Metal/PCIe
98// ~3-4 ms, Vulkan/4090 ~0.3 ms) — a static threshold cannot know whether
99// per-op offload pays off HERE. CMF_GPU_PROBE=0 → always trust the GPU.
100
101/// GPU-eligible op classes, each with an independent probe.
102#[derive(Clone, Copy)]
103pub enum OpClass {
104    /// Whole FFN chain in one submission (dense / MoE block).
105    Ffn = 0,
106    /// Large hybrid CPU∥GPU matvec (lm_head class).
107    Matvec = 1,
108    /// Prefill GEMM (matmat).
109    Matmat = 2,
110    /// Batched matvecs of one input (QKV).
111    Batch = 3,
112}
113
114/// Probe verdict for one call.
115pub enum ProbeArm {
116    /// Run the GPU path (during probing: timed, recorded).
117    Gpu,
118    /// Probing: run the CPU path under `cpu_scope`, timed, recorded.
119    CpuTimed,
120    /// Decided: CPU won — run the CPU path (under `cpu_scope`).
121    Cpu,
122}
123
124/// Clean samples per arm before a class decides.
125const PROBE_SAMPLES: u32 = 6;
126
127struct Probe {
128    /// 0 = probing, 1 = GPU won, 2 = CPU won.
129    state: AtomicU8,
130    flip: AtomicU32,
131    gpu_ns: AtomicU64,
132    gpu_n: AtomicU32,
133    cpu_ns: AtomicU64,
134    cpu_n: AtomicU32,
135}
136
137impl Probe {
138    const fn new() -> Self {
139        Self {
140            state: AtomicU8::new(0),
141            flip: AtomicU32::new(0),
142            gpu_ns: AtomicU64::new(0),
143            gpu_n: AtomicU32::new(0),
144            cpu_ns: AtomicU64::new(0),
145            cpu_n: AtomicU32::new(0),
146        }
147    }
148}
149
150static PROBES: [Probe; 4] = [Probe::new(), Probe::new(), Probe::new(), Probe::new()];
151
152fn probe_on() -> bool {
153    static ON: OnceLock<bool> = OnceLock::new();
154    *ON.get_or_init(|| {
155        std::env::var("CMF_GPU_PROBE")
156            .map(|v| v != "0" && v != "off")
157            .unwrap_or(true)
158    })
159}
160
161/// Which arm should this GPU-eligible call take? Consult AFTER the
162/// eligibility gates (`enabled_here` / `min_rows`) so only real
163/// candidates alternate.
164pub fn probe_arm(c: OpClass) -> ProbeArm {
165    if !probe_on() {
166        return ProbeArm::Gpu;
167    }
168    let p = &PROBES[c as usize];
169    match p.state.load(Ordering::Relaxed) {
170        1 => ProbeArm::Gpu,
171        2 => ProbeArm::Cpu,
172        _ => {
173            PROBE_COLD.with(|f| f.set(false));
174            if p.flip.fetch_add(1, Ordering::Relaxed) % 2 == 0 {
175                ProbeArm::Gpu
176            } else {
177                ProbeArm::CpuTimed
178            }
179        }
180    }
181}
182
183/// Record a timed arm sample; on the `PROBE_SAMPLES`-th clean sample of
184/// BOTH arms the class decides for the rest of the process.
185pub fn probe_record(c: OpClass, gpu: bool, dur: std::time::Duration) {
186    let p = &PROBES[c as usize];
187    if p.state.load(Ordering::Relaxed) != 0 {
188        return;
189    }
190    if gpu && PROBE_COLD.with(|f| f.replace(false)) {
191        return; // one-off cost in this call — not a steady-state sample
192    }
193    let ns = dur.as_nanos().min(u64::MAX as u128) as u64;
194    if gpu {
195        p.gpu_ns.fetch_add(ns, Ordering::Relaxed);
196        p.gpu_n.fetch_add(1, Ordering::Relaxed);
197    } else {
198        p.cpu_ns.fetch_add(ns, Ordering::Relaxed);
199        p.cpu_n.fetch_add(1, Ordering::Relaxed);
200    }
201    let (gn, cn) = (p.gpu_n.load(Ordering::Relaxed), p.cpu_n.load(Ordering::Relaxed));
202    if gn >= 2 && cn >= 2 {
203        let g = p.gpu_ns.load(Ordering::Relaxed) as f64 / gn as f64;
204        let cp = p.cpu_ns.load(Ordering::Relaxed) as f64 / cn as f64;
205        // Early verdict on a ≥3× gap — no reason to keep feeding the
206        // losing arm; close races take the full sample count.
207        if (gn < PROBE_SAMPLES || cn < PROBE_SAMPLES) && g < cp * 3.0 && cp < g * 3.0 {
208            return;
209        }
210        let winner = if g <= cp { 1 } else { 2 };
211        if p
212            .state
213            .compare_exchange(0, winner, Ordering::Relaxed, Ordering::Relaxed)
214            .is_ok()
215        {
216            tracing::info!(
217                "gpu probe [{}]: gpu {:.2} ms vs cpu {:.2} ms per op → {}",
218                ["ffn", "matvec", "matmat", "qkv-batch"][c as usize],
219                g / 1e6,
220                cp / 1e6,
221                if winner == 1 { "gpu" } else { "cpu" },
222            );
223        }
224    }
225}
226
227/// Is the class still collecting samples? (Call sites use this to route
228/// cold-weight calls away from the GPU arm during probing.)
229pub fn probe_deciding(c: OpClass) -> bool {
230    probe_on() && PROBES[c as usize].state.load(Ordering::Relaxed) == 0
231}
232
233/// Probing helper: true — tensor `idx`'s quant weights are ALREADY
234/// device-resident (a clean GPU sample is possible now); false — they
235/// were not (the upload starts within the VRAM budget, so a later call
236/// finds them warm) or the tensor cannot go to the GPU at all. Keeps the
237/// probe from billing a full cold dispatch+readback to a sample it will
238/// discard anyway. The verdict needs only a couple of warm tensors, so
239/// probe-driven uploads are capped — the losing-GPU machine should not
240/// pay for uploading the whole layer stack it will never use; if the GPU
241/// wins, the rest uploads lazily on demand, in the same first-touch order.
242#[allow(unused_variables)]
243pub fn q8_resident_or_upload(model: &Arc<CmfModel>, idx: usize) -> bool {
244    static PROBE_UPLOADS: AtomicU32 = AtomicU32::new(0);
245    let may_upload = PROBE_UPLOADS.load(Ordering::Relaxed) < 4;
246    let resident = match backend() {
247        #[cfg(target_os = "macos")]
248        Backend::Metal => crate::gpu_metal::q8_resident_or_upload(model, idx, may_upload),
249        #[cfg(feature = "gpu")]
250        Backend::Wgpu => crate::gpu_wgpu::q8_resident_or_upload(model, idx, may_upload),
251        Backend::None => false,
252    };
253    if !resident && may_upload {
254        PROBE_UPLOADS.fetch_add(1, Ordering::Relaxed);
255    }
256    resident
257}
258
259/// Test hook: reset all probes to the undecided state.
260#[cfg(test)]
261pub(crate) fn probe_reset() {
262    for p in &PROBES {
263        p.state.store(0, Ordering::Relaxed);
264        p.flip.store(0, Ordering::Relaxed);
265        p.gpu_ns.store(0, Ordering::Relaxed);
266        p.gpu_n.store(0, Ordering::Relaxed);
267        p.cpu_ns.store(0, Ordering::Relaxed);
268        p.cpu_n.store(0, Ordering::Relaxed);
269    }
270}
271
272#[cfg(test)]
273mod probe_tests {
274    use super::*;
275    use std::time::Duration;
276
277    // One test fn: PROBES is process-global and probe_reset touches all
278    // classes — parallel test threads would race.
279    #[test]
280    fn probe_alternates_discards_cold_and_decides() {
281        probe_reset();
282        // Probing: arms alternate.
283        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::Gpu));
284        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::CpuTimed));
285
286        // A cold GPU sample (upload noted) must be discarded: feed a
287        // catastrophic cold sample, then clean fast-GPU samples — GPU
288        // wins only if the cold one did not count.
289        probe_note_cold();
290        probe_record(OpClass::Ffn, true, Duration::from_secs(1000));
291        for _ in 0..PROBE_SAMPLES {
292            probe_record(OpClass::Ffn, true, Duration::from_millis(1));
293            probe_record(OpClass::Ffn, false, Duration::from_millis(4));
294        }
295        assert!(matches!(probe_arm(OpClass::Ffn), ProbeArm::Gpu));
296
297        // The reverse: a class where the CPU arm is faster decides CPU.
298        for _ in 0..PROBE_SAMPLES {
299            probe_record(OpClass::Matmat, true, Duration::from_millis(4));
300            probe_record(OpClass::Matmat, false, Duration::from_millis(1));
301        }
302        assert!(matches!(probe_arm(OpClass::Matmat), ProbeArm::Cpu));
303
304        // cpu_scope: gates off inside, restored after.
305        cpu_scope(|| CPU_ONLY.with(|c| assert!(c.get())));
306        CPU_ONLY.with(|c| assert!(!c.get()));
307        probe_reset();
308    }
309}
310
311/// Default row threshold: the GPU takes only larger matrices (lm_head
312/// class). Below it, the dispatch/readback cost does not pay off on unified memory.
313pub const GPU_MIN_ROWS: usize = 65_536;
314
315/// Effective threshold: `CMF_GPU_MIN_ROWS` overrides. Defaults differ
316/// by device class: on a DISCRETE card VRAM bandwidth pays off even for
317/// FFN/QKV-class matrices (4096), on unified memory only lm_head-class
318/// is worth the dispatch/readback (65536). Field case behind this: a
319/// 35B model on an RTX 4090 saw ~0 offload because every layer matrix
320/// sat below the old universal 65536.
321pub fn min_rows() -> usize {
322    if let Some(v) = std::env::var("CMF_GPU_MIN_ROWS").ok().and_then(|v| v.parse().ok()) {
323        return v;
324    }
325    if discrete() {
326        4096
327    } else {
328        GPU_MIN_ROWS
329    }
330}
331
332/// Is the active backend a discrete card (PCIe VRAM)?
333pub fn discrete() -> bool {
334    match backend() {
335        #[cfg(feature = "gpu")]
336        Backend::Wgpu => crate::gpu_wgpu::is_discrete(),
337        #[cfg(target_os = "macos")]
338        Backend::Metal => false, // UMA by the init() guard
339        Backend::None => false,
340    }
341}
342
343/// A single MoE-FFN job (an expert with its own weight), executed in one
344/// submission: (rows, cols, idx, row_scale) for gate/up/down + prescaled
345/// inputs + the down θ-field + the blending weight.
346pub struct MoeJob<'a> {
347    pub gate: (usize, usize, usize, &'a [f32]),
348    pub up: (usize, usize, usize, &'a [f32]),
349    pub down: (usize, usize, usize, &'a [f32]),
350    pub xs_gate: Vec<f32>,
351    pub xs_up: Vec<f32>,
352    pub down_col: &'a [f32],
353    pub w: f32,
354}
355
356/// A single independent batch matvec (GDN projections of one input).
357pub struct BatchJob<'a> {
358    pub idx: usize,
359    pub rows: usize,
360    pub cols: usize,
361    pub row_scale: &'a [f32],
362    pub xs: Vec<f32>,
363}
364
365#[derive(Clone, Copy, PartialEq, Eq)]
366enum Backend {
367    None,
368    #[cfg(target_os = "macos")]
369    Metal,
370    #[cfg(feature = "gpu")]
371    Wgpu,
372}
373
374fn backend() -> Backend {
375    #[cfg(feature = "gpu")]
376    if crate::gpu_wgpu::selected() {
377        return if crate::gpu_wgpu::enabled() { Backend::Wgpu } else { Backend::None };
378    }
379    #[cfg(target_os = "macos")]
380    if crate::gpu_metal::enabled() {
381        return Backend::Metal;
382    }
383    Backend::None
384}
385
386/// GPU enabled and initialized on the selected backend?
387pub fn enabled() -> bool {
388    backend() != Backend::None
389}
390
391/// q8_row/q8_2f matvec, rows [row0, row0+rows). `xs` — prescaled by the θ-field.
392#[allow(clippy::too_many_arguments, unused_variables)]
393pub fn q8_matvec_range(
394    model: &Arc<CmfModel>,
395    idx: usize,
396    row0: usize,
397    row_scale: &[f32],
398    xs: &[f32],
399    rows: usize,
400    cols: usize,
401    out: &mut [f32],
402) -> bool {
403    match backend() {
404        #[cfg(target_os = "macos")]
405        Backend::Metal => {
406            crate::gpu_metal::q8_matvec_range(model, idx, row0, row_scale, xs, rows, cols, out)
407        }
408        #[cfg(feature = "gpu")]
409        Backend::Wgpu => {
410            crate::gpu_wgpu::q8_matvec_range(model, idx, row0, row_scale, xs, rows, cols, out)
411        }
412        Backend::None => false,
413    }
414}
415
416/// GEMM of a prefill batch: `pre` — prescaled inputs row-major [b, cols],
417/// out — row-major [b, rows].
418#[allow(clippy::too_many_arguments, unused_variables)]
419pub fn q8_matmat(
420    model: &Arc<CmfModel>,
421    idx: usize,
422    row_scale: &[f32],
423    pre: &[f32],
424    b: usize,
425    rows: usize,
426    cols: usize,
427    out: &mut [f32],
428) -> bool {
429    match backend() {
430        #[cfg(target_os = "macos")]
431        Backend::Metal => {
432            crate::gpu_metal::q8_matmat(model, idx, row_scale, pre, b, rows, cols, out)
433        }
434        #[cfg(feature = "gpu")]
435        Backend::Wgpu => {
436            crate::gpu_wgpu::q8_matmat(model, idx, row_scale, pre, b, rows, cols, out)
437        }
438        Backend::None => false,
439    }
440}
441
442/// A layer's MoE-FFN in one submission (amortizing the dispatch cost).
443#[allow(unused_variables)]
444pub fn moe_block(model: &Arc<CmfModel>, jobs: &[MoeJob], out: &mut [f32]) -> bool {
445    match backend() {
446        #[cfg(target_os = "macos")]
447        Backend::Metal => crate::gpu_metal::moe_block(model, jobs, out),
448        #[cfg(feature = "gpu")]
449        Backend::Wgpu => crate::gpu_wgpu::moe_block(model, jobs, out),
450        Backend::None => false,
451    }
452}
453
454/// Independent matvecs of one input in a single submission (GDN projections).
455#[allow(unused_variables)]
456pub fn matvec_batch(model: &Arc<CmfModel>, jobs: &[BatchJob], out: &mut [&mut [f32]]) -> bool {
457    match backend() {
458        #[cfg(target_os = "macos")]
459        Backend::Metal => crate::gpu_metal::matvec_batch(model, jobs, out),
460        #[cfg(feature = "gpu")]
461        Backend::Wgpu => crate::gpu_wgpu::matvec_batch(model, jobs, out),
462        Backend::None => false,
463    }
464}