spark_model/layers/ops/fp8_act_quant.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! WHICH per-token FP8 activation quantizer this build launches, and on what
4//! grid.
5//!
6//! Two kernels compute the same bytes. The SHARED one
7//! (`kernels/gb10/common/per_token_group_quant_fp8.cu`) spends one 128-thread
8//! CTA on one 128-element K-group — one bf16 element per thread, a 256-byte
9//! load per CTA — and measures 633/641/636 GB/s on an H100, 18.9-19.1% of HBM,
10//! for 10.36% of a 4593-token prefill (#928; round-13 nsys, cell T1N). The
11//! HOPPER twin (`kernels/hopper/common/fp8_act_quant_hopper.cu`) gives each
12//! group 16 threads of one `uint4` each and packs 8 groups into a CTA, so the
13//! CTA's load is 2 KB. Its output is BIT-IDENTICAL by contract — see that file
14//! and `native_fp8_act_quant_hopper_microtest`.
15//!
16//! Selected by PRESENCE **and by WIDTH**. The twin exists only under
17//! `kernels/hopper` (`[kernels] overrides`), so on gb10/b200/strix
18//! `try_kernel` misses and the shared kernel runs. On Hopper the choice is
19//! then `[defaults] fp8_act_quant_hopper` plus a CTA-count floor, because
20//! presence alone shipped a 0.76x-0.95x REGRESSION at every decode width
21//! (round-16 receipt SS 2.1, Recommendation 2). The rule, the per-K
22//! thresholds and the once-per-branch route line live in
23//! `fp8_act_quant_floor.rs`; this file owns the PAIR and the two grids.
24//!
25//! There is still no numeric A/B to arm — the two kernels emit the same bytes
26//! — so `ATLAS_FP8_ACT_QUANT_HOPPER=0` is a SPEED kill switch, and the control
27//! for the GB/s claim remains a build without the file.
28//!
29//! [`Fp8ActQuant`] is a PAIR rather than a single resolved handle because the
30//! two kernels need DIFFERENT grids, and a bare `KernelHandle` cannot say which
31//! it is. Carrying the pair makes the mismatch unrepresentable: the grid is
32//! computed from the same value that chose the entry point, in one place
33//! ([`fp8_quant_grid`]), and every layer that holds a quantizer holds this.
34
35use spark_runtime::gpu::{GpuBackend, KernelHandle};
36
37use super::Fp8QuantPick;
38
39/// The shared quantizer's module and entry point — the same string in both
40/// slots, because the file and the kernel share a name.
41pub const FP8_QUANT_MODULE: &str = "per_token_group_quant_fp8";
42/// Entry point of the shared quantizer.
43pub const FP8_QUANT_ENTRY: &str = "per_token_group_quant_fp8";
44/// Module (file stem) of the Hopper twin.
45pub const FP8_QUANT_HOPPER_MODULE: &str = "fp8_act_quant_hopper";
46/// Entry point of the Hopper twin. A NEW name, not an override of the shared
47/// one: both kernels must be in the Hopper image at once, because the gate for
48/// the twin is byte equality against the shared kernel ON DEVICE.
49pub const FP8_QUANT_HOPPER_ENTRY: &str = "per_token_group_quant_fp8_hopper";
50
51/// K-groups one Hopper CTA covers: 128 threads / 16 threads per group. SSOT
52/// with `HQ_GROUPS_PER_CTA` in the `.cu`; the kernel derives its own span from
53/// `gridDim.y`, so this value only has to be the one that fills the block.
54pub const FP8_QUANT_HOPPER_GROUPS_PER_CTA: u32 = 8;
55
56/// The FP8 activation quantizer a layer will launch: the shared kernel, plus
57/// the Hopper twin when this target has one.
58#[derive(Clone, Copy, Debug)]
59pub struct Fp8ActQuant {
60 /// `per_token_group_quant_fp8`, present on every target.
61 pub shared: KernelHandle,
62 /// `per_token_group_quant_fp8_hopper`, or `KernelHandle(0)` off Hopper.
63 pub hopper: KernelHandle,
64}
65
66impl Default for Fp8ActQuant {
67 /// No quantizer at all — what a target with no FP8 path resolves, and
68 /// the negative every W8A8 selector is tested against.
69 fn default() -> Self {
70 Self {
71 shared: KernelHandle(0),
72 hopper: KernelHandle(0),
73 }
74 }
75}
76
77impl Fp8ActQuant {
78 /// Probe both. `try_kernel` for each: the shared one is optional on targets
79 /// with no FP8 path at all (the callers already gate on
80 /// [`Self::available`]), and the twin is optional everywhere but Hopper.
81 pub fn resolve(gpu: &dyn GpuBackend) -> Self {
82 Self {
83 shared: crate::layers::try_kernel(gpu, FP8_QUANT_MODULE, FP8_QUANT_ENTRY),
84 hopper: crate::layers::try_target_kernel(
85 gpu,
86 FP8_QUANT_HOPPER_MODULE,
87 FP8_QUANT_HOPPER_ENTRY,
88 ),
89 }
90 }
91
92 /// A quantizer with only the shared kernel — the state every non-Hopper
93 /// target resolves to, and the one unit tests construct.
94 pub fn shared_only(shared: KernelHandle) -> Self {
95 Self {
96 shared,
97 hopper: KernelHandle(0),
98 }
99 }
100
101 /// Is there a quantizer to launch at all? The replacement for the
102 /// `handle.0 != 0` test every W8A8 selector used to spell inline.
103 ///
104 /// EITHER handle, not [`Self::pick`]'s: the pick is width-dependent, and a
105 /// caller asking "is there an FP8 path on this target" is not asking about
106 /// one launch's M.
107 pub fn available(&self) -> bool {
108 self.shared.0 != 0 || self.hopper.0 != 0
109 }
110
111 /// Is the twin in this image? PRESENCE, which is a property of the build —
112 /// not "will the next launch use it", which is [`Fp8QuantPick::twin`].
113 pub fn twin_present(&self) -> bool {
114 self.hopper.0 != 0
115 }
116
117 /// Which kernel this `(m, k)` launches, on which grid, and why not the
118 /// other one — the process's resolved lever and the compiled target's SM
119 /// count. See [`Self::pick_with`] for the pure form.
120 pub fn pick(&self, m: u32, k: u32) -> Fp8QuantPick {
121 self.pick_with(
122 super::fp8_act_quant_hopper_enabled(),
123 m,
124 k,
125 atlas_kernels::TARGET_SM_COUNT,
126 )
127 }
128
129 /// [`Self::pick`] over an explicit lever and SM count — pure, so every
130 /// (M, K) the attribution prices is gradeable from a CPU test.
131 pub fn pick_with(&self, requested: bool, m: u32, k: u32, sm_count: u32) -> Fp8QuantPick {
132 let reject = super::fp8_act_quant_hopper_reject(
133 requested,
134 self.twin_present(),
135 self.shared.0 != 0,
136 m,
137 k,
138 sm_count,
139 );
140 let twin = reject.is_none();
141 Fp8QuantPick {
142 kernel: if twin { self.hopper } else { self.shared },
143 grid: fp8_quant_grid(twin, m, k),
144 twin,
145 reject,
146 requested,
147 }
148 }
149
150 /// The handle this `(m, k)` launches.
151 pub fn kernel(&self, m: u32, k: u32) -> KernelHandle {
152 self.pick(m, k).kernel
153 }
154
155 /// The grid for `(m, k)`, matching [`Self::kernel`] — from the SAME pick,
156 /// so a twin handle can never reach the parent's grid.
157 pub fn grid(&self, m: u32, k: u32) -> [u32; 3] {
158 self.pick(m, k).grid
159 }
160}
161
162/// Grid for one quantizer launch. PURE, so both arms are testable without a
163/// GPU (`fp8_act_quant_tests.rs`).
164///
165/// `M` goes on grid X in both arms — its limit is 2^31-1, where grid Y stops at
166/// 65535, and MoE `total_expanded` exceeds 65535.
167///
168/// Shared arm: `(M, K/128)`, one CTA per K-group, which is what the shared
169/// kernel indexes with `blockIdx.y`.
170///
171/// Hopper arm: `(M, ceil(K/128 / 8))`. The kernel re-derives its own group span
172/// as `ceil(L / gridDim.y)` rather than assuming 8, so this Y extent is a
173/// PERFORMANCE choice and not a correctness contract — any Y in `1..=L` covers
174/// the same groups exactly once.
175pub fn fp8_quant_grid(hopper: bool, m: u32, k: u32) -> [u32; 3] {
176 let groups = k / 128;
177 if hopper {
178 [
179 m,
180 groups.div_ceil(FP8_QUANT_HOPPER_GROUPS_PER_CTA).max(1),
181 1,
182 ]
183 } else {
184 [m, groups, 1]
185 }
186}
187
188/// The K-group half-open range one Hopper CTA owns, mirroring the `.cu`'s
189/// `gpc`/`g0`/`g_end`. Exists so `fp8_act_quant_tests.rs` can assert the
190/// launcher's Y extent and the kernel's span agree — a partition of `0..L` —
191/// without a device.
192pub fn fp8_quant_hopper_span(groups: u32, grid_y: u32, block_y: u32) -> (u32, u32) {
193 let gpc = groups.div_ceil(grid_y);
194 let g0 = block_y * gpc;
195 if g0 >= groups {
196 return (groups, groups);
197 }
198 (g0, (g0 + gpc).min(groups))
199}
200
201/// Which 8 elements of a 128-element group thread `tid` of a Hopper CTA owns,
202/// as a half-open `[start, end)` within the group, plus the group slot it is
203/// working on. Mirrors `sub`/`lane` in the `.cu`. Returns `None` for a thread
204/// whose group slot is past the CTA's span.
205pub fn fp8_quant_hopper_lane(tid: u32, span: u32) -> Option<(u32, u32, u32)> {
206 const LANES: u32 = 16;
207 const ELEMS: u32 = 8;
208 let sub = tid / LANES;
209 let lane = tid % LANES;
210 if sub >= span {
211 return None;
212 }
213 Some((sub, lane * ELEMS, lane * ELEMS + ELEMS))
214}
215
216#[cfg(test)]
217#[path = "fp8_act_quant_tests.rs"]
218mod fp8_act_quant_tests;