spark_model/layers/dense_ffn_batch16_decode.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! The 5..=32-row native-FP8 dense-FFN DECODE tier — `w8a16_gemv_batch16`.
4//!
5//! WHY (#927). Measured on 1xH100, 2026-09-11, Qwen/Qwen3.8-27B-FP8, tip
6//! `fbbe70767`: the decode step cost 44 ms at 4 active rows and **224 ms at
7//! 16** (TPOT), so raising the batch cap from 4 to 16 made C=16 aggregate
8//! throughput FALL from 76 to 62 tok/s. Five extra rows cost 5x the step.
9//!
10//! The cliff is a dispatch gap, not a kernel one. `dense_ffn.rs`'s `w8_gemm!`
11//! claimed only `(1..=4)` for `w8a16_gemv_batch4`; at m = 5..16 it fell to the
12//! transposed `w8a16_gemm_n128_m128` / `w8a16_gemm_pipelined` tile GEMMs. Those
13//! pad M to a 128-row MMA tile, so at M=16 seven eighths of every tile is
14//! padding and the kernel turns 5-12 TFLOP/s while the FFN at decode widths is
15//! purely weight-bandwidth bound. `w8a16_gemv_batch16` — the MAX_M=16
16//! instantiation of the SAME template as `w8a16_gemv_batch4`, already in
17//! `kernels/gb10/common/w8a16_gemv_batch4.cu` — makes ONE pass over the FP8
18//! weight for up to 16 rows.
19//!
20//! NUMERICS. Every row is bit-identical to the scalar `w8a16_gemv` that M=1
21//! decode runs: same K-iteration order, same per-row reduction tree, the
22//! accumulators are independent and `M` appears in no row's operand sequence.
23//! H100 receipt on #932: M=8 and M=16 both `unequal_bf16=0 max_abs=0`. So this
24//! moves widths 5..=32 from a REASSOCIATING tile GEMM onto the bits decode
25//! already produces at M=1 — the direction that removes a numerics seam rather
26//! than adding one.
27//!
28//! 17..=32 runs the same kernel TWICE on contiguous row halves. The FFN
29//! activations and outputs are contiguous `[m, k]` / `[m, n]`, so a half is a
30//! plain byte offset — no staging, no strided variant. Two weight passes still
31//! beat one M-padded MMA tile at these widths, and it means a `max_batch_size`
32//! of 32 never reaches the tile GEMMs at decode either.
33//!
34//! ARM ORDER in `w8_gemm!` (see `dense_ffn.rs`) is deliberate and this module
35//! owns the 4th and 5th rungs:
36//! 1. `m <= 4` -> `w8a16_gemv_batch4`
37//! 2. `m` 5..=16 -> `w8a16_gemm_m16` (ATLAS_FFN_M16_TC only)
38//! 3. `m` 17..=32 -> `w8a16_gemm_m16` x2 halves (ATLAS_FFN_M16_TC only)
39//! 4. `m` 5..=16 -> `w8a16_gemv_batch16` (here)
40//! 5. `m` 17..=32 -> `w8a16_gemv_batch16` x2 halves (here)
41//! 6. W8A8 block-scaled prefill (#917/#928)
42//! 7. transposed / pipelined / base W8A16 tile GEMMs
43//!
44//! Rungs 2-3 (`dense_ffn_m16_tc.rs`) are OFF by default and, when an operator
45//! sets the lever, they take these same widths onto a tensor-core MMA that
46//! REASSOCIATES the K reduction. This module's bit-exactness claim below is
47//! about the arm THIS module owns; with the lever set, the FFN's 5..=32 output
48//! is the MMA's, within 2 BF16 ULP of the scalar rather than equal to it.
49//!
50//! 🪤 CONSEQUENCE, stated because it is a real boundary move: the W8A8 prefill
51//! arm's own rule (`dense_ffn_w8a8_prefill.rs`) starts at `m > 4`, so with
52//! rungs 2-3 ahead of it the W8A8 path begins at **m > 32** in practice. 5..=32
53//! are decode widths where one weight pass beats any MMA tile — but a prefill
54//! of 5..=32 tokens (a very short prompt, or the TAIL CHUNK of a chunked
55//! prefill) takes the GEMV too. That consequence is what the serving A/B below
56//! caught, and it is why this tier ships disarmed.
57//!
58//! 🔴 DEFAULT OFF — OPT-IN VIA `ATLAS_FFN_BATCH16=1`. The cliff above is
59//! real and this kernel is the right shape for it, but on the one target where
60//! the tier has been A/B'd end to end it is a net LOSS in serving. H100 round
61//! 5, 2026-09-11, Qwen/Qwen3.8-27B-FP8, single variable — same binary, same
62//! 16-way burst, the tier the only difference:
63//!
64//! | 1024x256, C=16 | tier ON | tier OFF |
65//! |---------------------|----------|--------------|
66//! | aggregate tok/s | 121.4 | **128.0** |
67//! | TPOT p50 | 107.4 ms | **102.0 ms** |
68//! | 28-token smoke TTFT | 150 ms | **101 ms** |
69//!
70//! Per-phase at n=16 (`ATLAS_MS_PROFILE=1`, so eager — read the ratios): with
71//! the tier OFF the step goes 86.80 -> 82.32 ms, `ssm` 63.31 -> 59.91 ms and
72//! `attn` 19.90 -> 18.82 ms (-5.2% to -5.4% each); `head` does not move. `ssm`
73//! per layer returns to 1248 us against a pre-#927 1252 us — the tier's cost
74//! is the whole of the regression it introduced, not part of it.
75//!
76//! WHY it loses although the kernel wins at 16 rows: it dispatches by ROW
77//! COUNT, not by phase, so a chunked prefill's tail chunk lands in the band —
78//! a 1193-token prompt splits `1168 + 25`, and the 25-row tail takes the GEMV.
79//! That is a FIXED ~35 ms TTFT cost per request (49 ms on the 28-token smoke),
80//! which no decode-rate gain at these widths pays back.
81//!
82//! 🚨 AND IT HAS NEVER BEEN MEASURED ON GB10. `w8a16_gemv_batch16` is an
83//! instantiation in `w8a16_gemv_batch4.cu`, so the handle resolves on every
84//! target that carries that module — GB10 included. A default-ON tier would
85//! ship an unmeasured routing change to the target this repo serves, on the
86//! strength of an H100 number that came out negative. Opt-in is the honest
87//! default until a GB10 A/B exists; if one wins there, the lever to flip is
88//! this file's, not the caller's.
89//!
90//! WHAT STAYS DEFAULT-ON, and why it is a different lever: the attention
91//! `o_proj` groups-of-16 arm, the QKV band widening and the SSM MTP-verify
92//! arms key off their OWN kernel handles and never read this switch. They were
93//! ON in BOTH arms of the A/B above, so none of the movement in that table is
94//! theirs to claim or to blame — including the `attn` phase's -5.4%, which
95//! moved while they were untouched. Each is bit-identical per row to the M=1
96//! `w8a16_gemv` it replaces, which is a numerics improvement that does not
97//! depend on the FFN result either way.
98
99use anyhow::Result;
100use spark_runtime::gpu::DevicePtr;
101
102use super::DenseFfnLayer;
103use crate::layer::ForwardContext;
104use crate::layers::ops;
105use crate::weight_map::Fp8Weight;
106
107/// `ATLAS_FFN_BATCH16` opt-in: the value `1` — and only `1` — arms the
108/// 5..=32-row tier. Anything else, absence included, leaves those widths on
109/// the pre-#927 arms.
110///
111/// VALUE rather than the house PRESENCE convention (`ffn_w8a16_only` next
112/// door) because the polarity is the other way round: for a switch that ARMS
113/// an arm, it is `ATLAS_FFN_BATCH16=0` meaning "on" that would be the trap.
114/// Same shape as `moe_grouped_decode_forced` in `layers/mod.rs`, the other
115/// lever in this crate that arms rather than disarms.
116///
117/// `OnceLock`-cached and read ONCE PER LAYER, into `DenseFfnLayer`'s
118/// `batch16_enabled`: the route must be CONSTANT across CUDA-graph replays,
119/// and `std::env::var` walks the environment block on every call.
120pub fn ffn_batch16_enabled() -> bool {
121 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
122 *ON.get_or_init(|| std::env::var("ATLAS_FFN_BATCH16").as_deref() == Ok("1"))
123}
124
125/// How the batch16 tier serves `m` rows, or `None` when it does not claim them.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub(crate) enum Batch16Plan {
128 /// One launch covering rows `0..m` (m <= 16).
129 Single,
130 /// Two launches on contiguous row halves: rows `0..first`, then
131 /// `first..m`. `first` is `ceil(m/2)`, so both halves are <= 16 for every
132 /// m <= 32 and the FIRST half is the wider one (m=17 -> 9 + 8).
133 Halves { first: u32 },
134}
135
136/// The whole batch16 selection rule, as a pure function of the row count, the
137/// handle's presence and the opt-in.
138///
139/// Split out from the layer for the same reason `w8a8_prefill_selected` is:
140/// the CPU tests pin every rung without building a `ForwardContext`, and
141/// `enabled` is injected because a process-global `OnceLock` cannot be toggled
142/// per test. `enabled` reads FIRST in the guard: it is the default-off gate,
143/// and a reader asking "what does a stock serve do at m=8" should meet it
144/// before anything about handles.
145pub(crate) fn batch16_plan(m: u32, batch16_loaded: bool, enabled: bool) -> Option<Batch16Plan> {
146 if !enabled || !batch16_loaded {
147 return None;
148 }
149 match m {
150 5..=16 => Some(Batch16Plan::Single),
151 // Both halves must be <= 16, the kernel's MAX_M. `div_ceil` puts the
152 // odd row in the first half; either order is bit-identical per row.
153 17..=32 => Some(Batch16Plan::Halves {
154 first: m.div_ceil(2),
155 }),
156 _ => None,
157 }
158}
159
160impl DenseFfnLayer {
161 /// The plan for `m` rows on THIS layer — handle presence plus the opt-in
162 /// the layer latched at construction.
163 pub(crate) fn ffn_batch16_plan(&self, m: u32) -> Option<Batch16Plan> {
164 batch16_plan(m, self.w8a16_gemv_batch16_k.0 != 0, self.batch16_enabled)
165 }
166
167 /// Run one dense-FFN projection through `w8a16_gemv_batch16`.
168 ///
169 /// `input` is `[m, k]` BF16 and `out` is `[m, n]` BF16, both CONTIGUOUS —
170 /// which is what makes the `Halves` plan a pair of byte offsets rather
171 /// than a strided launch. (`ops::w8a16_gemv_batch16_strided` is the tool
172 /// when a caller's rows are NOT contiguous; the attention QKV path uses
173 /// it.)
174 #[allow(clippy::too_many_arguments)]
175 pub(crate) fn w8a16_batch16_proj(
176 &self,
177 ctx: &ForwardContext,
178 plan: Batch16Plan,
179 w: &Fp8Weight,
180 input: DevicePtr,
181 out: DevicePtr,
182 m: u32,
183 n: u32,
184 k: u32,
185 stream: u64,
186 ) -> Result<()> {
187 self.log_batch16_decode_route(ctx, plan);
188 const BF16: usize = 2;
189 let launch = |rows: u32, first: u32| {
190 ops::w8a16_gemv_batch16(
191 ctx.gpu,
192 self.w8a16_gemv_batch16_k,
193 input.offset(first as usize * k as usize * BF16),
194 w.weight,
195 w.row_scale,
196 out.offset(first as usize * n as usize * BF16),
197 rows,
198 n,
199 k,
200 stream,
201 )
202 };
203 match plan {
204 Batch16Plan::Single => launch(m, 0),
205 Batch16Plan::Halves { first } => {
206 launch(first, 0)?;
207 launch(m - first, first)
208 }
209 }
210 }
211
212 /// Log-once latch for the batch16 decode tier, in the same `log:ffn_*`
213 /// shape the other dense-FFN route logs use. It earns its line for a
214 /// reason the default-on version did not have: this arm now runs only
215 /// because an operator asked for it, and a serve quoting a 5..=32-row TPOT
216 /// number should be able to prove from its own log which side of the A/B
217 /// it ran.
218 fn log_batch16_decode_route(&self, ctx: &ForwardContext, plan: Batch16Plan) {
219 if ctx.stats.once("log:ffn_batch16_decode") {
220 let how = match plan {
221 Batch16Plan::Single => "one launch",
222 Batch16Plan::Halves { .. } => "two launches on contiguous row halves",
223 };
224 tracing::info!(
225 "[atlas] dense FFN decode: native FP8 w8a16_gemv_batch16 ({how}) \
226 for 5..=32 rows — one weight pass, bit-identical per row to the \
227 M=1 w8a16_gemv. ARMED BY ATLAS_FFN_BATCH16=1, off by default: it \
228 measured -5.4% aggregate and +50 ms TTFT on H100, and has never \
229 been measured on GB10 (#927)."
230 );
231 }
232 }
233}
234
235#[cfg(test)]
236#[path = "dense_ffn_batch16_decode_tests.rs"]
237mod tests;