spark_model/layers/ops/
ssm_ba_gates_hopper.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! The Hopper BA-gates twin: its launch geometry, and the grammar of the lever
4//! and the guards that select it (#928).
5//!
6//! The kernel is `kernels/hopper/common/ssm_ba_gates_hopper.cu`, which exists
7//! only under `kernels/hopper`, so its [`KernelHandle`] is `KernelHandle(0)`
8//! on gb10, b200 and strix and the launcher stays on the gb10 parent there
9//! without reading anything. That — not an env var — is what keeps the other
10//! targets on `ssm_preprocess::dense_gemm_ba_gates_prefill`.
11//!
12//! WHY IT EXISTS, in one receipt (full derivation in
13//! `SSM-BA-GATES-ATTRIBUTION.md`). The parent launches
14//! `Grid: (ceil(N/4), M_tokens, 1) Block: (256,1,1)`: one CTA computes four of
15//! the `N = ssm_ba_size = 2*nv` BA outputs for one token, and each of those
16//! four walks the token's entire `K` activation row — the CTA's 256 threads are
17//! four 64-lane groups, one per output. At the Qwen3.8-27B geometry `N=96`,
18//! `K=5120` that is 24 CTAs per token and **96 reads of every activation row**,
19//! one per BA output. nsys, 1xH100 80GB HBM3, Qwen/Qwen3.8-27B-FP8 @
20//! `815c9f160`, round 13 cell T1: the kernel is 26 881.8 us = 5.85% of the
21//! 4593-token prefill and 6 896.3 us = 3.13% of the 1168-token forward, 555.04
22//! us per launch at `M=4576`, observed grid `(24, 4576)`, 88 GB/s of compulsory
23//! traffic — 2.6% of HBM, so this was never a bandwidth problem. The twin reads
24//! the row 12 times and issues ~1.8x fewer instructions (SASS, sm_90a).
25//!
26//! BIT-IDENTICAL, and that is the contract, not an aspiration: the twin keeps
27//! the parent's lane-strided `kv` sweep, its 5-step shuffle butterfly and its
28//! `warp_even + warp_odd` cross-warp order, and only hoists A's (exact) bf16 ->
29//! f32 widening out of the output loop. `native_ssm_ba_gates_hopper_microtest`
30//! asserts byte equality of `gate` and `beta`, not a tolerance.
31//!
32//! ⚠️ **THE TOKEN-COUNT GUARD IS LOAD-BEARING.** One CTA per token means the
33//! grid IS the token count, and this kernel is on the BATCHED DECODE path too
34//! (`trait_decode_batched.rs`; nsys round 13 SS C.2 prices it at 227.5 us/step
35//! over 48 launches at n=16). A 16-row step would put 16 CTAs on 132 SMs where
36//! the parent puts 384. [`ssm_ba_gates_hopper_reject`] declines below
37//! `MIN_CTAS_PER_SM * sm_count` tokens and the parent runs, so promoting this
38//! lever cannot cost the decode step.
39
40use anyhow::Result;
41use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
42use spark_runtime::kernel_args::KernelLaunch;
43
44use crate::weight_map::DenseWeight;
45
46/// Threads per CTA — the parent's block, and a contract: the reduction order
47/// is a function of this, [`BA_GATES_LANES`] and [`BA_GATES_OUTS`].
48pub const BA_GATES_BLOCK: u32 = 256;
49/// Threads that cooperate on ONE BA output. The parent's `threads_per_out`.
50pub const BA_GATES_LANES: u32 = 64;
51/// BA outputs one CTA-width covers at once: `BA_GATES_BLOCK / BA_GATES_LANES`.
52pub const BA_GATES_OUTS: u32 = BA_GATES_BLOCK / BA_GATES_LANES;
53/// Warps per CTA, i.e. the width of the twin's cross-warp scratch row.
54pub const BA_GATES_WARPS: u32 = BA_GATES_BLOCK / 32;
55/// `BAH_GROUPS` in the kernel: output groups a thread accumulates at once, and
56/// therefore how many BA outputs one fetch of the activation row serves. The
57/// row is read `ceil(ceil(N/4)/8) * 4` times per token — 12 at N=96, against
58/// the parent's 96, which reads it once per output. Chosen on ptxas: 64
59/// registers and no spill at 8; 12 spills and 16 costs a quarter of the
60/// occupancy for 3.3% of the instructions.
61pub const BA_GATES_GROUPS: u32 = 8;
62
63/// Tokens per SM the twin insists on before it will take the launch.
64///
65/// Two, not one: at exactly one CTA per SM a trailing partial wave is the whole
66/// kernel, and the parent's `ceil(N/4)`-wide grid beats that comfortably. This
67/// is the only number here that is a judgement rather than a contract — it is
68/// deliberately conservative, because being wrong in this direction costs the
69/// prefill lever nothing (a 1168- or 4576-token chunk clears it by 4-17x) and
70/// being wrong in the other direction costs the decode step.
71pub const MIN_CTAS_PER_SM: u32 = 2;
72
73/// H100/H200 SXM5 SM count, used only when `GpuBackend::sm_count()` fails.
74///
75/// A wrong value here moves the guard's threshold, never an answer: both arms
76/// compute the same bits.
77pub const BA_GATES_FALLBACK_SM_COUNT: u32 = 132;
78
79/// The smallest token count the twin accepts on a device with `sm_count` SMs.
80pub fn ba_gates_min_tokens(sm_count: u32) -> u32 {
81    MIN_CTAS_PER_SM.saturating_mul(sm_count.max(1))
82}
83
84/// Is the twin selected? — `[defaults] ssm_ba_gates_hopper`, with
85/// `ATLAS_SSM_BA_GATES_HOPPER` overriding ([`super::target_defaults`]).
86///
87/// No `ATLAS_NO_*` rung, unlike the GDN families: this lever has no accuracy
88/// question behind it, so there is nothing for a kill switch to outrank that
89/// `ATLAS_SSM_BA_GATES_HOPPER=0` does not already say.
90pub fn ssm_ba_gates_hopper_enabled() -> bool {
91    super::target_defaults::resolved().ssm_ba_gates_hopper.value
92}
93
94/// Why the twin is NOT running — `None` means it is.
95///
96/// Pure, so the grammar is testable without a GPU or the process environment,
97/// and it NAMES THE GUARD THAT REFUSED. A perf path that asks to be enabled and
98/// silently is not measures as "no effect", which is how PR #296 shipped an
99/// ldmatrix GEMM that fell back with both gates green.
100///
101/// The shape guards are not defensive padding:
102///   * `K % 8 != 0` would leave a tail the uint4 sweep never reads — the parent
103///     has the same limit and the same silence about it, so it is stated here;
104///   * `K_stride < K` would read past the row;
105///   * `N == 0` has nothing to compute;
106///   * the token-count floor is the decode guard described in this module's
107///     header.
108pub fn ssm_ba_gates_hopper_reject(
109    requested: bool,
110    kernel_present: bool,
111    m: u32,
112    n: u32,
113    k: u32,
114    k_stride: u32,
115    sm_count: u32,
116) -> Option<&'static str> {
117    if !requested {
118        Some("not requested")
119    } else if !kernel_present {
120        Some("kernel absent from this image (kernels/hopper only)")
121    } else if n == 0 || k == 0 {
122        Some("empty BA projection")
123    } else if !k.is_multiple_of(8) {
124        Some("K is not a multiple of 8 (the uint4 K sweep would drop a tail)")
125    } else if k_stride < k {
126        Some("K_stride < K: the activation row is shorter than the reduction")
127    } else if m < ba_gates_min_tokens(sm_count) {
128        Some(BA_GATES_TOO_FEW_TOKENS)
129    } else {
130        None
131    }
132}
133
134/// Which kernel this launch runs, and why the other one did not.
135pub struct BaGatesPick {
136    pub kernel: KernelHandle,
137    /// `true` when `kernel` is the Hopper twin.
138    pub twin: bool,
139    /// `None` when the twin runs; the named guard when the parent does.
140    pub reject: Option<&'static str>,
141}
142
143/// Choose between the gb10 parent and the Hopper twin, once, here.
144///
145/// One entry point rather than an `if` at each of the five dispatch sites: the
146/// decision is a lever, a resolved handle and four shape facts, and a call site
147/// that spelled it itself would be a second copy of a rule that has already
148/// been wrong once elsewhere in this file's family.
149#[allow(clippy::too_many_arguments)]
150pub fn ba_gates_pick(
151    requested: bool,
152    parent: KernelHandle,
153    twin: KernelHandle,
154    m: u32,
155    n: u32,
156    k: u32,
157    k_stride: u32,
158    sm_count: u32,
159) -> BaGatesPick {
160    let reject = ssm_ba_gates_hopper_reject(requested, twin.0 != 0, m, n, k, k_stride, sm_count);
161    match reject {
162        None => BaGatesPick {
163            kernel: twin,
164            twin: true,
165            reject,
166        },
167        Some(_) => BaGatesPick {
168            kernel: parent,
169            twin: false,
170            reject,
171        },
172    }
173}
174
175/// Every guard string [`ssm_ba_gates_hopper_reject`] can return, in the order
176/// it tests them — and therefore the log's slot table.
177///
178/// A list, not a bare set of literals at the call sites, because
179/// [`ba_gates_log`] gives each ONE its own once-flag and a reason with no slot
180/// would silently share another's. `every_reject_reason_has_its_own_log_slot`
181/// drives the reject function over every guard and fails if a new string
182/// appears here without a slot.
183pub const BA_GATES_REJECTS: [&str; 6] = [
184    "not requested",
185    "kernel absent from this image (kernels/hopper only)",
186    "empty BA projection",
187    "K is not a multiple of 8 (the uint4 K sweep would drop a tail)",
188    "K_stride < K: the activation row is shorter than the reduction",
189    BA_GATES_TOO_FEW_TOKENS,
190];
191
192/// The token-count floor's guard string, named because it is the one the
193/// round-15 H100 serve logs printed forever while the twin ran (§3.2).
194pub const BA_GATES_TOO_FEW_TOKENS: &str = "too few tokens to fill the device at one CTA per token";
195
196/// Which line [`ba_gates_log`] would say for this verdict — `None` for silence.
197///
198/// ONE slot per branch, and that is the whole fix. The round-15 H100 serve logs
199/// carried `the Hopper twin is NOT running at M=27` on five of six cells for the
200/// life of the process while nsys showed the twin running 48x per prefill: a
201/// single `Once` shared by both branches, tripped by the smoke test's 27-token
202/// request, so the positive line could never be said. A reader of a serve log
203/// got the exact opposite of what the engine did.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum BaGatesLogSlot {
206    /// The twin took the launch.
207    Twin,
208    /// The parent took it, for the guard at this index of
209    /// [`BA_GATES_REJECTS`].
210    Reject(usize),
211}
212
213/// Total once-flags [`ba_gates_log`] keeps: one per guard, plus the twin's.
214pub const BA_GATES_LOG_SLOTS: usize = BA_GATES_REJECTS.len() + 1;
215
216/// The slot a verdict belongs to. Pure, so the once-set can be replayed on a
217/// CPU against a real serve's call order.
218pub fn ba_gates_log_slot(pick: &BaGatesPick, requested: bool) -> Option<BaGatesLogSlot> {
219    match pick.reject {
220        None => Some(BaGatesLogSlot::Twin),
221        // The lever is off: the parent is the ANSWER, not a refusal, and a
222        // line per process saying so is noise on every other target.
223        Some(_) if !requested => None,
224        Some(why) => BA_GATES_REJECTS
225            .iter()
226            .position(|r| *r == why)
227            .map(BaGatesLogSlot::Reject),
228    }
229}
230
231/// Say WHICH kernel runs and, when the lever asked for the twin and did not get
232/// it, WHICH guard refused — once per process PER BRANCH.
233///
234/// Once per branch, not once per call: this runs inside the per-layer prefill
235/// step, so on a 48-GDN-layer model an unconditional line is 48 of them per
236/// request. But the verdict CHANGES between calls — the token count is an
237/// argument — so a single flag records whichever M arrived first and then lies
238/// for the life of the process. Round 15 measured exactly that: a 27-token
239/// smoke request before the first real prefill, and the twin's own line never
240/// printed on any of the five serve cells that ran it. With a flag per branch a
241/// serve log now carries BOTH lines, each naming the shape it was reached at.
242pub fn ba_gates_log(pick: &BaGatesPick, requested: bool, m: u32) {
243    static SAID: [std::sync::Once; BA_GATES_LOG_SLOTS] =
244        [const { std::sync::Once::new() }; BA_GATES_LOG_SLOTS];
245    let Some(slot) = ba_gates_log_slot(pick, requested) else {
246        return;
247    };
248    let (idx, why) = match slot {
249        BaGatesLogSlot::Twin => (BA_GATES_LOG_SLOTS - 1, None),
250        BaGatesLogSlot::Reject(i) => (i, pick.reject),
251    };
252    SAID[idx].call_once(|| match why {
253        Some(why) => tracing::info!(
254            "SSM ba_gates: the Hopper twin is NOT running at M={m}: {why} \
255             (ATLAS_SSM_BA_GATES_HOPPER)"
256        ),
257        None => tracing::info!(
258            "SSM ba_gates: dense_gemm_ba_gates_prefill_hopper \
259             (ATLAS_SSM_BA_GATES_HOPPER) M={m} block={BA_GATES_BLOCK} grid=(M,1,1)"
260        ),
261    });
262}
263
264/// The SM count the guard compares against.
265pub fn ba_gates_sm_count(gpu: &dyn GpuBackend) -> u32 {
266    gpu.sm_count().unwrap_or(BA_GATES_FALLBACK_SM_COUNT).max(1)
267}
268
269/// Launch `dense_gemm_ba_gates_prefill_hopper` — one CTA per token.
270///
271/// Same ABI as the parent, deliberately: the two differ in `grid` and in
272/// nothing else a caller can see, which is what lets `ba_gates_pick` return a
273/// handle rather than a closure.
274#[allow(clippy::too_many_arguments)]
275pub fn dense_gemm_ba_gates_prefill_hopper(
276    gpu: &dyn GpuBackend,
277    kernel: KernelHandle,
278    input: DevicePtr,
279    ba_weight: &DenseWeight,
280    a_log: DevicePtr,
281    dt_bias: DevicePtr,
282    gate_out: DevicePtr,
283    m: u32,
284    n: u32,
285    k: u32,
286    k_stride: u32,
287    gate_stride: u32,
288    nv: u32,
289    vheads_per_group: u32,
290    stream: u64,
291) -> Result<()> {
292    KernelLaunch::new(gpu, kernel)
293        .grid([m, 1, 1])
294        .block([BA_GATES_BLOCK, 1, 1])
295        .arg_ptr(input)
296        .arg_ptr(ba_weight.weight)
297        .arg_ptr(a_log)
298        .arg_ptr(dt_bias)
299        .arg_ptr(gate_out)
300        .arg_u32(m)
301        .arg_u32(n)
302        .arg_u32(k)
303        .arg_u32(k_stride)
304        .arg_u32(gate_stride)
305        .arg_u32(nv)
306        .arg_u32(vheads_per_group)
307        .launch(stream)
308}
309
310// ── The index model, as pure functions the host tests grade ────────────────
311//
312// Both kernels' correctness rests on three mappings agreeing exactly, and none
313// of the three is visible in a diff of the two `.cu` files side by side. They
314// are spelled here so `ssm_ba_gates_hopper_tests` can grade them on a CPU:
315// a mismatch is a wrong ANSWER, not a slow kernel, and the microtest that would
316// otherwise be the only witness needs a GPU.
317
318/// The `kv` indices lane `lane` accumulates, in order — the parent's
319/// `for (kv = lane; kv < K_VEC; kv += threads_per_out)`, which the twin
320/// reproduces verbatim. The ORDER of this sequence is the reduction order.
321pub fn ba_gates_lane_kv(lane: u32, k_vec: u32) -> Vec<u32> {
322    let mut out = Vec::new();
323    let mut kv = lane;
324    while kv < k_vec {
325        out.push(kv);
326        kv += BA_GATES_LANES;
327    }
328    out
329}
330
331/// The BA output a thread owns.
332///
333/// PARENT: `n = blockIdx.x * BA_GATES_OUTS + local_out`.
334/// TWIN:   `n = (g0 + g) * BA_GATES_OUTS + local_out`, where `g0 + g` walks the
335/// same `0..ceil(N/4)` range in tiles of [`BA_GATES_GROUPS`].
336/// The two must be the same function of (output group, local_out).
337pub fn ba_gates_output(group: u32, local_out: u32) -> u32 {
338    group * BA_GATES_OUTS + local_out
339}
340
341/// The block-wide warp index that owns lane `lane` of output `local_out`.
342///
343/// The parent writes its warp partial to `smem[local_out * 2 + (lane / 32)]`;
344/// the twin writes `red[g * BA_GATES_WARPS + threadIdx.x / 32]`. Those are the
345/// same slot only because `threadIdx.x / 32 == local_out * 2 + lane / 32`,
346/// which is what this function and its test pin.
347pub fn ba_gates_warp(local_out: u32, lane: u32) -> u32 {
348    local_out * 2 + lane / 32
349}
350
351/// The two warp partials summed for output `local_out`, in order — the
352/// parent's `smem[local_out * 2] + smem[local_out * 2 + 1]`.
353pub fn ba_gates_cross_warp_pair(local_out: u32) -> (u32, u32) {
354    (local_out * 2, local_out * 2 + 1)
355}
356
357/// Where `n` lands in the `[gate(nv), beta(nv)]` output row: `Ok(vh)` for a
358/// gate (alpha) element, `Err(vh)` for a beta element — the parent's
359/// `within_group < vheads_per_group` split, which the twin copies verbatim.
360pub fn ba_gates_slot(n: u32, vheads_per_group: u32) -> Result<u32, u32> {
361    let group_dim_ba = 2 * vheads_per_group;
362    let within_group = n % group_dim_ba;
363    let group = n / group_dim_ba;
364    if within_group < vheads_per_group {
365        Err(group * vheads_per_group + within_group)
366    } else {
367        Ok(group * vheads_per_group + (within_group - vheads_per_group))
368    }
369}
370
371#[cfg(test)]
372#[path = "ssm_ba_gates_hopper_tests.rs"]
373mod ssm_ba_gates_hopper_tests;