metrale_model_layers/layers/ops/
w4a4_proj.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-28: W4A4 small-M projection on FP4 tensor cores (`w4a4_gemv_mx.cu`, module
4//! `w4a4_gemv_mx`).
5//!
6//! The block-scale FP4 MMA (`kind::mxf4nvf4`) takes the checkpoint's NVFP4
7//! weight bytes and E4M3 group scales as operands, with no dequant. The
8//! activations are first quantised per row to NVFP4 with a per-row FP32 global
9//! scale (`w4a4_quant_rows`), so the numerics are W4A4. Which projections take it
10//! is the `--weight-quantization` tier's answer (`WeightQuantTier::w4a4_rows`):
11//! under `declared`, a weight whose checkpoint declares FP4 activations (stamped
12//! `Nvfp4Act::A4`); under `nvfp4`, every NVFP4 weight when `--w4a4-downcast` is on.
13//! Every other NVFP4 weight stays W4A16.
14//!
15//! Scope: the projection sites that call [`nvfp4_proj_small_m`]: GDN
16//! qkvz/out_proj, attention q/k/v/o and dense-FFN gate/up/down, up to
17//! [`max_rows`] rows. The lm_head does not call it.
18//!
19//! Scratch: the quantised activations need `[M, K/2] + [M, K/16] + [M] f32` of
20//! device memory. [`prepare`] allocates it once per backend at model build
21//! (from `W4a16BatchmTiers::resolve`). Launches run on the caller's stream,
22//! and the scratch and the last-quantisation record are shared, so two
23//! projections on different streams at once would race on them.
24//!
25//! Owner: model-layers ops.
26//! Invariants:
27//! - Device scratch is allocated only in [`prepare`], never by a launch.
28//! - A projection reaches the FP4 kernels only when `WeightQuantTier::w4a4_rows` admits it.
29
30use std::sync::{Mutex, OnceLock};
31
32use anyhow::Result;
33use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
34
35use crate::weight_map::QuantizedWeight;
36
37/// 2026-09-25: Rows the narrow entries (`w4a4_gemv_mx32`) cover, and the widest
38/// W4A16 fallback (`w4a16_gemv_batch32`).
39pub const W4A4_MAX_M: u32 = 32;
40/// 2026-09-25: Rows the wide entries (`w4a4_gemv_mx64*`) cover.
41pub const W4A4_WIDE_MAX_M: u32 = 64;
42/// 2026-09-25: Largest K the scratch is sized for.
43pub const W4A4_MAX_K: u32 = 32768;
44
45#[derive(Clone, Copy)]
46struct W4a4State {
47    quant: KernelHandle,
48    mx8: KernelHandle,
49    mx16: KernelHandle,
50    mx32: KernelHandle,
51    /// 2026-09-25: Activation-reuse twins (`METRALE_W4A4_MX_NT`). The
52    /// `w4a4_gemv_nt_oracle` example compares them bit for bit with mx16/mx32.
53    mx16_nt2: KernelHandle,
54    mx32_nt4: KernelHandle,
55    /// 2026-09-25: Persistent activation-staged entries (`METRALE_W4A4_MX_PS`),
56    /// compared bit for bit with mx16/mx32 by the same example.
57    mx16_ps: KernelHandle,
58    mx32_ps: KernelHandle,
59    /// 2026-09-25: Streaming multiprocessors: the persistent entries' grid.
60    sms: u32,
61    /// 2026-09-28: 33..=64 rows (zero handles when this target lacks them).
62    mx64: KernelHandle,
63    mx64_nt2: KernelHandle,
64    /// 2026-09-28: [`W4A4_WIDE_MAX_M`] when `mx64`/`mx64_nt2` resolved, else
65    /// [`W4A4_MAX_M`]; the scratch is sized for it.
66    max_m: u32,
67    aq: DevicePtr,
68    a_scale: DevicePtr,
69    a_gs: DevicePtr,
70    /// 2026-09-25: W4A16 reference output for [`audit_enabled`] (null
71    /// otherwise).
72    audit_ref: DevicePtr,
73}
74
75/// 2026-09-25: Largest projection N the audit reference buffer holds; a wider
76/// launch is not audited.
77const AUDIT_MAX_N: usize = 65536;
78
79/// 2026-09-25: `METRALE_W4A4_PROJ_AUDIT` (non-empty), a diagnostic. Every W4A4
80/// projection launched outside a graph capture also runs the W4A16 path,
81/// synchronises, and accumulates `||y_w4a4 - y_w4a16|| / ||y_w4a16||` per call
82/// site, logged every 64 samples. Captured launches are not audited, so it
83/// needs graphs off (`METRALE_NO_MTP_VERIFY_GRAPHS=1
84/// METRALE_NO_DECODE_GRAPHS_MULTISEQ=1`) to see the decode and verify steps.
85fn audit_enabled() -> bool {
86    static ON: OnceLock<bool> = OnceLock::new();
87    *ON.get_or_init(|| std::env::var_os("METRALE_W4A4_PROJ_AUDIT").is_some_and(|v| !v.is_empty()))
88}
89
90// 2026-09-25: `w4a4_proj.rs` is loaded via `#[path = "ops/w4a4_proj.rs"]`, so
91// the explicit `#[path]` is required to nest the submodule under this file.
92#[path = "w4a4_proj/mx_plan.rs"]
93mod mx_plan;
94pub use mx_plan::*;
95#[path = "w4a4_proj/fixed.rs"]
96mod fixed;
97pub use fixed::{fixed_nvfp4_proj, nvfp4_proj_mx};
98#[path = "w4a4_proj/steps.rs"]
99mod steps;
100pub use steps::{Nvfp4ActBuf, W4a4Proj};
101
102fn cache() -> &'static Mutex<Vec<(usize, Option<W4a4State>)>> {
103    static CACHE: OnceLock<Mutex<Vec<(usize, Option<W4a4State>)>>> = OnceLock::new();
104    CACHE.get_or_init(|| Mutex::new(Vec::new()))
105}
106
107fn key(gpu: &dyn GpuBackend) -> usize {
108    gpu as *const dyn GpuBackend as *const () as usize
109}
110
111/// 2026-09-28: Resolve the kernels and allocate the scratch once per backend, when the
112/// published tier can take the W4A4 path (`WeightQuantTier::uses_w4a4_decode`). Call at
113/// model build, never inside a graph capture. When a kernel is missing it records `None`
114/// for the backend and logs, and every projection stays W4A16.
115pub fn prepare(gpu: &dyn GpuBackend) -> Result<()> {
116    let tier = crate::layers::weight_quantization();
117    // 2026-09-30: A fixed `--activation-quantization` may run NVFP4 activations under any tier
118    // (`nvfp4_proj_mx`).
119    let fixed = crate::layers::any_fixed();
120    if !tier.uses_w4a4_decode() && !fixed {
121        return Ok(());
122    }
123    let mut guard = cache().lock().unwrap_or_else(|p| p.into_inner());
124    if guard.iter().any(|(k, _)| *k == key(gpu)) {
125        return Ok(());
126    }
127    let h = |f: &str| crate::layers::try_kernel(gpu, "w4a4_gemv_mx", f);
128    let (quant, mx8, mx16, mx32) = (
129        h("w4a4_quant_rows"),
130        h("w4a4_gemv_mx8"),
131        h("w4a4_gemv_mx16"),
132        h("w4a4_gemv_mx32"),
133    );
134    let (mx16_nt2, mx32_nt4) = (h("w4a4_gemv_mx16_nt2"), h("w4a4_gemv_mx32_nt4"));
135    let (mx16_ps, mx32_ps) = (h("w4a4_gemv_mx16_ps"), h("w4a4_gemv_mx32_ps"));
136    let state = if [quant, mx8, mx16, mx32, mx16_nt2, mx32_nt4, mx16_ps, mx32_ps]
137        .iter()
138        .all(|k| k.0 != 0)
139    {
140        let sms = gpu.sm_count()?;
141        let (mx64, mx64_nt2, max_m) =
142            wide_entries(tier, fixed, h("w4a4_gemv_mx64"), h("w4a4_gemv_mx64_nt2"));
143        tracing::info!(
144            "w4a4 projection ({tier:?}): METRALE_W4A4_MX_NT={} METRALE_W4A4_MX_PS={} ({sms} SMs), max rows {max_m}",
145            mx_nt(),
146            u8::from(mx_ps()),
147        );
148        let (m, k) = (max_m as usize, W4A4_MAX_K as usize);
149        Some(W4a4State {
150            quant,
151            mx8,
152            mx16,
153            mx32,
154            mx16_nt2,
155            mx32_nt4,
156            mx16_ps,
157            mx32_ps,
158            sms,
159            mx64,
160            mx64_nt2,
161            max_m,
162            aq: gpu.alloc(m * k / 2)?,
163            a_scale: gpu.alloc(m * k / 16)?,
164            a_gs: gpu.alloc(m * 4)?,
165            audit_ref: if audit_enabled() {
166                gpu.alloc(m * AUDIT_MAX_N * 2)?
167            } else {
168                DevicePtr::NULL
169            },
170        })
171    } else {
172        tracing::info!("w4a4_gemv_mx kernels are not in this target; NVFP4 projections run W4A16");
173        None
174    };
175    guard.push((key(gpu), state));
176    Ok(())
177}
178
179/// 2026-09-28: The 33..=64-row entries and the scratch's row count. Under `declared` they are
180/// used when this target has them. Under `nvfp4` they belong to `--w4a4-downcast-wide`, as
181/// before the tiers existed: without it they are not resolved and the scratch holds 32 rows.
182/// 2026-09-30: Also under a fixed `--activation-quantization` (`fixed`), whose NVFP4 projections
183/// run in chunks of the widest entry.
184fn wide_entries(
185    tier: metrale_config::WeightQuantTier,
186    fixed: bool,
187    mx64: KernelHandle,
188    mx64_nt2: KernelHandle,
189) -> (KernelHandle, KernelHandle, u32) {
190    use metrale_config::{W4a4Downcast, WeightQuantization};
191    if fixed && mx64.0 != 0 && mx64_nt2.0 != 0 {
192        return (mx64, mx64_nt2, W4A4_WIDE_MAX_M);
193    }
194    match (tier.tier(), tier.downcast()) {
195        (WeightQuantization::Declared, _) if mx64.0 != 0 && mx64_nt2.0 != 0 => {
196            (mx64, mx64_nt2, W4A4_WIDE_MAX_M)
197        }
198        (WeightQuantization::Nvfp4, W4a4Downcast::Wide) => (mx64, mx64_nt2, W4A4_WIDE_MAX_M),
199        _ => (KernelHandle(0), KernelHandle(0), W4A4_MAX_M),
200    }
201}
202
203/// 2026-09-28: The widest row count the W4A4 path serves for `weight` on `gpu` under the
204/// published tier (`WeightQuantTier::w4a4_rows`); 0 means W4A16.
205pub fn weight_rows(gpu: &dyn GpuBackend, weight: &QuantizedWeight) -> u32 {
206    rows_for(weight, max_rows(gpu))
207}
208
209fn rows_for(weight: &QuantizedWeight, kernel_rows: u32) -> u32 {
210    crate::layers::weight_quantization().w4a4_rows(
211        weight.act,
212        W4A4_MAX_M,
213        W4A4_WIDE_MAX_M,
214        kernel_rows,
215    )
216}
217
218/// 2026-09-28: Widest row count the W4A4 kernels serve on `gpu`: 64 or 32 once
219/// [`prepare`] found them, else 0.
220pub fn max_rows(gpu: &dyn GpuBackend) -> u32 {
221    state(gpu).map_or(0, |s| s.max_m)
222}
223
224fn state(gpu: &dyn GpuBackend) -> Option<W4a4State> {
225    let guard = cache().lock().unwrap_or_else(|p| p.into_inner());
226    guard
227        .iter()
228        .find(|(k, _)| *k == key(gpu))
229        .and_then(|(_, s)| *s)
230}
231
232/// 2026-09-28: Pure: may the W4A4 path serve this launch? `max_m` is [`max_rows`].
233pub fn w4a4_route(m: u32, n: u32, k: u32, max_m: u32) -> bool {
234    (1..=max_m).contains(&m) && n > 0 && k > 0 && k.is_multiple_of(64) && k <= W4A4_MAX_K
235}
236
237/// 2026-09-28: The projection launcher: the W4A4 FP4 MMA when the tier admits the
238/// weight ([`weight_rows`]), the kernels are prepared and [`w4a4_route`] admits the
239/// shape, else the W4A16
240/// `w4a16_gemv_batchm` (which tries the tensor-core GEMV first). The W4A16
241/// fallback returns an error above [`W4A4_MAX_M`] rows.
242#[allow(clippy::too_many_arguments)]
243#[track_caller]
244pub fn nvfp4_proj_small_m(
245    gpu: &dyn GpuBackend,
246    batch_kernel: KernelHandle,
247    input: DevicePtr,
248    weight: &QuantizedWeight,
249    output: DevicePtr,
250    m: u32,
251    n: u32,
252    k: u32,
253    stream: u64,
254) -> Result<()> {
255    proj(
256        gpu,
257        batch_kernel,
258        input,
259        weight,
260        output,
261        m,
262        n,
263        k,
264        stream,
265        false,
266    )
267}
268
269/// 2026-09-25: [`nvfp4_proj_small_m`] for a projection whose `input` is byte for
270/// byte the input of the preceding projection on this stream (attention k/v
271/// after q, FFN up after gate). It skips re-quantising when the previous W4A4
272/// quantisation was of exactly `(backend, input, m, k, stream)`, and otherwise
273/// quantises as usual, so a wrong claim about the address cannot read a stale
274/// quantisation. The caller guarantees the contents did not change in between.
275#[allow(clippy::too_many_arguments)]
276#[track_caller]
277pub fn nvfp4_proj_small_m_same_input(
278    gpu: &dyn GpuBackend,
279    batch_kernel: KernelHandle,
280    input: DevicePtr,
281    weight: &QuantizedWeight,
282    output: DevicePtr,
283    m: u32,
284    n: u32,
285    k: u32,
286    stream: u64,
287) -> Result<()> {
288    proj(
289        gpu,
290        batch_kernel,
291        input,
292        weight,
293        output,
294        m,
295        n,
296        k,
297        stream,
298        true,
299    )
300}
301
302/// 2026-09-28: The prepared W4A4 kernels when [`nvfp4_proj_small_m`] takes the W4A4 path for
303/// this launch: the tier uses W4A4 decode, the kernels are prepared, and [`w4a4_route`] admits
304/// the shape at the weight's row edge.
305fn w4a4_state_for(
306    gpu: &dyn GpuBackend,
307    weight: &QuantizedWeight,
308    m: u32,
309    n: u32,
310    k: u32,
311) -> Option<W4a4State> {
312    if !crate::layers::weight_quantization().uses_w4a4_decode() {
313        return None;
314    }
315    state(gpu).filter(|s| w4a4_route(m, n, k, rows_for(weight, s.max_m)))
316}
317
318/// 2026-09-28: Whether [`nvfp4_proj_small_m`] launches the W4A4 kernels for this shape (and
319/// otherwise the W4A16 `w4a16_gemv_batchm`).
320pub fn routes_w4a4(gpu: &dyn GpuBackend, weight: &QuantizedWeight, m: u32, n: u32, k: u32) -> bool {
321    w4a4_state_for(gpu, weight, m, n, k).is_some()
322}
323
324/// 2026-09-25: What the scratch holds: (backend, input address, m, k, stream).
325type QuantKey = (usize, u64, u32, u32, u64);
326
327fn last_quant() -> &'static Mutex<Option<QuantKey>> {
328    static LAST: OnceLock<Mutex<Option<QuantKey>>> = OnceLock::new();
329    LAST.get_or_init(|| Mutex::new(None))
330}
331
332#[allow(clippy::too_many_arguments)]
333#[track_caller]
334fn proj(
335    gpu: &dyn GpuBackend,
336    batch_kernel: KernelHandle,
337    input: DevicePtr,
338    weight: &QuantizedWeight,
339    output: DevicePtr,
340    m: u32,
341    n: u32,
342    k: u32,
343    stream: u64,
344    same_input: bool,
345) -> Result<()> {
346    let tier = crate::layers::weight_quantization();
347    if let Some(s) = w4a4_state_for(gpu, weight, m, n, k) {
348        let p = W4a4Proj(s);
349        let want: QuantKey = (key(gpu), input.0, m, k, stream);
350        let mut last = last_quant().lock().unwrap_or_else(|p| p.into_inner());
351        if !(same_input && *last == Some(want)) {
352            p.quantize(gpu, input, p.scratch(), m, k, stream)?;
353            *last = Some(want);
354        }
355        drop(last);
356        p.gemv(gpu, p.scratch(), weight, output, m, n, k, stream)?;
357        if audit_enabled()
358            && !s.audit_ref.is_null()
359            && (n as usize) <= AUDIT_MAX_N
360            && !gpu.stream_is_capturing(stream)
361        {
362            let site = std::panic::Location::caller();
363            super::w4a16_gemv_batchm(
364                gpu,
365                batch_kernel,
366                input,
367                weight,
368                s.audit_ref,
369                m,
370                n,
371                k,
372                stream,
373            )?;
374            audit(
375                gpu,
376                output,
377                s.audit_ref,
378                (m * n) as usize,
379                stream,
380                site,
381                n,
382                k,
383            )?;
384        }
385        return Ok(());
386    }
387    // 2026-09-28: Under `declared` the projections of one group (q/k/v, gate/up) may route
388    // differently. When the first takes W4A16 it quantizes nothing, so a later W4A4 member
389    // passing `same_input` must not match a key an earlier layer left: forget it.
390    if !same_input && tier.tier() == metrale_config::WeightQuantization::Declared {
391        *last_quant().lock().unwrap_or_else(|p| p.into_inner()) = None;
392    }
393    anyhow::ensure!(
394        m <= W4A4_MAX_M,
395        "w4a4: {m} rows reached the W4A16 fallback, which covers at most {W4A4_MAX_M}"
396    );
397    super::w4a16_gemv_batchm(gpu, batch_kernel, input, weight, output, m, n, k, stream)
398}
399
400/// 2026-09-25: Per-site relative-error accumulator for [`audit_enabled`].
401#[allow(clippy::too_many_arguments)]
402fn audit(
403    gpu: &dyn GpuBackend,
404    got: DevicePtr,
405    reference: DevicePtr,
406    elems: usize,
407    stream: u64,
408    site: &'static std::panic::Location<'static>,
409    n: u32,
410    k: u32,
411) -> Result<()> {
412    type Acc = std::collections::HashMap<String, (u64, f64, f64)>;
413    static ACC: OnceLock<Mutex<Acc>> = OnceLock::new();
414    gpu.synchronize(stream)?;
415    let mut a = vec![0u8; elems * 2];
416    let mut b = vec![0u8; elems * 2];
417    gpu.copy_d2h(got, &mut a)?;
418    gpu.copy_d2h(reference, &mut b)?;
419    let bf = |x: &[u8]| f32::from_bits((u16::from_le_bytes([x[0], x[1]]) as u32) << 16) as f64;
420    let (mut num, mut den) = (0f64, 0f64);
421    for (x, y) in a.chunks_exact(2).zip(b.chunks_exact(2)) {
422        let (x, y) = (bf(x), bf(y));
423        num += (x - y) * (x - y);
424        den += y * y;
425    }
426    let rel = if den > 0.0 { (num / den).sqrt() } else { 0.0 };
427    let key = format!("{}:{} N={n} K={k}", site.file(), site.line());
428    let mut acc = ACC
429        .get_or_init(|| Mutex::new(Acc::new()))
430        .lock()
431        .unwrap_or_else(|p| p.into_inner());
432    let e = acc.entry(key.clone()).or_insert((0, 0.0, 0.0));
433    e.0 += 1;
434    e.1 += rel;
435    e.2 = e.2.max(rel);
436    if e.0.is_multiple_of(64) {
437        tracing::info!(
438            "W4A4_AUDIT site={key} samples={} mean_rel={:.5} max_rel={:.5}",
439            e.0,
440            e.1 / e.0 as f64,
441            e.2
442        );
443    }
444    Ok(())
445}
446
447#[cfg(test)]
448#[path = "w4a4_proj_tests.rs"]
449mod w4a4_proj_tests;