metrale_model_layers/layers/ops/ssm_ba_gates_hopper.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: The Hopper BA-gates twin `dense_gemm_ba_gates_prefill_hopper`
4//! (`kernels/gb10/common/ssm_ba_gates_hopper.cu`): its launcher, the choice
5//! between it and the gb10 parent `dense_gemm_ba_gates_prefill`
6//! (`ssm_preprocess.cu`), the log of that choice, and the index mappings both
7//! kernels must share.
8//!
9//! The parent runs `ceil(N/4)` blocks per token; each block's 256 threads are
10//! four 64-lane groups, one per BA output, and each group reads the token's
11//! whole activation row, so the row is read `N` times per token. The twin runs
12//! one block per token, and each thread accumulates [`BA_GATES_GROUPS`] output
13//! groups per read of the row: 12 reads at `N = 96`.
14//!
15//! The twin keeps the parent's reduction order (lane-strided `kv` sweep, the
16//! 16/8/4/2/1 shuffle, `warp_even + warp_odd`), and
17//! `native_ssm_ba_gates_hopper_microtest` checks its gate and beta output
18//! bytes against the parent's.
19//!
20//! 2026-09-28: gb10's common/ ships the twin (and hopper and b200 inherit it); a
21//! target without it has handle `KernelHandle(0)` and [`ba_gates_pick`] keeps the
22//! parent even when the lever is on. The lever is declared on in the gb10 and
23//! hopper `HARDWARE.toml`s and off in the b200 and b300 ones.
24//!
25//! One block per token makes the grid the token count, and the parent's
26//! callers include the batched decode (`qwen3_ssm/trait_decode_batched.rs`).
27//! [`ssm_ba_gates_hopper_reject`] therefore declines below
28//! `MIN_CTAS_PER_SM * sm_count` tokens, and the parent runs.
29//!
30//! Owner: model-layers ops.
31//! Invariants:
32//! - [`ba_gates_pick`] returns the twin only when [`ssm_ba_gates_hopper_reject`]
33//! returns `None`.
34
35use anyhow::Result;
36use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
37use metrale_gpu_runtime::kernel_args::KernelLaunch;
38
39use crate::weight_map::DenseWeight;
40
41/// 2026-09-25: Threads per block, as in the parent (`BAH_BLOCK` in the twin). The
42/// reduction order depends on it, [`BA_GATES_LANES`] and [`BA_GATES_OUTS`].
43pub const BA_GATES_BLOCK: u32 = 256;
44/// 2026-09-25: Threads that reduce one BA output: the parent's `threads_per_out`.
45pub const BA_GATES_LANES: u32 = 64;
46pub const BA_GATES_OUTS: u32 = BA_GATES_BLOCK / BA_GATES_LANES;
47/// 2026-09-25: Warps per block: the width of one row of the twin's cross-warp
48/// scratch `red`.
49pub const BA_GATES_WARPS: u32 = BA_GATES_BLOCK / 32;
50/// 2026-09-25: `BAH_GROUPS` in the twin: output groups a thread accumulates per
51/// read of the activation row. The row is read `ceil(ceil(N/4)/8) * 4` times per
52/// token, 12 at `N = 96`. The kernel source records the register and occupancy
53/// table behind the choice of 8.
54pub const BA_GATES_GROUPS: u32 = 8;
55
56/// 2026-09-25: Tokens per SM the twin requires before it takes a launch. It is a
57/// judgement, not a contract: set high, it only sends more launches to the
58/// parent, which computes the same bits.
59pub const MIN_CTAS_PER_SM: u32 = 2;
60
61/// 2026-09-25: SM count used when `GpuBackend::sm_count()` fails: the
62/// `sm_count` in `kernels/hopper/HARDWARE.toml`. A wrong value moves only the
63/// guard's threshold.
64pub const BA_GATES_FALLBACK_SM_COUNT: u32 = 132;
65
66/// 2026-09-25: The smallest token count the twin accepts with `sm_count` SMs
67/// (0 is treated as 1).
68pub fn ba_gates_min_tokens(sm_count: u32) -> u32 {
69 MIN_CTAS_PER_SM.saturating_mul(sm_count.max(1))
70}
71
72/// 2026-09-25: The lever: the target's `[defaults] ssm_ba_gates_hopper`, which
73/// `METRALE_SSM_BA_GATES_HOPPER` overrides (`0`, `false`, `off` or `no` turn it
74/// off, any other value on; [`super::target_defaults`]). There is no
75/// `METRALE_NO_*` spelling for it.
76pub fn ssm_ba_gates_hopper_enabled() -> bool {
77 super::target_defaults::resolved().ssm_ba_gates_hopper.value
78}
79
80/// 2026-09-25: Why the twin does not run, as one of [`BA_GATES_REJECTS`]; `None`
81/// means it runs. Pure, so tests cover it without a GPU. The shape guards:
82/// - `K % 8 != 0`: the uint4 sweep would skip the tail (the parent has the same
83/// limit and does not check it);
84/// - `K_stride < K`: the row is shorter than the reduction;
85/// - `N == 0` or `K == 0`: nothing to compute;
86/// - fewer than [`ba_gates_min_tokens`] tokens: the decode guard in the module
87/// header.
88pub fn ssm_ba_gates_hopper_reject(
89 requested: bool,
90 kernel_present: bool,
91 m: u32,
92 n: u32,
93 k: u32,
94 k_stride: u32,
95 sm_count: u32,
96) -> Option<&'static str> {
97 if !requested {
98 Some("not requested")
99 } else if !kernel_present {
100 Some("kernel absent from this image (kernels/hopper only)")
101 } else if n == 0 || k == 0 {
102 Some("empty BA projection")
103 } else if !k.is_multiple_of(8) {
104 Some("K is not a multiple of 8 (the uint4 K sweep would drop a tail)")
105 } else if k_stride < k {
106 Some("K_stride < K: the activation row is shorter than the reduction")
107 } else if m < ba_gates_min_tokens(sm_count) {
108 Some(BA_GATES_TOO_FEW_TOKENS)
109 } else {
110 None
111 }
112}
113
114/// 2026-09-25: The kernel a launch runs, and why the other did not.
115pub struct BaGatesPick {
116 pub kernel: KernelHandle,
117 /// 2026-09-25: `true` when `kernel` is the Hopper twin.
118 pub twin: bool,
119 /// 2026-09-25: `None` when the twin runs; the refusing guard when the parent does.
120 pub reject: Option<&'static str>,
121}
122
123/// 2026-09-25: Choose between the gb10 parent and the Hopper twin. Its one
124/// caller is `ssm_preproc::dense_gemm_ba_gates_prefill`, which the five BA-gate
125/// call sites share, so the rule exists once.
126#[allow(clippy::too_many_arguments)]
127pub fn ba_gates_pick(
128 requested: bool,
129 parent: KernelHandle,
130 twin: KernelHandle,
131 m: u32,
132 n: u32,
133 k: u32,
134 k_stride: u32,
135 sm_count: u32,
136) -> BaGatesPick {
137 let reject = ssm_ba_gates_hopper_reject(requested, twin.0 != 0, m, n, k, k_stride, sm_count);
138 match reject {
139 None => BaGatesPick {
140 kernel: twin,
141 twin: true,
142 reject,
143 },
144 Some(_) => BaGatesPick {
145 kernel: parent,
146 twin: false,
147 reject,
148 },
149 }
150}
151
152/// 2026-09-25: Every string [`ssm_ba_gates_hopper_reject`] can return, in the
153/// order it tests them. [`ba_gates_log`] gives each its own once-flag by index
154/// here; the test `every_reject_reason_has_its_own_log_slot` checks that every
155/// returned reason has an index.
156pub const BA_GATES_REJECTS: [&str; 6] = [
157 "not requested",
158 "kernel absent from this image (kernels/hopper only)",
159 "empty BA projection",
160 "K is not a multiple of 8 (the uint4 K sweep would drop a tail)",
161 "K_stride < K: the activation row is shorter than the reduction",
162 BA_GATES_TOO_FEW_TOKENS,
163];
164
165/// 2026-09-25: The token-count guard's string, named so tests can match it.
166pub const BA_GATES_TOO_FEW_TOKENS: &str = "too few tokens to fill the device at one CTA per token";
167
168/// 2026-09-25: The once-flag slot a verdict logs under in [`ba_gates_log`]; one
169/// slot per outcome, so an early small request cannot hide a later line about
170/// the other outcome.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum BaGatesLogSlot {
173 /// 2026-09-25: The twin took the launch.
174 Twin,
175 /// 2026-09-25: The parent took it, for the guard at this index of
176 /// [`BA_GATES_REJECTS`].
177 Reject(usize),
178}
179
180/// 2026-09-25: Once-flags [`ba_gates_log`] keeps: one per guard, plus the twin's.
181pub const BA_GATES_LOG_SLOTS: usize = BA_GATES_REJECTS.len() + 1;
182
183/// 2026-09-25: The slot a verdict belongs to, or `None` when the lever was not
184/// requested. Pure, so tests can replay a call sequence on the CPU.
185pub fn ba_gates_log_slot(pick: &BaGatesPick, requested: bool) -> Option<BaGatesLogSlot> {
186 match pick.reject {
187 None => Some(BaGatesLogSlot::Twin),
188 // 2026-09-25: With the lever off the parent is expected, so nothing is logged.
189 Some(_) if !requested => None,
190 Some(why) => BA_GATES_REJECTS
191 .iter()
192 .position(|r| *r == why)
193 .map(BaGatesLogSlot::Reject),
194 }
195}
196
197/// 2026-09-25: Log which kernel runs and, when the lever asked for the twin and
198/// a guard refused, which guard, once per process per slot. The call runs once
199/// per layer per step and its verdict depends on `m`, so each outcome keeps its
200/// own flag and names the `m` it was first reached at.
201pub fn ba_gates_log(pick: &BaGatesPick, requested: bool, m: u32) {
202 static SAID: [std::sync::Once; BA_GATES_LOG_SLOTS] =
203 [const { std::sync::Once::new() }; BA_GATES_LOG_SLOTS];
204 let Some(slot) = ba_gates_log_slot(pick, requested) else {
205 return;
206 };
207 let (idx, why) = match slot {
208 BaGatesLogSlot::Twin => (BA_GATES_LOG_SLOTS - 1, None),
209 BaGatesLogSlot::Reject(i) => (i, pick.reject),
210 };
211 SAID[idx].call_once(|| match why {
212 Some(why) => tracing::info!(
213 "SSM ba_gates: the Hopper twin is NOT running at M={m}: {why} \
214 (METRALE_SSM_BA_GATES_HOPPER)"
215 ),
216 None => tracing::info!(
217 "SSM ba_gates: dense_gemm_ba_gates_prefill_hopper \
218 (METRALE_SSM_BA_GATES_HOPPER) M={m} block={BA_GATES_BLOCK} grid=(M,1,1)"
219 ),
220 });
221}
222
223/// 2026-09-25: The device's SM count, or [`BA_GATES_FALLBACK_SM_COUNT`] when the
224/// query fails; at least 1.
225pub fn ba_gates_sm_count(gpu: &dyn GpuBackend) -> u32 {
226 gpu.sm_count().unwrap_or(BA_GATES_FALLBACK_SM_COUNT).max(1)
227}
228
229/// 2026-09-25: Launch `dense_gemm_ba_gates_prefill_hopper`, one block per token.
230/// It takes the parent's arguments in the parent's order; only the grid differs.
231#[allow(clippy::too_many_arguments)]
232pub fn dense_gemm_ba_gates_prefill_hopper(
233 gpu: &dyn GpuBackend,
234 kernel: KernelHandle,
235 input: DevicePtr,
236 ba_weight: &DenseWeight,
237 a_log: DevicePtr,
238 dt_bias: DevicePtr,
239 gate_out: DevicePtr,
240 m: u32,
241 n: u32,
242 k: u32,
243 k_stride: u32,
244 gate_stride: u32,
245 nv: u32,
246 vheads_per_group: u32,
247 stream: u64,
248) -> Result<()> {
249 KernelLaunch::new(gpu, kernel)
250 .grid([m, 1, 1])
251 .block([BA_GATES_BLOCK, 1, 1])
252 .arg_ptr(input)
253 .arg_ptr(ba_weight.weight)
254 .arg_ptr(a_log)
255 .arg_ptr(dt_bias)
256 .arg_ptr(gate_out)
257 .arg_u32(m)
258 .arg_u32(n)
259 .arg_u32(k)
260 .arg_u32(k_stride)
261 .arg_u32(gate_stride)
262 .arg_u32(nv)
263 .arg_u32(vheads_per_group)
264 .launch(stream)
265}
266
267// 2026-09-25: The index mappings the parent and the twin must share, as pure
268// functions that `ssm_ba_gates_hopper_tests` checks on the CPU.
269
270/// 2026-09-25: The `kv` indices lane `lane` accumulates, in order: the parent's
271/// `for (kv = lane; kv < K_VEC; kv += threads_per_out)`. The order is the
272/// reduction order.
273pub fn ba_gates_lane_kv(lane: u32, k_vec: u32) -> Vec<u32> {
274 let mut out = Vec::new();
275 let mut kv = lane;
276 while kv < k_vec {
277 out.push(kv);
278 kv += BA_GATES_LANES;
279 }
280 out
281}
282
283/// 2026-09-25: The BA output a thread owns: `group * BA_GATES_OUTS + local_out`.
284/// The parent's group is `blockIdx.x`; the twin's is `g0 + g`, walking
285/// `0..ceil(N/4)` in tiles of [`BA_GATES_GROUPS`].
286pub fn ba_gates_output(group: u32, local_out: u32) -> u32 {
287 group * BA_GATES_OUTS + local_out
288}
289
290/// 2026-09-25: The block-wide warp that holds lane `lane` of output `local_out`.
291/// The parent writes its warp partial to `smem[local_out * 2 + lane / 32]` and
292/// the twin to `red[g * BA_GATES_WARPS + threadIdx.x / 32]`; they match because
293/// `threadIdx.x / 32 == local_out * 2 + lane / 32`.
294pub fn ba_gates_warp(local_out: u32, lane: u32) -> u32 {
295 local_out * 2 + lane / 32
296}
297
298/// 2026-09-25: The two warp partials summed for output `local_out`, in order: the
299/// parent's `smem[local_out * 2] + smem[local_out * 2 + 1]`.
300pub fn ba_gates_cross_warp_pair(local_out: u32) -> (u32, u32) {
301 (local_out * 2, local_out * 2 + 1)
302}
303
304/// 2026-09-25: Where output `n` lands in the `[gate(nv), beta(nv)]` row: `Ok(vh)`
305/// for a gate (alpha) element, `Err(vh)` for a beta element, split as the
306/// parent splits them on `within_group < vheads_per_group`.
307pub fn ba_gates_slot(n: u32, vheads_per_group: u32) -> Result<u32, u32> {
308 let group_dim_ba = 2 * vheads_per_group;
309 let within_group = n % group_dim_ba;
310 let group = n / group_dim_ba;
311 if within_group < vheads_per_group {
312 Err(group * vheads_per_group + within_group)
313 } else {
314 Ok(group * vheads_per_group + (within_group - vheads_per_group))
315 }
316}
317
318#[cfg(test)]
319#[path = "ssm_ba_gates_hopper_tests.rs"]
320mod ssm_ba_gates_hopper_tests;