metrale_model_layers/layers/dense_ffn_w8a8_prefill.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: The W8A8 block-scaled arm of the native-FP8 dense FFN: which projections take it, and the GEMM it runs.
4//!
5//! Owner: model-layers (dense FFN).
6//! Invariants:
7//! - `w8a8_prefill_selected` holds only for `4 < m <= max_m`, `Fp8BlockScaled`
8//! weights, `k` and `n` multiples of 128, the quantizer and GEMM handles
9//! loaded, `METRALE_FFN_W8A16_ONLY` unset and
10//! `dispatch.fp8_blockscaled_prefill` on.
11//!
12//! The arm is reachable only on a layer that holds native-FP8 dense-FFN
13//! weights. The one loader that installs them, `qwen35_dense.rs`, does so only
14//! when `ffn_fp8_arm_selected` holds: `METRALE_DENSE_FP8` is exactly `1`,
15//! `config.tp_world_size.max(1) == 1`, the variant is
16//! `Nvfp4Variant::Fp8Dequanted`, and the layer's `mlp.gate_proj` is block-scaled
17//! FP8 on disk (`proj_is_native_fp8`). Without it neither route log in this file
18//! is emitted.
19//!
20//! The activation is quantized to E4M3 with one FP32 scale per token per
21//! 128-wide K group (`per_token_group_quant_fp8`). `w8a8_gemm` then runs
22//! cuBLASLt `fp8_gemm_act_weight_t_blkscaled` when `dispatch.cublas.ffn` holds
23//! (`METRALE_CUBLAS_GEMM` lists `ffn`, or is `1`, `true` or `all`) and its other
24//! clauses pass, and the in-tree `ops::fp8_gemm_t_blockscaled` otherwise.
25//! `examples/native_fp8_ffn_w8a8_microtest.rs` grades W8A8 against the W8A16
26//! reference at cosine >= 0.999 and relative RMS <= 2%.
27//!
28//! The upper M bound is per target: `kernels/<hw>/HARDWARE.toml` `[defaults]
29//! w8a8_prefill_max_m_widening` / `_narrowing`. gb10 declares 64 and 384 beside
30//! its measurement; every other target has no cap (`u32::MAX`).
31
32use anyhow::Result;
33use metrale_gpu_runtime::gpu::{DevicePtr, KernelHandle};
34
35use super::DenseFfnLayer;
36use crate::layer::ForwardContext;
37use crate::layers::ops;
38use crate::weight_map::{Fp8Weight, WeightQuantFormat};
39
40/// 2026-09-25: `METRALE_FFN_W8A16_ONLY`, by presence (any value, including
41/// empty or `0`): no dense-FFN projection takes the W8A8 arm. Read once per
42/// process (`OnceLock`).
43pub fn ffn_w8a16_only() -> bool {
44 static ONLY: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
45 *ONLY.get_or_init(|| std::env::var_os("METRALE_FFN_W8A16_ONLY").is_some())
46}
47
48/// 2026-09-25: The W8A8 upper M bound for a projection of shape `[n, k]`:
49/// `w8a8_prefill_max_m_widening` when `n > k` (gate/up) and
50/// `w8a8_prefill_max_m_narrowing` otherwise (down), from
51/// [`ops::target_defaults::resolved`].
52pub(crate) fn max_m_for(n: u32, k: u32) -> u32 {
53 let levers = ops::target_defaults::resolved();
54 if n > k {
55 levers.w8a8_prefill_max_m_widening.value
56 } else {
57 levers.w8a8_prefill_max_m_narrowing.value
58 }
59}
60
61/// 2026-09-25: The W8A8 selection rule, as a pure function, so the CPU tests
62/// can pin every clause without a `ForwardContext`. `w8a16_only` and `max_m`
63/// are arguments because their production readers are process-global.
64///
65/// Clauses:
66///
67/// * `m > 4`: `m <= 4` belongs to the `w8a16_gemv_batch4` rung.
68/// * `fp8_blockscaled_prefill`: cleared by `METRALE_FP8_SINGLE_SCALE=1`.
69/// * `Fp8BlockScaled`: the GEMM indexes `row_scale` as the `[N/128, K/128]`
70/// grid, which per-row and single-scale weights do not have.
71/// * `k % 128 == 0`: the activation quantizer emits one scale per 128-wide K
72/// group.
73/// * `n % 128 == 0`: the weight scale grid is `[N/128, K/128]`.
74/// * `m <= max_m`: the per-target upper bound. `u32::MAX` is no cap and 0
75/// turns the arm off.
76/// * the quantizer and the GEMM handles are loaded.
77#[allow(clippy::too_many_arguments)]
78pub(crate) fn w8a8_prefill_selected(
79 m: u32,
80 n: u32,
81 k: u32,
82 scale_format: WeightQuantFormat,
83 fp8_blockscaled_prefill: bool,
84 quant_k: ops::Fp8ActQuant,
85 gemm_k: KernelHandle,
86 w8a16_only: bool,
87 max_m: u32,
88) -> bool {
89 !w8a16_only
90 && m > 4
91 && m <= max_m
92 && fp8_blockscaled_prefill
93 && scale_format == WeightQuantFormat::Fp8BlockScaled
94 && k.is_multiple_of(128)
95 && n.is_multiple_of(128)
96 && quant_k.available()
97 && gemm_k.0 != 0
98}
99
100impl DenseFfnLayer {
101 /// 2026-09-25: Whether one dense-FFN projection takes the W8A8 arm.
102 ///
103 /// Beyond [`w8a8_prefill_selected`] this requires the arena's dense-FFN
104 /// activation scratch (`ffn_act_a`, `ffn_act_scale`), which is 0 for MoE
105 /// configs. It is a gate and not an assert: a null scratch would be a launch
106 /// writing to address 0.
107 pub(crate) fn prefill_w8a8_selected(
108 &self,
109 ctx: &ForwardContext,
110 m: u32,
111 n: u32,
112 k: u32,
113 w: &Fp8Weight,
114 ) -> bool {
115 w8a8_prefill_selected(
116 m,
117 n,
118 k,
119 w.scale_format,
120 ctx.dispatch.fp8_blockscaled_prefill,
121 self.per_token_group_quant_fp8_k,
122 self.fp8_gemm_t_blockscaled_k,
123 ffn_w8a16_only(),
124 max_m_for(n, k),
125 ) && ctx.buffers.ffn_act_a().0 != 0
126 && ctx.buffers.ffn_act_scale().0 != 0
127 }
128
129 /// 2026-09-26: Quantize `act[m, k]` BF16 into the arena's dense-FFN scratch as
130 /// FP8 E4M3 plus one FP32 scale per token per 128-wide K group; returns
131 /// `(a_fp8, a_scale)`.
132 ///
133 /// The arena has one scratch pair, so the caller consumes the result before
134 /// the next call. `prefill_fp8` (reached from `forward_prefill_inner`) issues the gate/up GEMMs, the
135 /// SiLU-mul and the down quantization in order on one stream. `k` is `hidden`
136 /// or `intermediate`; the scratch is sized for the larger.
137 pub(crate) fn w8a8_quant_act(
138 &self,
139 ctx: &ForwardContext,
140 act: DevicePtr,
141 m: u32,
142 k: u32,
143 stream: u64,
144 ) -> Result<(DevicePtr, DevicePtr)> {
145 let a_fp8 = ctx.buffers.ffn_act_a();
146 let a_scale = ctx.buffers.ffn_act_scale();
147 // 2026-09-25: Padded extents: the cuBLASLt arm reads `ceil16(m)` rows.
148 let rows = ops::cublas_fp8_m_pad(m) as usize;
149 debug_assert!(rows * k as usize <= ctx.buffers.ffn_act_a_bytes());
150 debug_assert!(rows * (k as usize / 128) * 4 <= ctx.buffers.ffn_act_scale_bytes());
151 ops::per_token_group_quant_fp8(
152 ctx.gpu,
153 self.per_token_group_quant_fp8_k,
154 act,
155 a_fp8,
156 a_scale,
157 m,
158 k,
159 stream,
160 )?;
161 Ok((a_fp8, a_scale))
162 }
163
164 /// 2026-09-25: `out[m, n] = a_fp8[m, k] @ weight[n, k]^T` with both scale sets
165 /// applied in FP32: cuBLASLt when `dispatch.cublas.ffn` holds and the clauses
166 /// below pass, else the in-tree kernel.
167 ///
168 /// `out_capacity_bytes` is the allocated size of `out`. The cuBLASLt helper
169 /// rounds M up to 16 and writes those rows too, so an `out` smaller than the
170 /// padded extent sends the GEMM to the in-tree kernel instead.
171 #[allow(clippy::too_many_arguments)]
172 pub(crate) fn w8a8_gemm(
173 &self,
174 ctx: &ForwardContext,
175 a_fp8: DevicePtr,
176 a_scale: DevicePtr,
177 w: &Fp8Weight,
178 out: DevicePtr,
179 out_capacity_bytes: usize,
180 m: u32,
181 n: u32,
182 k: u32,
183 stream: u64,
184 ) -> Result<()> {
185 let m_pad = ops::cublas_fp8_m_pad(m) as usize;
186 let padded_out_bytes = m_pad * n as usize * 2;
187 // 2026-09-25: The cuBLASLt arm also needs:
188 //
189 // * output room for the padded rows;
190 // * when the k-major scale layout is in use (the default;
191 // `METRALE_CUBLAS_SCALE_LAYOUT=rowmajor` turns it off), the
192 // `fp8_act_scale_to_kmajor` kernel and its scratch. cuBLASLt reads the
193 // activation scales token-contiguous. Measured on H100 2026-09-11 without
194 // the transpose: rel_rms 7.7e-2 against the in-tree kernel;
195 // * `k / 128` a multiple of 4 (`blk128x128_stride_ok`): the weight scales
196 // are handed over as the checkpoint's `[N/128, K/128]` grid.
197 let scale_layout_ready = ctx.buffers.ffn_act_scale_kmajor().0 != 0
198 && self.fp8_act_scale_kmajor_k.0 != 0
199 && ctx.buffers.ffn_act_scale_kmajor_bytes() >= m_pad * (k as usize / 128) * 4;
200 let cublas = ctx.dispatch.cublas.ffn
201 && padded_out_bytes <= out_capacity_bytes
202 && metrale_gpu_runtime::cublaslt::scale_layout::blk128x128_stride_ok(k as usize)
203 && (scale_layout_ready || !ops::cublas_scale_layout_kmajor());
204 self.log_w8a8_prefill_route(ctx, cublas);
205 if cublas {
206 return ops::cublas_fp8_proj_prequant(
207 ctx.gpu,
208 self.fp8_act_scale_kmajor_k,
209 a_fp8,
210 a_scale,
211 ctx.buffers.ffn_act_scale_kmajor(),
212 w,
213 out,
214 m,
215 n,
216 k,
217 stream,
218 );
219 }
220 ops::fp8_gemm_t_blockscaled(
221 ctx.gpu,
222 self.fp8_gemm_t_blockscaled_k,
223 a_fp8,
224 a_scale,
225 w.weight,
226 w.row_scale,
227 out,
228 m,
229 n,
230 k,
231 stream,
232 )
233 }
234
235 /// 2026-09-25: Logs once per model (`log:ffn_w8a8_prefill`) which GEMM the
236 /// W8A8 arm used.
237 fn log_w8a8_prefill_route(&self, ctx: &ForwardContext, cublas: bool) {
238 if ctx.stats.once("log:ffn_w8a8_prefill") {
239 let how = if cublas { "cuBLASLt" } else { "kernel" };
240 tracing::info!(
241 "[metrale] dense FFN prefill: W8A8 block-scaled via {how} \
242 (per-token 1x128 act scales x 128x128 weight scales, FP32 epilogue; \
243 vLLM-equivalent FP8 numerics). METRALE_FFN_W8A16_ONLY=1 restores W8A16."
244 );
245 }
246 }
247
248 /// 2026-09-25: Logs once per model (`log:ffn_w8a16_prefill`) when a prefill
249 /// takes the W8A8 arm for neither gate/up nor down while the W8A8 arm is
250 /// reachable (neither the batch16 nor a tensor-core tier claims `m`).
251 pub(crate) fn log_w8a16_prefill_route(&self, ctx: &ForwardContext) {
252 if ctx.stats.once("log:ffn_w8a16_prefill") {
253 tracing::info!(
254 "[metrale] dense FFN prefill: W8A16 (BF16 act x FP8 weight). \
255 W8A8 not selected — see #917/#928."
256 );
257 }
258 }
259}
260
261#[cfg(test)]
262#[path = "dense_ffn_w8a8_prefill_tests.rs"]
263mod tests;