Skip to main content

kopitiam_runtime/
rope.rs

1//! Rotary position embeddings (Su et al., 2021, "RoFormer").
2//!
3//! # Which pairing convention this implements: split-half, not interleaved
4//!
5//! RoPE rotates pairs of dimensions within each attention head by an angle
6//! proportional to sequence position. The *original* RoFormer paper (and
7//! GPT-J) pairs *adjacent* dimensions: `(x[0], x[1])`, `(x[2], x[3])`, ...,
8//! each pair rotated by its own frequency. LLaMA and every model derived
9//! from it — including every Qwen release — uses a *different* pairing that
10//! is easy to confuse with the first: dimension `i` is paired with
11//! dimension `i + rope_dim/2`, i.e. the rotary width is split into two
12//! halves and corresponding elements across the halves are rotated
13//! together. `llama.cpp`/ggml calls this variant `GGML_ROPE_TYPE_NEOX`.
14//!
15//! This module implements the **split-half** ("NEOX") convention
16//! exclusively, because that is what every GGUF Qwen/LLaMA export this
17//! crate loads was trained with. Getting this backwards is exactly the
18//! "silent-wrongness" failure mode the type system cannot catch: both
19//! conventions produce a full head_dim-sized output that *looks* like a
20//! valid rotation (same shape, same norm — rotation preserves vector
21//! length regardless of which pairing is used), so a wrong pairing does not
22//! fail to load or fail to run. It produces a model that emits fluent
23//! grammar and wrong facts, because every downstream attention score is
24//! computed from a subtly mispositioned query/key. See
25//! `apply_matches_split_half_hand_computation_and_differs_from_interleaved`
26//! below for the test that pins this down: it constructs a case where the
27//! two conventions provably disagree and asserts this implementation lands
28//! on the split-half answer.
29//!
30//! # The math
31//!
32//! For head dimension `d` (only the leading `rope_dim <= d` dimensions are
33//! rotated; any trailing dimensions pass through unchanged — see
34//! [`crate::config::QwenConfig::rope_dimension_count`]), position `m`, and
35//! `half = rope_dim / 2`:
36//!
37//! ```text
38//! freq[i]  = theta^(-2i / rope_dim)                  for i in 0..half
39//! angle    = m * freq[i]
40//! x1, x2   = x[0..half], x[half..rope_dim]            (the split halves)
41//! out[i]        = x1[i] * cos(angle) - x2[i] * sin(angle)
42//! out[half + i] = x2[i] * cos(angle) + x1[i] * sin(angle)
43//! ```
44//!
45//! which is the standard 2D rotation matrix `[[cos, -sin], [sin, cos]]`
46//! applied to the pair `(x1[i], x2[i])` — hence "rotary": each pair moves
47//! along a circle of radius `sqrt(x1[i]^2 + x2[i]^2)` as position advances,
48//! which is exactly why applying it never changes a vector's norm (see the
49//! `rotation_preserves_vector_norm` test).
50
51use kopitiam_core::{Error, Result};
52use kopitiam_tensor::Tensor;
53
54/// Precomputed `cos`/`sin` tables for every position up to `max_position`,
55/// so [`RotaryEmbedding::apply`] is a table lookup per (position, pair)
56/// rather than a `cos`/`sin` call — cheap enough to matter once this runs
57/// once per token per layer per head during decode.
58pub struct RotaryEmbedding {
59    rope_dim: usize,
60    half: usize,
61    /// `cos[pos * half + i]` / `sin[pos * half + i]`.
62    cos: Vec<f32>,
63    sin: Vec<f32>,
64}
65
66impl RotaryEmbedding {
67    /// Precomputes rotation tables for every position in `0..max_position`.
68    ///
69    /// `rope_dim` must be even (each pair needs two dimensions; see
70    /// [`crate::config::QwenConfig::from_metadata`], which rejects an odd
71    /// `rope_dimension_count` before a [`RotaryEmbedding`] is ever built).
72    pub fn new(rope_dim: usize, theta: f32, max_position: usize) -> Self {
73        debug_assert!(rope_dim.is_multiple_of(2), "rope_dim must be even");
74        let half = rope_dim / 2;
75        let freqs: Vec<f32> = (0..half).map(|i| theta.powf(-2.0 * i as f32 / rope_dim as f32)).collect();
76
77        let mut cos = vec![0.0f32; max_position * half];
78        let mut sin = vec![0.0f32; max_position * half];
79        for pos in 0..max_position {
80            for i in 0..half {
81                let angle = pos as f32 * freqs[i];
82                cos[pos * half + i] = angle.cos();
83                sin[pos * half + i] = angle.sin();
84            }
85        }
86        Self { rope_dim, half, cos, sin }
87    }
88
89    /// Rotates `x`, shaped `[n_heads, seq, head_dim]`, in place per
90    /// position. `positions[s]` is the absolute sequence position of
91    /// `x`'s `s`-th row — not necessarily `s` itself, since a KV-cache
92    /// decode step's single query token sits at `cache_len`, not at `0`
93    /// (see [`crate::kv_cache::KvCache`]).
94    ///
95    /// Only the leading `rope_dim` elements of each head are rotated;
96    /// `head_dim - rope_dim` trailing elements (when `rope_dim < head_dim`)
97    /// pass through unchanged, matching `llama.cpp`'s partial-rotary
98    /// support.
99    ///
100    /// # Errors
101    ///
102    /// [`Error::ShapeMismatch`] if `x` is not rank 3, if its last dimension
103    /// is smaller than `rope_dim`, or if `positions.len()` does not match
104    /// `x`'s sequence dimension.
105    pub fn apply(&self, x: &Tensor, positions: &[usize]) -> Result<Tensor> {
106        let dims = x.shape().dims();
107        if dims.len() != 3 {
108            return Err(shape_err(x));
109        }
110        let (n_heads, seq, head_dim) = (dims[0], dims[1], dims[2]);
111        if head_dim < self.rope_dim || seq != positions.len() {
112            return Err(shape_err(x));
113        }
114
115        let data = x.to_vec_f32()?; // row-major [n_heads, seq, head_dim]
116        let mut out = data.clone();
117
118        for h in 0..n_heads {
119            for (s, &pos) in positions.iter().enumerate() {
120                let base = (h * seq + s) * head_dim;
121                let table_base = pos * self.half;
122                for i in 0..self.half {
123                    let cos = self.cos[table_base + i];
124                    let sin = self.sin[table_base + i];
125                    let x1 = data[base + i];
126                    let x2 = data[base + self.half + i];
127                    out[base + i] = x1 * cos - x2 * sin;
128                    out[base + self.half + i] = x2 * cos + x1 * sin;
129                }
130                // Dimensions [rope_dim..head_dim] were already copied into
131                // `out` via `data.clone()` above and are left untouched.
132            }
133        }
134
135        Tensor::from_f32(out, x.shape().clone())
136    }
137}
138
139fn shape_err(x: &Tensor) -> Error {
140    Error::ShapeMismatch { expected: x.shape().clone(), actual: x.shape().clone() }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn assert_close(a: f32, b: f32) {
148        assert!((a - b).abs() < 1e-4, "expected {b}, got {a}");
149    }
150
151    /// Hand-computed (via an independent Python reference, not this
152    /// module's own formula transcribed) for `head_dim = rope_dim = 4`,
153    /// `theta = 10000`, position `1`, input `[1, 2, 3, 4]`.
154    #[test]
155    fn apply_matches_split_half_hand_computation_and_differs_from_interleaved() {
156        let rope = RotaryEmbedding::new(4, 10_000.0, 4);
157        let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [1, 1, 4]).unwrap();
158        let out = rope.apply(&x, &[1]).unwrap().to_vec_f32().unwrap();
159
160        // Split-half ("NEOX") reference: pairs (x[0], x[2]) and (x[1], x[3]).
161        let expected_split_half = [-1.984_110_6, 1.959_900_7, 2.462_378, 4.019_799_7];
162        for (o, e) in out.iter().zip(expected_split_half) {
163            assert_close(*o, e);
164        }
165
166        // The interleaved ("GPT-J") convention pairs adjacent dimensions
167        // instead -- (x[0], x[1]) and (x[2], x[3]) -- and provably gives a
168        // *different* answer for this same input. This is the assertion
169        // that actually distinguishes "implemented the right convention"
170        // from "implemented *a* rotation": if this module accidentally
171        // paired adjacent dimensions, `out` would match this vector
172        // instead, and the assertion above would already have failed, but
173        // this pins down that the two conventions are not coincidentally
174        // equal for this input either.
175        let interleaved_would_give = [-1.142_639_7, 1.922_075_6, 2.959_850_7, 4.029_799_5];
176        assert_ne!(
177            out, interleaved_would_give,
178            "split-half and interleaved RoPE must disagree on this input"
179        );
180    }
181
182    /// Rotation is an orthogonal linear map, so it can never change a
183    /// vector's Euclidean norm -- true for *any* position, not just the
184    /// hand-computed one above. A norm-changing bug (e.g. a sign error
185    /// that makes the "rotation" actually a shear) would be caught here
186    /// even if it happened to leave the hand-computed case's norm intact
187    /// by coincidence.
188    #[test]
189    fn rotation_preserves_vector_norm() {
190        let rope = RotaryEmbedding::new(8, 10_000.0, 16);
191        let input: Vec<f32> = (0..8).map(|i| (i as f32) * 0.37 - 1.1).collect();
192        let norm_in: f32 = input.iter().map(|v| v * v).sum::<f32>().sqrt();
193
194        for pos in 0..16 {
195            let x = Tensor::from_f32(input.clone(), [1, 1, 8]).unwrap();
196            let out = rope.apply(&x, &[pos]).unwrap().to_vec_f32().unwrap();
197            let norm_out: f32 = out.iter().map(|v| v * v).sum::<f32>().sqrt();
198            assert!((norm_in - norm_out).abs() < 1e-4, "pos {pos}: {norm_in} vs {norm_out}");
199        }
200    }
201
202    #[test]
203    fn position_zero_is_the_identity() {
204        let rope = RotaryEmbedding::new(4, 10_000.0, 4);
205        let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [1, 1, 4]).unwrap();
206        let out = rope.apply(&x, &[0]).unwrap().to_vec_f32().unwrap();
207        assert_close(out[0], 1.0);
208        assert_close(out[1], 2.0);
209        assert_close(out[2], 3.0);
210        assert_close(out[3], 4.0);
211    }
212
213    /// Partial rotary: `rope_dim < head_dim` leaves the trailing
214    /// dimensions untouched.
215    #[test]
216    fn dimensions_beyond_rope_dim_pass_through_unchanged() {
217        let rope = RotaryEmbedding::new(2, 10_000.0, 4);
218        let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [1, 1, 4]).unwrap();
219        let out = rope.apply(&x, &[1]).unwrap().to_vec_f32().unwrap();
220        // Dims [2, 3] (0-indexed) are beyond rope_dim=2, so unchanged.
221        assert_close(out[2], 3.0);
222        assert_close(out[3], 4.0);
223        // Dims [0, 1] did rotate (angle != 0 at position 1).
224        assert!((out[0] - 1.0).abs() > 1e-3 || (out[1] - 2.0).abs() > 1e-3);
225    }
226
227    #[test]
228    fn different_positions_in_the_same_batch_get_different_rotations() {
229        let rope = RotaryEmbedding::new(4, 10_000.0, 8);
230        let x = Tensor::from_f32(
231            vec![
232                1.0, 2.0, 3.0, 4.0, // seq index 0
233                1.0, 2.0, 3.0, 4.0, // seq index 1 -- same input, different position
234            ],
235            [1, 2, 4],
236        )
237        .unwrap();
238        let out = rope.apply(&x, &[0, 5]).unwrap().to_vec_f32().unwrap();
239        // Position 0 is the identity (see position_zero_is_the_identity);
240        // position 5 is not, so the two rows must differ despite identical
241        // input.
242        assert_eq!(&out[0..4], &[1.0, 2.0, 3.0, 4.0]);
243        assert_ne!(&out[4..8], &[1.0, 2.0, 3.0, 4.0]);
244    }
245
246    #[test]
247    fn wrong_rank_is_rejected() {
248        let rope = RotaryEmbedding::new(4, 10_000.0, 4);
249        let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [4]).unwrap();
250        assert!(matches!(rope.apply(&x, &[0]), Err(Error::ShapeMismatch { .. })));
251    }
252
253    #[test]
254    fn mismatched_positions_length_is_rejected() {
255        let rope = RotaryEmbedding::new(4, 10_000.0, 4);
256        let x = Tensor::from_f32(vec![1.0; 8], [1, 2, 4]).unwrap();
257        assert!(matches!(rope.apply(&x, &[0]), Err(Error::ShapeMismatch { .. })));
258    }
259}