Skip to main content

frink_core/
recurrent_state.rs

1//! What a layer with no KV history carries between tokens instead.
2//!
3//! A Mamba layer (and every other recurrent block llama.cpp keeps in
4//! `llama_memory_recurrent`) has no per-position rows to attend over;
5//! it has a fixed-size state that the next token reads and overwrites.
6//! LFM2's short convolution is the exception that proves the rule: its
7//! state IS the last `l_cache - 1` inputs, so `frink_models::shortconv`
8//! keeps it as the layer's KV history and needs nothing here. A Mamba
9//! state is a reduction over the whole prefix, not a window of it, and
10//! that is the one property every consumer of a per-layer cache has to
11//! know about:
12//!
13//! - it CLONES with the cache (a prefix-cache fork is a fork of the
14//!   state), and CLEARS with it;
15//! - it cannot be TRUNCATED to a middle position. llama.cpp's
16//!   `llama_memory_recurrent::seq_rm` refuses a `p0 > 0` for the same
17//!   reason and its server re-prefills. So [`KvCache::truncate`] on a
18//!   cache that holds one refuses anything but "to zero" or "to where
19//!   it is", and the callers that roll back -- the prefix cache,
20//!   speculative verification, the draft model, the whole-response
21//!   cache's back-off -- ask [`KvCache::can_truncate_to`] first or are
22//!   fenced off the model.
23//!
24//! The buffers are flat and the LAYER owns their geometry (its weights
25//! say what `d_conv`, the conv width and the scan dims are), so this
26//! type cannot disagree with the block about a shape: it is created by
27//! the block, on first use, at the size the block asks for.
28//!
29//! [`KvCache::truncate`]: crate::cache::KvCache::truncate
30//! [`KvCache::can_truncate_to`]: crate::cache::KvCache::can_truncate_to
31
32/// One sequence's state for one recurrent layer.
33#[derive(Debug, Clone, PartialEq)]
34pub struct RecurrentState {
35    /// The conv window, `[d_conv - 1][width]`, oldest row first
36    /// (`llama_hparams::n_embd_r`).
37    ///
38    /// Page-aligned for the same reason `ssm` is, and the reason is
39    /// measured: a fused recurrent branch that UPLOADED this window and
40    /// read it back cost 123 KB of copy per layer per token on Bonsai,
41    /// 11.8 MB a token, which was most of why the first version of that
42    /// launch ran slower than the host body it replaced.
43    pub conv: AlignedF32,
44    /// The SSM state, `[n_head][head_dim][d_state]`
45    /// (`llama_hparams::n_embd_s`).
46    ///
47    /// Page-aligned ([`AlignedF32`]) so a Metal kernel can read and
48    /// write these very bytes instead of a copy of them; it derefs to
49    /// `[f32]`, so a reader sees no difference.
50    pub ssm: AlignedF32,
51}
52
53impl RecurrentState {
54    /// A fresh sequence's state: zeros, as `build_rs` zeroes a new
55    /// sequence's (`llama-graph.cpp`, `llm_graph_input_rs`).
56    pub fn zeros(conv_len: usize, ssm_len: usize) -> Self {
57        Self {
58            conv: AlignedF32::zeros(conv_len),
59            ssm: AlignedF32::zeros(ssm_len),
60        }
61    }
62
63    /// Bytes this state holds.
64    pub fn bytes(&self) -> usize {
65        (self.conv.len() + self.ssm.len()) * std::mem::size_of::<f32>()
66    }
67}
68
69/// A page-aligned `f32` buffer.
70///
71/// Apple Silicon's GPU shares the CPU's memory, and Metal will wrap a
72/// host allocation as a buffer WITHOUT copying it
73/// (`newBufferWithBytesNoCopy`) when the pointer and the length are
74/// page-aligned. A recurrent state is the one buffer where that matters:
75/// Bonsai-2-27B's is 3.1 MB per layer, so a kernel that reads it by
76/// UPLOADING it and writes it back by downloading costs 300 MB of copies
77/// per token, which measured slower than leaving the recurrence on the
78/// host (`docs/plans/gdn-resident-state.md`). Aligned, the same kernel
79/// reads and writes the host's own bytes.
80///
81/// It derefs to `[f32]`, so every existing reader keeps working.
82pub struct AlignedF32 {
83    ptr: std::ptr::NonNull<f32>,
84    len: usize,
85    /// The allocation's byte length, which is `len * 4` rounded up to a
86    /// page: Metal requires the LENGTH to be page-aligned too, and
87    /// `dealloc` must be handed the layout `alloc` got.
88    bytes: usize,
89}
90
91/// 16 KiB on Apple Silicon, 4 KiB elsewhere; over-aligning is never
92/// wrong, so the larger value is used on every target rather than
93/// guessed per platform (`crate::weight_matrix` makes the same choice
94/// for mmap-backed weights).
95pub const PAGE: usize = 16384;
96
97// SAFETY: the allocation is owned exclusively by this value and holds
98// plain `f32`, so moving it between threads and sharing `&` are both
99// sound. A Metal buffer wrapping it is created and consumed inside one
100// call under `&mut`, which is what keeps the GPU's view exclusive.
101unsafe impl Send for AlignedF32 {}
102unsafe impl Sync for AlignedF32 {}
103
104impl AlignedF32 {
105    /// `len` zeroed floats, page-aligned, with the allocation rounded
106    /// up to a whole page.
107    pub fn zeros(len: usize) -> Self {
108        let bytes = (len * std::mem::size_of::<f32>()).max(1).div_ceil(PAGE) * PAGE;
109        let layout = std::alloc::Layout::from_size_align(bytes, PAGE).expect("page layout");
110        // SAFETY: a non-zero layout; the pointer is checked below and
111        // freed in `Drop` with the same layout.
112        let raw = unsafe { std::alloc::alloc_zeroed(layout) } as *mut f32;
113        let ptr = std::ptr::NonNull::new(raw).expect("allocation failed");
114        Self { ptr, len, bytes }
115    }
116
117    /// The whole allocation's byte length, page-aligned: what
118    /// `newBufferWithBytesNoCopy` must be given.
119    pub fn alloc_bytes(&self) -> usize {
120        self.bytes
121    }
122
123    /// The allocation's base pointer, page-aligned.
124    pub fn as_ptr(&self) -> *mut f32 {
125        self.ptr.as_ptr()
126    }
127}
128
129impl Drop for AlignedF32 {
130    fn drop(&mut self) {
131        let layout = std::alloc::Layout::from_size_align(self.bytes, PAGE)
132            .expect("the layout it was made with");
133        // SAFETY: allocated by `zeros` with this exact layout, and this
134        // is the only owner.
135        unsafe { std::alloc::dealloc(self.ptr.as_ptr() as *mut u8, layout) };
136    }
137}
138
139impl std::ops::Deref for AlignedF32 {
140    type Target = [f32];
141    fn deref(&self) -> &[f32] {
142        // SAFETY: `len` floats were allocated and zeroed by `zeros`.
143        unsafe { std::slice::from_raw_parts(self.ptr.as_ptr(), self.len) }
144    }
145}
146
147impl std::ops::DerefMut for AlignedF32 {
148    fn deref_mut(&mut self) -> &mut [f32] {
149        // SAFETY: as `deref`, with the exclusive borrow this takes.
150        unsafe { std::slice::from_raw_parts_mut(self.ptr.as_ptr(), self.len) }
151    }
152}
153
154impl Clone for AlignedF32 {
155    fn clone(&self) -> Self {
156        let mut copy = Self::zeros(self.len);
157        copy.copy_from_slice(self);
158        copy
159    }
160}
161
162impl std::fmt::Debug for AlignedF32 {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        write!(f, "AlignedF32({} floats)", self.len)
165    }
166}
167
168impl PartialEq for AlignedF32 {
169    fn eq(&self, other: &Self) -> bool {
170        **self == **other
171    }
172}
173
174impl FromIterator<f32> for AlignedF32 {
175    fn from_iter<I: IntoIterator<Item = f32>>(iter: I) -> Self {
176        let v: Vec<f32> = iter.into_iter().collect();
177        let mut out = Self::zeros(v.len());
178        out.copy_from_slice(&v);
179        out
180    }
181}
182
183#[cfg(test)]
184mod aligned_tests {
185    use super::*;
186
187    #[test]
188    fn an_aligned_buffer_is_page_aligned_in_pointer_and_length() {
189        for len in [1usize, 1024, 48 * 128 * 128] {
190            let b = AlignedF32::zeros(len);
191            assert_eq!(b.as_ptr() as usize % PAGE, 0, "pointer");
192            assert_eq!(b.alloc_bytes() % PAGE, 0, "length");
193            assert!(b.alloc_bytes() >= len * 4);
194            assert_eq!(b.len(), len);
195            assert!(b.iter().all(|v| *v == 0.0), "zeroed");
196        }
197    }
198
199    #[test]
200    fn it_clones_by_value_and_compares_by_contents() {
201        let mut a = AlignedF32::zeros(8);
202        a[3] = 1.5;
203        let b = a.clone();
204        assert_eq!(a, b);
205        assert_eq!(b[3], 1.5);
206    }
207}