kopitiam_runtime/rope.rs
1//! Rotary position embeddings (Su et al., 2021, "RoFormer").
2//!
3//! # Two pairing conventions, and the architecture decides which
4//!
5//! RoPE rotates pairs of dimensions within each attention head by an angle
6//! proportional to sequence position. Which dimensions pair up is **not**
7//! universal, and getting it wrong is the "silent-wrongness" failure the type
8//! system cannot catch: both conventions produce a full `head_dim`-sized
9//! output that looks like a valid rotation (same shape, same norm — rotation
10//! preserves length either way), so the model loads, runs, and emits fluent
11//! grammar built on mispositioned queries and keys.
12//!
13//! * [`RopeKind::Interleaved`] pairs *adjacent* dimensions — `(x[0], x[1])`,
14//! `(x[2], x[3])`, ... This is the original RoFormer/GPT-J pairing, and
15//! ggml calls it the "normal" RoPE (`LLAMA_ROPE_TYPE_NORM`, mode 0).
16//! * [`RopeKind::SplitHalf`] pairs dimension `i` with `i + rope_dim/2`, i.e.
17//! the rotary width is split in half and corresponding elements across the
18//! halves rotate together. ggml calls this `GGML_ROPE_TYPE_NEOX`.
19//!
20//! **This crate got the mapping backwards and it cost a long hunt (`bd-b9x`).**
21//! The previous docs here claimed "LLaMA and every model derived from it —
22//! including every Qwen release — uses split-half". Only the Qwen half is
23//! true. `llama.cpp`'s `llama_model_rope_type`
24//! (`src/llama-model.cpp`, the switch commented *"use what we call a normal
25//! RoPE, operating on pairs of consecutive head values"*) puts
26//! `LLM_ARCH_LLAMA` — and Deci, Baichuan, StarCoder, InternLM2, MiniCPM,
27//! Command-R, OLMo — in the **NORM/interleaved** group, while `LLM_ARCH_QWEN2`,
28//! Phi and Gemma sit in the **NEOX/split-half** group.
29//!
30//! Why HF and GGUF disagree, which is the trap underneath the trap:
31//! HuggingFace's `LlamaAttention` really does use `rotate_half` (split-half),
32//! but `convert_hf_to_gguf.py` **permutes the Q and K weight rows** on the way
33//! into a GGUF precisely so that ggml's cheaper interleaved rotation
34//! reproduces the same result. So a GGUF LLaMA file expects interleaved RoPE:
35//! reading the HF modelling code and concluding "split-half" is correct about
36//! the *architecture* and wrong about the *file*.
37//!
38//! Symptomatically this is nasty: at position 0 the rotation angle is 0, so
39//! both conventions are the identity and short prompts still answer correctly.
40//! The error grows with position, so quality degrades the longer the context —
41//! which reads exactly like "the model is small and weak" rather than like a
42//! bug.
43//!
44//! Element indexing is transcribed from ggml's `rotate_pairs`
45//! (`ggml/src/ggml-cpu/ops.cpp`, MIT): pair index `j` uses frequency
46//! `theta^(-2j/rope_dim)` in *both* conventions — only the two elements it
47//! rotates differ.
48//!
49//! # The math
50//!
51//! For head dimension `d` (only the leading `rope_dim <= d` dimensions are
52//! rotated; any trailing dimensions pass through unchanged — see
53//! [`crate::config::QwenConfig::rope_dimension_count`]), position `m`, and
54//! `half = rope_dim / 2`:
55//!
56//! ```text
57//! freq[i] = theta^(-2i / rope_dim) for i in 0..half
58//! angle = m * freq[i]
59//! x1, x2 = x[0..half], x[half..rope_dim] (the split halves)
60//! out[i] = x1[i] * cos(angle) - x2[i] * sin(angle)
61//! out[half + i] = x2[i] * cos(angle) + x1[i] * sin(angle)
62//! ```
63//!
64//! which is the standard 2D rotation matrix `[[cos, -sin], [sin, cos]]`
65//! applied to the pair `(x1[i], x2[i])` — hence "rotary": each pair moves
66//! along a circle of radius `sqrt(x1[i]^2 + x2[i]^2)` as position advances,
67//! which is exactly why applying it never changes a vector's norm (see the
68//! `rotation_preserves_vector_norm` test).
69
70use kopitiam_core::{Error, Result};
71use kopitiam_tensor::Tensor;
72
73/// Precomputed `cos`/`sin` tables for every position up to `max_position`,
74/// so [`RotaryEmbedding::apply`] is a table lookup per (position, pair)
75/// rather than a `cos`/`sin` call — cheap enough to matter once this runs
76/// once per token per layer per head during decode.
77/// Which dimensions RoPE pairs up. See this module's docs — the choice is
78/// per-architecture and picking wrong is silently, progressively wrong rather
79/// than an error.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum RopeKind {
82 /// Adjacent pairs `(2j, 2j+1)`. ggml's `LLAMA_ROPE_TYPE_NORM` (mode 0).
83 /// **This is what GGUF `llama`-architecture files want**, including every
84 /// SmolLM2 release.
85 Interleaved,
86 /// Pairs `(j, j + rope_dim/2)`. ggml's `GGML_ROPE_TYPE_NEOX`. What
87 /// `qwen2`, Phi and Gemma files want.
88 SplitHalf,
89}
90
91pub struct RotaryEmbedding {
92 rope_dim: usize,
93 half: usize,
94 kind: RopeKind,
95 /// `cos[pos * half + i]` / `sin[pos * half + i]`.
96 cos: Vec<f32>,
97 sin: Vec<f32>,
98}
99
100impl RotaryEmbedding {
101 /// Precomputes rotation tables for every position in `0..max_position`.
102 ///
103 /// `rope_dim` must be even (each pair needs two dimensions; see
104 /// [`crate::config::QwenConfig::from_metadata`], which rejects an odd
105 /// `rope_dimension_count` before a [`RotaryEmbedding`] is ever built).
106 pub fn new(rope_dim: usize, theta: f32, max_position: usize, kind: RopeKind) -> Self {
107 debug_assert!(rope_dim.is_multiple_of(2), "rope_dim must be even");
108 let half = rope_dim / 2;
109 let freqs: Vec<f32> = (0..half).map(|i| theta.powf(-2.0 * i as f32 / rope_dim as f32)).collect();
110
111 let mut cos = vec![0.0f32; max_position * half];
112 let mut sin = vec![0.0f32; max_position * half];
113 for pos in 0..max_position {
114 for i in 0..half {
115 let angle = pos as f32 * freqs[i];
116 cos[pos * half + i] = angle.cos();
117 sin[pos * half + i] = angle.sin();
118 }
119 }
120 Self { rope_dim, half, kind, cos, sin }
121 }
122
123 /// Rotates `x`, shaped `[n_heads, seq, head_dim]`, in place per
124 /// position. `positions[s]` is the absolute sequence position of
125 /// `x`'s `s`-th row — not necessarily `s` itself, since a KV-cache
126 /// decode step's single query token sits at `cache_len`, not at `0`
127 /// (see [`crate::kv_cache::KvCache`]).
128 ///
129 /// Only the leading `rope_dim` elements of each head are rotated;
130 /// `head_dim - rope_dim` trailing elements (when `rope_dim < head_dim`)
131 /// pass through unchanged, matching `llama.cpp`'s partial-rotary
132 /// support.
133 ///
134 /// # Errors
135 ///
136 /// [`Error::ShapeMismatch`] if `x` is not rank 3, if its last dimension
137 /// is smaller than `rope_dim`, or if `positions.len()` does not match
138 /// `x`'s sequence dimension.
139 pub fn apply(&self, x: &Tensor, positions: &[usize]) -> Result<Tensor> {
140 let dims = x.shape().dims();
141 if dims.len() != 3 {
142 return Err(shape_err(x));
143 }
144 let (n_heads, seq, head_dim) = (dims[0], dims[1], dims[2]);
145 if head_dim < self.rope_dim || seq != positions.len() {
146 return Err(shape_err(x));
147 }
148
149 let data = x.to_vec_f32()?; // row-major [n_heads, seq, head_dim]
150 let mut out = data.clone();
151
152 for h in 0..n_heads {
153 for (s, &pos) in positions.iter().enumerate() {
154 let base = (h * seq + s) * head_dim;
155 let table_base = pos * self.half;
156 // Pair index `j` carries the same frequency in both
157 // conventions; only which two elements it rotates differs.
158 // Transcribed from ggml's `rotate_pairs`.
159 for j in 0..self.half {
160 let cos = self.cos[table_base + j];
161 let sin = self.sin[table_base + j];
162 let (lo, hi) = match self.kind {
163 RopeKind::Interleaved => (base + 2 * j, base + 2 * j + 1),
164 RopeKind::SplitHalf => (base + j, base + self.half + j),
165 };
166 let x1 = data[lo];
167 let x2 = data[hi];
168 out[lo] = x1 * cos - x2 * sin;
169 out[hi] = x2 * cos + x1 * sin;
170 }
171 // Dimensions [rope_dim..head_dim] were already copied into
172 // `out` via `data.clone()` above and are left untouched.
173 }
174 }
175
176 Tensor::from_f32(out, x.shape().clone())
177 }
178}
179
180fn shape_err(x: &Tensor) -> Error {
181 Error::ShapeMismatch { expected: x.shape().clone(), actual: x.shape().clone() }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 fn assert_close(a: f32, b: f32) {
189 assert!((a - b).abs() < 1e-4, "expected {b}, got {a}");
190 }
191
192 /// Hand-computed (via an independent Python reference, not this
193 /// module's own formula transcribed) for `head_dim = rope_dim = 4`,
194 /// `theta = 10000`, position `1`, input `[1, 2, 3, 4]`.
195 #[test]
196 fn apply_matches_split_half_hand_computation_and_differs_from_interleaved() {
197 let rope = RotaryEmbedding::new(4, 10_000.0, 4, RopeKind::SplitHalf);
198 let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [1, 1, 4]).unwrap();
199 let out = rope.apply(&x, &[1]).unwrap().to_vec_f32().unwrap();
200
201 // Split-half ("NEOX") reference: pairs (x[0], x[2]) and (x[1], x[3]).
202 let expected_split_half = [-1.984_110_6, 1.959_900_7, 2.462_378, 4.019_799_7];
203 for (o, e) in out.iter().zip(expected_split_half) {
204 assert_close(*o, e);
205 }
206
207 // The interleaved ("GPT-J") convention pairs adjacent dimensions
208 // instead -- (x[0], x[1]) and (x[2], x[3]) -- and provably gives a
209 // *different* answer for this same input. This is the assertion
210 // that actually distinguishes "implemented the right convention"
211 // from "implemented *a* rotation": if this module accidentally
212 // paired adjacent dimensions, `out` would match this vector
213 // instead, and the assertion above would already have failed, but
214 // this pins down that the two conventions are not coincidentally
215 // equal for this input either.
216 let interleaved_would_give = [-1.142_639_7, 1.922_075_6, 2.959_850_7, 4.029_799_5];
217 assert_ne!(
218 out, interleaved_would_give,
219 "split-half and interleaved RoPE must disagree on this input"
220 );
221 }
222
223 /// The other half of the pair above — and the regression guard for
224 /// `bd-b9x`, where `llama`-architecture models were silently given the
225 /// split-half rotation. The expected vector is the very one the test above
226 /// names as "what interleaved would give", so the two tests pin each
227 /// convention against the same hand-computed input from opposite sides.
228 #[test]
229 fn interleaved_matches_the_hand_computed_adjacent_pairing() {
230 let rope = RotaryEmbedding::new(4, 10_000.0, 4, RopeKind::Interleaved);
231 let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [1, 1, 4]).unwrap();
232 let out = rope.apply(&x, &[1]).unwrap().to_vec_f32().unwrap();
233
234 // Interleaved ("NORM"/GPT-J): pairs (x[0], x[1]) and (x[2], x[3]).
235 let expected = [-1.142_639_7, 1.922_075_6, 2.959_850_7, 4.029_799_5];
236 for (o, e) in out.iter().zip(expected) {
237 assert_close(*o, e);
238 }
239 }
240
241 /// Why this bug hid for so long, encoded as a test: at position 0 the
242 /// angle is 0, so cos=1/sin=0 and **both conventions are the identity**.
243 /// A wrong choice therefore costs nothing on the first token and grows
244 /// with position — which reads as "small model, weak answers" rather than
245 /// as a bug. Anyone tempted to validate RoPE with a single-token prompt
246 /// should read this first.
247 #[test]
248 fn both_conventions_are_the_identity_at_position_zero() {
249 let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [1, 1, 4]).unwrap();
250 for kind in [RopeKind::Interleaved, RopeKind::SplitHalf] {
251 let rope = RotaryEmbedding::new(4, 10_000.0, 4, kind);
252 let out = rope.apply(&x, &[0]).unwrap().to_vec_f32().unwrap();
253 assert_eq!(out, vec![1.0, 2.0, 3.0, 4.0], "{kind:?} must be identity at position 0");
254 }
255 }
256
257 /// Both conventions preserve each rotated pair's norm, so "the output
258 /// looks like a valid rotation" can never distinguish them — the reason
259 /// this class of bug is invisible to shape and sanity checks alike.
260 #[test]
261 fn both_conventions_preserve_norm_so_neither_looks_wrong() {
262 let v = vec![1.0f32, 2.0, 3.0, 4.0];
263 let x = Tensor::from_f32(v.clone(), [1, 1, 4]).unwrap();
264 let norm = |s: &[f32]| s.iter().map(|a| a * a).sum::<f32>().sqrt();
265 for kind in [RopeKind::Interleaved, RopeKind::SplitHalf] {
266 let rope = RotaryEmbedding::new(4, 10_000.0, 8, kind);
267 let out = rope.apply(&x, &[5]).unwrap().to_vec_f32().unwrap();
268 assert!(
269 (norm(&out) - norm(&v)).abs() < 1e-4,
270 "{kind:?} changed the vector norm"
271 );
272 }
273 }
274
275 /// Rotation is an orthogonal linear map, so it can never change a
276 /// vector's Euclidean norm -- true for *any* position, not just the
277 /// hand-computed one above. A norm-changing bug (e.g. a sign error
278 /// that makes the "rotation" actually a shear) would be caught here
279 /// even if it happened to leave the hand-computed case's norm intact
280 /// by coincidence.
281 #[test]
282 fn rotation_preserves_vector_norm() {
283 let rope = RotaryEmbedding::new(8, 10_000.0, 16, RopeKind::SplitHalf);
284 let input: Vec<f32> = (0..8).map(|i| (i as f32) * 0.37 - 1.1).collect();
285 let norm_in: f32 = input.iter().map(|v| v * v).sum::<f32>().sqrt();
286
287 for pos in 0..16 {
288 let x = Tensor::from_f32(input.clone(), [1, 1, 8]).unwrap();
289 let out = rope.apply(&x, &[pos]).unwrap().to_vec_f32().unwrap();
290 let norm_out: f32 = out.iter().map(|v| v * v).sum::<f32>().sqrt();
291 assert!((norm_in - norm_out).abs() < 1e-4, "pos {pos}: {norm_in} vs {norm_out}");
292 }
293 }
294
295 #[test]
296 fn position_zero_is_the_identity() {
297 let rope = RotaryEmbedding::new(4, 10_000.0, 4, RopeKind::SplitHalf);
298 let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [1, 1, 4]).unwrap();
299 let out = rope.apply(&x, &[0]).unwrap().to_vec_f32().unwrap();
300 assert_close(out[0], 1.0);
301 assert_close(out[1], 2.0);
302 assert_close(out[2], 3.0);
303 assert_close(out[3], 4.0);
304 }
305
306 /// Partial rotary: `rope_dim < head_dim` leaves the trailing
307 /// dimensions untouched.
308 #[test]
309 fn dimensions_beyond_rope_dim_pass_through_unchanged() {
310 let rope = RotaryEmbedding::new(2, 10_000.0, 4, RopeKind::SplitHalf);
311 let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [1, 1, 4]).unwrap();
312 let out = rope.apply(&x, &[1]).unwrap().to_vec_f32().unwrap();
313 // Dims [2, 3] (0-indexed) are beyond rope_dim=2, so unchanged.
314 assert_close(out[2], 3.0);
315 assert_close(out[3], 4.0);
316 // Dims [0, 1] did rotate (angle != 0 at position 1).
317 assert!((out[0] - 1.0).abs() > 1e-3 || (out[1] - 2.0).abs() > 1e-3);
318 }
319
320 #[test]
321 fn different_positions_in_the_same_batch_get_different_rotations() {
322 let rope = RotaryEmbedding::new(4, 10_000.0, 8, RopeKind::SplitHalf);
323 let x = Tensor::from_f32(
324 vec![
325 1.0, 2.0, 3.0, 4.0, // seq index 0
326 1.0, 2.0, 3.0, 4.0, // seq index 1 -- same input, different position
327 ],
328 [1, 2, 4],
329 )
330 .unwrap();
331 let out = rope.apply(&x, &[0, 5]).unwrap().to_vec_f32().unwrap();
332 // Position 0 is the identity (see position_zero_is_the_identity);
333 // position 5 is not, so the two rows must differ despite identical
334 // input.
335 assert_eq!(&out[0..4], &[1.0, 2.0, 3.0, 4.0]);
336 assert_ne!(&out[4..8], &[1.0, 2.0, 3.0, 4.0]);
337 }
338
339 #[test]
340 fn wrong_rank_is_rejected() {
341 let rope = RotaryEmbedding::new(4, 10_000.0, 4, RopeKind::SplitHalf);
342 let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [4]).unwrap();
343 assert!(matches!(rope.apply(&x, &[0]), Err(Error::ShapeMismatch { .. })));
344 }
345
346 #[test]
347 fn mismatched_positions_length_is_rejected() {
348 let rope = RotaryEmbedding::new(4, 10_000.0, 4, RopeKind::SplitHalf);
349 let x = Tensor::from_f32(vec![1.0; 8], [1, 2, 4]).unwrap();
350 assert!(matches!(rope.apply(&x, &[0]), Err(Error::ShapeMismatch { .. })));
351 }
352}