Skip to main content

needle_core/
attn.rs

1//! Multi-head attention with GQA (Grouped Query Attention) and KV cache.
2//!
3//! Python model: 8 Q heads, 4 KV heads → kv_repeat = 2.
4//! Each KV head serves 2 Q heads (broadcast, not repeat-copy in kernel).
5//!
6//! Projections per head:
7//!   Q: [d_model, num_heads * head_dim]
8//!   K: [d_model, num_kv_heads * head_dim]
9//!   V: [d_model, num_kv_heads * head_dim]
10//!   O: [num_heads * head_dim, d_model]
11//!
12//! Q and K are ZCRMSNorm'd before RoPE (matches Python MultiHeadAttention).
13
14use crate::math;
15use crate::norm::zc_rms_norm_vec;
16use crate::ops::{dot, softmax_inplace};
17use crate::quant::QuantizedWeight;
18use crate::rope::RopeCache;
19use alloc::vec;
20use alloc::vec::Vec;
21
22pub struct KvCache {
23    /// Keys:   [max_len, num_kv_heads, head_dim]
24    pub k: Vec<f32>,
25    /// Values: [max_len, num_kv_heads, head_dim]
26    pub v: Vec<f32>,
27    pub len: usize,
28    pub max_len: usize,
29    pub num_kv_heads: usize,
30    pub head_dim: usize,
31}
32
33impl KvCache {
34    pub fn new(max_len: usize, num_kv_heads: usize, head_dim: usize) -> Self {
35        Self {
36            k: vec![0.0; max_len * num_kv_heads * head_dim],
37            v: vec![0.0; max_len * num_kv_heads * head_dim],
38            len: 0,
39            max_len,
40            num_kv_heads,
41            head_dim,
42        }
43    }
44
45    pub fn reset(&mut self) {
46        self.len = 0;
47    }
48
49    fn kv_stride(&self) -> usize {
50        self.num_kv_heads * self.head_dim
51    }
52
53    pub fn push_kv(&mut self, k_vec: &[f32], v_vec: &[f32]) {
54        if self.len >= self.max_len {
55            #[cfg(feature = "std")]
56            eprintln!(
57                "[needle-core] KV cache full at {} steps — output will degrade",
58                self.max_len
59            );
60            return;
61        }
62        let stride = self.kv_stride();
63        let base = self.len * stride;
64        self.k[base..base + stride].copy_from_slice(k_vec);
65        self.v[base..base + stride].copy_from_slice(v_vec);
66        self.len += 1;
67    }
68}
69
70/// Weights for a single MHA layer (shared between encoder self-attn, decoder self-attn, cross-attn).
71pub struct AttnWeights {
72    pub wq: QuantizedWeight, // [d_model, num_heads * head_dim]
73    pub wk: QuantizedWeight, // [d_model, num_kv_heads * head_dim]
74    pub wv: QuantizedWeight, // [d_model, num_kv_heads * head_dim]
75    pub wo: QuantizedWeight, // [num_heads * head_dim, d_model]
76    pub q_norm: Vec<f32>,    // ZCRMSNorm scale [head_dim]
77    pub k_norm: Vec<f32>,    // ZCRMSNorm scale [head_dim]
78}
79
80pub struct AttnConfig {
81    pub num_heads: usize,
82    pub num_kv_heads: usize,
83    pub head_dim: usize,
84    pub d_model: usize,
85}
86
87impl AttnConfig {
88    pub fn kv_repeat(&self) -> usize {
89        self.num_heads / self.num_kv_heads
90    }
91}
92
93/// Self-attention forward pass for a single token (incremental decode with KV cache).
94///
95/// `x`:      input vector [d_model] — modified in-place to accumulate output
96/// `out`:    output buffer [d_model]
97/// `attn_mask`: optional additive mask; None = full causal (lower-triangular)
98/// `cache`:  KV cache; len tokens already stored, this call appends position `cache.len`
99#[allow(clippy::needless_range_loop)]
100pub fn self_attn_incremental(
101    x: &[f32],
102    out: &mut Vec<f32>,
103    weights: &AttnWeights,
104    cfg: &AttnConfig,
105    rope: &RopeCache,
106    cache: &mut KvCache,
107    attn_bias: Option<&[f32]>, // additive bias [cache.len+1]
108) {
109    let d = cfg.d_model;
110    let h = cfg.num_heads;
111    let kv_h = cfg.num_kv_heads;
112    let hd = cfg.head_dim;
113    let kv_dim = kv_h * hd;
114    let q_dim = h * hd;
115
116    // Project Q, K, V
117    let mut q = vec![0.0f32; q_dim];
118    let mut k = vec![0.0f32; kv_dim];
119    let mut v = vec![0.0f32; kv_dim];
120
121    weights.wq.matvec(x, &mut q);
122    weights.wk.matvec(x, &mut k);
123    weights.wv.matvec(x, &mut v);
124
125    // ZCRMSNorm per head on Q and K
126    for hi in 0..h {
127        zc_rms_norm_vec(&mut q[hi * hd..(hi + 1) * hd], &weights.q_norm);
128    }
129    for ki in 0..kv_h {
130        zc_rms_norm_vec(&mut k[ki * hd..(ki + 1) * hd], &weights.k_norm);
131    }
132
133    // RoPE on Q and K (single token, offset = cache.len)
134    rope.apply(&mut q, 1, h, hd, cache.len);
135    rope.apply(&mut k, 1, kv_h, hd, cache.len);
136
137    // Append to KV cache
138    cache.push_kv(&k, &v);
139    let ctx_len = cache.len; // now includes current token
140
141    // Scaled dot-product attention, one head at a time
142    let scale = math::powf(hd as f32, -0.5);
143    let mut attn_scores = vec![0.0f32; ctx_len];
144    let kv_stride = kv_h * hd;
145
146    out.clear();
147    out.resize(d, 0.0);
148    let mut head_out = vec![0.0f32; d]; // accumulate O-proj input
149
150    for hi in 0..h {
151        let kv_hi = hi / cfg.kv_repeat(); // GQA: which KV head serves this Q head
152        let q_head = &q[hi * hd..(hi + 1) * hd];
153
154        // Compute attention scores for all cached positions
155        for t in 0..ctx_len {
156            let k_head = &cache.k[t * kv_stride + kv_hi * hd..t * kv_stride + (kv_hi + 1) * hd];
157            let mut score = dot(q_head, k_head) * scale;
158            if let Some(bias) = attn_bias {
159                score += bias[t];
160            }
161            attn_scores[t] = score;
162        }
163
164        // Causal mask: last token attends to [0..ctx_len], which is exactly what's cached — no masking needed
165        softmax_inplace(&mut attn_scores[..ctx_len]);
166
167        // Weighted sum of V
168        let out_head = &mut head_out[hi * hd..(hi + 1) * hd];
169        for v_el in out_head.iter_mut() {
170            *v_el = 0.0;
171        }
172        for t in 0..ctx_len {
173            let v_head = &cache.v[t * kv_stride + kv_hi * hd..t * kv_stride + (kv_hi + 1) * hd];
174            let a = attn_scores[t];
175            for (o, &vi) in out_head.iter_mut().zip(v_head.iter()) {
176                *o += a * vi;
177            }
178        }
179    }
180
181    // Output projection: out = head_out @ Wo
182    weights.wo.matvec(&head_out, out);
183}
184
185/// Full-sequence self-attention (encoder, no KV cache, no causal mask by default).
186/// `x`:     [seq_len, d_model] input, modified in-place
187/// `out`:   [seq_len, d_model] output
188/// `mask`:  optional additive bias [seq_len, seq_len]  (−inf for masked positions)
189pub fn self_attn_full(
190    x: &[f32],
191    out: &mut [f32],
192    weights: &AttnWeights,
193    cfg: &AttnConfig,
194    rope: &RopeCache,
195    seq_len: usize,
196    mask: Option<&[f32]>,
197) {
198    let d = cfg.d_model;
199    let h = cfg.num_heads;
200    let kv_h = cfg.num_kv_heads;
201    let hd = cfg.head_dim;
202    let q_dim = h * hd;
203    let kv_dim = kv_h * hd;
204
205    debug_assert_eq!(x.len(), seq_len * d);
206    debug_assert_eq!(out.len(), seq_len * d);
207
208    let mut q = vec![0.0f32; seq_len * q_dim];
209    let mut k = vec![0.0f32; seq_len * kv_dim];
210    let mut v = vec![0.0f32; seq_len * kv_dim];
211
212    // Project all tokens
213    weights.wq.matmul(x, seq_len, &mut q);
214    weights.wk.matmul(x, seq_len, &mut k);
215    weights.wv.matmul(x, seq_len, &mut v);
216
217    // ZCRMSNorm per head on Q and K
218    for t in 0..seq_len {
219        for hi in 0..h {
220            zc_rms_norm_vec(
221                &mut q[(t * h + hi) * hd..(t * h + hi + 1) * hd],
222                &weights.q_norm,
223            );
224        }
225        for ki in 0..kv_h {
226            zc_rms_norm_vec(
227                &mut k[(t * kv_h + ki) * hd..(t * kv_h + ki + 1) * hd],
228                &weights.k_norm,
229            );
230        }
231    }
232
233    // RoPE (no offset for encoder)
234    rope.apply(&mut q, seq_len, h, hd, 0);
235    rope.apply(&mut k, seq_len, kv_h, hd, 0);
236
237    let scale = math::powf(hd as f32, -0.5);
238    let mut head_out = vec![0.0f32; seq_len * q_dim];
239    let mut scores = vec![0.0f32; seq_len];
240
241    for t in 0..seq_len {
242        for hi in 0..h {
243            let kv_hi = hi / cfg.kv_repeat();
244            let q_head = &q[(t * h + hi) * hd..(t * h + hi + 1) * hd];
245
246            for s in 0..seq_len {
247                let k_head = &k[(s * kv_h + kv_hi) * hd..(s * kv_h + kv_hi + 1) * hd];
248                let mut score = dot(q_head, k_head) * scale;
249                if let Some(m) = mask {
250                    score += m[t * seq_len + s];
251                }
252                scores[s] = score;
253            }
254
255            softmax_inplace(&mut scores[..seq_len]);
256
257            let out_head = &mut head_out[(t * h + hi) * hd..(t * h + hi + 1) * hd];
258            for o in out_head.iter_mut() {
259                *o = 0.0;
260            }
261            for s in 0..seq_len {
262                let v_head = &v[(s * kv_h + kv_hi) * hd..(s * kv_h + kv_hi + 1) * hd];
263                let a = scores[s];
264                for (o, &vi) in out_head.iter_mut().zip(v_head.iter()) {
265                    *o += a * vi;
266                }
267            }
268        }
269    }
270
271    // O projection
272    weights.wo.matmul(&head_out, seq_len, out);
273}
274
275/// Cross-attention: queries from decoder, keys/values from encoder memory.
276/// `q_in`:   [1, d_model] decoder query (single step)
277/// `mem`:    [enc_len, d_model] encoder output
278/// `out`:    [d_model] output vector
279/// Uses precomputed encoder K/V (stored in a separate KvCache filled from encoder run).
280#[allow(clippy::needless_range_loop)]
281pub fn cross_attn_incremental(
282    q_in: &[f32],
283    out: &mut Vec<f32>,
284    weights: &AttnWeights,
285    cfg: &AttnConfig,
286    enc_kv: &KvCache, // precomputed cross-attn K/V from encoder hidden states; no RoPE (Python: rope=None)
287) {
288    let d = cfg.d_model;
289    let h = cfg.num_heads;
290    let kv_h = cfg.num_kv_heads;
291    let hd = cfg.head_dim;
292    let q_dim = h * hd;
293    let ctx_len = enc_kv.len;
294
295    let mut q = vec![0.0f32; q_dim];
296    weights.wq.matvec(q_in, &mut q);
297
298    for hi in 0..h {
299        zc_rms_norm_vec(&mut q[hi * hd..(hi + 1) * hd], &weights.q_norm);
300    }
301
302    // Python passes rope=None for cross-attention, so neither Q nor K are RoPE'd here.
303    // Encoder K already has RoPE applied from the encoder forward pass.
304
305    let scale = math::powf(hd as f32, -0.5);
306    let kv_stride = kv_h * hd;
307    let mut scores = vec![0.0f32; ctx_len];
308    let mut head_out = vec![0.0f32; q_dim];
309
310    for hi in 0..h {
311        let kv_hi = hi / cfg.kv_repeat();
312        let q_head = &q[hi * hd..(hi + 1) * hd];
313
314        for t in 0..ctx_len {
315            let k_head = &enc_kv.k[t * kv_stride + kv_hi * hd..t * kv_stride + (kv_hi + 1) * hd];
316            scores[t] = dot(q_head, k_head) * scale;
317        }
318
319        softmax_inplace(&mut scores[..ctx_len]);
320
321        let out_head = &mut head_out[hi * hd..(hi + 1) * hd];
322        for o in out_head.iter_mut() {
323            *o = 0.0;
324        }
325        for t in 0..ctx_len {
326            let v_head = &enc_kv.v[t * kv_stride + kv_hi * hd..t * kv_stride + (kv_hi + 1) * hd];
327            let a = scores[t];
328            for (o, &vi) in out_head.iter_mut().zip(v_head.iter()) {
329                *o += a * vi;
330            }
331        }
332    }
333
334    out.clear();
335    out.resize(d, 0.0);
336    weights.wo.matvec(&head_out, out);
337}