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}