spark_model/layers/dense_ffn_w8a8_prefill.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! W8A8 block-scaled dense-FFN PREFILL — the gate/up/down GEMM arm that the
4//! native-FP8 dispatch in `dense_ffn.rs` reaches ahead of its W8A16 branches.
5//!
6//! # Reachability — read this before measuring anything here
7//!
8//! This whole module is **opt-in and unreached by default**. It runs only when
9//! the dense FFN holds block-scaled FP8 weights, and
10//! `qwen35_dense.rs::load_layers` installs those only when
11//!
12//! ```text
13//! dense_fp8_enabled() && tp_world_size == 1
14//! && variant == Nvfp4Variant::Fp8Dequanted
15//! && proj_is_native_fp8(gate_proj)
16//! ```
17//!
18//! and `dense_fp8_enabled()` is `ATLAS_DENSE_FP8 == "1"`. `git grep
19//! ATLAS_DENSE_FP8` returns ONE hit — its own reader. No CI job, no
20//! `BENCH.toml` entry and no gate sets it, so no certification record on any
21//! branch has ever exercised this code. Without it `self.fp8_weights` is
22//! `None`, `forward_prefill_inner` never reaches the selection below, and
23//! NEITHER of the two route log lines is emitted. Three served A/B attempts
24//! were spent discovering that, each reading as "the lever did not arm".
25//!
26//! It is off by default because on GB10 native dense FP8 LOSES to the NVFP4
27//! autoquant fallback, and not narrowly. Same box, same binary, same
28//! byte-identical 949-token prompt, `Qwen3.6-27B-FP8`, TTFT median of 5
29//! (spark-256a, 2026-09-11):
30//!
31//! ```text
32//! default (NVFP4) 1437.2 ms 1.00x
33//! ATLAS_DENSE_FP8=1, W8A16 (capped) 2555.8 ms 1.78x slower
34//! ATLAS_DENSE_FP8=1, W8A8 (no cap) 3343.3 ms 2.33x slower
35//! ```
36//!
37//! So the ceiling below is worth 23.4% *within* the dense-FP8 path, and the
38//! dense-FP8 path is still the slower choice on this arch. GB10's FP8 W8A16
39//! kernels are simply less tuned than its NVFP4 W4A16 ones (unfused
40//! per-projection GEMV, non-transposed prefill GEMM); closing that is kernel
41//! work, not loader wiring. On H100 the trade is the other way round, which is
42//! why the ceiling is per-arch data in `kernels/<hw>/HARDWARE.toml` and not a
43//! constant here.
44//!
45//! WHY (#917 / #928). On a native-FP8 checkpoint the dense FFN's prefill GEMMs
46//! ran `w8a16_gemm_pipelined`: BF16 activations against E4M3 weights, so the
47//! MMA is the BF16 tensor-core path and the FP8 bytes are pure memory savings.
48//! Measured on H100 (2026-09-11, 1193-token prompt): TTFT 1075 ms against
49//! vLLM's 287 ms for the same model and prompt, with the pipelined kernel
50//! turning ~12 TFLOP/s on the FFN shapes. The attention Q/K/V/O projections had
51//! already moved to the W8A8 block-scaled path (`paged_qkv.rs` /
52//! `paged_oproj.rs`), and the MoE shared expert with them
53//! (`moe/forward_prefill_fp8.rs`) — the dense FFN was the one large prefill
54//! consumer still on W8A16, and on a dense model it is most of the FLOPs.
55//!
56//! This gives it the same arithmetic vLLM uses: per-token 1x128 FP32
57//! activation scales (`per_token_group_quant_fp8`) multiplied against the
58//! checkpoint's 128x128 FP32 weight scales in an FP32 epilogue, with the
59//! product accumulated by `mma.sync.m16n8k32.e4m3` — native on sm_90a (H100)
60//! and sm_121. Two GEMM implementations sit behind one selector:
61//!
62//! * cuBLASLt `fp8_gemm_act_weight_t_blkscaled` (weight as A with
63//! BLK128x128 scales, activation as B with VEC128 scales — the DeepSeek
64//! block-FP8 scheme), when `ATLAS_CUBLAS_GEMM=1`. The Hopper fast path.
65//! Its VEC128 scales go through `fp8_act_scale_to_kmajor` first: cuBLASLt
66//! documents that operand's scales with the TOKEN index contiguous, which
67//! is the transpose of what the quantizer writes (see
68//! `spark_runtime::cublaslt::scale_layout`).
69//! * `ops::fp8_gemm_t_blockscaled`, the in-tree kernel, otherwise.
70//!
71//! Both consume the SAME quantized activation and the same FP32 epilogue, so
72//! they are expected to agree to a BF16 ULP or two; the microtest
73//! (`examples/native_fp8_ffn_w8a8_microtest.rs`) pins that.
74//!
75//! ACCURACY. W8A8 is lossier than W8A16 by construction — the activation is
76//! quantized to E4M3 per 128-element group instead of kept in BF16. That is
77//! vLLM's dynamic W8A8 arithmetic and a deliberate precision trade, not a bug:
78//! the microtest gates it at cosine >= 0.999 / relative RMS <= 2% against the
79//! W8A16 reference, and the serve logs the selected path once at INFO so which
80//! arithmetic ran is visible in any TTFT report. `ATLAS_FFN_W8A16_ONLY=1`
81//! restores the old path byte-for-byte.
82
83use anyhow::Result;
84use spark_runtime::gpu::{DevicePtr, KernelHandle};
85
86use super::DenseFfnLayer;
87use crate::layer::ForwardContext;
88use crate::layers::ops;
89use crate::weight_map::{Fp8Weight, WeightQuantFormat};
90
91/// `ATLAS_FFN_W8A16_ONLY` kill switch: PRESENCE (any value, including empty)
92/// keeps the dense-FFN prefill on today's W8A16 kernels. Presence rather than
93/// `=1` because this is an escape hatch an operator reaches for while a serve
94/// is misbehaving, and `ATLAS_FFN_W8A16_ONLY=0` meaning "on" is a trap.
95///
96/// `OnceLock`-cached: the selector runs per projection per layer per prefill
97/// (3 x num_layers times), and `std::env::var_os` walks the environment block
98/// on every call. Cached process-wide is correct here — the variable is read
99/// once at first prefill and a serve never rewrites its own environment.
100pub fn ffn_w8a16_only() -> bool {
101 static ONLY: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
102 *ONLY.get_or_init(|| std::env::var_os("ATLAS_FFN_W8A16_ONLY").is_some())
103}
104
105/// The ceiling that applies to a projection of shape `[n, k]`.
106///
107/// `n > k` is the WIDENING case (gate/up: N=17408, K=5120 on Qwen3.8-27B);
108/// `n <= k` the NARROWING one (down: N=5120, K=17408). Two rows and not one
109/// because the measured crossover differs by about 6x between the two shapes —
110/// M~64-128 widening against M~384-512 narrowing.
111///
112/// Read from [`ops::target_defaults::resolved`] rather than carried on
113/// `GemmDispatch`: that resolution is `OnceLock`-cached precisely so it can be
114/// read per projection per layer per step, and it is where the other
115/// target-declared levers already live. `ffn_w8a16_only` above is the same
116/// shape of process-global, which is why `w8a8_prefill_selected` takes the
117/// ceiling as an ARGUMENT — a `OnceLock` cannot be toggled per test.
118pub(crate) fn max_m_for(n: u32, k: u32) -> u32 {
119 let levers = ops::target_defaults::resolved();
120 if n > k {
121 levers.w8a8_prefill_max_m_widening.value
122 } else {
123 levers.w8a8_prefill_max_m_narrowing.value
124 }
125}
126
127/// The whole W8A8 selection rule, as a pure function of shape + format +
128/// handles. Split out from the layer method so the CPU tests can pin every
129/// clause without a `ForwardContext` (`w8a16_only` is injected for the same
130/// reason — a process-global `OnceLock` cannot be toggled per test).
131///
132/// Clauses, each load-bearing:
133///
134/// * `m > 4` — M<=4 stays on the batch4 GEMV, which streams each weight once
135/// and beats any MMA tile at those shapes.
136/// * `fp8_blockscaled_prefill` — the `ATLAS_FP8_SINGLE_SCALE` kill switch that
137/// already governs the attention W8A8 path.
138/// * `Fp8BlockScaled` — a per-ROW scale is a different `row_scale` layout; the
139/// block-scaled GEMM would read it as `[N/128, K/128]`.
140/// * `k % 128 == 0` — the activation quantizer emits one scale per 128-wide K
141/// group and the GEMM folds per K-block.
142/// * `n % 128 == 0` — the weight scale grid is `[N/128, K/128]`.
143/// * `m <= max_m` — the per-arch upper bound. W8A8 beats W8A16 only while the
144/// per-token activation quantization and its FP32 scale epilogue are small
145/// against the GEMM; past that the quantization is the bill and W8A16's
146/// larger MMA wins. Where that crosses is a property of the arch, so it is
147/// declared in `kernels/<hw>/HARDWARE.toml` `[defaults]` rather than being a
148/// constant here. `u32::MAX` (the baseline) is no cap.
149/// * both handles loaded — a model shadow may not carry either entry point.
150#[allow(clippy::too_many_arguments)]
151pub(crate) fn w8a8_prefill_selected(
152 m: u32,
153 n: u32,
154 k: u32,
155 scale_format: WeightQuantFormat,
156 fp8_blockscaled_prefill: bool,
157 quant_k: ops::Fp8ActQuant,
158 gemm_k: KernelHandle,
159 w8a16_only: bool,
160 max_m: u32,
161) -> bool {
162 !w8a16_only
163 && m > 4
164 && m <= max_m
165 && fp8_blockscaled_prefill
166 && scale_format == WeightQuantFormat::Fp8BlockScaled
167 && k.is_multiple_of(128)
168 && n.is_multiple_of(128)
169 && quant_k.available()
170 && gemm_k.0 != 0
171}
172
173impl DenseFfnLayer {
174 /// Whether ONE dense-FFN prefill projection takes the W8A8 branch.
175 ///
176 /// Beyond [`w8a8_prefill_selected`] this also requires the shared
177 /// dense-FFN activation scratch (`ffn_act_a` / `ffn_act_scale`, sized once
178 /// for `max_batch_tokens x max(hidden, intermediate)` in
179 /// `BufferSizes::from_config`). That scratch is NULL for MoE configs, which
180 /// never take this path — but a null pointer here would be a kernel launch
181 /// writing to address 0, so it is a gate and not an assert.
182 pub(crate) fn prefill_w8a8_selected(
183 &self,
184 ctx: &ForwardContext,
185 m: u32,
186 n: u32,
187 k: u32,
188 w: &Fp8Weight,
189 ) -> bool {
190 w8a8_prefill_selected(
191 m,
192 n,
193 k,
194 w.scale_format,
195 ctx.dispatch.fp8_blockscaled_prefill,
196 self.per_token_group_quant_fp8_k,
197 self.fp8_gemm_t_blockscaled_k,
198 ffn_w8a16_only(),
199 max_m_for(n, k),
200 ) && ctx.buffers.ffn_act_a().0 != 0
201 && ctx.buffers.ffn_act_scale().0 != 0
202 }
203
204 /// Quantize `act[m, k]` BF16 into the shared dense-FFN scratch as FP8 E4M3
205 /// plus per-token/128-group FP32 scales; returns `(a_fp8, a_scale)`.
206 ///
207 /// Callers must consume the result before the next call: there is ONE
208 /// scratch pair per arena, deliberately (the previous per-call
209 /// `alloc`/`synchronize`/`free` pattern — still used by the MoE shared
210 /// expert — costs a full stream sync per projection). The dense-FFN
211 /// ordering is safe because everything runs on one stream: gate and up read
212 /// the `input` quantization, then `silu_mul` produces the down input, then
213 /// down re-quantizes over the same bytes.
214 ///
215 /// `k` may be `hidden` (gate/up) or `intermediate` (down); the scratch is
216 /// sized for `max(hidden, intermediate)` so neither overflows.
217 pub(crate) fn w8a8_quant_act(
218 &self,
219 ctx: &ForwardContext,
220 act: DevicePtr,
221 m: u32,
222 k: u32,
223 stream: u64,
224 ) -> Result<(DevicePtr, DevicePtr)> {
225 let a_fp8 = ctx.buffers.ffn_act_a();
226 let a_scale = ctx.buffers.ffn_act_scale();
227 // Padded extents, because the cuBLASLt arm READS `ceil16(m)` rows.
228 let rows = ops::cublas_fp8_m_pad(m) as usize;
229 debug_assert!(rows * k as usize <= ctx.buffers.ffn_act_a_bytes());
230 debug_assert!(rows * (k as usize / 128) * 4 <= ctx.buffers.ffn_act_scale_bytes());
231 ops::per_token_group_quant_fp8(
232 ctx.gpu,
233 self.per_token_group_quant_fp8_k,
234 act,
235 a_fp8,
236 a_scale,
237 m,
238 k,
239 stream,
240 )?;
241 Ok((a_fp8, a_scale))
242 }
243
244 /// `out[m, n] = a_fp8[m, k] @ weight[n, k]ᵀ` with both block-scale sets
245 /// folded in FP32 — cuBLASLt when `ATLAS_CUBLAS_GEMM` is set and the output
246 /// buffer has room for the padded M, else the in-tree kernel.
247 ///
248 /// `out_capacity_bytes` is the allocated size of `out`'s arena buffer. The
249 /// cuBLASLt helper rounds M up to 16 and WRITES those phantom rows (their
250 /// activation scales are zeroed, so the values are defined, but the stores
251 /// happen); the arena sizes that headroom in, and this check is what keeps
252 /// a future re-sizing from turning into a silent cross-buffer write.
253 #[allow(clippy::too_many_arguments)]
254 pub(crate) fn w8a8_gemm(
255 &self,
256 ctx: &ForwardContext,
257 a_fp8: DevicePtr,
258 a_scale: DevicePtr,
259 w: &Fp8Weight,
260 out: DevicePtr,
261 out_capacity_bytes: usize,
262 m: u32,
263 n: u32,
264 k: u32,
265 stream: u64,
266 ) -> Result<()> {
267 let m_pad = ops::cublas_fp8_m_pad(m) as usize;
268 let padded_out_bytes = m_pad * n as usize * 2;
269 // Every clause the cuBLASLt arm needs beyond the shared W8A8 gate:
270 //
271 // * output room for the phantom rows the padded M writes;
272 // * the VEC128 scale-layout adapter — kernel AND its scratch. cuBLASLt
273 // reads the activation scales token-contiguous, so without the
274 // transpose the GEMM is fast and WRONG (H100 2026-09-11: 1140 TFLOP/s
275 // at rel_rms 7.7e-2 vs this same in-tree kernel). Falling back is the
276 // only safe answer when either is missing;
277 // * `k % 512 == 0` — the BLK128x128 weight scales are handed over as
278 // the checkpoint's `[N/128, K/128]` grid, and cuBLASLt requires that
279 // tensor's column stride (K/128) to be a multiple of 4.
280 let scale_layout_ready = ctx.buffers.ffn_act_scale_kmajor().0 != 0
281 && self.fp8_act_scale_kmajor_k.0 != 0
282 && ctx.buffers.ffn_act_scale_kmajor_bytes() >= m_pad * (k as usize / 128) * 4;
283 let cublas = ctx.dispatch.cublas.ffn
284 && padded_out_bytes <= out_capacity_bytes
285 && spark_runtime::cublaslt::scale_layout::blk128x128_stride_ok(k as usize)
286 && (scale_layout_ready || !ops::cublas_scale_layout_kmajor());
287 self.log_w8a8_prefill_route(ctx, cublas);
288 if cublas {
289 return ops::cublas_fp8_proj_prequant(
290 ctx.gpu,
291 self.fp8_act_scale_kmajor_k,
292 a_fp8,
293 a_scale,
294 ctx.buffers.ffn_act_scale_kmajor(),
295 w,
296 out,
297 m,
298 n,
299 k,
300 stream,
301 );
302 }
303 ops::fp8_gemm_t_blockscaled(
304 ctx.gpu,
305 self.fp8_gemm_t_blockscaled_k,
306 a_fp8,
307 a_scale,
308 w.weight,
309 w.row_scale,
310 out,
311 m,
312 n,
313 k,
314 stream,
315 )
316 }
317
318 /// Log-once latch for the selected dense-FFN prefill arithmetic, matching
319 /// the `log:ffn_*` lines the other prefill levers in `dense_ffn.rs` emit.
320 /// The line matters beyond bookkeeping: W8A8 is a deliberate precision
321 /// trade, so a TTFT or quality report has to be able to say which
322 /// arithmetic produced it.
323 fn log_w8a8_prefill_route(&self, ctx: &ForwardContext, cublas: bool) {
324 if ctx.stats.once("log:ffn_w8a8_prefill") {
325 let how = if cublas { "cuBLASLt" } else { "kernel" };
326 tracing::info!(
327 "[atlas] dense FFN prefill: W8A8 block-scaled via {how} \
328 (per-token 1x128 act scales x 128x128 weight scales, FP32 epilogue; \
329 vLLM-equivalent FP8 numerics). ATLAS_FFN_W8A16_ONLY=1 restores W8A16."
330 );
331 }
332 }
333
334 /// Counterpart log for the unchanged W8A16 path, so the absence of the
335 /// W8A8 line is never ambiguous between "kill switch set" and "log lost".
336 pub(crate) fn log_w8a16_prefill_route(&self, ctx: &ForwardContext) {
337 if ctx.stats.once("log:ffn_w8a16_prefill") {
338 tracing::info!(
339 "[atlas] dense FFN prefill: W8A16 (BF16 act x FP8 weight). \
340 W8A8 not selected — see #917/#928."
341 );
342 }
343 }
344}
345
346#[cfg(test)]
347#[path = "dense_ffn_w8a8_prefill_tests.rs"]
348mod tests;