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;