metrale_model_layers/layers/ops/
dispatch_proj_decode.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: W8A8 block-scaled cuBLASLt route for the 5..=16-row decode
4//! projections (GDN `in_proj_qkvz` and `out_proj`, attention Q/K/V and O), and
5//! the strided-output extent those need.
6//!
7//! On this route a projection runs W8A8 instead of the W8A16 GEMV: an E4M3
8//! activation with one FP32 scale per token and 128 of K, against the
9//! checkpoint's E4M3 weight and its 128x128 FP32 block scales.
10//!
11//! Owner: model-layers ops.
12//! Invariants:
13//! - [`decode_w8a8_selected`] reads the environment only through values cached
14//!   once per process ([`w8a8_decode_proj_disabled`],
15//!   `cublas_scale_layout_kmajor`), so for the same padded rows, shape, weight
16//!   format and buffers it gives the same answer on every step, CUDA-graph
17//!   capture included.
18
19use anyhow::Result;
20use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
21
22use super::{
23    Fp8ActQuant, cublas_fp8_m_pad, cublas_scale_layout_kmajor, fp8_act_scale_to_kmajor,
24    per_token_group_quant_fp8,
25};
26
27/// 2026-09-25: Kill switch: `METRALE_NO_W8A8_DECODE_PROJ` set to any value,
28/// empty and `0` included, turns this route off. Read once, so it is constant
29/// for the process and safe to branch on under CUDA-graph capture.
30pub fn w8a8_decode_proj_disabled() -> bool {
31    static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
32    *OFF.get_or_init(|| std::env::var_os("METRALE_NO_W8A8_DECODE_PROJ").is_some())
33}
34
35/// 2026-09-25: The padded decode row counts this route takes. Any other count
36/// stays on the caller's own kernels.
37pub const DECODE_W8A8_ROWS: std::ops::RangeInclusive<usize> = 5..=16;
38
39/// 2026-09-25: Elements from the start of the output to one past the last
40/// element a cuBLASLt GEMM writes when its `m_pad` output rows of `n` elements
41/// are `ldc` elements apart: `(m_pad - 1) * ldc + n`. Pass the padded row
42/// count, because the pad rows are written too. The strided call sites bound
43/// their buffers with it.
44pub fn strided_out_extent_elems(m_pad: u32, ldc: u32, n: u32) -> usize {
45    debug_assert!(m_pad >= 1);
46    (m_pad as usize - 1) * ldc as usize + n as usize
47}
48
49/// 2026-09-25: The shape and output of one decode projection, as
50/// [`decode_w8a8_selected`] judges it. The route is taken only when all of
51/// these hold:
52///
53/// * `family_armed`: the caller's [`super::CublasScope`] family (`ssm` or
54///   `attn`) is on.
55/// * `disabled` is false ([`w8a8_decode_proj_disabled`]).
56/// * `rows` is in [`DECODE_W8A8_ROWS`].
57/// * The weight is `Fp8BlockScaled`, since cuBLASLt is given its scales as a
58///   `[N/128, K/128]` grid.
59/// * `n` and `k` are multiples of 128, and `blk128x128_stride_ok(k)` holds
60///   (`k` a multiple of 512), the weight-scale stride rule that
61///   `metrale_gpu_runtime::cublaslt::scale_layout` quotes.
62/// * `ldc >= n`.
63/// * The output holds the padded write extent ([`strided_out_extent_elems`]).
64/// * The quantizer resolved, and the scratch holds `m_pad` rows in the active
65///   scale layout, adapter kernel and buffer included when it is K-major.
66#[derive(Clone, Copy, Debug)]
67pub struct DecodeW8a8Plan {
68    /// 2026-09-25: Padded decode rows (the forward context's `n`).
69    pub rows: usize,
70    /// 2026-09-25: Output width of this projection.
71    pub n: u32,
72    /// 2026-09-25: Contract width of this projection.
73    pub k: u32,
74    /// 2026-09-25: Output row pitch in BF16 elements (`n` for a contiguous
75    /// output).
76    pub ldc: u32,
77    /// 2026-09-25: Allocated size of the output buffer, in bytes.
78    pub out_capacity_bytes: usize,
79}
80
81impl DecodeW8a8Plan {
82    /// 2026-09-25: A contiguous `[rows, n]` output (GDN `in_proj_qkvz` and
83    /// `out_proj`, attention `o_proj`).
84    pub fn contiguous(rows: usize, n: u32, k: u32, out_capacity_bytes: usize) -> Self {
85        Self {
86            rows,
87            n,
88            k,
89            ldc: n,
90            out_capacity_bytes,
91        }
92    }
93
94    /// 2026-09-25: A strided output: rows `ldc` BF16 elements apart (attention
95    /// Q/K/V into the `[n, per_seq_qkv]` multi-sequence QKV buffer).
96    pub fn strided(rows: usize, n: u32, k: u32, ldc: u32, out_capacity_bytes: usize) -> Self {
97        Self {
98            rows,
99            n,
100            k,
101            ldc,
102            out_capacity_bytes,
103        }
104    }
105
106    /// 2026-09-25: The padded M cuBLASLt is handed.
107    pub fn m_pad(&self) -> u32 {
108        cublas_fp8_m_pad(self.rows as u32)
109    }
110
111    /// 2026-09-25: Bytes of `out` this projection may write, pad rows included.
112    pub fn write_extent_bytes(&self) -> usize {
113        strided_out_extent_elems(self.m_pad(), self.ldc, self.n) * 2
114    }
115}
116
117/// 2026-09-25: The activation-quant scratch buffers with their capacities, and
118/// the quantizer and scale-layout adapter kernels.
119#[derive(Clone, Copy, Debug)]
120pub struct DecodeW8a8Scratch {
121    pub act_fp8: DevicePtr,
122    pub act_fp8_bytes: usize,
123    pub act_scale: DevicePtr,
124    pub act_scale_bytes: usize,
125    pub act_scale_kmajor: DevicePtr,
126    pub act_scale_kmajor_bytes: usize,
127    pub quant_k: Fp8ActQuant,
128    pub scale_kmajor_k: KernelHandle,
129}
130
131impl DecodeW8a8Scratch {
132    /// 2026-09-25: Whether the quantizer resolved and the scratch holds `m_pad`
133    /// rows of a K-wide activation in the active scale layout.
134    fn fits(&self, m_pad: u32, k: u32) -> bool {
135        let rows = m_pad as usize;
136        let kg = k as usize / 128;
137        self.act_fp8.0 != 0
138            && self.act_scale.0 != 0
139            && self.quant_k.available()
140            && self.act_fp8_bytes >= rows * k as usize
141            && self.act_scale_bytes >= rows * kg * 4
142            && (!cublas_scale_layout_kmajor()
143                || (self.scale_kmajor_k.0 != 0
144                    && self.act_scale_kmajor.0 != 0
145                    && self.act_scale_kmajor_bytes >= rows * kg * 4))
146    }
147}
148
149/// 2026-09-25: Whether one decode projection takes the W8A8 cuBLASLt route;
150/// [`DecodeW8a8Plan`] lists the conditions.
151pub fn decode_w8a8_selected(
152    family_armed: bool,
153    disabled: bool,
154    plan: &DecodeW8a8Plan,
155    scale_format: crate::weight_map::WeightQuantFormat,
156    scratch: &DecodeW8a8Scratch,
157) -> bool {
158    let m_pad = plan.m_pad();
159    family_armed
160        && !disabled
161        && DECODE_W8A8_ROWS.contains(&plan.rows)
162        && scale_format == crate::weight_map::WeightQuantFormat::Fp8BlockScaled
163        && plan.n.is_multiple_of(128)
164        && plan.k.is_multiple_of(128)
165        && metrale_gpu_runtime::cublaslt::scale_layout::blk128x128_stride_ok(plan.k as usize)
166        && plan.ldc >= plan.n
167        && plan.write_extent_bytes() <= plan.out_capacity_bytes
168        && scratch.fits(m_pad, plan.k)
169}
170
171/// 2026-09-25: Quantize BF16 `act[rows, k]` into the scratch: FP8 E4M3 bytes
172/// and one FP32 scale per token and 128 of K, with the pad rows
173/// `rows..ceil16(rows)` zeroed, and the scales transposed for cuBLASLt when
174/// the layout is K-major.
175///
176/// Separate from the GEMM so projections over one activation share one
177/// quantization; the attention Q/K/V do.
178///
179/// The pad rows' FP8 bytes are zeroed as well as their scales: a zero scale
180/// alone is not enough, because `NaN * 0.0` is `NaN`.
181pub fn decode_w8a8_quant_act(
182    gpu: &dyn GpuBackend,
183    scratch: &DecodeW8a8Scratch,
184    act_bf16: DevicePtr,
185    rows: u32,
186    k: u32,
187    stream: u64,
188) -> Result<()> {
189    per_token_group_quant_fp8(
190        gpu,
191        scratch.quant_k,
192        act_bf16,
193        scratch.act_fp8,
194        scratch.act_scale,
195        rows,
196        k,
197        stream,
198    )?;
199    let m_pad = cublas_fp8_m_pad(rows);
200    if m_pad > rows {
201        gpu.memset_async(
202            scratch.act_fp8.offset(rows as usize * k as usize),
203            0,
204            (m_pad - rows) as usize * k as usize,
205            stream,
206        )?;
207    }
208    if cublas_scale_layout_kmajor() {
209        // 2026-09-25: Writes every `[K/128, m_pad]` slot, pad rows included.
210        fp8_act_scale_to_kmajor(
211            gpu,
212            scratch.scale_kmajor_k,
213            scratch.act_scale,
214            scratch.act_scale_kmajor,
215            rows,
216            m_pad,
217            k,
218            stream,
219        )?;
220    } else {
221        // 2026-09-25: In the `rowmajor` layout the pad rows are a contiguous
222        // tail, so they are zeroed in place.
223        let kg = k as usize / 128;
224        if m_pad > rows {
225            gpu.memset_async(
226                scratch.act_scale.offset(rows as usize * kg * 4),
227                0,
228                (m_pad - rows) as usize * kg * 4,
229                stream,
230            )?;
231        }
232    }
233    Ok(())
234}
235
236/// 2026-09-25: `out[m_pad, n] = act_fp8[m_pad, k] @ weight[n, k]ᵀ` at row pitch
237/// `plan.ldc`, with both sets of scales applied. The activation must already
238/// be through [`decode_w8a8_quant_act`].
239pub fn decode_w8a8_gemm(
240    scratch: &DecodeW8a8Scratch,
241    fp8w: &crate::weight_map::Fp8Weight,
242    out: DevicePtr,
243    plan: &DecodeW8a8Plan,
244    stream: u64,
245) -> Result<()> {
246    let b_scale = if cublas_scale_layout_kmajor() {
247        scratch.act_scale_kmajor
248    } else {
249        scratch.act_scale
250    };
251    metrale_gpu_runtime::cublaslt::fp8_gemm_act_weight_t_blkscaled_ldc(
252        scratch.act_fp8.0,
253        b_scale.0,
254        fp8w.weight.0,
255        fp8w.row_scale.0,
256        out.0,
257        plan.m_pad(),
258        plan.n,
259        plan.k,
260        plan.ldc,
261        stream,
262    )
263}
264
265#[cfg(test)]
266#[path = "dispatch_proj_decode_tests.rs"]
267mod tests;