spark_model/layers/ops/fp8_act_quant_floor.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! WHEN the Hopper FP8 activation-quant twin takes the launch: the CTA-count
4//! floor, the lever that arms it, and the one line per branch that says which
5//! arm ran (#928, #927).
6//!
7//! # The defect this closes
8//!
9//! Round-16 H100 receipt, `native_fp8_act_quant_hopper_microtest` on
10//! `9919b1810` (§2.1, Anomaly 3, Recommendation 2). The twin is the largest
11//! kernel win the campaign has measured AT PREFILL WIDTHS — 3.30-3.59x, 63.7
12//! to 68.4% of HBM — and a REGRESSION at decode widths:
13//!
14//! | M | K | parent | twin | speed-up |
15//! |---:|---:|---:|---:|---:|
16//! | 16 | 5120 | 3.22 us | 3.84 us | **0.84x** |
17//! | 17 | 5120 | 3.69 us | 3.89 us | **0.95x** |
18//! | 25 | 5120 | 3.20 us | 4.21 us | **0.76x** |
19//! | 16 | 6144 | 3.18 us | 3.88 us | **0.82x** |
20//! | 17 | 6144 | 3.09 us | 3.87 us | **0.80x** |
21//! | 25 | 6144 | 3.31 us | 3.83 us | **0.87x** |
22//! | 16..=25 | 17408 | 3.95-4.67 us | 3.89-4.32 us | 1.02-1.08x |
23//! | 1168..=4576 | 5120..=17408 | 30.8-377.8 us | 9.1-105.3 us | 3.30-3.59x |
24//!
25//! Six of fifteen arms were losses, and the twin was selected by kernel
26//! PRESENCE — no lever, no floor — so EVERY decode-width W8A8 call took the
27//! slower arm. The mechanism is the twin's own design: 8 K-groups per CTA is
28//! 8x fewer CTAs, and at M <= 25 the parent's `M x K/128` grid is already under
29//! one wave on 132 SMs, so dividing it by 8 removes parallelism that was doing
30//! useful work.
31//!
32//! # The rule, and why it is CTAs rather than tokens
33//!
34//! The twin runs when its own grid is at least
35//! [`FP8_QUANT_MIN_CTAS_PER_SM`] x `sm_count` CTAs — 264 on an H100 — which is
36//! the `ssm_ba_gates_hopper` floor's number and its argument (a trailing
37//! partial wave is the whole kernel at one CTA per SM). It is spelled in CTAs
38//! and not in tokens because this kernel's grid is `M x ceil(K/128 / 8)`, so
39//! the same M is a different amount of machine at a different K, and the
40//! measurement says exactly that: at K=17408 the twin is already AHEAD at
41//! M=16 while at K=5120 it is 0.76x at M=25. A token floor would have to be
42//! set for the widest K and would then forfeit the K=17408 decode win, or set
43//! for the narrowest and keep the K=5120 loss.
44//!
45//! The per-K thresholds it produces (`sm_count = 132`, floor 264 CTAs), and
46//! the microtest arm each one is graded against:
47//!
48//! | K | K/128 | grid Y | min M | measured |
49//! |---:|---:|---:|---:|---|
50//! | 5120 | 40 | 5 | **53** | parent at 16/17/25 (0.76-0.95x), twin at 1168/4576 |
51//! | 6144 | 48 | 6 | **44** | parent at 16/17/25 (0.80-0.87x), twin at 1168/4576 |
52//! | 17408 | 136 | 17 | **16** | twin at 16/17/25 (1.02-1.08x) and above |
53//!
54//! That reproduces the SIGN of all fifteen measured arms, which a flat `M >=
55//! 128` would not: it would put K=17408's M in {16,17,25} on the parent and
56//! give back a measured 1.02-1.08x for nothing.
57//!
58//! ⚠️ The floor is a JUDGEMENT, like the BA-gates one, and deliberately
59//! conservative in the same direction: being wrong high costs the prefill
60//! lever nothing (M=1168 clears every threshold by 22-73x) and being wrong low
61//! costs the decode step, which is where the loss was measured.
62
63use spark_runtime::gpu::KernelHandle;
64
65/// CTAs per SM the twin insists on before it will take the launch.
66///
67/// Two, not one, and the same value as `ssm_ba_gates_hopper`'s
68/// `MIN_CTAS_PER_SM` for the same reason: at exactly one CTA per SM a trailing
69/// partial wave IS the kernel.
70pub const FP8_QUANT_MIN_CTAS_PER_SM: u32 = 2;
71
72/// The smallest twin grid this device is worth launching.
73pub fn fp8_quant_min_ctas(sm_count: u32) -> u32 {
74 FP8_QUANT_MIN_CTAS_PER_SM.saturating_mul(sm_count.max(1))
75}
76
77/// CTAs the TWIN's grid would launch for `(m, k)` — the product of
78/// [`super::fp8_quant_grid`]'s Hopper arm, read from that function rather than
79/// restated, so the floor cannot come to disagree with the launch it guards.
80pub fn fp8_quant_hopper_ctas(m: u32, k: u32) -> u32 {
81 let [x, y, z] = super::fp8_quant_grid(true, m, k);
82 x.saturating_mul(y).saturating_mul(z)
83}
84
85/// The smallest `M` the twin accepts at this `K` — the table in this module's
86/// header, as a function. Exists for the tests and for anyone reading a serve
87/// log's refusal and asking "at what width would it have run?".
88pub fn fp8_quant_hopper_min_m(k: u32, sm_count: u32) -> u32 {
89 let [_, y, _] = super::fp8_quant_grid(true, 1, k);
90 fp8_quant_min_ctas(sm_count).div_ceil(y.max(1))
91}
92
93/// Is the twin selected? — `[defaults] fp8_act_quant_hopper`, with
94/// `ATLAS_FP8_ACT_QUANT_HOPPER` overriding ([`super::target_defaults`]).
95///
96/// No `ATLAS_NO_*` rung: the two kernels emit the same bytes, so there is no
97/// accuracy question for a kill switch to outrank what
98/// `ATLAS_FP8_ACT_QUANT_HOPPER=0` already says.
99pub fn fp8_act_quant_hopper_enabled() -> bool {
100 super::target_defaults::resolved()
101 .fp8_act_quant_hopper
102 .value
103}
104
105/// The floor's guard string, named because it is the one a decode-width serve
106/// log will print.
107pub const FP8_QUANT_TOO_FEW_CTAS: &str = "too few CTAs to fill the device at 8 K-groups per CTA";
108
109/// Every guard [`fp8_act_quant_hopper_reject`] can return, in the order it
110/// tests them — and therefore the log's slot table. A list rather than bare
111/// literals at the call site because [`fp8_quant_log`] gives each ONE its own
112/// once-flag, and a reason with no slot would silently share another's.
113pub const FP8_QUANT_REJECTS: [&str; 3] = [
114 "not requested",
115 "kernel absent from this image (kernels/hopper only)",
116 FP8_QUANT_TOO_FEW_CTAS,
117];
118
119/// Why the twin is NOT running — `None` means it is.
120///
121/// Pure, so the grammar is gradeable without a GPU or the process environment,
122/// and it NAMES the guard that refused: a perf path that asks to be enabled
123/// and silently is not measures as "no effect".
124///
125/// `parent_present` is LOAD-BEARING and not defensive padding. A rejection
126/// hands the launch to `Fp8ActQuant::shared`, so with no parent to hand it to
127/// there is nothing to decline TO and the twin runs — which is exactly the
128/// pair `native_fp8_act_quant_hopper_microtest` builds to force each arm at
129/// every `M` it measures (`shared: KernelHandle(0)`). Without this branch the
130/// microtest's Hopper arm would launch handle 0 at the six small-M arms whose
131/// numbers opened this lever.
132///
133/// It is tested ABOVE the lever as well as above the floor, deliberately: an
134/// operator's `ATLAS_FP8_ACT_QUANT_HOPPER=0` means "prefer the parent", and on
135/// a pair that has none it must not come to mean "launch nothing".
136pub fn fp8_act_quant_hopper_reject(
137 requested: bool,
138 twin_present: bool,
139 parent_present: bool,
140 m: u32,
141 k: u32,
142 sm_count: u32,
143) -> Option<&'static str> {
144 if !twin_present {
145 Some(FP8_QUANT_REJECTS[1])
146 } else if !parent_present {
147 None
148 } else if !requested {
149 Some(FP8_QUANT_REJECTS[0])
150 } else if fp8_quant_hopper_ctas(m, k) < fp8_quant_min_ctas(sm_count) {
151 Some(FP8_QUANT_TOO_FEW_CTAS)
152 } else {
153 None
154 }
155}
156
157/// Which kernel this launch runs, on what grid, and why the other one did not.
158///
159/// Entry point AND grid together, which is the invariant `Fp8ActQuant` was
160/// introduced for: the two kernels need different grids, and a verdict that
161/// handed back only a handle would let one kernel reach the other's grid.
162///
163/// No `PartialEq`: `KernelHandle` is a foreign newtype without one, and the
164/// thing a test wants to compare is the VERDICT (`twin`, `reject`, `grid`),
165/// not two opaque handles.
166#[derive(Debug, Clone, Copy)]
167pub struct Fp8QuantPick {
168 pub kernel: KernelHandle,
169 pub grid: [u32; 3],
170 /// `true` when `kernel` is the Hopper twin.
171 pub twin: bool,
172 /// `None` when the twin runs; the named guard when the parent does.
173 pub reject: Option<&'static str>,
174 /// What the lever said, carried so the log does not read it a second time.
175 pub requested: bool,
176}
177
178/// Which line [`fp8_quant_log`] would say for this verdict — `None` for
179/// silence.
180///
181/// ONE slot per branch. The round-15 H100 serve logs carried the BA-gates
182/// twin's refusal line for the life of the process while nsys showed the twin
183/// running, because a single `Once` was tripped by a 27-token smoke request
184/// before the first real prefill. The verdict here changes between calls for
185/// the same reason — the width is an argument — so this lever gets the slot
186/// table from the start: a serve log carries BOTH lines, the positive at the
187/// first prefill width and the negative at the first decode width.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum Fp8QuantLogSlot {
190 /// The twin took the launch.
191 Twin,
192 /// The parent took it, for the guard at this index of
193 /// [`FP8_QUANT_REJECTS`].
194 Reject(usize),
195}
196
197/// Total once-flags [`fp8_quant_log`] keeps: one per guard, plus the twin's.
198pub const FP8_QUANT_LOG_SLOTS: usize = FP8_QUANT_REJECTS.len() + 1;
199
200/// The slot a verdict belongs to. Pure, so the once-set can be replayed on a
201/// CPU against a real serve's call order.
202pub fn fp8_quant_log_slot(pick: &Fp8QuantPick) -> Option<Fp8QuantLogSlot> {
203 match pick.reject {
204 None => Some(Fp8QuantLogSlot::Twin),
205 // The lever is off, or this target has no twin at all: the parent is
206 // the ANSWER, not a refusal, and a line per process saying so is noise
207 // on gb10, b200 and strix, which is every target but one.
208 Some(_) if !pick.requested => None,
209 Some(why) => FP8_QUANT_REJECTS
210 .iter()
211 .position(|r| *r == why)
212 .map(Fp8QuantLogSlot::Reject),
213 }
214}
215
216/// Say WHICH quantizer runs and, when the lever asked for the twin and did not
217/// get it, WHICH guard refused — once per process PER BRANCH.
218///
219/// Not once per call: this is reached per projection per layer per step, so an
220/// unconditional line is dozens per request.
221pub fn fp8_quant_log(pick: &Fp8QuantPick, m: u32, k: u32) {
222 static SAID: [std::sync::Once; FP8_QUANT_LOG_SLOTS] =
223 [const { std::sync::Once::new() }; FP8_QUANT_LOG_SLOTS];
224 let Some(slot) = fp8_quant_log_slot(pick) else {
225 return;
226 };
227 let idx = match slot {
228 Fp8QuantLogSlot::Twin => FP8_QUANT_LOG_SLOTS - 1,
229 Fp8QuantLogSlot::Reject(i) => i,
230 };
231 let [gx, gy, _] = pick.grid;
232 SAID[idx].call_once(|| match pick.reject {
233 Some(why) => tracing::info!(
234 "FP8 act-quant: the Hopper twin is NOT running at M={m} K={k}: {why} \
235 (ATLAS_FP8_ACT_QUANT_HOPPER)"
236 ),
237 None => tracing::info!(
238 "FP8 act-quant: per_token_group_quant_fp8_hopper \
239 (ATLAS_FP8_ACT_QUANT_HOPPER) M={m} K={k} grid=({gx},{gy},1) block=128"
240 ),
241 });
242}