spark_model/layers/
dense_ffn_gateup_fused.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! The FUSED dense-FFN gate+up DECODE GEMM — one block-scaled FP8 cuBLASLt
4//! call at `N = 2 * intermediate` in place of two at `N = intermediate`.
5//!
6//! # WHY (#927)
7//!
8//! nsys `--cuda-graph-trace=node`, 1xH100 80GB HBM3, `Qwen/Qwen3.8-27B-FP8` @
9//! `3717cb05e`, round 13 cell V, median `n = 16` decode step **19.887 ms** of
10//! kernel busy (`h100-r13-attribution.md` §§C.2–C.4). Resolved by grid shape,
11//! the dense FFN's gate and up projections are **128 graph nodes, 5 730.5 µs =
12//! 44.77 µs/node**, `K = 5120` `N = 17408` each. At 89.1 MB of E4M3 weight per
13//! node that is **1 991 GB/s = 59.4 % of HBM**.
14//!
15//! In the SAME step, on the SAME arm, moving the SAME bytes:
16//!
17//! | projection | K | N | nodes | µs/node | GB/s | % HBM |
18//! |---|---|---|---|---|---|---|
19//! | FFN gate + up | 5120 | 17408 | 128 | 44.77 | 1 991 | **59.4 %** |
20//! | FFN `down` | 17408 | 5120 | 64 | 37.25 | 2 393 | 71.4 % |
21//! | SSM `in_proj_qkvz` | 5120 | 16384 | 48 | 34.21 | 2 453 | 73.2 % |
22//!
23//! `down` reads the same 89.1 MB as one of the gate/up nodes and is 7.5 µs
24//! faster; the difference between the 59.4 % arm and the 71–73 % arms is that
25//! the first issues **two launches per layer for one weight pass**. The weight
26//! bytes are read once either way — this is not a traffic saving. One launch
27//! of twice the N halves the per-launch fixed cost and doubles the tile count
28//! per wave, which is what the 71.4 % row already demonstrates on this card.
29//! At an 80 % target the pair costs `11.41 GB / (0.8 × 3.35 TB/s) = 4.26 ms`
30//! against a measured 5 730.5 µs → **1 476 µs/step, 7.4 % of the step** and
31//! the largest single decode kernel item in the round-13 table.
32//!
33//! # Numerics: a bit claim, not a tolerance
34//!
35//! The fused weight is the two `[inter, K]` E4M3 blocks appended along N, and
36//! its `[N/128, K/128]` FP32 block-scale grid is the two grids appended along
37//! N/128. Splitting N therefore produces **independent output columns over the
38//! same K with the same scales**: output element `(m, j)` of the fused GEMM is
39//! the same dot product, in the same order, as element `(m, j)` of gate (for
40//! `j < inter`) or `(m, j - inter)` of up. Same cuBLASLt op, same epilogue.
41//! `examples/native_fp8_ffn_gateup_fused_microtest.rs` asserts **byte
42//! equality** of both halves at `M ∈ {5, 8, 16}` rather than a cosine.
43//!
44//! # Layout, and why it is N-concatenation rather than an interleave
45//!
46//! The fused output is `[m, 2*inter]` with gate in columns `[0, inter)` and up
47//! in `[inter, 2*inter)` — a row is `[gate | up]`. Three things stay simple
48//! that a tile-interleave would complicate: the loader's fused weight is a
49//! straight device-to-device append, its scale grid is the same append one
50//! row-block wider, and **each half remains addressable as an un-fused
51//! `Fp8Weight` VIEW**, so every other rung of `dense_ffn.rs`'s `w8_gemm!`
52//! ladder keeps working on the same bytes with no change at all. The consumer
53//! pays a row stride instead of a flat index (`ops::silu_mul_strided`), and
54//! coalescing survives it: a row half is `inter` contiguous BF16 — 34 816 B at
55//! these shapes — so every warp's 128-byte segments are whole and only the
56//! jump BETWEEN rows differs.
57//!
58//! # Residency: net zero, by construction
59//!
60//! A second copy of gate+up is 178.3 MB × 64 layers = **11.4 GB**, which would
61//! not fit beside the bs32 KV budget. So the loader does not make one: it
62//! builds the fused buffer, re-points `gate_proj` and `up_proj` at VIEWS
63//! inside it, and `Qwen35DenseWeightLoader::prune_after_load` releases the two
64//! source store tensors the copy consumed. Steady-state delta is zero, and
65//! `predicted_residency` prices it as zero for the preflight ring fit. The
66//! load-time transient is one layer's 178.3 MB at a time against the store
67//! tensors that have not been pruned yet — the same shape, and the same
68//! precedent, as the SSM `[QKV|Z]` concat that has shipped since #915.
69//!
70//! # Band
71//!
72//! 5..=[`spark_runtime::buffers::GATEUP_FUSED_MAX_M`] rows.
73//!
74//! * **Below 5** rung 1 of `w8_gemm!` (`w8a16_gemv_batch4`) owns the width and
75//!   already makes ONE pass over each weight; there is no second launch to
76//!   fuse, and the W8A8 rule this arm rides on starts at `m > 4` anyway.
77//! * **Above 16** the arm stops because the saving does. At the prefill widths
78//!   these same two GEMMs run at **68.6 % of FP8 PEAK** (M=4576,
79//!   `h100-r13-attribution.md` §A.4) — compute-bound, where a launch buys
80//!   nothing measurable — and the attribution's own advice is to keep the
81//!   lever scoped to decode until a prefill microtest says otherwise.
82//!
83//! The lever is `[defaults] ffn_gateup_fused`: **hopper `true`**, gb10 and
84//! b200 `false` (no receipt, and both declare `cublas_gemm_scope = "off"`, so
85//! the arm this changes is not even armed there). The strided SiLU consumer
86//! lives in `kernels/hopper/common/silu_mul_strided.cu` — HOPPER-OWNED
87//! (`[kernels] overrides`, an addition), so the two other NVIDIA targets do
88//! not compile a kernel they can never launch and no new cross-hardware
89//! symlink is created. `ATLAS_FFN_GATEUP_FUSED=0` kills it; on gb10/b200
90//! `=1` arms a lever whose kernel lookup returns 0 and the arm declines.
91
92use anyhow::Result;
93use spark_runtime::buffers::GATEUP_FUSED_MAX_M;
94use spark_runtime::gpu::DevicePtr;
95
96use super::DenseFfnLayer;
97use crate::layer::ForwardContext;
98use crate::layers::ops;
99use crate::weight_map::Fp8Weight;
100
101/// Whether the compiled target arms the fused gate+up decode GEMM.
102///
103/// The target declares it (`kernels/<hw>/HARDWARE.toml` `[defaults]
104/// ffn_gateup_fused`); `ATLAS_FFN_GATEUP_FUSED` overrides it under the
105/// 2026-09-11 grammar, so `=0`/`=off`/`=false` turn it off and anything else
106/// turns it on. There is no `ATLAS_NO_*` legacy spelling: the lever is new, so
107/// no script predates the grammar and none can be surprised by it.
108pub fn ffn_gateup_fused() -> bool {
109    ops::target_defaults::resolved().ffn_gateup_fused.value
110}
111
112/// Bytes the fused `[ceil16(m), 2 * inter]` BF16 output occupies.
113///
114/// `ceil16` because `cublas_fp8_proj_prequant` hands cuBLASLt `ceil16(M)` and
115/// the phantom rows are WRITTEN. SSOT for both the arena sizing check below
116/// and the microtest's guard bands.
117pub(crate) fn fused_out_bytes(m: u32, inter: u32) -> usize {
118    ops::cublas_fp8_m_pad(m) as usize * 2 * inter as usize * 2
119}
120
121/// The whole fused-arm selection rule, as a pure function.
122///
123/// Split out from the layer for the reason `w8a8_prefill_selected` and
124/// `batch16_plan` are: the CPU tests pin every clause without a
125/// `ForwardContext`, and `lever` is injected because the process-global
126/// `OnceLock` behind [`ffn_gateup_fused`] cannot be toggled per test.
127///
128/// Clauses, each load-bearing:
129///
130/// * `lever` — the target's declaration, environment-overridable.
131/// * `gate_up_w8a8` — the W8A8 block-scaled arm was selected for BOTH gate and
132///   up. This arm IS that arm with a wider N: if the ladder would have put
133///   either half on a W8A16 rung, fusing would silently change the
134///   ARITHMETIC of that half, not just its launch count. It also carries every
135///   clause of `w8a8_prefill_selected` transitively — format, `k % 128`,
136///   `n % 128`, the per-arch ceiling, both kernel handles, the shared scratch.
137/// * `5..=GATEUP_FUSED_MAX_M` — the band, see the module docs.
138/// * `fused_installed` — the loader built the `[2*inter, K]` weight. Absent on
139///   any checkpoint or route the fusion was not built for, which is the only
140///   thing that makes this arm optional at runtime.
141/// * `silu_strided_loaded` — the strided SiLU consumer. Without it the fused
142///   output has no reader, and a target whose kernel set lacks the entry point
143///   must decline rather than launch `moe_silu_mul` over the wrong stride.
144/// * `out_capacity_bytes` — the arena's `ffn_gate_up_fused` buffer holds the
145///   PADDED extent. A gate and not an assert, for the reason the null-scratch
146///   check in `prefill_w8a8_selected` is one: too small is a cross-buffer
147///   write, and declining is always sound.
148#[allow(clippy::too_many_arguments)]
149pub(crate) fn gateup_fused_selected(
150    m: u32,
151    inter: u32,
152    lever: bool,
153    gate_up_w8a8: bool,
154    fused_installed: bool,
155    silu_strided_loaded: bool,
156    out_capacity_bytes: usize,
157) -> bool {
158    lever
159        && gate_up_w8a8
160        && fused_installed
161        && silu_strided_loaded
162        && (5..=GATEUP_FUSED_MAX_M as u32).contains(&m)
163        && fused_out_bytes(m, inter) <= out_capacity_bytes
164}
165
166impl DenseFfnLayer {
167    /// Whether THIS layer fuses gate+up for `m` rows.
168    ///
169    /// `gate_up_w8a8` is the caller's because `forward_prefill_inner` has
170    /// already resolved it for both projections — asking again here would be a
171    /// second copy of the W8A8 rule that could disagree with the one the
172    /// `w8_gemm!` ladder uses.
173    pub(crate) fn gateup_fused_plan(
174        &self,
175        ctx: &ForwardContext,
176        m: u32,
177        inter: u32,
178        gate_up_w8a8: bool,
179    ) -> Option<&Fp8Weight> {
180        let fused = self.fp8_gate_up_fused.as_ref();
181        // SiLU only, and not because the fusion cares: the consumer this arm
182        // launches IS the SiLU·mul, so a GeLU layer would silently get the
183        // wrong activation. GeLU keeps the `w8_gemm!` pair and its
184        // `self.act_mul`, which is the gelu kernel. Same shape as the
185        // packed-Q2 and LoRA paths, which refuse rather than assume.
186        let silu = self.activation == super::FfnActivation::SiLU;
187        gateup_fused_selected(
188            m,
189            inter,
190            self.gateup_fused && silu,
191            gate_up_w8a8,
192            fused.is_some(),
193            self.silu_mul_strided_k.0 != 0,
194            ctx.buffers.ffn_gate_up_fused_bytes(),
195        )
196        .then_some(fused)
197        .flatten()
198    }
199
200    /// gate+up in ONE GEMM, then SiLU·mul straight out of its `[m, 2*inter]`
201    /// output into the contiguous `[m, inter]` the down projection reads.
202    ///
203    /// Allocates nothing: `fused_out` is the arena's `ffn_gate_up_fused`, and
204    /// `w8a8_gemm`'s own operands are the arena's shared W8A8 scratch.
205    #[allow(clippy::too_many_arguments)]
206    pub(crate) fn w8a8_gate_up_fused(
207        &self,
208        ctx: &ForwardContext,
209        a_fp8: DevicePtr,
210        a_scale: DevicePtr,
211        fused_w: &Fp8Weight,
212        gate_out: DevicePtr,
213        m: u32,
214        inter: u32,
215        h: u32,
216        stream: u64,
217    ) -> Result<()> {
218        self.log_gateup_fused_route(ctx, m);
219        let fused_out = ctx.buffers.ffn_gate_up_fused();
220        let cap = ctx.buffers.ffn_gate_up_fused_bytes();
221        debug_assert!(fused_out_bytes(m, inter) <= cap);
222        self.w8a8_gemm(
223            ctx,
224            a_fp8,
225            a_scale,
226            fused_w,
227            fused_out,
228            cap,
229            m,
230            2 * inter,
231            h,
232            stream,
233        )?;
234        // `up` is the same buffer, one row-half along: BF16, so `inter`
235        // elements is `inter * 2` bytes. The output is the CONTIGUOUS
236        // `[m, inter]` every downstream consumer already expects, so nothing
237        // past this launch knows the projection was fused.
238        const BF16: usize = 2;
239        ops::silu_mul_strided(
240            ctx.gpu,
241            self.silu_mul_strided_k,
242            fused_out,
243            fused_out.offset(inter as usize * BF16),
244            gate_out,
245            m,
246            inter,
247            2 * inter,
248            inter,
249            stream,
250        )
251    }
252
253    /// Log-once latch, in the same `log:ffn_*` shape the other dense-FFN route
254    /// logs use. Worth a line: a TPOT report at 5..=16 rows is measuring this
255    /// arm, and its absence at a width that should have it is the first thing
256    /// to check when the round-13 59.4 %-of-HBM figure appears to be back.
257    fn log_gateup_fused_route(&self, ctx: &ForwardContext, m: u32) {
258        if ctx.stats.once("log:ffn_gateup_fused") {
259            tracing::info!(
260                "[atlas] dense FFN decode: gate+up FUSED into ONE W8A8 \
261                 block-scaled GEMM at N=2*intermediate (m={m}, band 5..={max}) \
262                 — same weight bytes, one launch instead of two. Round 13 \
263                 priced the un-fused pair at 5 730.5 us/step, 59.4% of HBM, \
264                 against `down`'s 71.4% for the same bytes in one launch. \
265                 Bit-identical per element; ATLAS_FFN_GATEUP_FUSED=0 restores \
266                 the two-GEMM arm (#927).",
267                max = GATEUP_FUSED_MAX_M,
268            );
269        }
270    }
271}
272
273#[cfg(test)]
274#[path = "dense_ffn_gateup_fused_tests.rs"]
275mod tests;