Skip to main content

yo_vector/
rotate.rs

1//! The random rotation every vector goes through before it is quantised
2//! (`10` section 3).
3//!
4//! RaBitQ quantises a coordinate to its sign, and a sign carries information
5//! only when the coordinates are all about the same size. Real embeddings are
6//! not like that: a handful of dimensions hold most of the energy, and the sign
7//! of the rest is close to a coin toss. A random rotation fixes it, because
8//! rotating a vector by a random orthogonal matrix spreads its length evenly
9//! over the coordinates while leaving every distance and every angle exactly
10//! where it was. That is the whole reason the estimator's error bound holds.
11//!
12//! ```
13//! use yo_vector::Rotation;
14//!
15//! let r = Rotation::new(8, 42);
16//! let mut v = [1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
17//! r.apply(&mut v);
18//!
19//! // The length did not move, and neither did anything else about the vector.
20//! let len: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
21//! assert!((len - 1.0).abs() < 1e-5);
22//! // The one coordinate that held everything now holds a share of it.
23//! assert!(v.iter().all(|x| x.abs() < 0.95));
24//! ```
25//!
26//! # Why this shape and not a matrix
27//!
28//! The obvious rotation is a dense `D` by `D` orthogonal matrix from a QR
29//! decomposition, and it costs `D^2` multiplies per vector. At 768 dimensions
30//! that is 590 thousand multiplies to insert one vector, and the ingest target
31//! is fifty thousand vectors a second on one core, which asks for 29 GFLOP/s of
32//! nothing but rotation. It does not fit, so the rotation has to be structured.
33//!
34//! The usual structured answer is a random sign flip followed by a Hadamard
35//! transform, which is `D log D` and lovely, and needs `D` to be a power of two.
36//! Padding 768 up to 1024 would make a one bit code 128 bytes instead of 96,
37//! which is a third more index for every vector in the collection, so that is
38//! not free either.
39//!
40//! What is here is the same idea without the power of two. A round flips the
41//! sign of a random half of the coordinates, pairs them all up at random, and
42//! replaces each pair with its sum and its difference over the square root of
43//! two. Every step of that is orthogonal by construction rather than
44//! approximately orthogonal after rounding, and a quarter turn on a pair splits
45//! whatever it was holding evenly between the two, which is the part that
46//! actually spreads a spike out. Pairing at random rather than by a fixed stride
47//! is what lets any coordinate reach any other, so `log2(D)` rounds and a couple
48//! more is enough.
49//!
50//! The angle is not random, and that is deliberate. A round with a random angle
51//! keeps roughly nine tenths of what the larger side was holding, so a spike
52//! only decays like `0.9^rounds` and it takes something like sixty rounds at 256
53//! dimensions to flatten. A quarter turn halves it every time and takes eight.
54//! The randomness the estimator needs comes from the pairing and the signs, and
55//! there is plenty of it.
56//!
57//! # It is not written down anywhere
58//!
59//! A rotation is `dim` and a seed, and both live in the collection's catalogue
60//! entry. Rebuilding it is deterministic on every machine and every target,
61//! because the generator underneath is, so the file never holds the tables and
62//! two processes that open the same collection compute the same rotation.
63
64use yo_common::Rng;
65
66/// A quarter turn on a pair is a sum and a difference, both over this.
67const INV_ROOT2: f32 = core::f32::consts::FRAC_1_SQRT_2;
68
69/// One sweep: a sign for every coordinate and a pairing of all of them.
70///
71/// The pairs are stored rather than the permutation they came from, because
72/// rotating in place off a pair list is one pass over the vector and no scratch
73/// buffer, where a permutation would want somewhere to write the shuffled copy.
74#[derive(Debug)]
75struct Round {
76    /// The sign mask for every coordinate, either nothing or the top bit, ready
77    /// to be exclusive ored straight into the float.
78    ///
79    /// This used to be one bit per coordinate, unpacked with a shift and a mask
80    /// inside the loop, which is four integer operations per float and stops
81    /// the loop being one wide exclusive or over a run of them. A word each is
82    /// `dim` times `rounds` times four bytes for the whole collection, a few
83    /// kilobytes, and no vector anywhere pays for it twice.
84    signs: Vec<u32>,
85    /// `(i, j)` for each pair, which together cover every index once, or every
86    /// index but one when the dimension is odd.
87    pairs: Vec<(u32, u32)>,
88}
89
90/// A random orthogonal transform, rebuilt from its seed rather than stored.
91#[derive(Debug)]
92pub struct Rotation {
93    dim: usize,
94    seed: u64,
95    rounds: Vec<Round>,
96}
97
98impl Rotation {
99    /// The rotation a collection of `dim` dimensional vectors uses, from the
100    /// seed in its catalogue entry.
101    ///
102    /// # Panics
103    ///
104    /// If `dim` is zero. A collection of vectors with no coordinates is a
105    /// mistake made somewhere further up rather than a case to handle.
106    #[must_use]
107    pub fn new(dim: usize, seed: u64) -> Rotation {
108        assert!(dim > 0, "a vector has at least one dimension");
109        let mut rng = Rng::new(seed);
110        let sweeps = sweeps(dim);
111        let mut rounds = Vec::with_capacity(sweeps);
112        // The identity, shuffled fresh for every round, which is what makes the
113        // pairing different each time and so lets a coordinate reach across the
114        // whole vector rather than staying in the half it started in.
115        let mut order: Vec<u32> = (0..dim as u32).collect();
116        for _ in 0..sweeps {
117            shuffle(&mut order, &mut rng);
118            let half = dim / 2;
119            let pairs: Vec<(u32, u32)> = (0..half)
120                .map(|k| (order[2 * k], order[2 * k + 1]))
121                .collect();
122            // Drawn in this order because that is the order they were drawn in
123            // when they were two separate tables, and the rotation a seed gives
124            // has to stay the one it always gave.
125            let flip = bits(dim, &mut rng);
126            let turn = bits(half, &mut rng);
127            let mut signs: Vec<u32> = (0..dim)
128                .map(|i| u32::from((flip[i / 64] >> (i % 64)) & 1 == 1) << 31)
129                .collect();
130            // Turning a pair the other way round is the same thing as flipping
131            // the sign of its second coordinate first. With b negated the sum
132            // becomes the difference and the difference becomes the sum, and
133            // both of those are exact in floating point, so folding the turn
134            // into the sign here gives bit for bit what the branch inside the
135            // pair loop used to give and the loop no longer has a coin toss to
136            // mispredict in it.
137            for (k, &(_, j)) in pairs.iter().enumerate() {
138                if (turn[k / 64] >> (k % 64)) & 1 == 1 {
139                    signs[j as usize] ^= 1 << 31;
140                }
141            }
142            rounds.push(Round { signs, pairs });
143        }
144        Rotation { dim, seed, rounds }
145    }
146
147    /// How many coordinates a vector this rotates has.
148    #[must_use]
149    pub fn dim(&self) -> usize {
150        self.dim
151    }
152
153    /// The seed this was built from, which is what a catalogue stores.
154    #[must_use]
155    pub fn seed(&self) -> u64 {
156        self.seed
157    }
158
159    /// Rotate a vector where it lies.
160    ///
161    /// # Panics
162    ///
163    /// If `v` is not [`Rotation::dim`] long.
164    pub fn apply(&self, v: &mut [f32]) {
165        assert_eq!(
166            v.len(),
167            self.dim,
168            "this rotation is for {} dimensions and was handed {}",
169            self.dim,
170            v.len()
171        );
172        for round in &self.rounds {
173            // A sign is the top bit of the float, so flipping one is an xor and
174            // there is no branch to mispredict on a coin toss. Zipping two
175            // slices of the same length rather than indexing one by a counter
176            // is what lets the compiler drop the bounds check and do a whole
177            // register of them at once.
178            for (c, &sign) in v.iter_mut().zip(&round.signs) {
179                *c = f32::from_bits(c.to_bits() ^ sign);
180            }
181            for &(i, j) in &round.pairs {
182                let (i, j) = (i as usize, j as usize);
183                let (a, b) = (v[i], v[j]);
184                v[i] = (a + b) * INV_ROOT2;
185                v[j] = (a - b) * INV_ROOT2;
186            }
187        }
188    }
189}
190
191/// How many sweeps it takes for a spike to be spread over the whole vector.
192///
193/// A round splits whatever a coordinate is holding between two, so the set one
194/// can have reached after `k` of them is `2^k` wide. `log2(dim)` rounded up is
195/// where that covers the vector, and the two on top are because the pairings are
196/// drawn independently and so overlap near the end rather than tiling neatly.
197fn sweeps(dim: usize) -> usize {
198    ((usize::BITS - (dim - 1).leading_zeros()) as usize + 2).max(4)
199}
200
201/// Fisher and Yates, so that every pairing is equally likely.
202fn shuffle(order: &mut [u32], rng: &mut Rng) {
203    for i in (1..order.len()).rev() {
204        order.swap(i, rng.below(i + 1));
205    }
206}
207
208/// `n` coin tosses, packed.
209fn bits(n: usize, rng: &mut Rng) -> Vec<u64> {
210    (0..n.div_ceil(64)).map(|_| rng.next_u64()).collect()
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn dot(a: &[f32], b: &[f32]) -> f32 {
218        a.iter().zip(b).map(|(x, y)| x * y).sum()
219    }
220
221    /// A number in `[0, 1)` from the generator, for the tests that want one.
222    fn unit(rng: &mut Rng) -> f32 {
223        (rng.next_u64() >> 40) as f32 / (1u32 << 24) as f32
224    }
225
226    /// A handful of vectors that are not all alike.
227    fn sample(dim: usize, n: usize, seed: u64) -> Vec<Vec<f32>> {
228        let mut rng = Rng::new(seed);
229        (0..n)
230            .map(|_| (0..dim).map(|_| unit(&mut rng) * 2.0 - 1.0).collect())
231            .collect()
232    }
233
234    #[test]
235    fn a_rotation_keeps_every_length_and_every_angle() {
236        let r = Rotation::new(64, 7);
237        let vs = sample(64, 8, 11);
238        for v in &vs {
239            let mut spun = v.clone();
240            r.apply(&mut spun);
241            let before = dot(v, v).sqrt();
242            let after = dot(&spun, &spun).sqrt();
243            assert!((before - after).abs() < 1e-4, "{before} became {after}");
244        }
245        // And the angle between any two of them, which is what a distance is
246        // made of and so is the property that actually has to survive.
247        for a in 0..vs.len() {
248            for b in 0..a {
249                let mut x = vs[a].clone();
250                let mut y = vs[b].clone();
251                let before = dot(&x, &y);
252                r.apply(&mut x);
253                r.apply(&mut y);
254                let after = dot(&x, &y);
255                assert!((before - after).abs() < 1e-3, "{before} became {after}");
256            }
257        }
258    }
259
260    /// The rotation is not written into the file, it is rebuilt from a seed, so
261    /// a build that computes a different one from the same seed cannot read a
262    /// collection an older build wrote. Nothing else in the crate would notice:
263    /// the codes would still be self consistent and recall would still look
264    /// fine, and only the vectors already on disk would be wrong. So the bits
265    /// are pinned here.
266    ///
267    /// If this fails and the change was deliberate, the file format version is
268    /// the thing that has to move, not this number.
269    #[test]
270    fn a_seed_gives_the_rotation_it_has_always_given() {
271        for (dim, want) in [
272            (8usize, 0xef2f_9c0c_1aad_5cdfu64),
273            (33, 0x97e7_79cf_5820_4a3f),
274            (128, 0xde6f_cf6b_69cf_490c),
275        ] {
276            let mut v: Vec<f32> = (0..dim).map(|i| (i as f32 + 1.0) / 8.0).collect();
277            Rotation::new(dim, 0xB0A7).apply(&mut v);
278            let mut got: u64 = 0xcbf2_9ce4_8422_2325;
279            for c in &v {
280                for byte in c.to_bits().to_le_bytes() {
281                    got ^= u64::from(byte);
282                    got = got.wrapping_mul(0x0100_0000_01b3);
283                }
284            }
285            assert_eq!(got, want, "the rotation at {dim} dimensions has moved");
286        }
287    }
288
289    #[test]
290    fn the_same_seed_is_the_same_rotation() {
291        let v = sample(32, 1, 3).pop().expect("one vector");
292        let mut a = v.clone();
293        let mut b = v;
294        Rotation::new(32, 99).apply(&mut a);
295        Rotation::new(32, 99).apply(&mut b);
296        assert_eq!(a, b);
297
298        let mut c = a.clone();
299        Rotation::new(32, 100).apply(&mut c);
300        assert_ne!(a, c, "two seeds should not be one rotation");
301    }
302
303    /// The point of the whole thing: a vector whose length sits in one
304    /// coordinate comes out with it spread over all of them, which is what
305    /// makes the sign of a coordinate worth a bit.
306    #[test]
307    fn a_spike_comes_out_flat() {
308        for dim in [64usize, 256, 768] {
309            let r = Rotation::new(dim, 5);
310            let mut v = vec![0.0f32; dim];
311            v[0] = 1.0;
312            r.apply(&mut v);
313
314            let even = 1.0 / (dim as f32).sqrt();
315            let biggest = v.iter().fold(0.0f32, |m, x| m.max(x.abs()));
316            assert!(biggest < even * 5.0, "{dim}: {biggest} against {even}");
317            // And nothing is left sitting at zero either, which is the failure
318            // a fixed pairing would have: half the vector never touched.
319            let alive = v.iter().filter(|x| x.abs() > even / 4.0).count();
320            assert!(alive > dim * 3 / 4, "{dim}: only {alive} coordinates moved");
321        }
322    }
323
324    #[test]
325    fn an_odd_dimension_still_rotates() {
326        let r = Rotation::new(7, 1);
327        let mut v = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
328        let before = dot(&v, &v).sqrt();
329        r.apply(&mut v);
330        let after = dot(&v, &v).sqrt();
331        assert!((before - after).abs() < 1e-3);
332    }
333
334    #[test]
335    fn one_dimension_is_only_a_sign() {
336        // There is nothing to pair it with, so all a round can do is flip it,
337        // and the length still comes out where it went in.
338        let r = Rotation::new(1, 1);
339        let mut v = [3.0f32];
340        r.apply(&mut v);
341        assert_eq!(v[0].abs(), 3.0);
342    }
343}