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;