Skip to main content

ferritin_plms/esmc/layers/
attention.rs

1use crate::esmc::layers::rotary::RotaryEmbedding;
2use crate::esmc::models::esmc::ESMCConfig;
3use candle_core::{Module, Result, Tensor};
4use candle_nn::{self as nn, LayerNorm, LayerNormConfig, VarBuilder};
5// use scaled_dot_product_attention;
6
7pub struct MultiHeadAttention {
8    d_model: usize,
9    n_heads: usize,
10    d_head: usize,
11    layernorm_qkv: nn::Sequential,
12    out_proj: nn::Linear,
13    q_ln: Box<dyn Module>,
14    k_ln: Box<dyn Module>,
15    rotary: RotaryEmbedding,
16}
17
18impl MultiHeadAttention {
19    // pub fn new(d_model: usize, n_heads: usize, bias: bool, qk_layernorm: bool) -> Result<Self> {
20    //     let d_head = d_model / n_heads;
21
22    //     let layernorm = nn::LayerNorm::new(d_model)?;
23    //     let linear = nn::linear(d_model, d_model * 3, bias)?;
24    //     let layernorm_qkv = nn::seq().add(layernorm).add(linear);
25
26    //     let out_proj = nn::linear(d_model, d_model, bias)?;
27
28    //     let (q_ln, k_ln): (Box<dyn Module>, Box<dyn Module>) = if qk_layernorm {
29    //         (
30    //             Box::new(nn::LayerNorm::new(d_model)?),
31    //             Box::new(nn::LayerNorm::new(d_model)?),
32    //         )
33    //     } else {
34    //         (Box::new(nn::Identity), Box::new(nn::Identity))
35    //     };
36
37    //     Ok(Self {
38    //         d_model,
39    //         n_heads,
40    //         d_head,
41    //         layernorm_qkv,
42    //         out_proj,
43    //         q_ln,
44    //         k_ln,
45    //         rotary: RotaryEmbedding::new(d_model / n_heads)?,
46    //     })
47    // }
48    pub fn load(vb: VarBuilder, config: &ESMCConfig) -> Result<Self> {
49        let ESMCConfig {
50            d_model, n_heads, ..
51        } = config;
52
53        let d_head = d_model / n_heads;
54
55        // layernorm_qkv.0 has both weight and bias in the checkpoint.
56        let layernorm = nn::layer_norm(
57            *d_model,
58            LayerNormConfig::from(1e-5),
59            vb.pp("layernorm_qkv.0"),
60        )?;
61        let linear = nn::linear_no_bias(*d_model, d_model * 3, vb.pp("layernorm_qkv.1"))?;
62        let layernorm_qkv = nn::seq().add(layernorm).add(linear);
63        let out_proj = nn::linear_no_bias(*d_model, *d_model, vb.pp("out_proj"))?;
64
65        // q_ln / k_ln have weight but no bias in the checkpoint — use new_no_bias.
66        let q_ln: Box<dyn Module> = {
67            let w = vb.pp("q_ln").get((*d_model,), "weight")?;
68            Box::new(LayerNorm::new_no_bias(w, 1e-5))
69        };
70        let k_ln: Box<dyn Module> = {
71            let w = vb.pp("k_ln").get((*d_model,), "weight")?;
72            Box::new(LayerNorm::new_no_bias(w, 1e-5))
73        };
74
75        let rotary = RotaryEmbedding::load(vb.pp("rotary"), config)?;
76
77        Ok(Self {
78            d_model: *d_model,
79            n_heads: *n_heads,
80            d_head,
81            layernorm_qkv,
82            out_proj,
83            q_ln,
84            k_ln,
85            rotary,
86        })
87    }
88
89    pub fn forward(&self, x: &Tensor, sequence_id: Option<&Tensor>) -> Result<Tensor> {
90        let (b, l, _) = x.dims3()?;
91        // QKV projection: (B, L, d_model) → (B, L, 3*d_model) → split
92        let qkv = self.layernorm_qkv.forward(x)?;
93        let chunks = qkv.chunk(3, candle_core::D::Minus1)?;
94        let (q, k, v) = (&chunks[0], &chunks[1], &chunks[2]);
95
96        // Per-head layer norms
97        let q = self.q_ln.forward(q)?;
98        let k = self.k_ln.forward(k)?;
99
100        // Reshape to (B, n_heads, L, d_head) for rotary + SDPA
101        let q = q
102            .reshape((b, l, self.n_heads, self.d_head))?
103            .transpose(1, 2)?;
104        let k = k
105            .reshape((b, l, self.n_heads, self.d_head))?
106            .transpose(1, 2)?;
107        let v = v
108            .reshape((b, l, self.n_heads, self.d_head))?
109            .transpose(1, 2)?;
110
111        // Apply rotary positional embeddings
112        let (q, k) = self.rotary.forward(&q, &k)?;
113
114        // Scaled dot-product attention.
115        // k must be made contiguous after transpose — Metal (and some CPU paths) require
116        // contiguous tensors for batched matmul.
117        let scale = (self.d_head as f64).sqrt().recip();
118        let k_t = k
119            .transpose(candle_core::D::Minus1, candle_core::D::Minus2)?
120            .contiguous()?;
121        let attn = (q.contiguous()?.matmul(&k_t)? * scale)?;
122
123        // Optional key-padding mask from sequence_id (True = real token, False = pad)
124        let attn = if let Some(seq_id) = sequence_id {
125            // seq_id: (B, L) bool-like; build (B, 1, 1, L) mask so padded keys are masked out
126            let mask = seq_id
127                .unsqueeze(1)?
128                .unsqueeze(1)?
129                .broadcast_as(attn.shape())?;
130            let neg_inf = (Tensor::ones_like(&attn)? * f64::NEG_INFINITY)?;
131            mask.where_cond(&attn, &neg_inf)?
132        } else {
133            attn
134        };
135
136        let attn = candle_nn::ops::softmax(&attn, candle_core::D::Minus1)?;
137
138        // Weighted sum over values, reshape back to (B, L, d_model)
139        let context = attn.matmul(&v.contiguous()?)?; // (B, n_heads, L, d_head)
140        let context = context
141            .transpose(1, 2)?
142            .contiguous()?
143            .reshape((b, l, self.d_model))?;
144
145        self.out_proj.forward(&context)
146    }
147
148    // fn apply_rotary(&self, q: &Tensor, k: &Tensor) -> Result<(Tensor, Tensor)> {
149    //     let q = q.reshape((-1, self.n_heads, self.d_head))?;
150    //     let k = k.reshape((-1, self.n_heads, self.d_head))?;
151    //     let (q, k) = self.rotary.forward(&q, &k)?;
152    //     let q = q.flatten_from(1)?;
153    //     let k = k.flatten_from(1)?;
154    //     Ok((q, k))
155    // }
156
157    // pub fn forward(&self, x: &Tensor, seq_id: Option<&Tensor>) -> Result<Tensor> {
158    //     let qkv = self.layernorm_qkv.forward(x)?;
159    //     let chunks = qkv.chunk(3, -1)?;
160    //     let (query, key, value) = (&chunks[0], &chunks[1], &chunks[2]);
161
162    //     let query = self.q_ln.forward(query)?;
163    //     let key = self.k_ln.forward(key)?;
164    //     let (query, key) = self.apply_rotary(&query, &key)?;
165
166    //     let query = query.reshape((query.dims()[0], self.n_heads, -1, self.d_head))?;
167    //     let key = key.reshape((key.dims()[0], self.n_heads, -1, self.d_head))?;
168    //     let value = value.reshape((value.dims()[0], self.n_heads, -1, self.d_head))?;
169
170    //     let context = if let Some(seq_id) = seq_id {
171    //         let mask = seq_id.unsqueeze(-1)?.eq(&seq_id.unsqueeze(-2)?)?;
172    //         let mask = mask.unsqueeze(1)?;
173    //         scaled_dot_product_attention(&query, &key, &value, Some(&mask))?
174    //     } else {
175    //         scaled_dot_product_attention(&query, &key, &value, None)?
176    //     };
177
178    //     let context = context.flatten_from(2)?;
179    //     self.out_proj.forward(&context)
180    // }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::esmc::models::esmc::ESMCConfig;
187    use candle_core::{DType, Device};
188    use std::collections::HashMap;
189
190    /// A tiny `MultiHeadAttention` with deterministic weights.
191    ///
192    /// This is the attention ESM-C *and* ESM-3 both use — `esm3::layers::blocks`
193    /// loads this same type — so masking correctness here covers both runners'
194    /// `embed_batch` (ferritin-100.12).
195    fn tiny_attention(device: &Device) -> Result<(MultiHeadAttention, ESMCConfig)> {
196        let config = ESMCConfig {
197            d_model: 8,
198            n_heads: 2,
199            n_layers: 1,
200            ..ESMCConfig::esmc_300m()
201        };
202        let d = config.d_model;
203
204        // Tiny LCG: reproducible without pulling in an RNG crate.
205        let mut state = 0x9E37_79B9_7F4A_7C15u64;
206        let mut rand = |dims: &[usize], device: &Device| -> Result<Tensor> {
207            let n: usize = dims.iter().product();
208            let v: Vec<f32> = (0..n)
209                .map(|_| {
210                    state = state
211                        .wrapping_mul(6364136223846793005)
212                        .wrapping_add(1442695040888963407);
213                    ((state >> 33) as f32 / (1u64 << 31) as f32) - 0.5
214                })
215                .collect();
216            Tensor::from_vec(v, dims, device)
217        };
218
219        let mut ts: HashMap<String, Tensor> = HashMap::new();
220        ts.insert(
221            "layernorm_qkv.0.weight".into(),
222            Tensor::ones(d, DType::F32, device)?,
223        );
224        ts.insert(
225            "layernorm_qkv.0.bias".into(),
226            Tensor::zeros(d, DType::F32, device)?,
227        );
228        ts.insert("layernorm_qkv.1.weight".into(), rand(&[d * 3, d], device)?);
229        ts.insert("out_proj.weight".into(), rand(&[d, d], device)?);
230        ts.insert("q_ln.weight".into(), Tensor::ones(d, DType::F32, device)?);
231        ts.insert("k_ln.weight".into(), Tensor::ones(d, DType::F32, device)?);
232
233        let vb = VarBuilder::from_tensors(ts, DType::F32, device);
234        let attn = MultiHeadAttention::load(vb, &config)?;
235        Ok((attn, config))
236    }
237
238    fn max_abs_diff(a: &Tensor, b: &Tensor) -> Result<f32> {
239        let a: Vec<f32> = a.flatten_all()?.to_vec1()?;
240        let b: Vec<f32> = b.flatten_all()?.to_vec1()?;
241        assert_eq!(a.len(), b.len(), "shape mismatch in comparison");
242        Ok(a.iter()
243            .zip(b.iter())
244            .map(|(x, y)| (x - y).abs())
245            .fold(0f32, f32::max))
246    }
247
248    /// Deterministic `(1, len, d_model)` activations, then right-padded with a
249    /// *different* constant so unmasked padding is guaranteed to perturb.
250    fn inputs(len: usize, padded: usize, d: usize, device: &Device) -> Result<(Tensor, Tensor)> {
251        let real: Vec<f32> = (0..len * d).map(|i| (i as f32 * 0.37).sin()).collect();
252        let mut padded_v = real.clone();
253        padded_v.extend(std::iter::repeat_n(0.9f32, (padded - len) * d));
254        Ok((
255            Tensor::from_vec(real, (1, len, d), device)?,
256            Tensor::from_vec(padded_v, (1, padded, d), device)?,
257        ))
258    }
259
260    fn mask_row(len: usize, padded: usize, device: &Device) -> Result<Tensor> {
261        let mut m = vec![1u8; len];
262        m.resize(padded, 0);
263        Tensor::from_vec(m, (1, padded), device)
264    }
265
266    /// `sequence_id` is a `where_cond` predicate — 1 keeps a key, 0 drops it.
267    /// With it, right-padding must leave the real positions untouched; without
268    /// it, the same padding must change them (so the check is not vacuous).
269    #[test]
270    fn test_sequence_id_mask_isolates_padded_keys() -> Result<()> {
271        let device = Device::Cpu;
272        let (attn, config) = tiny_attention(&device)?;
273        let (len, padded) = (5usize, 9usize);
274        let (x, x_padded) = inputs(len, padded, config.d_model, &device)?;
275
276        let alone = attn.forward(&x, None)?;
277
278        let mask = mask_row(len, padded, &device)?;
279        let masked = attn.forward(&x_padded, Some(&mask))?.narrow(1, 0, len)?;
280        let diff = max_abs_diff(&alone, &masked)?;
281        assert!(
282            diff < 1e-5,
283            "masked padding changed the real positions: max abs diff {diff}"
284        );
285
286        let unmasked = attn.forward(&x_padded, None)?.narrow(1, 0, len)?;
287        let leak = max_abs_diff(&alone, &unmasked)?;
288        assert!(
289            leak > 1e-4,
290            "padding without sequence_id should perturb the real positions, but max abs diff was {leak}"
291        );
292        Ok(())
293    }
294
295    /// Two rows of different lengths in one batch: each must match the same
296    /// input run alone. This is the ordering check — the mask is broadcast
297    /// `(B, 1, 1, L) -> (B, heads, L, L)`, so a row that picked up the other
298    /// row's mask would show up here and only here.
299    #[test]
300    fn test_batched_rows_match_single_row_attention() -> Result<()> {
301        let device = Device::Cpu;
302        let (attn, config) = tiny_attention(&device)?;
303        let d = config.d_model;
304        let (long, short) = (7usize, 3usize);
305
306        let (x_long, _) = inputs(long, long, d, &device)?;
307        let (x_short, x_short_padded) = inputs(short, long, d, &device)?;
308
309        let batch = Tensor::cat(&[&x_long, &x_short_padded], 0)?;
310        let mask = Tensor::cat(
311            &[
312                &mask_row(long, long, &device)?,
313                &mask_row(short, long, &device)?,
314            ],
315            0,
316        )?;
317        let batched = attn.forward(&batch, Some(&mask))?;
318
319        for (row, x, len) in [(0usize, &x_long, long), (1usize, &x_short, short)] {
320            let alone = attn.forward(x, None)?;
321            let from_batch = batched.narrow(0, row, 1)?.narrow(1, 0, len)?;
322            let diff = max_abs_diff(&alone, &from_batch)?;
323            assert!(
324                diff < 1e-5,
325                "batch row {row} disagrees with its single-row attention: {diff}"
326            );
327        }
328        Ok(())
329    }
330}