Skip to main content

ferrum_models/common/
decoder_unified.rs

1//! Shared helpers for decoder-only unified mixed-batch forward.
2//!
3//! The Llama / Qwen3-MoE / future decoder families all share the same
4//! outer scaffolding for unified forward: cu_seqlens construction,
5//! block-table stacking, final-token index lookup, graph-cache keying.
6//! These are pure functions — no kernel calls, no model state — extracted
7//! here so each family's `unified_forward_internal` reads as
8//! "scaffolding + family-specific layer loop", not "scaffolding +
9//! 700 lines of scaffolding clone".
10//!
11//! Per `docs/decoder-unified-runner-abstraction.md`. Phase 2A.
12
13/// Cumulative q-token counts: `cu_seqlens_q[i+1] - cu_seqlens_q[i] =
14/// items[i].q_tokens.len()`. The varlen attention + paged-KV-write
15/// kernels read this to find each sequence's slice of the flat
16/// `[M_total, *]` tensor.
17///
18/// Also returns the flat `q_lens[i] = items[i].q_tokens.len()` and
19/// `m_total = sum(q_lens)`.
20pub fn compute_cu_seqlens_q(
21    items: &[(String, Vec<u32>, usize, bool)],
22) -> (Vec<usize>, Vec<u32>, usize) {
23    let q_lens: Vec<usize> = items.iter().map(|it| it.1.len()).collect();
24    let mut cu_seqlens_q: Vec<u32> = Vec::with_capacity(items.len() + 1);
25    cu_seqlens_q.push(0);
26    for &l in &q_lens {
27        let prev = *cu_seqlens_q.last().unwrap();
28        cu_seqlens_q.push(prev + l as u32);
29    }
30    let m_total = *cu_seqlens_q.last().unwrap() as usize;
31    (q_lens, cu_seqlens_q, m_total)
32}
33
34/// Per-item starting absolute KV position for the FIRST q-token in
35/// `items[i].q_tokens`. Zero for fresh prefill, prior `kv_len` for
36/// chunked-prefill continuations or decode steps. Returned as `u32`
37/// to match the device-side index buffers the varlen kernels read.
38pub fn compute_pos_offsets(items: &[(String, Vec<u32>, usize, bool)]) -> Vec<u32> {
39    items.iter().map(|it| it.2 as u32).collect()
40}
41
42/// Causal max over `(pos_offset + q_len)` — needed for the varlen
43/// attention kernel's shared-mem sizing (must fit the longest reachable
44/// `kv_pos` across all items in the batch).
45pub fn compute_max_kv_len(items: &[(String, Vec<u32>, usize, bool)]) -> usize {
46    items.iter().map(|it| it.2 + it.1.len()).max().unwrap_or(0)
47}
48
49/// Flatten all items' q-tokens into one concatenated `[M_total]` vec.
50/// Caller passes this to `embedding_lookup` so the entire batch's
51/// embeddings end up contiguous in the unified residual buffer.
52pub fn concat_q_tokens(items: &[(String, Vec<u32>, usize, bool)]) -> Vec<u32> {
53    items.iter().flat_map(|it| it.1.iter().copied()).collect()
54}
55
56/// Pack per-(seq, layer-0) page indices into the dense
57/// `[num_seqs, max_blocks_per_seq]` layout that the varlen attention
58/// kernel reads. Layer indexing is "first layer's block table"
59/// because in ferrum's paged-KV layout every layer shares the same
60/// block-table list (the layer-specific data lives inside each KV
61/// pool; the table itself is per-sequence).
62///
63/// `lookup` returns the block-indices slice for each item's cache_id;
64/// the caller wires this to its model's `kv_caches.get(cid)`.
65pub fn stack_block_tables<F: Fn(&str) -> Vec<u32>>(
66    items: &[(String, Vec<u32>, usize, bool)],
67    max_blocks_per_seq: usize,
68    lookup: F,
69) -> Vec<u32> {
70    let mut stacked: Vec<u32> = vec![0u32; items.len() * max_blocks_per_seq];
71    for (i, (cid, _, _, _)) in items.iter().enumerate() {
72        let blocks = lookup(cid);
73        let n_to_copy = blocks.len().min(max_blocks_per_seq);
74        stacked[i * max_blocks_per_seq..i * max_blocks_per_seq + n_to_copy]
75            .copy_from_slice(&blocks[..n_to_copy]);
76    }
77    stacked
78}
79
80/// For each `is_final_chunk = true` item, return `(orig_index, global_token_index)`
81/// where `global_token_index` is the position in the flat `[M_total, hidden]`
82/// residual buffer of that item's LAST q-token. The final-norm + lm_head
83/// stages slice these rows out for sampling.
84pub fn compute_final_indices(
85    items: &[(String, Vec<u32>, usize, bool)],
86    cu_seqlens_q: &[u32],
87) -> Vec<(usize, usize)> {
88    items
89        .iter()
90        .enumerate()
91        .filter(|(_, it)| it.3)
92        .map(|(orig_idx, it)| {
93            let last_token_local = it.1.len() - 1;
94            let global = (cu_seqlens_q[orig_idx] as usize) + last_token_local;
95            (orig_idx, global)
96        })
97        .collect()
98}
99
100/// Compact key for host-side varlen-attention launch decisions captured in a
101/// unified CUDA graph.
102///
103/// The raw `max_kv_len` changes almost every decode step. The non-split-K CUDA
104/// launcher rounds it to a power-of-two shared-memory bucket; include that
105/// bucket here so replay is safe without allocating the full configured
106/// context window for every short-context CTA. Split-K still uses an exact
107/// chunk-shaped dynamic shared allocation, so keep it conservative there.
108pub fn unified_attention_launch_key(
109    total_q_tokens: usize,
110    num_seqs: usize,
111    max_kv_len: usize,
112    split_k_attn: Option<bool>,
113) -> u64 {
114    let use_split_k = split_k_attn
115        .unwrap_or_else(|| total_q_tokens <= 64 && (num_seqs <= 4 || max_kv_len >= 768));
116    if use_split_k {
117        let num_splits = match max_kv_len {
118            kv if kv <= 384 => 2usize,
119            kv if kv <= 1024 => 4,
120            kv if kv <= 2048 => 8,
121            _ => 16,
122        };
123        let chunk = (max_kv_len + num_splits - 1) / num_splits;
124        return 0x7370_6c69_7400_0000u64 ^ ((num_splits as u64) << 32) ^ (chunk.max(1) as u64);
125    }
126
127    let shared_kv = ferrum_kernels::backend::attention_score_capacity_bucket(max_kv_len);
128    0x7368_6d65_6d00_0000u64 ^ (shared_kv as u64)
129}
130
131/// Graph cache key for a unified mixed-batch forward. High bit set so we
132/// never collide with legacy decode/batched keys (which use the low 63
133/// bits for `m_padded` / `SINGLE_ITEM = 0`).
134///
135/// The captured region bakes in more than launch grid shape:
136/// - varlen attention freezes the dynamic shared-memory size and split-K
137///   branch represented by `attention_launch_key`;
138/// - final-token packing records concrete `copy_slice` offsets from
139///   `final_indices`.
140///
141/// Include those host-side shape decisions in the key so a graph is only
142/// replayed for the same captured launch/memcpy layout. Device buffers
143/// such as cu_seqlens, position offsets, and block tables remain dynamic.
144pub fn unified_graph_key(
145    m_total: usize,
146    num_seqs: usize,
147    attention_launch_key: u64,
148    final_indices: &[(usize, usize)],
149) -> u64 {
150    scoped_unified_graph_key(
151        0x6675_6c6c_5f67_7261,
152        m_total,
153        num_seqs,
154        attention_launch_key,
155        final_indices,
156    )
157}
158
159/// Graph cache key for a diagnostic unified graph that captures only the
160/// transformer layer loop. Final norm, final-row packing, and lm_head remain
161/// eager, so final-token offsets are intentionally not part of the key.
162pub fn unified_layers_only_graph_key(
163    m_total: usize,
164    num_seqs: usize,
165    attention_launch_key: u64,
166) -> u64 {
167    scoped_unified_graph_key(
168        0x6c61_7965_725f_6772,
169        m_total,
170        num_seqs,
171        attention_launch_key,
172        &[],
173    )
174}
175
176/// Graph cache key for a diagnostic unified graph that captures transformer
177/// layers plus final norm / final-row packing, while leaving lm_head eager.
178pub fn unified_lm_head_eager_graph_key(
179    m_total: usize,
180    num_seqs: usize,
181    attention_launch_key: u64,
182    final_indices: &[(usize, usize)],
183) -> u64 {
184    scoped_unified_graph_key(
185        0x6c6d_6865_5f65_6772,
186        m_total,
187        num_seqs,
188        attention_launch_key,
189        final_indices,
190    )
191}
192
193fn scoped_unified_graph_key(
194    scope_tag: u64,
195    m_total: usize,
196    num_seqs: usize,
197    attention_launch_key: u64,
198    final_indices: &[(usize, usize)],
199) -> u64 {
200    fn feed(mut hash: u64, value: u64) -> u64 {
201        hash ^= value;
202        hash = hash.wrapping_mul(0x100000001b3);
203        hash
204    }
205
206    let mut hash = 0xcbf29ce484222325u64;
207    hash = feed(hash, scope_tag);
208    hash = feed(hash, m_total as u64);
209    hash = feed(hash, num_seqs as u64);
210    hash = feed(hash, attention_launch_key);
211    hash = feed(hash, final_indices.len() as u64);
212    for &(orig_idx, global_idx) in final_indices {
213        hash = feed(hash, orig_idx as u64);
214        hash = feed(hash, global_idx as u64);
215    }
216    (1u64 << 63) | (hash & !(1u64 << 63))
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn item(
224        cid: &str,
225        q_len: usize,
226        pos: usize,
227        final_chunk: bool,
228    ) -> (String, Vec<u32>, usize, bool) {
229        (cid.to_string(), vec![0u32; q_len], pos, final_chunk)
230    }
231
232    #[test]
233    fn cu_seqlens_q_mixed_lengths() {
234        let items = vec![
235            item("a", 5, 0, true),
236            item("b", 1, 100, true),
237            item("c", 3, 10, false),
238        ];
239        let (q_lens, cu, m_total) = compute_cu_seqlens_q(&items);
240        assert_eq!(q_lens, vec![5, 1, 3]);
241        assert_eq!(cu, vec![0, 5, 6, 9]);
242        assert_eq!(m_total, 9);
243    }
244
245    #[test]
246    fn pos_offsets_and_max_kv_len() {
247        let items = vec![
248            item("a", 5, 0, true),
249            item("b", 1, 100, true),
250            item("c", 3, 10, false),
251        ];
252        assert_eq!(compute_pos_offsets(&items), vec![0u32, 100, 10]);
253        assert_eq!(compute_max_kv_len(&items), 101); // b: 100 + 1
254    }
255
256    #[test]
257    fn final_indices_only_final_chunks() {
258        let items = vec![
259            item("a", 5, 0, true),   // last token at global 4
260            item("b", 1, 100, true), // last at global 5
261            item("c", 3, 10, false), // not final
262        ];
263        let (_, cu, _) = compute_cu_seqlens_q(&items);
264        let fi = compute_final_indices(&items, &cu);
265        assert_eq!(fi, vec![(0, 4), (1, 5)]);
266    }
267
268    #[test]
269    fn graph_key_high_bit_set() {
270        let launch_key = unified_attention_launch_key(32, 4, 128, None);
271        let k = unified_graph_key(32, 4, launch_key, &[(0, 0), (1, 1), (2, 2), (3, 3)]);
272        assert!(k & (1u64 << 63) != 0, "high bit must be set");
273        // Legacy key with same low bits should differ.
274        let legacy = ((32u64) << 32) | 4u64;
275        assert_ne!(k, legacy);
276    }
277
278    #[test]
279    fn attention_launch_key_coalesces_non_split_k_within_power_of_two_bucket() {
280        let short = unified_attention_launch_key(16, 16, 129, None);
281        let longer_in_bucket = unified_attention_launch_key(16, 16, 256, None);
282        let next_bucket = unified_attention_launch_key(16, 16, 257, None);
283
284        assert_eq!(short, longer_in_bucket);
285        assert_ne!(short, next_bucket);
286    }
287
288    #[test]
289    fn attention_launch_key_keeps_split_k_chunk_shape() {
290        let short = unified_attention_launch_key(2, 2, 128, None);
291        let longer = unified_attention_launch_key(2, 2, 256, None);
292        let forced_off = unified_attention_launch_key(2, 2, 128, Some(false));
293
294        assert_ne!(short, longer);
295        assert_ne!(short, forced_off);
296    }
297
298    #[test]
299    fn graph_key_uses_attention_launch_shape_and_final_offsets() {
300        let decode_final = vec![(0, 0), (1, 1)];
301        let same_launch_short = unified_attention_launch_key(16, 16, 129, None);
302        let same_launch_long = unified_attention_launch_key(16, 16, 256, None);
303        let next_bucket = unified_attention_launch_key(16, 16, 257, None);
304        let same_grid_short_kv = unified_graph_key(16, 16, same_launch_short, &decode_final);
305        let same_grid_long_kv = unified_graph_key(16, 16, same_launch_long, &decode_final);
306        let different_launch = unified_graph_key(16, 16, next_bucket, &decode_final);
307        assert_eq!(same_grid_short_kv, same_grid_long_kv);
308        assert_ne!(same_grid_short_kv, different_launch);
309
310        let prefill_final = vec![(0, 4), (1, 5)];
311        let prefill_launch = unified_attention_launch_key(6, 2, 128, None);
312        let different_final_offsets = unified_graph_key(6, 2, prefill_launch, &prefill_final);
313        let same_grid_other_offsets = unified_graph_key(6, 2, prefill_launch, &[(0, 2), (1, 5)]);
314        assert_ne!(different_final_offsets, same_grid_other_offsets);
315
316        let no_sample = unified_graph_key(6, 2, prefill_launch, &[]);
317        assert_ne!(different_final_offsets, no_sample);
318    }
319
320    #[test]
321    fn graph_key_distinguishes_capture_scope() {
322        let launch_short = unified_attention_launch_key(6, 2, 128, None);
323        let launch_long = unified_attention_launch_key(6, 2, 640, None);
324        let full_no_sample = unified_graph_key(6, 2, launch_short, &[]);
325        let layers_only = unified_layers_only_graph_key(6, 2, launch_short);
326        let lm_head_eager = unified_lm_head_eager_graph_key(6, 2, launch_short, &[(0, 4), (1, 5)]);
327        assert_ne!(full_no_sample, layers_only);
328        assert_ne!(full_no_sample, lm_head_eager);
329        assert_ne!(layers_only, lm_head_eager);
330        assert_ne!(
331            layers_only,
332            unified_layers_only_graph_key(6, 2, launch_long)
333        );
334        assert_ne!(
335            lm_head_eager,
336            unified_lm_head_eager_graph_key(6, 2, launch_long, &[(0, 4), (1, 5)])
337        );
338    }
339
340    #[test]
341    fn stack_block_tables_pads_and_truncates() {
342        let items = vec![item("a", 1, 0, true), item("b", 1, 0, true)];
343        // Item a has 2 blocks; b has 5 but max_blocks_per_seq=3
344        let stacked = stack_block_tables(&items, 3, |cid| match cid {
345            "a" => vec![10u32, 11u32],
346            "b" => vec![20u32, 21u32, 22u32, 23u32, 24u32],
347            _ => unreachable!(),
348        });
349        // a: [10, 11, 0]  (padded with 0)
350        // b: [20, 21, 22] (truncated to 3)
351        assert_eq!(stacked, vec![10, 11, 0, 20, 21, 22]);
352    }
353
354    #[test]
355    fn empty_items() {
356        let items: Vec<(String, Vec<u32>, usize, bool)> = Vec::new();
357        let (q_lens, cu, m_total) = compute_cu_seqlens_q(&items);
358        assert!(q_lens.is_empty());
359        assert_eq!(cu, vec![0]);
360        assert_eq!(m_total, 0);
361    }
362}