metrale_kernels/attn_splitk.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Paged-decode attention launch geometry: the split-K count, the
4//! split-K workspace size and the GQA-packed kernel gate, as pure functions
5//! of configuration.
6//!
7//! The online-softmax split merge is not associative, so a split count that
8//! moved with the co-batched sequence count would change a sequence's output
9//! bytes depending on what it is batched with.
10//!
11//! Owner: kernels crate (paged-decode attention policy).
12//! Invariants:
13//! - Under [`SplitkPolicy::Auto`] and [`SplitkPolicy::Pinned`] the split count
14//! depends only on `(sm_count, num_q_heads)` and the pin; only
15//! [`SplitkPolicy::Legacy`] reads the reference batch.
16//! - [`resolve_policy`] is the one resolution of the target declaration and
17//! `METRALE_ATTN_DECODE_SPLITK`; the metrale-model-layers dispatch and the
18//! metrale-gpu-runtime workspace sizing both call it.
19//! - [`gqa_pack_shape_ok`] admits only `num_q_heads == num_kv_heads *
20//! DECODE_GQA_PACK_WIDTH` at `head_dim == DECODE_GQA_PACK_HEAD_DIM`.
21//!
22//! [`SplitkPolicy::Legacy`] computes, with `NUM_SMS` standing for the target's
23//! `sm_count`:
24//!
25//! ```text
26//! current_ctas = num_q_heads * split_ref_seqs(num_seqs, max_decode_seqs);
27//! num_splits = if current_ctas >= NUM_SMS { 1 } else { NUM_SMS / current_ctas };
28//! ```
29//!
30//! Short contexts are handled inside the Hopper split-K kernel, which floors
31//! each sequence's split size at `PD_MIN_KV_PER_SPLIT` from that sequence's own
32//! `seq_len` (`kernels/hopper/common/paged_decode_splitk_hopper.cuh`).
33
34/// 2026-09-25: Waves of CTAs [`SplitkPolicy::Auto`] aims to put on the device
35/// with one sequence in flight (see [`auto_splits`]).
36pub const SPLITK_TARGET_WAVES: u32 = 2;
37
38/// 2026-09-25: Ceiling on the split count under every policy but `Legacy`,
39/// and so on the split-K workspace, which is
40/// `rows * num_q_heads * num_splits * (head_dim + 2)` F32 values. Without it
41/// a model with few q heads would ask for `2 * sm_count / num_q_heads` splits
42/// (264 for one head on 132 SMs). At 24 q heads on the 148-SM targets
43/// (`kernels/b200`, `kernels/b300`) `auto` asks for 13. A pinned count is
44/// clamped to it.
45pub const MAX_DECODE_SPLITS: u32 = 16;
46
47/// 2026-09-25: How a target picks its paged-decode split count.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum SplitkPolicy {
50 /// 2026-09-25: `sm_count / (num_q_heads * ref_seqs)`, or 1 when that
51 /// product already covers the device ([`legacy_splits`]). Reads the
52 /// reference batch. The build default for a target that declares none.
53 Legacy,
54 /// 2026-09-25: Fill [`SPLITK_TARGET_WAVES`] waves with one sequence in
55 /// flight: `clamp(ceil(WAVES * sm_count / num_q_heads), 1,
56 /// MAX_DECODE_SPLITS)`.
57 Auto,
58 /// 2026-09-25: A pinned count, clamped to `1..=MAX_DECODE_SPLITS`. `0`,
59 /// `off`, `false` and `no` parse to `Pinned(1)`: one split is no split-K.
60 Pinned(u32),
61}
62
63impl SplitkPolicy {
64 /// 2026-09-25: The spelling [`parse`] reads back as this policy, printed
65 /// in the `target defaults (<hw>): …` line.
66 pub fn label(self) -> String {
67 match self {
68 SplitkPolicy::Legacy => "legacy".to_string(),
69 SplitkPolicy::Auto => "auto".to_string(),
70 SplitkPolicy::Pinned(n) => n.to_string(),
71 }
72 }
73}
74
75/// 2026-09-25: `legacy` | `auto` | `0`/`off`/`false`/`no` | a decimal count
76/// (clamped to `1..=MAX_DECODE_SPLITS`), trimmed and case-insensitive.
77///
78/// `None` for anything else; [`resolve_policy`] then keeps the declaration.
79pub fn parse(spelling: &str) -> Option<SplitkPolicy> {
80 match spelling.trim().to_ascii_lowercase().as_str() {
81 "legacy" => Some(SplitkPolicy::Legacy),
82 "auto" => Some(SplitkPolicy::Auto),
83 "0" | "off" | "false" | "no" => Some(SplitkPolicy::Pinned(1)),
84 other => other
85 .parse::<u32>()
86 .ok()
87 .map(|n| SplitkPolicy::Pinned(n.clamp(1, MAX_DECODE_SPLITS))),
88 }
89}
90
91/// 2026-09-25: The target's declaration, overridden by a parseable
92/// `METRALE_ATTN_DECODE_SPLITK`. An unparseable declaration is `Legacy`.
93///
94/// Returns `(policy, came_from_env)`. Both the dispatch (metrale-model-layers
95/// `target_defaults::resolve`) and the workspace sizing (via
96/// [`policy_from_env`]) call this, so the split count a launch uses and the
97/// one the workspace was sized for come from the same rule.
98pub fn resolve_policy(declared: &str, env_raw: Option<&str>) -> (SplitkPolicy, bool) {
99 let declared = parse(declared).unwrap_or(SplitkPolicy::Legacy);
100 match env_raw.and_then(parse) {
101 Some(p) => (p, true),
102 None => (declared, false),
103 }
104}
105
106/// 2026-09-25: [`resolve_policy`] against the process environment and this
107/// binary's baked declaration. Used by the split-K workspace sizing in
108/// metrale-gpu-runtime (`sizes.rs`), which metrale-model-layers depends on and
109/// so cannot call the model-layers resolver.
110pub fn policy_from_env() -> SplitkPolicy {
111 resolve_policy(
112 crate::TARGET_DEFAULTS.attn_decode_splitk,
113 std::env::var("METRALE_ATTN_DECODE_SPLITK").ok().as_deref(),
114 )
115 .0
116}
117
118/// 2026-09-25: `clamp(ceil(WAVES * sm_count / num_q_heads), 1,
119/// MAX_DECODE_SPLITS)`.
120///
121/// The split-K grid is `(num_q_heads, num_splits, num_seqs)`, so with one
122/// sequence this puts at least `WAVES * sm_count` CTAs on the device when the
123/// cap allows. A wider batch launches more CTAs with the same count.
124pub fn auto_splits(sm_count: u32, num_q_heads: u32) -> u32 {
125 let heads = num_q_heads.max(1);
126 let target = SPLITK_TARGET_WAVES.saturating_mul(sm_count.max(1));
127 target.div_ceil(heads).clamp(1, MAX_DECODE_SPLITS)
128}
129
130/// 2026-09-25: The [`SplitkPolicy::Legacy`] rule. The dispatch passes
131/// `ref_seqs = split_ref_seqs(num_seqs, max_decode_seqs)`, which is
132/// `max(max_decode_seqs, num_seqs)`.
133pub fn legacy_splits(sm_count: u32, num_q_heads: u32, ref_seqs: u32) -> u32 {
134 let current_ctas = num_q_heads.max(1).saturating_mul(ref_seqs.max(1));
135 if current_ctas >= sm_count {
136 1
137 } else {
138 sm_count / current_ctas
139 }
140}
141
142/// 2026-09-25: The split count for a launch.
143///
144/// `legacy_ref_seqs` is read by [`SplitkPolicy::Legacy`] only; the other arms
145/// do not depend on the batch, which
146/// `the_auto_split_count_does_not_move_with_the_co_batched_count` tests.
147pub fn num_splits(
148 policy: SplitkPolicy,
149 sm_count: u32,
150 num_q_heads: u32,
151 legacy_ref_seqs: u32,
152) -> u32 {
153 match policy {
154 SplitkPolicy::Legacy => legacy_splits(sm_count, num_q_heads, legacy_ref_seqs),
155 SplitkPolicy::Auto => auto_splits(sm_count, num_q_heads),
156 SplitkPolicy::Pinned(n) => n.clamp(1, MAX_DECODE_SPLITS),
157 }
158}
159
160/// 2026-09-25: `[o[head_dim], m, l]` slots the split-K workspace must hold.
161///
162/// The split-K kernel addresses
163/// `((seq * num_q_heads) + head) * num_splits + split`, so `max_rows` must be
164/// the widest batch the decode metadata accepts (`sizes.rs` passes
165/// `DecodeMetaLayout::rows()`); a short allocation is an out-of-bounds device
166/// write with no error.
167///
168/// `Legacy` needs `sm_count` slots: when it splits, `num_q_heads * ref_seqs <
169/// sm_count` with `ref_seqs >= num_seqs`, so `num_seqs * num_q_heads *
170/// num_splits <= sm_count`; at one split the non-split kernel runs and the
171/// workspace is not touched.
172pub fn workspace_slots(
173 policy: SplitkPolicy,
174 sm_count: u32,
175 num_q_heads: u32,
176 max_rows: u32,
177 legacy_ref_seqs: u32,
178) -> u32 {
179 match policy {
180 SplitkPolicy::Legacy => sm_count.max(1),
181 other => max_rows
182 .max(1)
183 .saturating_mul(num_q_heads.max(1))
184 .saturating_mul(num_splits(other, sm_count, num_q_heads, legacy_ref_seqs)),
185 }
186}
187
188// 2026-09-25: GQA-packed paged decode. The unpacked `paged_decode_attn{,_fp8}`
189// kernels launch `grid (num_q_heads, num_seqs)`, and the CTAs of one GQA group
190// each read that KV head's whole K and V. The packed kernels
191// (`kernels/gb10/common/paged_decode_attn_{fp8,bf16}_gqa.cu`) put the group in
192// one CTA and launch `grid (num_kv_heads, num_seqs)`. The dispatch uses them
193// only on the non-split arm: under split-K, packing would change the split
194// count and so the online-softmax merge tree.
195
196/// 2026-09-25: Query heads one packed CTA carries: the Rust copy of the
197/// kernels' `#define PD_GQA`, which sizes their `q_reg`/`o_reg` register
198/// arrays at compile time. `cuda_sources_declare_the_pack_width_rust_dispatches_on`
199/// fails if the two differ.
200///
201/// 6 is Qwen3.8-27B's ratio (`q_heads = 24`, `kv_heads = 4` in
202/// `kernels/hopper/qwen3.8-27b/MODEL.toml`). [`gqa_pack_shape_ok`] refuses any
203/// other ratio, which keeps the unpacked kernel.
204pub const DECODE_GQA_PACK_WIDTH: u32 = 6;
205
206/// 2026-09-25: The head dim the packed kernels are compiled for: both sources
207/// `#define PD_HDIM 256`, and [`gqa_pack_shape_ok`] refuses any other head dim.
208pub const DECODE_GQA_PACK_HEAD_DIM: u32 = 256;
209
210/// 2026-09-25: The packed kernels' declared state: off, for every target.
211/// `METRALE_ATTN_DECODE_GQA_PACK` overrides it ([`resolve_gqa_pack`]).
212pub const DECODE_GQA_PACK_DECLARED: bool = false;
213
214/// 2026-09-25: `1`/`on`/`true`/`yes` | `0`/`off`/`false`/`no`, trimmed and
215/// case-insensitive.
216///
217/// `None` for anything else, so an unparseable value keeps the declaration.
218pub fn parse_gqa_pack(spelling: &str) -> Option<bool> {
219 match spelling.trim().to_ascii_lowercase().as_str() {
220 "1" | "on" | "true" | "yes" => Some(true),
221 "0" | "off" | "false" | "no" => Some(false),
222 _ => None,
223 }
224}
225
226/// 2026-09-25: `declared`, overridden by a parseable
227/// `METRALE_ATTN_DECODE_GQA_PACK` value. Pure; [`gqa_pack_enabled`] supplies
228/// the environment.
229pub fn resolve_gqa_pack(declared: bool, env_raw: Option<&str>) -> bool {
230 env_raw.and_then(parse_gqa_pack).unwrap_or(declared)
231}
232
233/// 2026-09-25: Whether the packed kernels are armed in this process, resolved
234/// once. The dispatch reads it on every non-split BF16 and FP8 paged-decode
235/// launch, and a fixed answer keeps a captured CUDA graph consistent with
236/// later launches.
237pub fn gqa_pack_enabled() -> bool {
238 static ARMED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
239 *ARMED.get_or_init(|| {
240 resolve_gqa_pack(
241 DECODE_GQA_PACK_DECLARED,
242 std::env::var("METRALE_ATTN_DECODE_GQA_PACK")
243 .ok()
244 .as_deref(),
245 )
246 })
247}
248
249/// 2026-09-25: Whether this launch's shape is one the packed kernels can
250/// serve. Each condition is a precondition of the kernel:
251///
252/// * `num_kv_heads > 0`: it is the grid's x extent.
253/// * `num_q_heads == num_kv_heads * DECODE_GQA_PACK_WIDTH`: the kernel takes
254/// its query heads from `kv_head * PD_GQA`, so any other ratio reads and
255/// writes the wrong heads. The test multiplies because division truncates:
256/// 25 q heads over 4 kv heads divide to 6.
257/// * `head_dim == DECODE_GQA_PACK_HEAD_DIM`.
258///
259/// When this is false the caller keeps the unpacked kernel.
260pub fn gqa_pack_shape_ok(num_q_heads: u32, num_kv_heads: u32, head_dim: u32) -> bool {
261 num_kv_heads > 0
262 && num_q_heads == num_kv_heads.saturating_mul(DECODE_GQA_PACK_WIDTH)
263 && head_dim == DECODE_GQA_PACK_HEAD_DIM
264}
265
266/// 2026-09-25: CTAs a packed launch puts on the device: `num_kv_heads *
267/// num_seqs`, a `DECODE_GQA_PACK_WIDTH`-fold cut from the unpacked
268/// `num_q_heads * num_seqs`. With 4 KV heads on gb10's 48 SMs the packed grid
269/// covers the SMs only from `num_seqs = 12`.
270pub fn gqa_pack_ctas(num_kv_heads: u32, num_seqs: u32) -> u32 {
271 num_kv_heads.saturating_mul(num_seqs)
272}
273
274#[cfg(test)]
275#[path = "attn_splitk_tests.rs"]
276mod tests;