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}