spark_model/layers/dense_ffn_m16_tc.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! The 5..=32-row native-FP8 dense-FFN decode tier on TENSOR CORES —
4//! `w8a16_gemm_m16`, behind `ATLAS_FFN_M16_TC` (#927).
5//!
6//! WHY. Measured on 1xH100, 2026-09-11, Qwen/Qwen3.8-27B-FP8, tip
7//! `2962cfed7`: at a decode batch of 16 the step is **86.7 ms**, of which the
8//! 48 SSM layers are 63.3 ms and the dense FFN inside them is **63%**
9//! (~833 us/layer). The tier that serves those widths today,
10//! `w8a16_gemv_batch16` (rungs 2-3 of `dense_ffn_batch16_decode.rs`), is
11//! bit-exact but **FP32-FMA-bound** at M=16, not bandwidth-bound:
12//!
13//! | shape | batch16 GEMV @ M=16 | HBM3 |
14//! |---|---|---|
15//! | gate/up N=17408 K=5120 | 0.260 ms / **342 GB/s** | ~3,000 GB/s |
16//! | down N=5120 K=17408 | 0.330 ms / **270 GB/s** | ~3,000 GB/s |
17//!
18//! An 89 MB FP8 weight matrix should stream in ~30 us. The GEMV spends ~37 ALU
19//! ops per weight BYTE (16 scalar FFMA across the 16 rows, 16 BF16->FP32
20//! converts, a LUT lookup, a scale multiply), which caps it near 350 GB/s no
21//! matter how fast the DRAM is. `w8a16_gemm_m16` replaces those 16 FFMA with
22//! one `mma.sync.m16n8k16` lane-slot — the M tile IS 16 rows, so nothing is
23//! padded, which is the whole difference from the tile GEMMs that pad M to 128
24//! and waste 7/8 of every tile — and cuts the dequant to ~2 instructions per
25//! byte. Target: >= 1,500 GB/s at M=16, >= 1,000 GB/s at M=8.
26//!
27//! 🔴 NUMERICS — THIS ARM REASSOCIATES; THE BATCH16 ARM DOES NOT.
28//! `w8a16_gemv_batch16` reduces each output in ONE FP32 accumulator walked in
29//! strict K order, which makes it bit-identical to the scalar `w8a16_gemv` that
30//! M=1 decode runs. An MMA reduces 16 K-products in the tensor core's own order
31//! first, so THIS arm is not. Its contract is <= 2 BF16 ULP per element, with
32//! the 128-K block scale still folded once per block onto an FP32 outer
33//! accumulator (the two-level fold, preserved exactly). That is a seam, and it
34//! is why the lever exists and defaults OFF.
35//!
36//! It is not a NEW seam, though: the arm the FFN reached at these widths BEFORE
37//! #927 was `w8a16_gemm_n128_m128` / `w8a16_gemm_pipelined`, both m16n8k16 MMA
38//! kernels with exactly this reassociation. Turning the lever on returns 5..=32
39//! to MMA numerics while keeping the ONE-weight-pass property #927 bought.
40//!
41//! ARM ORDER in `w8_gemm!` (`dense_ffn.rs`) with the lever ON:
42//! 1. `m <= 4` -> `w8a16_gemv_batch4` (bit-exact)
43//! 2. `m` 5..=16 -> `w8a16_gemm_m16` (here, MMA)
44//! 3. `m` 17..=32 -> `w8a16_gemm_m16` x2 halves (here, MMA)
45//! 4. `m` 5..=16 -> `w8a16_gemv_batch16` (bit-exact)
46//! 5. `m` 17..=32 -> `w8a16_gemv_batch16` x2 halves (bit-exact)
47//! 6. W8A8 block-scaled prefill (#917/#928)
48//! 7. transposed / pipelined / base W8A16 tile GEMMs
49//! With the lever OFF (the default) rungs 2-3 vanish and the ladder is exactly
50//! what #927 shipped.
51//!
52//! 17..=32 runs the kernel TWICE on contiguous row halves, for the same reason
53//! `batch16_decode.rs` does: the FFN activations and outputs are contiguous
54//! `[m, k]` / `[m, n]`, so a half is a plain byte offset, and two weight passes
55//! still beat one M-padded MMA tile at these widths.
56//!
57//! ── THE LEVER IS SPLIT PER PROJECTION FAMILY (round 6) ─────────────────────
58//! Round 6's serving A/B on 1xH100 (2026-09-11, bs16, `ATLAS_MS_PROFILE`) with
59//! the single old lever turned the WHOLE route on at once and measured two
60//! opposite results in one number:
61//!
62//! | phase | tier | Δ step time |
63//! |---|---|---|
64//! | attention | QKV + o_proj (`w8a16_gemm_m16{,_strided}`) | **−21.7%** |
65//! | SSM layers | dense FFN arm (`w8a16_gemm_m16`) | **+13.7%** |
66//! | net | | **+5.2%** |
67//!
68//! One lever could only ship both or neither, so the win was unbuyable. The
69//! grammar is now three presence-based variables, all default OFF:
70//!
71//! | variable | turns on |
72//! |---|---|
73//! | `ATLAS_ATTN_M16_TC` | the QKV and o_proj tiers |
74//! | `ATLAS_FFN_M16_TC` | the dense-FFN arm (rungs 2-3 above) |
75//! | `ATLAS_M16_TC` | BOTH — the umbrella, i.e. round 6's behaviour |
76//!
77//! ⚠ `ATLAS_FFN_M16_TC=1` MEANS SOMETHING NARROWER THAN IT DID IN ROUND 6.
78//! Before this commit it was the only lever and it reached all three tiers;
79//! round 6's serve J and its +5.2% were measured with it. The recipe that
80//! reproduces round 6 is now `ATLAS_M16_TC=1`. The recipe that buys the
81//! attention win WITHOUT the FFN loss — the point of the split — is
82//! `ATLAS_ATTN_M16_TC=1` alone.
83//!
84//! ── WHY THE FFN ARM LOSES WHERE THE ATTENTION TIERS WIN (HYPOTHESIS) ───────
85//! Same kernel, same M, same weight format; the one thing that differs is N,
86//! and therefore the grid:
87//!
88//! | tier | N | CTAs at `N_TILE=32` |
89//! |---|---|---|
90//! | o_proj | 5120 | 160 |
91//! | QKV (K, V) | 1024 | 32 |
92//! | QKV (Q) | 6144 | 192 |
93//! | **dense FFN gate/up** | **17408** | **544** |
94//! | dense FFN down | 5120 | 160 |
95//!
96//! The kernel is 4 warps at 19,456 B of smem under `__launch_bounds__(128, 4)`,
97//! so an H100 SM holds 4 CTAs and the machine holds 132 × 4 = **528**. Every
98//! attention tier fits inside one partial wave and runs at full occupancy from
99//! the first instruction. gate/up at 544 is **one full wave plus a 16-CTA
100//! tail**: 3% of the work costs a second wave's worth of launch, prologue and
101//! HBM-latency ramp, none of which is overlapped with anything, because by then
102//! 116 SMs are idle. That is the leading explanation for a tier that beats
103//! `w8a16_gemv_batch16` 3.71× in the microtest (which times ONE shape in
104//! isolation, with no tail to pay) and still loses 13.7% in the serve.
105//!
106//! A second, non-exclusive explanation: at `N_TILE=32` each 16-row A tile is
107//! read by twice as many CTAs as at 64, and gate/up's 89 MB weight evicts A
108//! from a 50 MB L2 between passes, so the "A stays L2-resident" claim in the
109//! kernel header — which holds comfortably at N=1024 — may not hold at
110//! N=17408.
111//!
112//! Both hypotheses predict the same fix, which is why `ATLAS_FFN_M16_TC_NTILE`
113//! exists: `=64` selects `w8a16_gemm_m16_n64`, taking gate/up to 272 CTAs
114//! (inside one wave) and doubling A reuse. Default stays 32 — the tile with the
115//! receipt. NEITHER hypothesis has been measured; the A/B that settles it is
116//! `ATLAS_FFN_M16_TC=1 ATLAS_FFN_M16_TC_NTILE=64` against
117//! `ATLAS_FFN_M16_TC=1` on the same serve.
118//!
119//! ── THE ROUND-6 M=32 RED CELL WAS THE ORACLE, NOT THE SPLIT ────────────────
120//! Round 6's microtest reported `gate/up M=32` at `max_ulp 28`, 5 of 557,056
121//! elements over the 2-ULP budget, `sign_flips 0`, `rel_rms 4.2e-5`, while
122//! `down M=32` and every M ≤ 16 cell was green. It was read as a possible
123//! row/pitch defect in the two-halves rung. It is not: a host simulation of the
124//! exact geometry (`dense_ffn_m16_tc_m32_tests.rs`) reproduces the signature —
125//! 5 over-budget elements, none in rows 0..15 — with NO offset arithmetic at
126//! all. Every one of them is an output that cancelled to |ref| between 5.7e-6
127//! and 1.6e-4 against a reference RMS of 39.1, i.e. to ~1e-7..4e-6 of the
128//! matrix scale, where one FP32 accumulation rounding spans hundreds of ordinal
129//! BF16 ULP. M=32 trips it and M=16 does not because M=32 samples twice the
130//! outputs; gate/up trips it and down does not because gate/up has 3.4× the
131//! columns. The fix is in the oracle's comparison (a mixed absolute/relative
132//! criterion), not here — see `examples/native_fp8_ffn_m16_tc_microtest.rs`.
133
134use anyhow::Result;
135use spark_runtime::gpu::DevicePtr;
136
137/// The tier's NUMERICS CONTRACT — the one comparison the GPU oracle
138/// (`examples/native_fp8_ffn_m16_tc_microtest.rs`) and the host simulation both
139/// evaluate, so a receipt and a unit test cannot be grading different things.
140#[path = "dense_ffn_m16_tc_oracle.rs"]
141pub mod oracle;
142
143pub use oracle::{
144 M16_TC_ACC_FLOOR_MARGIN, M16_TC_MAX_ULP, bf16_ord, m16_tc_acc_floor, within_m16_tc_budget,
145};
146
147use super::DenseFfnLayer;
148use crate::layer::ForwardContext;
149use crate::layers::ops;
150use crate::weight_map::Fp8Weight;
151use spark_runtime::gpu::KernelHandle;
152
153/// Which projection families the tensor-core tier serves, and at what CTA
154/// width. SSOT for the whole lever grammar; every call site resolves it ONCE at
155/// construction into a field.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct M16TcLevers {
158 /// The dense-FFN arm (`ATLAS_FFN_M16_TC`).
159 pub ffn: bool,
160 /// The multi-seq FP8 QKV tier and the FP8 o_proj tier
161 /// (`ATLAS_ATTN_M16_TC`).
162 pub attn: bool,
163 /// CTA N width for the FFN arm: 32 (default) or 64
164 /// (`ATLAS_FFN_M16_TC_NTILE=64`). Attention always runs 32 — the wide tile
165 /// has no strided twin and its N is already CTA-starved.
166 pub ffn_n_tile: u32,
167}
168
169/// The grammar, as a pure function of the three variables' PRESENCE plus the
170/// N-tile string — so the rule is testable without touching the process
171/// environment.
172///
173/// Presence rather than `=1` everywhere (the N tile aside, which needs a
174/// value): it keeps every A/B recipe a bare `VAR=1` prefix with no "=0 means
175/// on" trap, the same contract `ATLAS_FFN_NO_BATCH16` uses next door. All three
176/// default OFF, which is the opposite polarity to that kill switch and
177/// deliberately so: it is an operator's escape hatch from a shipped default,
178/// these are opt-ins to a route that trades #927's bit-exactness for bandwidth.
179///
180/// An unrecognised `ATLAS_FFN_M16_TC_NTILE` falls back to 32 rather than
181/// failing the boot: the tile is a perf A/B knob, and the route log says which
182/// one actually ran.
183pub(crate) fn resolve_m16_tc_levers(ffn: bool, attn: bool, n_tile: Option<&str>) -> M16TcLevers {
184 M16TcLevers {
185 ffn,
186 attn,
187 ffn_n_tile: match n_tile {
188 Some("64") => ops::W8A16_GEMM_M16_N_TILE_WIDE,
189 _ => ops::W8A16_GEMM_M16_N_TILE,
190 },
191 }
192}
193
194/// The resolved levers for this process.
195///
196/// `OnceLock`-cached for the same reason the batch16 switch is: the selector
197/// runs per projection per layer per step, and a per-call `var_os` could change
198/// the captured launch set across CUDA-graph replays.
199pub fn m16_tc_levers() -> M16TcLevers {
200 static ON: std::sync::OnceLock<M16TcLevers> = std::sync::OnceLock::new();
201 *ON.get_or_init(|| {
202 let n_tile = std::env::var("ATLAS_FFN_M16_TC_NTILE").ok();
203 resolve_m16_tc_levers(
204 // ★ THE TARGET'S DECLARATION, environment second. `ffn_m16_tc` is
205 // a `[defaults]` row (`kernels/<hw>/HARDWARE.toml`), so an H100
206 // serve reproduces round 6's verdict — the FFN arm OFF — with an
207 // empty environment, and `ATLAS_FFN_M16_TC` / `ATLAS_M16_TC`
208 // remain the A/B. Both variables are folded in by the resolver,
209 // not here: an umbrella that could also DISARM a declaration would
210 // make the recipe depend on export order.
211 ops::target_defaults::resolved().ffn_m16_tc.value,
212 ops::target_defaults::resolved().attn_m16_tc.value,
213 n_tile.as_deref(),
214 )
215 })
216}
217
218/// How the tensor-core tier serves `m` rows, or `None` when it does not claim
219/// them. Mirrors `Batch16Plan` so the two ladders read the same way.
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub(crate) enum M16TcPlan {
222 /// One launch covering rows `0..m` (m <= 16 = the kernel's M tile).
223 Single,
224 /// Two launches on contiguous row halves: rows `0..first`, then
225 /// `first..m`. `first` is `ceil(m/2)`, so both halves are <= 16 for every
226 /// m <= 32 and the FIRST half is the wider one (m=17 -> 9 + 8).
227 Halves { first: u32 },
228}
229
230/// The whole selection rule, as a pure function of the row count, the reduction
231/// depth, the handle's presence and the lever.
232///
233/// `k` is part of the rule and not an `ensure!` at the call site: the kernel
234/// indexes `block_scale[n_block * (K/128) + k/128]`, so a K that is not a whole
235/// number of 128-wide scale blocks has no correct scale to fold and the tier
236/// must DECLINE rather than launch and be wrong. Every Atlas FP8 FFN shape
237/// satisfies it (Qwen3.8-27B: 5120 and 17408), but a model whose hidden size is
238/// not a multiple of 128 would otherwise fall off this cliff silently.
239pub(crate) fn m16_tc_plan(m: u32, k: u32, loaded: bool, enabled: bool) -> Option<M16TcPlan> {
240 if !enabled || !loaded || !k.is_multiple_of(128) {
241 return None;
242 }
243 match m {
244 5..=16 => Some(M16TcPlan::Single),
245 // Both halves must be <= 16, the kernel's M tile. `div_ceil` puts the
246 // odd row in the first half; the split changes no row's arithmetic.
247 17..=32 => Some(M16TcPlan::Halves {
248 first: m.div_ceil(2),
249 }),
250 _ => None,
251 }
252}
253
254/// Which contiguous instantiation the FFN arm launches, given the requested
255/// tile and which entry points this shadow actually carries.
256///
257/// A shadow built before the wide arm existed has no `w8a16_gemm_m16_n64`, so
258/// `ATLAS_FFN_M16_TC_NTILE=64` must fall back to the 32-wide kernel rather than
259/// launch a zero handle. The fallback is silent by design — the route log names
260/// the tile that ran.
261pub(crate) fn m16_tc_kernel(
262 n_tile: u32,
263 narrow: KernelHandle,
264 wide: KernelHandle,
265) -> (ops::ContiguousM16Gemm, KernelHandle, u32) {
266 if n_tile == ops::W8A16_GEMM_M16_N_TILE_WIDE && wide.0 != 0 {
267 (
268 ops::w8a16_gemm_m16_n64,
269 wide,
270 ops::W8A16_GEMM_M16_N_TILE_WIDE,
271 )
272 } else {
273 (ops::w8a16_gemm_m16, narrow, ops::W8A16_GEMM_M16_N_TILE)
274 }
275}
276
277impl DenseFfnLayer {
278 /// The plan for `m` rows at reduction depth `k` on THIS layer — handle
279 /// presence plus the lever.
280 pub(crate) fn ffn_m16_tc_plan(&self, m: u32, k: u32) -> Option<M16TcPlan> {
281 m16_tc_plan(m, k, self.w8a16_gemm_m16_k.0 != 0, self.m16_tc)
282 }
283
284 /// Run one dense-FFN projection through `w8a16_gemm_m16`.
285 ///
286 /// `input` is `[m, k]` BF16 and `out` is `[m, n]` BF16, both CONTIGUOUS,
287 /// which is what makes the `Halves` plan a pair of byte offsets rather than
288 /// a strided launch. (`ops::w8a16_gemm_m16_strided` is the tool when a
289 /// caller's rows are not contiguous; the attention QKV path uses it.)
290 #[allow(clippy::too_many_arguments)]
291 pub(crate) fn w8a16_m16_tc_proj(
292 &self,
293 ctx: &ForwardContext,
294 plan: M16TcPlan,
295 w: &Fp8Weight,
296 input: DevicePtr,
297 out: DevicePtr,
298 m: u32,
299 n: u32,
300 k: u32,
301 stream: u64,
302 ) -> Result<()> {
303 let (gemm, kernel, n_tile) = m16_tc_kernel(
304 self.m16_tc_n_tile,
305 self.w8a16_gemm_m16_k,
306 self.w8a16_gemm_m16_n64_k,
307 );
308 self.log_m16_tc_route(ctx, plan, n_tile);
309 const BF16: usize = 2;
310 let launch = |rows: u32, first: u32| {
311 gemm(
312 ctx.gpu,
313 kernel,
314 input.offset(first as usize * k as usize * BF16),
315 w.weight,
316 w.row_scale,
317 out.offset(first as usize * n as usize * BF16),
318 rows,
319 n,
320 k,
321 stream,
322 )
323 };
324 match plan {
325 M16TcPlan::Single => launch(m, 0),
326 M16TcPlan::Halves { first } => {
327 launch(first, 0)?;
328 launch(m - first, first)
329 }
330 }
331 }
332
333 /// Log-once latch, in the same `log:ffn_*` shape the other dense-FFN route
334 /// logs use. It is worth a line because this arm is the one that is NOT
335 /// bit-identical to the M=1 decode path: a TPOT report or a parity
336 /// complaint at 5..=32 rows needs to say which of the two tiers ran.
337 fn log_m16_tc_route(&self, ctx: &ForwardContext, plan: M16TcPlan, n_tile: u32) {
338 if ctx.stats.once("log:ffn_m16_tc_decode") {
339 let how = match plan {
340 M16TcPlan::Single => "one launch",
341 M16TcPlan::Halves { .. } => "two launches on contiguous row halves",
342 };
343 let asked = self.m16_tc_n_tile;
344 tracing::info!(
345 "[atlas] dense FFN decode: ATLAS_FFN_M16_TC — tensor-core w8a16_gemm_m16 \
346 N_TILE={n_tile} (asked {asked}) ({how}) for 5..=32 rows, ahead of \
347 w8a16_gemv_batch16. One weight pass, m16n8k16 MMA, so outputs are \
348 REASSOCIATED vs the scalar w8a16_gemv (<= 2 BF16 ULP), unlike the batch16 \
349 tier. This lever no longer reaches the attention tiers — that is \
350 ATLAS_ATTN_M16_TC, and ATLAS_M16_TC is both. Unset it to restore the \
351 bit-exact tier (#927)."
352 );
353 }
354 }
355}
356
357#[cfg(test)]
358#[path = "dense_ffn_m16_tc_lever_tests.rs"]
359mod lever_tests;
360
361#[cfg(test)]
362#[path = "dense_ffn_m16_tc_tests.rs"]
363mod tests;
364
365/// The host simulation that settles round 6's `gate/up M=32` red cell: it
366/// reproduces the two-halves geometry and the oracle's comparison on the CPU,
367/// with no GPU and no offset arithmetic to get wrong.
368#[cfg(test)]
369#[path = "dense_ffn_m16_tc_m32_tests.rs"]
370mod m32_tests;