Skip to main content

yo_vector/
quant.rs

1//! How a vector set squeezes a vector before it stores it.
2//!
3//! Three ways, and they are Redis's three rather than ours, because a client can
4//! see all of them. `VEMB` hands back what was stored and not what was sent,
5//! `VEMB RAW` hands back the bytes, and `VSIM` scores whatever is in there, so a
6//! set written with `Q8` and a set written with `NOQUANT` answer differently and
7//! a client is entitled to both answers.
8//!
9//! # What is stored is a direction and a length
10//!
11//! Every one of the three splits a vector into the length it had and the
12//! direction it pointed, keeps the length as one float beside the element, and
13//! squeezes only the direction. That is what makes the three comparable: they
14//! differ in how much of the direction survives and in nothing else.
15//!
16//! [`Quant::None`] keeps the direction as it was, four bytes a coordinate.
17//! [`Quant::Int8`] keeps it as a signed byte a coordinate against a scale that
18//! is the largest coordinate there is. [`Quant::Bin`] keeps one bit a
19//! coordinate, which is the sign, and throws the rest away.
20//!
21//! # The arithmetic is the arithmetic a real server does
22//!
23//! Down to which multiplication happens first, because the answers are visible.
24//! A code is `round(unit * (127 / range))` with the reciprocal formed once, and
25//! not `round(unit / range * 127)`, which disagrees about one coordinate in ten.
26//! What comes back out is `(code * range) / 127`, and not `(code / 127) * range`,
27//! which disagrees about half the time. Both were read off a real server over
28//! several hundred vectors rather than guessed.
29//!
30//! The length is [`norm`], which is the strangest of the three and the one that
31//! took the longest to pin down, because it is neither an accurate sum of
32//! squares nor a naive one. It is Redis's loop from `hnsw.c` including its
33//! unroll by four and which of its multiplies the compiler fused into the adds
34//! beside them, and it matched a real server on 800 vectors out of 800 where
35//! every simpler shape matched about seven in ten.
36
37/// How a vector set stores the direction of its vectors.
38#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
39pub enum Quant {
40    /// `NOQUANT`, which stores the direction as it arrived.
41    #[default]
42    None,
43    /// `Q8`, one signed byte a coordinate against the widest one.
44    Int8,
45    /// `BIN`, one bit a coordinate, which is the sign of it.
46    Bin,
47}
48
49impl Quant {
50    /// What `VINFO` calls this, which is the name of the stored form and not the
51    /// name of the option that asked for it.
52    #[must_use]
53    pub fn token(self) -> &'static str {
54        match self {
55            Quant::None => "f32",
56            Quant::Int8 => "int8",
57            Quant::Bin => "bin",
58        }
59    }
60
61    /// How many bytes `VEMB RAW` writes for a `dim` wide vector.
62    ///
63    /// The binary form is rounded up to whole eight byte words rather than to
64    /// whole bytes, because it is a run of 64 bit words on the wire and a set of
65    /// 65 dimensions writes sixteen bytes and not nine.
66    #[must_use]
67    pub fn code_bytes(self, dim: usize) -> usize {
68        match self {
69            Quant::None => dim * 4,
70            Quant::Int8 => dim,
71            Quant::Bin => dim.div_ceil(64) * 8,
72        }
73    }
74}
75
76/// A vector split into the direction that gets stored and the two numbers that
77/// turn it back into what the client sent.
78#[derive(Debug, Clone)]
79pub struct Squeezed {
80    /// The direction, of unit length, in the form the quantisation leaves it.
81    pub dir: Vec<f32>,
82    /// How long the client's vector was.
83    pub norm: f32,
84    /// The widest coordinate of the direction, which is `Q8`'s scale and is 0
85    /// for the other two because they have no scale.
86    pub range: f32,
87}
88
89/// The euclidean length of a vector, to the bit.
90///
91/// This is a strange shape for a sum of squares and every part of it is on
92/// purpose. Redis computes this one in `hnsw.c`, in a loop unrolled by four that
93/// adds the four squares together and then adds that to the running total, and
94/// the compiler fuses three of the four multiplies into the adds next to them.
95/// The result is a different last bit from a plain sum, from a fully fused sum
96/// and from an exact one, and it is the number `VEMB` prints and the number the
97/// stored direction was divided by, so getting it right is the difference
98/// between matching a real server on every vector and matching it on about
99/// nine in ten.
100///
101/// [`f32::mul_add`] is fused by contract in Rust rather than by whatever the
102/// compiler felt like, so this answers the same on every machine it runs on,
103/// which is a thing a real server cannot quite say about its own.
104#[must_use]
105pub fn norm(v: &[f32]) -> f32 {
106    let mut sum = 0.0f32;
107    let (four, rest) = v.as_chunks::<4>();
108    for c in four {
109        let block = c[0].mul_add(c[0], c[1] * c[1]);
110        let block = c[2].mul_add(c[2], block);
111        let block = c[3].mul_add(c[3], block);
112        sum += block;
113    }
114    for &x in rest {
115        sum = x.mul_add(x, sum);
116    }
117    sum.sqrt()
118}
119
120/// Split `v` into what gets stored and what gets kept beside it.
121///
122/// A vector of no length has no direction, so it is stored as the origin and
123/// comes back as the origin, which is the one input where `VEMB` of what went in
124/// is not what went in and there is nothing else it could be.
125#[must_use]
126pub fn squeeze(quant: Quant, v: &[f32]) -> Squeezed {
127    let norm = norm(v);
128    if norm <= 0.0 || !norm.is_finite() {
129        return Squeezed {
130            dir: vec![0.0; v.len()],
131            norm: 0.0,
132            range: 0.0,
133        };
134    }
135    let mut dir: Vec<f32> = v.iter().map(|x| x / norm).collect();
136    let range = dir.iter().fold(0.0f32, |wide, x| wide.max(x.abs()));
137    match quant {
138        Quant::None => {}
139        Quant::Int8 => {
140            // The reciprocal is formed once and multiplied, which is both faster
141            // and the grouping a real server uses.
142            let step = 127.0 / range;
143            for x in &mut dir {
144                *x = f32::from(eighth(*x, step)) * range / 127.0;
145            }
146        }
147        Quant::Bin => {
148            // Unit length rather than plus and minus one, so that the distance
149            // between two of these is the distance between two directions and
150            // the index does not have to know a binary set from any other.
151            #[allow(clippy::cast_precision_loss)]
152            let w = (v.len() as f32).sqrt().recip();
153            for x in &mut dir {
154                *x = if *x > 0.0 { w } else { -w };
155            }
156        }
157    }
158    Squeezed { dir, norm, range }
159}
160
161/// What `VEMB` says about a stored direction, which is the client's vector back
162/// again as near as the quantisation kept it.
163///
164/// The binary form is the exception and it is Redis's exception: it answers plus
165/// and minus one and does not multiply the length back on, because a sign has no
166/// length in it to scale.
167#[must_use]
168pub fn restore(quant: Quant, dir: &[f32], norm: f32) -> Vec<f32> {
169    match quant {
170        Quant::Bin => dir
171            .iter()
172            .map(|x| if *x > 0.0 { 1.0 } else { -1.0 })
173            .collect(),
174        _ => dir.iter().map(|x| x * norm).collect(),
175    }
176}
177
178/// The bytes `VEMB RAW` writes for a stored direction.
179///
180/// Little endian floats for the full precision form, one signed byte a
181/// coordinate for `Q8`, and for the binary form a run of 64 bit words with the
182/// first coordinate in the lowest bit of the first word.
183#[must_use]
184pub fn raw(quant: Quant, dir: &[f32], range: f32) -> Vec<u8> {
185    let mut bytes = Vec::with_capacity(quant.code_bytes(dir.len()));
186    match quant {
187        Quant::None => {
188            for x in dir {
189                bytes.extend_from_slice(&x.to_le_bytes());
190            }
191        }
192        Quant::Int8 => {
193            for x in dir {
194                bytes.push(code(*x, range) as u8);
195            }
196        }
197        Quant::Bin => {
198            bytes.resize(quant.code_bytes(dir.len()), 0);
199            for (at, x) in dir.iter().enumerate() {
200                if *x > 0.0 {
201                    bytes[at / 8] |= 1 << (at % 8);
202                }
203            }
204        }
205    }
206    bytes
207}
208
209/// The code a stored `Q8` coordinate came from.
210///
211/// The coordinate is `(code * range) / 127` and the way back is the way in with
212/// the scale the other way up. It round trips exactly rather than nearly,
213/// because a code is a whole number no bigger than 127 and the error either
214/// direction is nowhere near half of one.
215fn code(x: f32, range: f32) -> i8 {
216    if range <= 0.0 || !range.is_finite() {
217        return 0;
218    }
219    eighth(x, 127.0 / range)
220}
221
222/// One coordinate of a `Q8` direction, in the byte that holds it.
223///
224/// Clamped rather than wrapped, because a coordinate that is the range itself
225/// lands on 127 give or take a rounding and the byte below it is the one a
226/// wrapping cast would give.
227#[allow(clippy::cast_possible_truncation)]
228fn eighth(x: f32, step: f32) -> i8 {
229    (x * step).round().clamp(-127.0, 127.0) as i8
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    /// The numbers here are a real server's answers for the same input, read off
237    /// `VEMB` and `VEMB RAW` rather than worked out from the formula, which is
238    /// the only way this test can catch the formula being wrong.
239    #[test]
240    fn a_q8_vector_is_squeezed_the_way_a_real_server_squeezes_it() {
241        let v = [
242            -1.057f32, -2.095, 0.906, -2.565, 0.215, -0.806, -2.652, 0.045,
243        ];
244        let s = squeeze(Quant::Int8, &v);
245        assert_eq!(s.norm, 4.542_832_4);
246        assert_eq!(s.range, 0.583_776_8);
247        let bytes = raw(Quant::Int8, &s.dir, s.range);
248        assert_eq!(bytes, [0xcd, 0x9c, 0x2b, 0x85, 0x0a, 0xd9, 0x81, 0x02]);
249        let back = restore(Quant::Int8, &s.dir, s.norm);
250        assert_eq!(back[0], -1.064_976_5);
251        assert_eq!(back[7], 0.041_763_78);
252    }
253
254    /// Two vectors a real server answers differently from both a plain sum of
255    /// squares and an exactly rounded one, which is what makes this test worth
256    /// having: any of the three obvious ways to write [`norm`] passes on most
257    /// input and fails on these.
258    #[test]
259    fn the_length_is_the_length_a_real_server_measures() {
260        let six = [
261            -0.937_271_f32,
262            -0.990_583_06,
263            -3.973_563_2,
264            -3.560_724_7,
265            -4.341_83,
266            -2.311_353_2,
267        ];
268        assert_eq!(norm(&six), 7.383_869_6);
269        let eight = [
270            3.911_460_9_f32,
271            -4.397_685_5,
272            1.571_724_4,
273            1.238_847_4,
274            4.993_485_5,
275            4.326_879,
276            -0.686_563_8,
277            2.032_343_4,
278        ];
279        assert_eq!(norm(&eight), 9.322_167);
280        // A vector short enough that the unrolled loop never runs, where the
281        // fused tail is the whole answer.
282        assert_eq!(norm(&[3.0, 4.0]), 5.0);
283    }
284
285    #[test]
286    fn nothing_is_lost_when_nothing_is_squeezed() {
287        let v = [3.0f32, 4.0];
288        let s = squeeze(Quant::None, &v);
289        assert_eq!(s.norm, 5.0);
290        assert_eq!(restore(Quant::None, &s.dir, s.norm), [3.0, 4.0]);
291        assert_eq!(raw(Quant::None, &s.dir, s.range).len(), 8);
292    }
293
294    #[test]
295    fn a_binary_vector_is_its_signs_and_keeps_no_length() {
296        let v = [1.0f32, -2.0, 0.0, 4.0];
297        let s = squeeze(Quant::Bin, &v);
298        assert_eq!(restore(Quant::Bin, &s.dir, s.norm), [1.0, -1.0, -1.0, 1.0]);
299        // Four dimensions still write one whole word, because the wire form is
300        // words and not bytes.
301        assert_eq!(
302            raw(Quant::Bin, &s.dir, s.range),
303            [0b1001, 0, 0, 0, 0, 0, 0, 0]
304        );
305    }
306
307    #[test]
308    fn a_squeezed_direction_is_still_a_direction() {
309        let v = [0.3f32, -1.7, 2.2, 0.9, -0.4];
310        for quant in [Quant::None, Quant::Int8, Quant::Bin] {
311            let s = squeeze(quant, &v);
312            let len = norm(&s.dir);
313            assert!((len - 1.0).abs() < 1e-3, "{} is {len} long", quant.token());
314        }
315    }
316
317    #[test]
318    fn a_vector_of_no_length_is_stored_as_the_origin() {
319        let s = squeeze(Quant::Int8, &[0.0, 0.0, 0.0]);
320        assert_eq!(s.norm, 0.0);
321        assert_eq!(s.dir, [0.0, 0.0, 0.0]);
322        assert_eq!(raw(Quant::Int8, &s.dir, s.range), [0, 0, 0]);
323    }
324
325    #[test]
326    fn how_wide_the_bytes_are_is_how_wide_they_turn_out_to_be() {
327        for dim in [1usize, 7, 8, 63, 64, 65, 300] {
328            let v = vec![0.5f32; dim];
329            for quant in [Quant::None, Quant::Int8, Quant::Bin] {
330                let s = squeeze(quant, &v);
331                let bytes = raw(quant, &s.dir, s.range);
332                assert_eq!(
333                    bytes.len(),
334                    quant.code_bytes(dim),
335                    "{} at {dim}",
336                    quant.token()
337                );
338            }
339        }
340    }
341}