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}