metrale_model_layers/layers/ops/
w8a8_decode.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-28: W8A8 decode projections for 1..=64 rows (`w8a8_gemv.cu`,
4//! `w8a8_act_quant.cu`): the BF16 activation is quantized to E4M3 with a
5//! dynamic FP32 scale per token (per-row weights) or per token and 128-wide K
6//! group (128x128 block-scaled weights), then multiplied against the
7//! checkpoint's E4M3 weight on E4M3 tensor-core MMAs with FP32 accumulation.
8//! This is the precision an FP8 W8A8 checkpoint declares for these layers.
9//!
10//! Owner: model-layers ops.
11//! Invariants:
12//! - A row's output bits do not depend on how many rows share the launch: the
13//!   WxAy engine (`wxay_engine.cuh`) gives every entry the same K split (8
14//!   warps, 128-wide chunks, warp-order reduction), the entries differ only in
15//!   token tiles and load depth, and the quantizer's scales are per row.
16//! - [`w8a8_decode_available`] is true only when every launch [`w8a8_proj`]
17//!   would make is in range of its kernel and scratch; [`w8a8_proj`] refuses
18//!   (errors, launches nothing) whenever it is false.
19
20use anyhow::{Result, bail, ensure};
21use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
22use metrale_gpu_runtime::kernel_args::KernelLaunch;
23
24use crate::layers::try_kernel;
25use crate::weight_map::{Fp8Weight, WeightQuantFormat};
26
27/// 2026-09-28: The most rows one W8A8 projection serves: 128-row launches, the weight read
28/// once per launch (a C128 verify step has 256 rows).
29pub const W8A8_MAX_ROWS: usize = 256;
30/// 2026-09-28: The most rows one GEMV launch serves (16 token tiles of 8).
31pub const W8A8_LAUNCH_ROWS: usize = 128;
32/// 2026-09-28: The K width of one scale group and of one kernel K unit.
33pub const W8A8_K_UNIT: u32 = 128;
34const GEMV_MODULE: &str = "w8a8_gemv";
35const QUANT_MODULE: &str = "w8a8_act_quant";
36const QUANT_THREADS: u32 = 256;
37/// 2026-09-28: The engine's block: 8 warps.
38const GEMV_THREADS: u32 = 256;
39/// 2026-09-28: Weight rows per CTA.
40const GEMV_ROWS: u32 = 16;
41/// 2026-09-28: Entry name suffixes, indexed by [`entry_index`].
42const ENTRIES: [&str; 5] = ["mb1_ku8", "mb2", "mb4", "mb8", "mb16"];
43
44/// 2026-09-28: How a W8A8 weight carries its scales, and so how the
45/// activation is quantized: one scale per output row with one per token, or
46/// one per 128x128 weight block with one per (token, 128-wide K group).
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum W8a8Scale {
49    PerRow,
50    Block128,
51}
52
53impl W8a8Scale {
54    /// 2026-09-28: The layout of `format`, or `None` for a format this family
55    /// does not read.
56    pub fn of(format: WeightQuantFormat) -> Option<Self> {
57        match format {
58            WeightQuantFormat::Fp8PerRow => Some(Self::PerRow),
59            WeightQuantFormat::Fp8BlockScaled => Some(Self::Block128),
60            _ => None,
61        }
62    }
63
64    /// 2026-09-28: Output rows a segment boundary must be a multiple of: one
65    /// kernel row tile (16), or one scale block (128).
66    fn segment_align(self) -> u32 {
67        match self {
68            Self::PerRow => 16,
69            Self::Block128 => 128,
70        }
71    }
72
73    /// 2026-09-28: FP32 activation scales per row for a K-wide activation.
74    pub fn act_scales_per_row(self, k: u32) -> usize {
75        match self {
76            Self::PerRow => 1,
77            Self::Block128 => (k / W8A8_K_UNIT) as usize,
78        }
79    }
80}
81
82/// 2026-09-28: One W8A8 projection: up to three E4M3 weights with the same K
83/// and scale layout, stacked by rows into one output (attention Q|K|V, GDN
84/// QKV|Z, FFN gate|up). The checkpoint's tensors are read in place.
85#[derive(Clone, Copy, Debug)]
86pub struct W8a8Weight {
87    segs: [Fp8Weight; 3],
88    count: usize,
89    n: u32,
90    k: u32,
91    scale: W8a8Scale,
92}
93
94impl W8a8Weight {
95    /// 2026-09-28: Stack `segs` (1..=3). Errors unless they share K and a scale
96    /// layout this family reads, K is a multiple of 128, and every segment but
97    /// the last is a multiple of the layout's segment alignment.
98    pub fn new(segs: &[Fp8Weight]) -> Result<Self> {
99        ensure!(
100            (1..=3).contains(&segs.len()),
101            "W8a8Weight: {} segments, want 1..=3",
102            segs.len()
103        );
104        let Some(scale) = W8a8Scale::of(segs[0].scale_format) else {
105            bail!(
106                "W8a8Weight: scale format {:?} is not per-row or 128x128 block",
107                segs[0].scale_format
108            );
109        };
110        let k = segs[0].k;
111        ensure!(
112            k > 0 && k.is_multiple_of(W8A8_K_UNIT),
113            "W8a8Weight: K={k} is not a positive multiple of {W8A8_K_UNIT}"
114        );
115        for (i, s) in segs.iter().enumerate() {
116            ensure!(
117                s.k == k && s.scale_format == segs[0].scale_format,
118                "W8a8Weight: segment {i} is [{}, {}] {:?}, segment 0 is [.., {k}] {:?}",
119                s.n,
120                s.k,
121                s.scale_format,
122                segs[0].scale_format
123            );
124            ensure!(
125                s.n > 0 && s.weight != DevicePtr::NULL && s.row_scale != DevicePtr::NULL,
126                "W8a8Weight: segment {i} is empty"
127            );
128            if i + 1 < segs.len() {
129                ensure!(
130                    s.n.is_multiple_of(scale.segment_align()),
131                    "W8a8Weight: segment {i} has {} rows, not a multiple of {}",
132                    s.n,
133                    scale.segment_align()
134                );
135            }
136        }
137        let mut all = [segs[0]; 3];
138        all[..segs.len()].copy_from_slice(segs);
139        Ok(Self {
140            segs: all,
141            count: segs.len(),
142            n: segs.iter().map(|s| s.n).sum(),
143            k,
144            scale,
145        })
146    }
147
148    /// 2026-09-28: Output rows (all segments).
149    pub fn n(&self) -> u32 {
150        self.n
151    }
152    /// 2026-09-28: Contract width.
153    pub fn k(&self) -> u32 {
154        self.k
155    }
156    pub fn scale(&self) -> W8a8Scale {
157        self.scale
158    }
159
160    /// 2026-09-28: `(n1, n2)`, the first output rows of segments 1 and 2 (`n` for
161    /// an absent segment).
162    fn boundaries(&self) -> (u32, u32) {
163        let n0 = self.segs[0].n;
164        match self.count {
165            1 => (self.n, self.n),
166            2 => (n0, self.n),
167            _ => (n0, n0 + self.segs[1].n),
168        }
169    }
170}
171
172/// 2026-09-28: The quantizers and the ten GEMV entry points (2 scale layouts
173/// x the five row entries, `mb1_ku8` to `mb16`). A handle is 0 when its module is not compiled.
174#[derive(Clone, Copy, Debug)]
175pub struct W8a8Kernels {
176    quant_row: KernelHandle,
177    quant_g128: KernelHandle,
178    quant_silu_row: KernelHandle,
179    quant_silu_g128: KernelHandle,
180    rowscale: [KernelHandle; 5],
181    blk128: [KernelHandle; 5],
182}
183
184impl W8a8Kernels {
185    pub fn load(gpu: &dyn GpuBackend) -> Self {
186        let tiles = |layout: &str| {
187            ENTRIES.map(|e| try_kernel(gpu, GEMV_MODULE, &format!("w8a8_gemv_{layout}_{e}")))
188        };
189        Self {
190            quant_row: try_kernel(gpu, QUANT_MODULE, "w8a8_act_quant_row"),
191            quant_g128: try_kernel(gpu, QUANT_MODULE, "w8a8_act_quant_g128"),
192            quant_silu_row: try_kernel(gpu, QUANT_MODULE, "w8a8_act_quant_silu_row"),
193            quant_silu_g128: try_kernel(gpu, QUANT_MODULE, "w8a8_act_quant_silu_g128"),
194            rowscale: tiles("rowscale"),
195            blk128: tiles("blk128"),
196        }
197    }
198
199    /// 2026-09-28: No kernel resolved (a build without the modules).
200    pub fn none() -> Self {
201        let z = KernelHandle(0);
202        Self {
203            quant_row: z,
204            quant_g128: z,
205            quant_silu_row: z,
206            quant_silu_g128: z,
207            rowscale: [z; 5],
208            blk128: [z; 5],
209        }
210    }
211
212    /// 2026-09-28: Whether every kernel of `scale`'s layout resolved (both
213    /// quantizers and every GEMV entry).
214    pub fn resolved(&self, scale: W8a8Scale) -> bool {
215        let (q, qs, g) = match scale {
216            W8a8Scale::PerRow => (self.quant_row, self.quant_silu_row, &self.rowscale),
217            W8a8Scale::Block128 => (self.quant_g128, self.quant_silu_g128, &self.blk128),
218        };
219        q.0 != 0 && qs.0 != 0 && g.iter().all(|h| h.0 != 0)
220    }
221}
222
223/// 2026-09-28: Device scratch for one quantized activation: `[rows, k]` E4M3
224/// and its FP32 scales. One per model; every W8A8 projection on the stream
225/// quantizes into it immediately before its GEMV.
226#[derive(Clone, Copy, Debug)]
227pub struct W8a8Scratch {
228    pub q: DevicePtr,
229    pub q_bytes: usize,
230    pub scale: DevicePtr,
231    pub scale_bytes: usize,
232}
233
234impl W8a8Scratch {
235    /// 2026-09-28: Room for [`W8A8_MAX_ROWS`] rows of a `max_k`-wide
236    /// activation in either scale layout.
237    pub fn alloc(gpu: &dyn GpuBackend, max_k: u32) -> Result<Self> {
238        let rows = W8A8_MAX_ROWS;
239        let q_bytes = rows * max_k as usize;
240        let scale_bytes = rows * (max_k.div_ceil(W8A8_K_UNIT) as usize).max(1) * 4;
241        Ok(Self {
242            q: gpu.alloc(q_bytes)?,
243            q_bytes,
244            scale: gpu.alloc(scale_bytes)?,
245            scale_bytes,
246        })
247    }
248
249    fn fits(&self, rows: usize, k: u32, scale: W8a8Scale) -> bool {
250        self.q != DevicePtr::NULL
251            && self.q_bytes >= rows * k as usize
252            && self.scale_bytes >= rows * scale.act_scales_per_row(k) * 4
253    }
254}
255
256/// 2026-09-28: The GEMV entry (index into [`ENTRIES`]) for a launch of `rows` tokens: 1, 2,
257/// 4, 8 and 16 token tiles up to 8, 16, 32, 64 and 128 rows. Every entry sums in the same
258/// order, so the choice changes speed only.
259fn entry_index(rows: usize) -> usize {
260    match rows {
261        0..=8 => 0,
262        9..=16 => 1,
263        17..=32 => 2,
264        33..=64 => 3,
265        _ => 4,
266    }
267}
268
269/// 2026-09-28: The "W8A8 available for this layer and shape" hook: the kernels
270/// of `w`'s layout resolved, `rows` is in `1..=W8A8_MAX_ROWS`, and the scratch
271/// holds the quantized activation. The declared-precision policy decides
272/// whether W8A8 is wanted; this says whether it can run.
273pub fn w8a8_decode_available(
274    kernels: &W8a8Kernels,
275    w: &W8a8Weight,
276    rows: usize,
277    scratch: &W8a8Scratch,
278) -> bool {
279    (1..=W8A8_MAX_ROWS).contains(&rows)
280        && kernels.resolved(w.scale)
281        && scratch.fits(rows, w.k, w.scale)
282}
283
284/// 2026-09-28: Quantize `rows` rows of the BF16 activation `x` (`[rows, ldx]`,
285/// the first `k` columns) into `scratch` for `scale`'s layout.
286#[allow(clippy::too_many_arguments)]
287pub fn w8a8_act_quant(
288    gpu: &dyn GpuBackend,
289    kernels: &W8a8Kernels,
290    scale: W8a8Scale,
291    x: DevicePtr,
292    ldx: u32,
293    rows: usize,
294    k: u32,
295    scratch: &W8a8Scratch,
296    stream: u64,
297) -> Result<()> {
298    ensure!(
299        (1..=W8A8_MAX_ROWS).contains(&rows) && scratch.fits(rows, k, scale),
300        "w8a8_act_quant: {rows} rows of K={k} do not fit the scratch"
301    );
302    ensure!(
303        k.is_multiple_of(W8A8_K_UNIT) && ldx >= k && ldx.is_multiple_of(8),
304        "w8a8_act_quant: K={k}, ldx={ldx} (want K % 128 == 0, ldx >= K, ldx % 8 == 0)"
305    );
306    let kernel = match scale {
307        W8a8Scale::PerRow => kernels.quant_row,
308        W8a8Scale::Block128 => kernels.quant_g128,
309    };
310    ensure!(
311        kernel.0 != 0,
312        "w8a8_act_quant: {scale:?} quantizer not compiled"
313    );
314    KernelLaunch::new(gpu, kernel)
315        .grid([rows as u32, 1, 1])
316        .block([QUANT_THREADS, 1, 1])
317        .arg_ptr(x)
318        .arg_ptr(scratch.q)
319        .arg_ptr(scratch.scale)
320        .arg_u32(k)
321        .arg_u32(ldx)
322        .arg_u32(k)
323        .launch(stream)
324}
325
326/// 2026-09-28: [`w8a8_act_quant`] of `bf16(silu(gate) * up)`, computed in the
327/// same launch with the arithmetic of `moe_silu_mul`: gate and up are
328/// `[rows, ld]` BF16, the first `k` columns read.
329#[allow(clippy::too_many_arguments)]
330pub fn w8a8_act_quant_silu(
331    gpu: &dyn GpuBackend,
332    kernels: &W8a8Kernels,
333    scale: W8a8Scale,
334    gate: DevicePtr,
335    up: DevicePtr,
336    ld: u32,
337    rows: usize,
338    k: u32,
339    scratch: &W8a8Scratch,
340    stream: u64,
341) -> Result<()> {
342    ensure!(
343        (1..=W8A8_MAX_ROWS).contains(&rows) && scratch.fits(rows, k, scale),
344        "w8a8_act_quant_silu: {rows} rows of K={k} do not fit the scratch"
345    );
346    ensure!(
347        k.is_multiple_of(W8A8_K_UNIT) && ld >= k && ld.is_multiple_of(8),
348        "w8a8_act_quant_silu: K={k}, ld={ld} (want K % 128 == 0, ld >= K, ld % 8 == 0)"
349    );
350    let kernel = match scale {
351        W8a8Scale::PerRow => kernels.quant_silu_row,
352        W8a8Scale::Block128 => kernels.quant_silu_g128,
353    };
354    ensure!(
355        kernel.0 != 0,
356        "w8a8_act_quant_silu: {scale:?} quantizer not compiled"
357    );
358    KernelLaunch::new(gpu, kernel)
359        .grid([rows as u32, 1, 1])
360        .block([QUANT_THREADS, 1, 1])
361        .arg_ptr(gate)
362        .arg_ptr(up)
363        .arg_ptr(scratch.q)
364        .arg_ptr(scratch.scale)
365        .arg_u32(k)
366        .arg_u32(ld)
367        .arg_u32(ld)
368        .arg_u32(k)
369        .launch(stream)
370}
371
372/// 2026-09-28: The GEMV over the activation [`w8a8_act_quant`] left in
373/// `scratch`: `out[m, 0..n]` (row pitch `ldc` BF16 elements) for `m < rows`.
374pub fn w8a8_gemv(
375    gpu: &dyn GpuBackend,
376    kernels: &W8a8Kernels,
377    w: &W8a8Weight,
378    scratch: &W8a8Scratch,
379    rows: usize,
380    out: DevicePtr,
381    ldc: u32,
382    stream: u64,
383) -> Result<()> {
384    ensure!(
385        w8a8_decode_available(kernels, w, rows, scratch),
386        "w8a8_gemv: not available for {rows} rows of [{}, {}] {:?}",
387        w.n,
388        w.k,
389        w.scale
390    );
391    ensure!(ldc >= w.n, "w8a8_gemv: ldc={ldc} < N={}", w.n);
392    let (n1, n2) = w.boundaries();
393    let s = &w.segs;
394    let a_scales = w.scale.act_scales_per_row(w.k) * 4;
395    // 2026-09-28: 128-row launches; a row's arithmetic does not depend on its launch.
396    let mut done = 0;
397    while done < rows {
398        let m = (rows - done).min(W8A8_LAUNCH_ROWS);
399        let e = entry_index(m);
400        let kernel = match w.scale {
401            W8a8Scale::PerRow => kernels.rowscale[e],
402            W8a8Scale::Block128 => kernels.blk128[e],
403        };
404        KernelLaunch::new(gpu, kernel)
405            .grid([w.n.div_ceil(GEMV_ROWS), 1, 1])
406            .block([GEMV_THREADS, 1, 1])
407            .arg_ptr(scratch.q.offset(done * w.k as usize))
408            .arg_ptr(scratch.scale.offset(done * a_scales))
409            .arg_ptr(s[0].weight)
410            .arg_ptr(s[0].row_scale)
411            .arg_ptr(s[1].weight)
412            .arg_ptr(s[1].row_scale)
413            .arg_ptr(s[2].weight)
414            .arg_ptr(s[2].row_scale)
415            .arg_ptr(out.offset(done * ldc as usize * 2))
416            .arg_u32(m as u32)
417            .arg_u32(w.n)
418            .arg_u32(w.k)
419            .arg_u32(w.k)
420            .arg_u32(ldc)
421            .arg_u32(n1)
422            .arg_u32(n2)
423            .launch(stream)?;
424        done += m;
425    }
426    Ok(())
427}
428
429/// 2026-09-28: One W8A8 projection: quantize `x` (`[rows, ldx]` BF16) and
430/// multiply, writing `out` (`[rows, ldc]` BF16, the first `w.n()` columns).
431#[allow(clippy::too_many_arguments)]
432pub fn w8a8_proj(
433    gpu: &dyn GpuBackend,
434    kernels: &W8a8Kernels,
435    w: &W8a8Weight,
436    x: DevicePtr,
437    ldx: u32,
438    rows: usize,
439    out: DevicePtr,
440    ldc: u32,
441    scratch: &W8a8Scratch,
442    stream: u64,
443) -> Result<()> {
444    ensure!(
445        w8a8_decode_available(kernels, w, rows, scratch),
446        "w8a8_proj: not available for {rows} rows of [{}, {}] {:?}",
447        w.n,
448        w.k,
449        w.scale
450    );
451    w8a8_act_quant(gpu, kernels, w.scale, x, ldx, rows, w.k, scratch, stream)?;
452    w8a8_gemv(gpu, kernels, w, scratch, rows, out, ldc, stream)
453}
454
455#[path = "w8a8_decode/parts.rs"]
456mod parts;
457
458#[cfg(test)]
459#[path = "w8a8_decode_tests.rs"]
460mod tests;