atlas_kernels/attn_splitk.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! The paged-decode attention SPLIT-K policy: how many KV splits a launch
4//! uses, as a pure function of CONFIGURATION.
5//!
6//! # The defect (#928)
7//!
8//! `crates/spark-model/.../decode/run_paged_decode.rs` imported
9//! `atlas_core::device::sm121::NUM_SMS` — the GB10 constant, 48 — and picked
10//!
11//! ```text
12//! current_ctas = num_q_heads * split_ref_seqs(num_seqs, max_decode_seqs);
13//! num_splits = if current_ctas >= NUM_SMS { 1 } else { NUM_SMS / current_ctas };
14//! ```
15//!
16//! On Qwen3.8-27B at `--max-batch-size 16` that is `24 * 16 = 384 >= 48`, so
17//! `num_splits` was 1 at EVERY batch size including C=1. nsys on 1xH100 80GB
18//! HBM3 (round 13 cell T1N) has the receipt: `paged_decode_attn_fp8` runs
19//! `grid=(24,1,1)` — 24 CTAs on 132 SMs — 231.51 us/launch for 9.93 MB of KV,
20//! i.e. 42.9 GB/s = **1.28% of HBM**, and with the BF16-KV sibling the pair is
21//! 3.79 ms of a 16.69 ms C=1 decode step (22.7%). The source comment
22//! dismissing occupancy here ("attention occupancy is NOT the long-ctx
23//! bottleneck") records a GB10 A/B on a 48-SM part.
24//!
25//! Two things were wrong and both are fixed here: the SM count is a property
26//! of the compiled TARGET (`kernels/<hw>/HARDWARE.toml` `[hardware] sm_count`,
27//! baked as [`crate::TARGET_SM_COUNT`]), and the occupancy the rule should
28//! size for is the SINGLE-STREAM shape, which is the one that starves.
29//!
30//! # The determinism invariant — why this module is pure
31//!
32//! The online-softmax split-merge is NON-ASSOCIATIVE. If `num_splits` moved
33//! with the runtime co-batched count, one sequence would traverse a different
34//! reduction tree alone than beside fifteen others and flip its temp-0 argmax
35//! — the nondeterminism `split_ref_seqs` was introduced to stop
36//! (`tasks/determinism_investigation.md`). So [`SplitkPolicy::Auto`] and
37//! [`SplitkPolicy::Pinned`] read `(sm_count, num_q_heads, max_decode_seqs)`
38//! and NOTHING else: the split count is fixed for the life of a serve.
39//! [`SplitkPolicy::Legacy`] is the pre-#928 rule preserved verbatim, including
40//! its dependence on `split_ref_seqs`, because every target but Hopper still
41//! runs it and "unchanged" has to mean unchanged.
42//!
43//! Short contexts are handled INSIDE the kernel
44//! (`kernels/hopper/common/paged_decode_splitk_hopper.cuh`,
45//! `PD_MIN_KV_PER_SPLIT`), from each sequence's own `seq_len`: the host may not
46//! branch on `seq_lens`, which is device memory and behind a captured CUDA
47//! graph, and a per-sequence rule stays co-batch invariant where a per-batch
48//! one would not.
49
50/// Waves of CTAs the `auto` policy aims to put on the device at the
51/// single-stream shape.
52///
53/// TWO, not one: the attention CTAs are memory-latency bound (a paged K/V
54/// gather, a shuffle reduction and an `__expf` per position), so one CTA per
55/// SM leaves the SM stalled on loads. Two resident waves is the smallest
56/// number that lets one cover the other's misses, and it is also where the
57/// split stops being free — every extra split is another partial for the
58/// reduce to merge and another eighth-of-a-CTA of Q load to repeat. Round 14
59/// measures the curve: the microtest prints GB/s for `num_splits` 1/2/4/6 at
60/// three context lengths.
61pub const SPLITK_TARGET_WAVES: u32 = 2;
62
63/// Hard ceiling on the split count, and the bound the split-K workspace is
64/// sized against.
65///
66/// A cap rather than a pure occupancy answer because the workspace is
67/// `rows * num_q_heads * num_splits * (head_dim + 2)` F32 — linear in this
68/// number — and because a model with very few q heads (an MQA decode head, a
69/// draft model) would otherwise ask for a split per KV block. 16 covers
70/// `2 * 148 / 24 = 13` on the widest declared target
71/// (`kernels/b200`, 148 SMs) with room, and an explicit
72/// `attn_decode_splitk = N` is clamped to it.
73pub const MAX_DECODE_SPLITS: u32 = 16;
74
75/// How a target picks its paged-decode split count.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum SplitkPolicy {
78 /// The pre-#928 rule: `sm_count / (num_q_heads * split_ref_seqs)`, or 1
79 /// when that product already covers the device. Reads the runtime
80 /// reference batch, which is why it is not the default anywhere new.
81 Legacy,
82 /// Fill [`SPLITK_TARGET_WAVES`] waves at the SINGLE-STREAM shape:
83 /// `clamp(ceil(WAVES * sm_count / num_q_heads), 1, MAX_DECODE_SPLITS)`.
84 Auto,
85 /// An operator-pinned count, clamped to `1..=MAX_DECODE_SPLITS`. `0` and
86 /// `off` arrive here as `Pinned(1)` — one split IS no split-K.
87 Pinned(u32),
88}
89
90impl SplitkPolicy {
91 /// The spelling this policy round-trips through [`parse`], for the serve
92 /// log's `target defaults (<hw>): …` line.
93 pub fn label(self) -> String {
94 match self {
95 SplitkPolicy::Legacy => "legacy".to_string(),
96 SplitkPolicy::Auto => "auto".to_string(),
97 SplitkPolicy::Pinned(n) => n.to_string(),
98 }
99 }
100}
101
102/// `legacy` | `auto` | `0`/`off`/`false`/`no` | a decimal count.
103///
104/// `None` for anything else — the caller keeps the target's declaration rather
105/// than guessing, because a typo that silently resolved to `auto` would arm a
106/// geometry change on a card with no receipt for it.
107pub fn parse(spelling: &str) -> Option<SplitkPolicy> {
108 match spelling.trim().to_ascii_lowercase().as_str() {
109 "legacy" => Some(SplitkPolicy::Legacy),
110 "auto" => Some(SplitkPolicy::Auto),
111 "0" | "off" | "false" | "no" => Some(SplitkPolicy::Pinned(1)),
112 other => other
113 .parse::<u32>()
114 .ok()
115 .map(|n| SplitkPolicy::Pinned(n.clamp(1, MAX_DECODE_SPLITS))),
116 }
117}
118
119/// The target's declaration, overridden by `ATLAS_ATTN_DECODE_SPLITK`.
120///
121/// Returns `(policy, came_from_env)`. THE one resolution rule: both the
122/// dispatch (via `spark_model`'s `target_defaults::resolve`) and the buffer
123/// arena (via [`policy_from_env`]) call this, so the split count a launch uses
124/// and the split count the workspace was sized for cannot disagree.
125pub fn resolve_policy(declared: &str, env_raw: Option<&str>) -> (SplitkPolicy, bool) {
126 let declared = parse(declared).unwrap_or(SplitkPolicy::Legacy);
127 match env_raw.and_then(parse) {
128 Some(p) => (p, true),
129 None => (declared, false),
130 }
131}
132
133/// [`resolve_policy`] against the process environment and this binary's baked
134/// declaration.
135///
136/// For the buffer-sizing call site, which lives below `spark-model` in the
137/// dependency graph and so cannot reach its resolver. It is the SAME pure
138/// rule, not a second one; the serve log still reports the value once, from
139/// `spark-model`.
140pub fn policy_from_env() -> SplitkPolicy {
141 resolve_policy(
142 crate::TARGET_DEFAULTS.attn_decode_splitk,
143 std::env::var("ATLAS_ATTN_DECODE_SPLITK").ok().as_deref(),
144 )
145 .0
146}
147
148/// `clamp(ceil(WAVES * sm_count / num_q_heads), 1, MAX_DECODE_SPLITS)`.
149///
150/// The single-stream occupancy answer: at C=1 the grid is
151/// `(num_q_heads, num_splits, 1)`, so this is the split count that puts
152/// `WAVES * sm_count` CTAs on the device with one sequence in flight. At a
153/// wider batch the same count over-subscribes — total WORK is unchanged, only
154/// its partition is — which is the trade this policy takes deliberately: a
155/// C=1 step is 24 CTAs without it and a C=16 step is already 3 waves with it.
156pub fn auto_splits(sm_count: u32, num_q_heads: u32) -> u32 {
157 let heads = num_q_heads.max(1);
158 let target = SPLITK_TARGET_WAVES.saturating_mul(sm_count.max(1));
159 target.div_ceil(heads).clamp(1, MAX_DECODE_SPLITS)
160}
161
162/// The pre-#928 rule, preserved verbatim for every target that still declares
163/// it. `ref_seqs` is `split_ref_seqs(num_seqs, max_decode_seqs)`.
164pub fn legacy_splits(sm_count: u32, num_q_heads: u32, ref_seqs: u32) -> u32 {
165 let current_ctas = num_q_heads.max(1).saturating_mul(ref_seqs.max(1));
166 if current_ctas >= sm_count {
167 1
168 } else {
169 sm_count / current_ctas
170 }
171}
172
173/// The split count for a launch.
174///
175/// ⚠️ `legacy_ref_seqs` is read by [`SplitkPolicy::Legacy`] ONLY. Every other
176/// arm is a pure function of configuration, which is the determinism invariant
177/// this module exists to hold — see the module header, and
178/// `the_auto_split_count_does_not_move_with_the_co_batched_count`.
179pub fn num_splits(
180 policy: SplitkPolicy,
181 sm_count: u32,
182 num_q_heads: u32,
183 legacy_ref_seqs: u32,
184) -> u32 {
185 match policy {
186 SplitkPolicy::Legacy => legacy_splits(sm_count, num_q_heads, legacy_ref_seqs),
187 SplitkPolicy::Auto => auto_splits(sm_count, num_q_heads),
188 SplitkPolicy::Pinned(n) => n.clamp(1, MAX_DECODE_SPLITS),
189 }
190}
191
192/// `[o[head_dim], m, l]` slots the split-K workspace must hold.
193///
194/// The split-K kernel addresses
195/// `((seq * num_q_heads) + head) * num_splits + split`, so the arena has to
196/// cover the WIDEST batch the decode-metadata layout will accept
197/// (`DecodeMetaLayout::rows()`), not the pinned max batch — a short
198/// allocation here is an out-of-bounds device write, silently.
199///
200/// `Legacy` keeps its old constant bound and its old arena: that rule picks
201/// `sm_count / (num_q_heads * max(max_batch, num_seqs))`, so
202/// `num_seqs * num_q_heads * num_splits <= sm_count` for every batch, which is
203/// exactly what `sizes.rs` allocated before #928.
204pub fn workspace_slots(
205 policy: SplitkPolicy,
206 sm_count: u32,
207 num_q_heads: u32,
208 max_rows: u32,
209 legacy_ref_seqs: u32,
210) -> u32 {
211 match policy {
212 SplitkPolicy::Legacy => sm_count.max(1),
213 other => max_rows
214 .max(1)
215 .saturating_mul(num_q_heads.max(1))
216 .saturating_mul(num_splits(other, sm_count, num_q_heads, legacy_ref_seqs)),
217 }
218}
219
220#[cfg(test)]
221#[path = "attn_splitk_tests.rs"]
222mod tests;