Skip to main content

rlx_runtime/
paged_kv.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Paged KV cache + continuous batching (plan #31).
6//!
7//! Borrowed from MAX's `serve/scheduler/{prefill_scheduler,
8//! decode_scheduler, text_generation_scheduler, batch_constructor/}`.
9//! The standard LLM-serving arch:
10//!
11//!   - KV cache lives in fixed-size **pages** (block of N tokens
12//!     per page per layer), not contiguous per-sequence buffers.
13//!     A sequence is a list of page IDs; reaching the end of a
14//!     page allocates the next from a pool.
15//!   - **Continuous batching**: prefill chunks of new sequences
16//!     pack into the same forward pass as decode steps of
17//!     in-flight sequences. The batch constructor decides which
18//!     work goes into the next forward.
19//!
20//! Throughput vs. naive max-padding: 5-10× higher at the same
21//! latency budget, mostly because GPU utilization stays high
22//! when sequences finish at different times.
23//!
24//! This module is the **data layer** — pool allocation, the
25//! sequence-to-page mapping, and the batch packing logic. Kernel
26//! integration (gather KV bytes from pages into the attention
27//! input) is per-attention-kernel work that lands when an
28//! autoregressive LLM model runners.
29
30use std::collections::{BTreeSet, VecDeque};
31
32/// Opaque physical-page identifier from a [`KvPagePool`].
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
34pub struct KvPageId(pub u32);
35
36/// Fixed-size KV-cache page descriptor. Owns a contiguous range
37/// of byte offsets into a backing arena (managed externally).
38#[derive(Debug, Clone, Copy)]
39pub struct KvPageDesc {
40    /// Byte offset into the KV arena for this page.
41    pub offset: usize,
42    /// Bytes per page = `tokens_per_page * num_layers * 2 (k+v) * num_heads * head_dim * dtype_bytes`.
43    pub bytes: usize,
44    /// How many tokens this page holds in its leading slots.
45    /// Used during prefill where a page may be partial.
46    pub filled: u16,
47}
48
49/// Pool of fixed-size physical pages. Allocates from a free list;
50/// `free` returns a page so the next allocation can reuse it.
51///
52/// Pool capacity is `num_pages` * `bytes_per_page`. Caller owns the
53/// underlying byte arena (typically a single large MTLBuffer or
54/// host Vec); the pool tracks which IDs are free.
55pub struct KvPagePool {
56    /// Sorted set of free page IDs.
57    free: BTreeSet<u32>,
58    /// Per-page metadata. `descs[i].offset = i * bytes_per_page`.
59    descs: Vec<KvPageDesc>,
60    /// Constants exposed for ergonomics.
61    pub bytes_per_page: usize,
62    pub tokens_per_page: u16,
63}
64
65impl KvPagePool {
66    pub fn new(num_pages: u32, bytes_per_page: usize, tokens_per_page: u16) -> Self {
67        let descs: Vec<KvPageDesc> = (0..num_pages)
68            .map(|i| KvPageDesc {
69                offset: (i as usize) * bytes_per_page,
70                bytes: bytes_per_page,
71                filled: 0,
72            })
73            .collect();
74        let free: BTreeSet<u32> = (0..num_pages).collect();
75        Self {
76            free,
77            descs,
78            bytes_per_page,
79            tokens_per_page,
80        }
81    }
82
83    pub fn capacity(&self) -> u32 {
84        self.descs.len() as u32
85    }
86    pub fn free_count(&self) -> u32 {
87        self.free.len() as u32
88    }
89    pub fn used_count(&self) -> u32 {
90        self.capacity() - self.free_count()
91    }
92
93    /// Allocate one page. Returns `None` when the pool is empty.
94    pub fn alloc(&mut self) -> Option<KvPageId> {
95        let id = *self.free.iter().next()?;
96        self.free.remove(&id);
97        // Reset filled count on alloc — caller starts fresh.
98        self.descs[id as usize].filled = 0;
99        Some(KvPageId(id))
100    }
101
102    pub fn free(&mut self, id: KvPageId) {
103        self.free.insert(id.0);
104    }
105
106    pub fn descriptor(&self, id: KvPageId) -> &KvPageDesc {
107        &self.descs[id.0 as usize]
108    }
109
110    pub fn descriptor_mut(&mut self, id: KvPageId) -> &mut KvPageDesc {
111        &mut self.descs[id.0 as usize]
112    }
113}
114
115/// Per-sequence map of logical-token-position → physical page.
116/// Token `t` lives at `pages[t / tokens_per_page]` slot
117/// `t % tokens_per_page`.
118#[derive(Debug, Clone, Default)]
119pub struct KvBlockTable {
120    pages: Vec<KvPageId>,
121    /// Number of tokens this sequence currently has cached.
122    pub seq_len: u32,
123}
124
125impl KvBlockTable {
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    /// Append a new page from the pool. Used when seq_len mod
131    /// tokens_per_page == 0 (boundary).
132    pub fn push_page(&mut self, page: KvPageId) {
133        self.pages.push(page);
134    }
135
136    /// Look up which page holds token `t`. Returns `None` if `t`
137    /// is past the cached region.
138    pub fn page_for_token(&self, t: u32, tokens_per_page: u16) -> Option<KvPageId> {
139        let idx = (t / tokens_per_page as u32) as usize;
140        self.pages.get(idx).copied()
141    }
142
143    pub fn page_count(&self) -> usize {
144        self.pages.len()
145    }
146
147    /// Free every page back to the pool. Called when a sequence
148    /// finishes / is evicted.
149    pub fn release(&mut self, pool: &mut KvPagePool) {
150        for p in self.pages.drain(..) {
151            pool.free(p);
152        }
153        self.seq_len = 0;
154    }
155
156    /// Slice of page IDs. Useful for kernel-side gather.
157    pub fn pages(&self) -> &[KvPageId] {
158        &self.pages
159    }
160}
161
162/// One slot in a continuous batch — either a decode step
163/// (single new token from a sequence with prior cache) or a
164/// prefill chunk (multiple new tokens for a fresh sequence).
165#[derive(Debug, Clone)]
166pub struct BatchEntry {
167    pub seq_id: u64,
168    pub kind: BatchKind,
169    /// Tokens to feed in this forward pass.
170    pub input_tokens: Vec<u32>,
171    /// Pre-existing KV-cache length (number of cached tokens before this batch).
172    pub cached_len: u32,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum BatchKind {
177    /// Prefill (or prefill chunk): N new tokens for a sequence
178    /// with `cached_len` already prefilled.
179    Prefill,
180    /// Decode: one token sampled from the previous step.
181    Decode,
182}
183
184/// Constructs a continuous batch: pulls pending decode requests
185/// first (cheap, one-token forwards), then fills remaining
186/// **token budget** with prefill chunks. The token budget — not
187/// the sequence count — is the gating constraint because
188/// prefills can be arbitrarily long.
189pub struct BatchConstructor {
190    /// Maximum tokens per forward across all entries.
191    pub max_tokens_per_batch: usize,
192    /// Maximum entries per forward (also bounds memory/scheduler
193    /// overhead).
194    pub max_entries: usize,
195}
196
197impl BatchConstructor {
198    pub fn new(max_tokens_per_batch: usize, max_entries: usize) -> Self {
199        Self {
200            max_tokens_per_batch,
201            max_entries,
202        }
203    }
204
205    /// Build the next batch. Walks `decode_queue` first (one
206    /// token each, cheap), then fills remaining token budget by
207    /// chunking from `prefill_queue`. Sequences that didn't fit
208    /// stay in their queues for the next call.
209    pub fn build(
210        &self,
211        decode_queue: &mut VecDeque<BatchEntry>,
212        prefill_queue: &mut VecDeque<BatchEntry>,
213    ) -> Vec<BatchEntry> {
214        let mut batch: Vec<BatchEntry> = Vec::new();
215        let mut tokens_used = 0usize;
216
217        while batch.len() < self.max_entries {
218            if let Some(d) = decode_queue.front() {
219                let need = d.input_tokens.len();
220                if tokens_used + need > self.max_tokens_per_batch {
221                    break;
222                }
223                batch.push(decode_queue.pop_front().unwrap());
224                tokens_used += need;
225            } else {
226                break;
227            }
228        }
229
230        while batch.len() < self.max_entries {
231            let want = match prefill_queue.front() {
232                Some(p) => p.input_tokens.len(),
233                None => break,
234            };
235            let remaining = self.max_tokens_per_batch.saturating_sub(tokens_used);
236            if remaining == 0 {
237                break;
238            }
239
240            if want <= remaining {
241                batch.push(prefill_queue.pop_front().unwrap());
242                tokens_used += want;
243            } else {
244                // Chunk: take `remaining` tokens off the front,
245                // leave the rest for the next batch.
246                let mut p = prefill_queue.pop_front().unwrap();
247                let chunk: Vec<u32> = p.input_tokens.drain(..remaining).collect();
248                let chunk_entry = BatchEntry {
249                    seq_id: p.seq_id,
250                    kind: BatchKind::Prefill,
251                    input_tokens: chunk,
252                    cached_len: p.cached_len,
253                };
254                p.cached_len += remaining as u32;
255                batch.push(chunk_entry);
256                prefill_queue.push_front(p);
257                break;
258            }
259        }
260
261        batch
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn pool_alloc_free_round_trip() {
271        let mut pool = KvPagePool::new(4, 1024, 16);
272        assert_eq!(pool.free_count(), 4);
273        let p1 = pool.alloc().unwrap();
274        let p2 = pool.alloc().unwrap();
275        assert_eq!(pool.free_count(), 2);
276        pool.free(p1);
277        pool.free(p2);
278        assert_eq!(pool.free_count(), 4);
279    }
280
281    #[test]
282    fn pool_returns_none_when_exhausted() {
283        let mut pool = KvPagePool::new(2, 64, 4);
284        let _a = pool.alloc().unwrap();
285        let _b = pool.alloc().unwrap();
286        assert!(pool.alloc().is_none());
287    }
288
289    #[test]
290    fn pool_descriptor_offsets_are_unique_and_aligned() {
291        let pool = KvPagePool::new(4, 256, 16);
292        for i in 0..4u32 {
293            let d = pool.descriptor(KvPageId(i));
294            assert_eq!(d.offset, i as usize * 256);
295            assert_eq!(d.bytes, 256);
296        }
297    }
298
299    #[test]
300    fn block_table_page_for_token() {
301        let mut pool = KvPagePool::new(8, 64, 4);
302        let mut bt = KvBlockTable::new();
303        for _ in 0..3 {
304            bt.push_page(pool.alloc().unwrap());
305        }
306        // tokens_per_page = 4 → tokens 0..4 in page 0, 4..8 in page 1, ...
307        assert_eq!(bt.page_for_token(0, 4), Some(bt.pages()[0]));
308        assert_eq!(bt.page_for_token(7, 4), Some(bt.pages()[1]));
309        assert_eq!(bt.page_for_token(11, 4), Some(bt.pages()[2]));
310        assert_eq!(bt.page_for_token(12, 4), None);
311    }
312
313    #[test]
314    fn block_table_release_returns_pages() {
315        let mut pool = KvPagePool::new(8, 64, 4);
316        let mut bt = KvBlockTable::new();
317        for _ in 0..3 {
318            bt.push_page(pool.alloc().unwrap());
319        }
320        assert_eq!(pool.free_count(), 5);
321        bt.release(&mut pool);
322        assert_eq!(pool.free_count(), 8);
323        assert_eq!(bt.page_count(), 0);
324    }
325
326    #[test]
327    fn batch_constructor_decodes_first_then_prefill() {
328        let bc = BatchConstructor::new(8, 16);
329        let mut decodes: VecDeque<BatchEntry> = (0..3)
330            .map(|i| BatchEntry {
331                seq_id: i,
332                kind: BatchKind::Decode,
333                input_tokens: vec![100 + i as u32],
334                cached_len: 50,
335            })
336            .collect();
337        let mut prefills: VecDeque<BatchEntry> = (0..2)
338            .map(|i| BatchEntry {
339                seq_id: 100 + i,
340                kind: BatchKind::Prefill,
341                input_tokens: vec![1; 3],
342                cached_len: 0,
343            })
344            .collect();
345
346        let batch = bc.build(&mut decodes, &mut prefills);
347        // Three decodes (3 tokens) + first prefill (3 tokens) =
348        // 6 tokens, fits in budget 8. Second prefill chunks (2
349        // tokens of 3) into the remaining 2 slots; the rest
350        // stays for the next call.
351        assert_eq!(batch.len(), 5);
352        // First three are decodes.
353        for entry in batch.iter().take(3) {
354            assert_eq!(entry.kind, BatchKind::Decode);
355        }
356        for entry in batch.iter().skip(3).take(2) {
357            assert_eq!(entry.kind, BatchKind::Prefill);
358        }
359        let total_tokens: usize = batch.iter().map(|e| e.input_tokens.len()).sum();
360        assert_eq!(total_tokens, 8);
361
362        // The leftover prefill should still be queued (1 token left).
363        assert_eq!(prefills.len(), 1);
364        assert_eq!(prefills[0].input_tokens.len(), 1);
365        assert_eq!(prefills[0].cached_len, 2);
366    }
367
368    #[test]
369    fn batch_constructor_respects_max_entries() {
370        let bc = BatchConstructor::new(1024, 2); // very generous tokens, only 2 entries
371        let mut decodes: VecDeque<BatchEntry> = (0..5)
372            .map(|i| BatchEntry {
373                seq_id: i,
374                kind: BatchKind::Decode,
375                input_tokens: vec![1],
376                cached_len: 0,
377            })
378            .collect();
379        let mut prefills: VecDeque<BatchEntry> = VecDeque::new();
380        let batch = bc.build(&mut decodes, &mut prefills);
381        assert_eq!(batch.len(), 2);
382        assert_eq!(decodes.len(), 3);
383    }
384}