S2.22: memory attention with rotary encoding matches PyTorch

Self and cross attention with axial 2-D RoPE (keys tiled over memory frames,
object-pointer tokens left unrotated). Max abs error 2.6e-5.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Kristian MyramandClaude Sonnet 5.5 committed 2026-10-30 08:43:30 +01:00
1 parent e4f4fea0f2
commit ea2899d077
3 files changed
+195

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ use std::fmt;
pub mod decoder;
pub mod hiera;
pub mod memattn;
pub mod memory;
pub mod model;
pub mod neck;
+173
View File
@@ -0,0 +1,173 @@
// Modified work: ported from Hugging Face `transformers` `models/sam2_video/modeling_sam2_video.py`
// (Sam2VideoMemoryAttention, Sam2VideoMemoryAttentionLayer, Sam2VideoRoPEAttention,
// Sam2VideoVisionRotaryEmbedding; Apache-2.0, Copyright The HuggingFace Inc. team) to Rust on
// candle. See NOTICE.
//! Memory attention: the current frame's features attend to themselves and to the memory bank
//! (memory frames plus object pointers) with axial 2-D rotary position encoding.
use candle_core::{D, Device, Tensor};
use candle_nn::{Linear, ops::softmax_last_dim};
use crate::nn::{LayerNorm, apply, layer_norm, linear};
use crate::weights::Weights;
use crate::{Error, Result};
const DIM: usize = 256;
const LAYERS: usize = 4;
/// Side of the feature grid the rotary encoding is defined on.
pub const GRID: usize = 64;
/// `cos` and `sin` of the axial rotary encoding for the `GRID x GRID` grid, each `[GRID², 256]`.
fn rope_tables(theta: f32) -> Result<(Tensor, Tensor)> {
let freqs = DIM / 2 / 2; // per axis: head_dim / 4
let inv: Vec<f32> = (0..freqs).map(|i| 1.0 / theta.powf(i as f32 / freqs as f32)).collect();
let n = GRID * GRID;
let (mut cos, mut sin) = (vec![0f32; n * DIM], vec![0f32; n * DIM]);
for p in 0..n {
let (x, y) = ((p % GRID) as f32, (p / GRID) as f32);
for j in 0..DIM / 2 {
let f = if j < freqs { x * inv.get(j).copied().unwrap_or(0.0) } else { y * inv.get(j - freqs).copied().unwrap_or(0.0) };
for k in 0..2 {
if let (Some(c), Some(s)) = (cos.get_mut(p * DIM + 2 * j + k), sin.get_mut(p * DIM + 2 * j + k)) {
*c = f.cos();
*s = f.sin();
}
}
}
}
Ok((Tensor::from_vec(cos, (n, DIM), &Device::Cpu)?, Tensor::from_vec(sin, (n, DIM), &Device::Cpu)?))
}
/// `(x0, x1) -> (-x1, x0)` over adjacent pairs of the last dimension.
fn rotate_pairwise(x: &Tensor) -> Result<Tensor> {
let (n, d) = x.dims2()?;
let pairs = x.reshape((n, d / 2, 2))?;
let (x1, x2) = (pairs.narrow(2, 0, 1)?.squeeze(2)?, pairs.narrow(2, 1, 1)?.squeeze(2)?);
Ok(Tensor::stack(&[&x2.neg()?, &x1], D::Minus1)?.reshape((n, d))?)
}
fn rope(x: &Tensor, cos: &Tensor, sin: &Tensor) -> Result<Tensor> {
Ok(((x * cos)? + (rotate_pairwise(x)? * sin)?)?)
}
struct RopeAttention {
q: Linear,
k: Linear,
v: Linear,
o: Linear,
}
impl RopeAttention {
fn load(w: &Weights, prefix: &str, kv_dim: usize) -> Result<RopeAttention> {
Ok(RopeAttention {
q: linear(w, &format!("{prefix}.q_proj"), DIM, DIM)?,
k: linear(w, &format!("{prefix}.k_proj"), kv_dim, DIM)?,
v: linear(w, &format!("{prefix}.v_proj"), kv_dim, DIM)?,
o: linear(w, &format!("{prefix}.o_proj"), DIM, DIM)?,
})
}
/// `query`: `[N, 256]`, `key`/`value`: `[M, kv]`. The first `M - exclude` keys are rotated
/// (their table is `cos`/`sin` tiled to length); the last `exclude` (object pointers) are not.
fn forward(&self, query: &Tensor, key: &Tensor, value: &Tensor, tables: &(Tensor, Tensor), exclude: usize) -> Result<Tensor> {
let (cos, sin) = tables;
let q = rope(&apply(&self.q, query)?, cos, sin)?;
let k = apply(&self.k, key)?;
let v = apply(&self.v, value)?;
let m = k.dim(0)?;
let rotated = m.checked_sub(exclude).ok_or_else(|| Error::Input("more object pointers than memory tokens".into()))?;
let k = if rotated == 0 {
k
} else {
let n = cos.dim(0)?;
let reps = if rotated == n {
1
} else if rotated % n == 0 {
rotated / n
} else {
return Err(Error::Input(format!("memory length {rotated} is not a multiple of {n}")));
};
let tile = |t: &Tensor| -> Result<Tensor> { Ok(Tensor::cat(&vec![t; reps], 0)?) };
let k_rot = rope(&k.narrow(0, 0, rotated)?, &tile(cos)?, &tile(sin)?)?;
if exclude == 0 { k_rot } else { Tensor::cat(&[&k_rot, &k.narrow(0, rotated, exclude)?], 0)? }
};
let scale = (DIM as f64).powf(-0.5);
let att = softmax_last_dim(&((q * scale)?.matmul(&k.t()?.contiguous()?)?))?;
apply(&self.o, &att.matmul(&v)?)
}
}
struct Layer {
self_attn: RopeAttention,
cross: RopeAttention,
ln1: LayerNorm,
ln2: LayerNorm,
ln3: LayerNorm,
lin1: Linear,
lin2: Linear,
}
pub struct MemoryAttention {
layers: Vec<Layer>,
final_ln: LayerNorm,
tables: (Tensor, Tensor),
}
impl MemoryAttention {
pub fn load(w: &Weights) -> Result<MemoryAttention> {
let layers = (0..LAYERS)
.map(|i| {
let p = format!("memory_attention.layers.{i}");
Ok(Layer {
self_attn: RopeAttention::load(w, &format!("{p}.self_attn"), DIM)?,
cross: RopeAttention::load(w, &format!("{p}.cross_attn_image"), 64)?,
ln1: layer_norm(w, &format!("{p}.layer_norm1"), DIM, 1e-5)?,
ln2: layer_norm(w, &format!("{p}.layer_norm2"), DIM, 1e-5)?,
ln3: layer_norm(w, &format!("{p}.layer_norm3"), DIM, 1e-5)?,
lin1: linear(w, &format!("{p}.linear1"), DIM, 2048)?,
lin2: linear(w, &format!("{p}.linear2"), 2048, DIM)?,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(MemoryAttention { layers, final_ln: layer_norm(w, "memory_attention.layer_norm", DIM, 1e-5)?, tables: rope_tables(10000.0)? })
}
/// Condition the current frame on memory.
///
/// `current` and `current_pos`: `[4096, 256]` (the lowest-resolution neck level and its
/// position encoding, one row per cell); `memory` and `memory_pos`: `[M, 64]` (memory frames
/// followed by `num_pointers` object-pointer tokens). Returns `[4096, 256]`.
pub fn forward(&self, current: &Tensor, current_pos: &Tensor, memory: &Tensor, memory_pos: &Tensor, num_pointers: usize) -> Result<Tensor> {
if current.dims() != [GRID * GRID, DIM] {
return Err(Error::Input(format!("current features must be [{}, {DIM}], got {:?}", GRID * GRID, current.dims())));
}
let keys_pos = (memory + memory_pos)?;
let mut out = (current + (current_pos * 0.1)?)?;
for l in &self.layers {
let q = l.ln1.forward(&out)?;
out = (&out + l.self_attn.forward(&q, &q, &q, &self.tables, 0)?)?;
let q = l.ln2.forward(&out)?;
out = (&out + l.cross.forward(&q, &keys_pos, memory, &self.tables, num_pointers)?)?;
let q = l.ln3.forward(&out)?;
out = (&out + apply(&l.lin2, &apply(&l.lin1, &q)?.relu()?)?)?;
}
self.final_ln.forward(&out)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rotation_is_pairwise_and_tables_start_at_identity() {
let x = Tensor::new(&[[1f32, 2.0, 3.0, 4.0]], &Device::Cpu).unwrap();
let r = rotate_pairwise(&x).unwrap().to_vec2::<f32>().unwrap();
assert_eq!(r, vec![vec![-2.0, 1.0, -4.0, 3.0]]);
let (cos, sin) = rope_tables(10000.0).unwrap();
// cell (0, 0): angle 0 everywhere
let c = cos.narrow(0, 0, 1).unwrap().to_vec2::<f32>().unwrap();
let s = sin.narrow(0, 0, 1).unwrap().to_vec2::<f32>().unwrap();
assert!(c[0].iter().all(|v| *v == 1.0) && s[0].iter().all(|v| *v == 0.0));
}
}
+21
View File
@@ -173,3 +173,24 @@ fn memory_encoder_matches_pytorch() {
eprintln!("memory position encoding: max abs error {e:e}");
assert!(e < 1e-5, "memory position error {e}");
}
#[test]
fn memory_attention_matches_pytorch() {
let Some((w, r)) = load_video() else { return };
let att = effectcraft_sam2::memattn::MemoryAttention::load(&w).unwrap();
let get = |k: &str| r.get(k).unwrap().squeeze(1).unwrap();
let pointers = r.get("mem.attn.num_pointers").unwrap().to_vec1::<f32>().unwrap()[0] as usize;
let out = att
.forward(
&get("mem.attn.current_vision_features"),
&get("mem.attn.current_vision_position_embeddings"),
&get("mem.attn.memory"),
&get("mem.attn.memory_posision_embeddings"),
pointers,
)
.unwrap();
let want = r.get("mem.attn.out").unwrap().squeeze(0).unwrap().squeeze(0).unwrap();
let e = max_abs(&out, &want);
eprintln!("memory attention: max abs error {e:e} (pointers {pointers})");
assert!(e < MEMORY_TOL, "memory attention error {e}");
}