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}