spark_model/layers/
dense_ffn_fp8_down.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! The native-FP8 M=1 decode DOWN projection — which arm runs it (#928).
4//!
5//! WHY. nsys, 1xH100, Qwen/Qwen3.8-27B-FP8, 2026-09-11 round 7, C=1
6//! steady-state decode step **21.891 ms**, GPU busy 96%:
7//!
8//! | kernel | shape | grid | launches | us each | ms/step | GB/s |
9//! |---|---|---|---|---|---|---|
10//! | `w8a16_gemv_silu_input` (down) | N=5120 K=17408 | 1280 | 64 | 103.9 | **6.65 (30.4%)** | **858** |
11//! | `w8a16_gemv_dual` (gate+up) | N=17408x2 K=5120 | 4352 | 64 | 90.1 | 5.77 | **1,979** |
12//! | `w8a16_gemv` | N=16384 K=5120 | 4096 | - | - | - | 1,852 |
13//!
14//! Same 89.1 MB of FP8 weights per layer on the first two rows. The down
15//! projection reads them at **43% of the rate** the gate/up pair reads its
16//! own. Two independent causes; this arm addresses the first.
17//!
18//! **1. The fused SwiGLU is recomputed per OUTPUT, not per CTA.** In
19//! `w8a16_gemv_silu_input`, each of the `ceil(N/4)` CTAs gives each of its 4
20//! outputs a 64-lane team, and every team walks the whole K computing
21//! `silu(gate[k])*up[k]` for itself. That is `N*K` = 5,120 x 17,408 =
22//! **89.1 M** SwiGLU evaluations per launch where the token needs K = 17,408
23//! — a 5,120x redundancy — and each one is an `__expf` plus a TRUE FP32
24//! division: `kernels/gb10/common/KERNEL.toml` builds with `--fmad=false` and
25//! no `-use_fast_math`, so `g / (1.0f + __expf(-g))` lowers to the IEEE
26//! division sequence, not a reciprocal. Counting ~20 ops per K element
27//! against the dual GEMV's ~6, the fused kernel issues ~56 M warp
28//! instructions per launch — ~60 us of pure issue on 132 SMs at 4
29//! instructions/SM/cycle — on top of a 26.6 us weight stream. It is
30//! issue-bound, not bandwidth-bound. The NVFP4 arm reached the same verdict
31//! with ncu (SM 57% vs memory 23%) and has staged the activation once ever
32//! since; the FP8 arm simply never got the same treatment. Staging it here is
33//! what this module's default arm does.
34//!
35//! **2. This kernel family's grid is a pure function of N — LEFT OPEN.**
36//! `w8a16_gemv.cu` puts `N_PER_BLOCK=4` outputs in a 256-thread CTA, so K
37//! never enters the CTA count. ~8 such CTAs co-reside per SM, and H100 has 132
38//! of them: grid 4352 is ~4.1 full waves (a ~2% tail), grid 1280 is ~1.2 waves
39//! — one full wave plus a 224-CTA tail. A split-K GEMV was written for exactly
40//! this and MEASURED AS A NULL on the H100 (down 61.8 us split-K vs 58.9 us
41//! for the staged scalar kernel; the k/v shape came back at 0.67x), so it was
42//! dropped rather than shipped behind a lever. Whatever the residual cost is,
43//! wave quantisation alone does not explain it, and the next attempt should
44//! start from a fresh profile rather than from that plan.
45//!
46//! PTXAS RECEIPT (`nvcc -cubin -Xptxas -v -arch=sm_90a --fmad=false`, CUDA
47//! 13.0, taken 2026-09-11 on the gate box): `w8a16_gemv` uses **32 registers /
48//! 1,056 B smem / 0 spills**, so 32 x 256 x 8 = 65,536 = exactly the SM's
49//! register file — 8 CTAs/SM is not an estimate, it is the ptxas-pinned
50//! ceiling. `w8a16_gemv_silu_input` uses **53 registers**, which is 13,568 per
51//! CTA and therefore only **4 CTAs/SM**. The fused kernel halves resident
52//! warps on top of the redundant transcendentals — a third, independent cost,
53//! and one staging the activation removes for free.
54//!
55//! A CHILD module of `dense_ffn`, not a sibling: `dense_ffn.rs` is already at
56//! the CI size cap, and the nsys attribution above needs room it does not
57//! have.
58
59/// Which arm the native-FP8 SiLU decode uses for `down_proj`.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub(crate) enum Fp8DownArm {
62    /// `w8a16_gemv_dual`, then `moe_silu_mul` stages `silu(gate)*up` once into
63    /// `gate_out`, then the plain `w8a16_gemv` for down. DEFAULT.
64    ///
65    /// NOT bit-identical to `FusedSilu`: `moe_silu_mul` rounds
66    /// `g*(1/(1+e^-g))*u` to BF16 before the GEMV consumes it, where the fused
67    /// kernel keeps `(g/(1+e^-g))*u` in FP32 all the way into the dot product
68    /// — a BF16 round of the activation plus a reciprocal-vs-divide
69    /// difference. It is the numerics PREFILL already runs, which is the
70    /// reason the NVFP4 arm made the same trade its default.
71    SplitSilu,
72    /// `w8a16_gemv_dual`, then the fused `w8a16_gemv_silu_input`. What shipped
73    /// before #928; reachable via `ATLAS_NO_DECODE_SPLIT_SILU`, or when this
74    /// target lacks `moe_silu_mul` / `w8a16_gemv`.
75    FusedSilu,
76    /// Neither fused path is usable — the 4-launch per-projection
77    /// `w8a16_gemv` x2 + `moe_silu_mul` + down sequence.
78    PerProjection,
79}
80
81/// The arm rule, as a pure function of the resolved handles and the lever, so
82/// the CPU tests can pin every combination without a GPU. SSOT for the
83/// `match` in `DenseFfnLayer::forward`.
84///
85/// Each `bool` is "this handle resolved on this target" (`KernelHandle(0)` on
86/// a shadow that lacks the entry point) except `split_silu_lever`, which is
87/// `ModelLevers::decode_split_silu` (`ATLAS_NO_DECODE_SPLIT_SILU`, presence).
88pub(crate) fn fp8_down_arm(
89    is_silu: bool,
90    dual: bool,
91    fused_silu: bool,
92    act_mul: bool,
93    plain_gemv: bool,
94    split_silu_lever: bool,
95) -> Fp8DownArm {
96    // The dual GEMV feeds BOTH fused arms; without it there is no gate/up pair
97    // staged in `gate_out`/`up_out` for either to consume.
98    if !is_silu || !dual {
99        return Fp8DownArm::PerProjection;
100    }
101    if split_silu_lever && act_mul && plain_gemv {
102        Fp8DownArm::SplitSilu
103    } else if fused_silu {
104        Fp8DownArm::FusedSilu
105    } else {
106        Fp8DownArm::PerProjection
107    }
108}