Skip to main content

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}