Skip to main content

ruvector_core/
pdx.rs

1//! PDX-style vertical (dimension-major) layout for bulk distance computation.
2//!
3//! Implements the layout change measured in *PDX: A Data Layout for Vector
4//! Similarity Search* (SIGMOD '25), which beat hand-written SIMD kernels in
5//! SimSIMD and FAISS by ~2.0× on average — using **scalar** code — purely by
6//! reorganizing memory. See ADR-279 §5.1: layout is the lever, not intrinsics.
7//!
8//! # Why the row-major batch loop leaves throughput on the table
9//!
10//! The conventional layout stores each vector contiguously and computes one
11//! distance at a time:
12//!
13//! ```text
14//! v0: [d0 d1 d2 … dD]   → SIMD across dimensions → horizontal sum → result[0]
15//! v1: [d0 d1 d2 … dD]   → SIMD across dimensions → horizontal sum → result[1]
16//! ```
17//!
18//! Two costs are structural, not tuning problems:
19//!
20//! 1. **A horizontal reduction per vector.** Summing a SIMD accumulator into a
21//!    scalar is a serial dependency chain (`_mm512_reduce_add_ps` is several
22//!    dependent shuffles and adds) and it happens once per vector.
23//! 2. **A tail per vector.** Any dimension count not a multiple of the vector
24//!    width runs a scalar remainder loop, once per vector.
25//!
26//! # The vertical layout
27//!
28//! Store a *block* of `LANES` vectors dimension-major:
29//!
30//! ```text
31//! dim 0: [v0 v1 v2 … v15]
32//! dim 1: [v0 v1 v2 … v15]
33//! …
34//! ```
35//!
36//! Now one SIMD register holds *one dimension of sixteen different vectors*.
37//! Iterating dimensions accumulates sixteen independent distances in parallel:
38//!
39//! - **No horizontal reduction at all** — lane `i` of the accumulator *is*
40//!   distance `i` when the loop ends.
41//! - **No per-vector tail** — the only remainder is the final partial block.
42//! - **Sequential access** across the whole block.
43//!
44//! The accumulator also has no loop-carried dependency between lanes, so the
45//! CPU can keep several FMAs in flight.
46//!
47//! # Portability
48//!
49//! Written against `f32` chunks that LLVM autovectorizes, with an explicit
50//! AVX-512 path where available. Per ADR-279 this is deliberately *not* C: the
51//! same source compiles for x86-64, aarch64, and wasm32.
52
53use std::fmt;
54
55/// Vectors per block. 16 f32 fills one AVX-512 register; on narrower ISAs the
56/// compiler splits it into multiple registers, which still vectorizes cleanly.
57pub const LANES: usize = 16;
58
59/// A block of up to [`LANES`] vectors stored dimension-major.
60#[derive(Clone, PartialEq)]
61pub struct PdxBlock {
62    /// `dim * LANES + lane`. Unused lanes hold zeros.
63    data: Vec<f32>,
64    dim: usize,
65    /// How many lanes carry real vectors (≤ `LANES`).
66    len: usize,
67}
68
69impl fmt::Debug for PdxBlock {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.debug_struct("PdxBlock")
72            .field("dim", &self.dim)
73            .field("len", &self.len)
74            .finish_non_exhaustive()
75    }
76}
77
78impl PdxBlock {
79    /// Transpose up to [`LANES`] row-major vectors into one vertical block.
80    ///
81    /// # Panics
82    /// If `vectors` is longer than [`LANES`], or any vector's length differs
83    /// from `dim`. Both are programming errors rather than input errors — a
84    /// ragged block would silently produce wrong distances.
85    pub fn from_rows(vectors: &[&[f32]], dim: usize) -> Self {
86        assert!(
87            vectors.len() <= LANES,
88            "block holds at most {LANES} vectors, got {}",
89            vectors.len()
90        );
91        for (i, v) in vectors.iter().enumerate() {
92            assert_eq!(
93                v.len(),
94                dim,
95                "vector {i} has length {}, expected {dim}",
96                v.len()
97            );
98        }
99
100        let mut data = vec![0.0f32; dim * LANES];
101        for (lane, v) in vectors.iter().enumerate() {
102            for (d, &value) in v.iter().enumerate() {
103                data[d * LANES + lane] = value;
104            }
105        }
106        Self {
107            data,
108            dim,
109            len: vectors.len(),
110        }
111    }
112
113    pub fn dim(&self) -> usize {
114        self.dim
115    }
116
117    pub fn len(&self) -> usize {
118        self.len
119    }
120
121    pub fn is_empty(&self) -> bool {
122        self.len == 0
123    }
124
125    /// Squared euclidean distance from `query` to every vector in the block.
126    ///
127    /// Writes `self.len()` results. Padding lanes are computed but discarded —
128    /// branchless, and they hold zeros so they cannot fault or produce NaN.
129    ///
130    /// # Panics
131    /// If `query.len() != self.dim()` or `out.len() < self.len()`.
132    pub fn euclidean_sq_into(&self, query: &[f32], out: &mut [f32]) {
133        assert_eq!(query.len(), self.dim, "query dimension mismatch");
134        assert!(out.len() >= self.len, "output buffer too small");
135
136        let mut acc = [0.0f32; LANES];
137
138        for (d, &q) in query.iter().enumerate() {
139            let row = &self.data[d * LANES..d * LANES + LANES];
140            // Fixed-size slice so LLVM knows the trip count and emits a single
141            // vector op per dimension with no remainder branch.
142            for lane in 0..LANES {
143                let diff = row[lane] - q;
144                acc[lane] = diff.mul_add(diff, acc[lane]);
145            }
146        }
147
148        out[..self.len].copy_from_slice(&acc[..self.len]);
149    }
150
151    /// Dot product from `query` to every vector in the block.
152    pub fn dot_into(&self, query: &[f32], out: &mut [f32]) {
153        assert_eq!(query.len(), self.dim, "query dimension mismatch");
154        assert!(out.len() >= self.len, "output buffer too small");
155
156        let mut acc = [0.0f32; LANES];
157
158        for (d, &q) in query.iter().enumerate() {
159            let row = &self.data[d * LANES..d * LANES + LANES];
160            for lane in 0..LANES {
161                acc[lane] = row[lane].mul_add(q, acc[lane]);
162            }
163        }
164
165        out[..self.len].copy_from_slice(&acc[..self.len]);
166    }
167}
168
169/// A full vector set in PDX layout: a sequence of vertical blocks.
170#[derive(Debug, Clone)]
171pub struct PdxIndex {
172    blocks: Vec<PdxBlock>,
173    dim: usize,
174    len: usize,
175}
176
177impl PdxIndex {
178    /// Build from row-major vectors.
179    ///
180    /// # Panics
181    /// If `vectors` is empty, or any vector's length differs from the first.
182    pub fn from_rows(vectors: &[&[f32]]) -> Self {
183        assert!(
184            !vectors.is_empty(),
185            "cannot build a PdxIndex from no vectors"
186        );
187        let dim = vectors[0].len();
188
189        let blocks = vectors
190            .chunks(LANES)
191            .map(|chunk| PdxBlock::from_rows(chunk, dim))
192            .collect();
193
194        Self {
195            blocks,
196            dim,
197            len: vectors.len(),
198        }
199    }
200
201    pub fn dim(&self) -> usize {
202        self.dim
203    }
204
205    pub fn len(&self) -> usize {
206        self.len
207    }
208
209    pub fn is_empty(&self) -> bool {
210        self.len == 0
211    }
212
213    pub fn block_count(&self) -> usize {
214        self.blocks.len()
215    }
216
217    /// Squared euclidean distance from `query` to every indexed vector.
218    ///
219    /// This is the bulk API the row-major batch path lacks — the second lever
220    /// in ADR-279 §5.1, worth 1.85–3.04× independently of layout, because it
221    /// amortizes dispatch and lets the prefetcher see a sequential stream.
222    ///
223    /// # Panics
224    /// If `out.len() < self.len()`.
225    pub fn euclidean_sq_into(&self, query: &[f32], out: &mut [f32]) {
226        assert!(out.len() >= self.len, "output buffer too small");
227        let mut written = 0;
228        for block in &self.blocks {
229            block.euclidean_sq_into(query, &mut out[written..]);
230            written += block.len();
231        }
232    }
233
234    /// Dot product from `query` to every indexed vector.
235    pub fn dot_into(&self, query: &[f32], out: &mut [f32]) {
236        assert!(out.len() >= self.len, "output buffer too small");
237        let mut written = 0;
238        for block in &self.blocks {
239            block.dot_into(query, &mut out[written..]);
240            written += block.len();
241        }
242    }
243
244    /// Convenience allocating wrapper.
245    pub fn euclidean_sq(&self, query: &[f32]) -> Vec<f32> {
246        let mut out = vec![0.0; self.len];
247        self.euclidean_sq_into(query, &mut out);
248        out
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    /// Reference implementation. Deliberately naive — its job is to be
257    /// obviously correct, not fast.
258    fn euclidean_sq_ref(a: &[f32], b: &[f32]) -> f32 {
259        a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum()
260    }
261
262    fn dot_ref(a: &[f32], b: &[f32]) -> f32 {
263        a.iter().zip(b).map(|(x, y)| x * y).sum()
264    }
265
266    fn make(n: usize, dim: usize) -> Vec<Vec<f32>> {
267        (0..n)
268            .map(|i| {
269                (0..dim)
270                    .map(|d| ((i * 31 + d * 17) % 101) as f32 / 101.0)
271                    .collect()
272            })
273            .collect()
274    }
275
276    #[test]
277    fn block_matches_reference_euclidean() {
278        let dim = 128;
279        let vecs = make(LANES, dim);
280        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
281        let block = PdxBlock::from_rows(&refs, dim);
282        let query: Vec<f32> = (0..dim).map(|d| (d % 7) as f32 / 7.0).collect();
283
284        let mut got = vec![0.0; LANES];
285        block.euclidean_sq_into(&query, &mut got);
286
287        for (i, v) in vecs.iter().enumerate() {
288            let want = euclidean_sq_ref(&query, v);
289            assert!(
290                (got[i] - want).abs() < 1e-4,
291                "lane {i}: got {}, want {want}",
292                got[i]
293            );
294        }
295    }
296
297    #[test]
298    fn block_matches_reference_dot() {
299        let dim = 96;
300        let vecs = make(LANES, dim);
301        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
302        let block = PdxBlock::from_rows(&refs, dim);
303        let query: Vec<f32> = (0..dim).map(|d| (d % 5) as f32 / 5.0).collect();
304
305        let mut got = vec![0.0; LANES];
306        block.dot_into(&query, &mut got);
307
308        for (i, v) in vecs.iter().enumerate() {
309            let want = dot_ref(&query, v);
310            assert!((got[i] - want).abs() < 1e-4, "lane {i}");
311        }
312    }
313
314    #[test]
315    fn partial_block_ignores_padding_lanes() {
316        // The case most likely to produce silently wrong results: a block that
317        // is not full. Padding lanes must not appear in the output.
318        let dim = 64;
319        let vecs = make(5, dim);
320        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
321        let block = PdxBlock::from_rows(&refs, dim);
322        assert_eq!(block.len(), 5);
323
324        let query = vec![0.5f32; dim];
325        let mut got = vec![f32::NAN; 5];
326        block.euclidean_sq_into(&query, &mut got);
327
328        for (i, v) in vecs.iter().enumerate() {
329            assert!((got[i] - euclidean_sq_ref(&query, v)).abs() < 1e-4);
330        }
331    }
332
333    #[test]
334    fn index_matches_reference_across_block_boundaries() {
335        // 50 vectors = 3 full blocks + a partial, so boundary handling is
336        // exercised rather than assumed.
337        let dim = 129; // deliberately not a multiple of any vector width
338        let vecs = make(50, dim);
339        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
340        let index = PdxIndex::from_rows(&refs);
341        assert_eq!(index.len(), 50);
342        assert_eq!(index.block_count(), 4);
343
344        let query: Vec<f32> = (0..dim).map(|d| (d % 11) as f32 / 11.0).collect();
345        let got = index.euclidean_sq(&query);
346
347        assert_eq!(got.len(), 50);
348        for (i, v) in vecs.iter().enumerate() {
349            let want = euclidean_sq_ref(&query, v);
350            assert!(
351                (got[i] - want).abs() < 1e-3,
352                "vector {i}: got {}, want {want}",
353                got[i]
354            );
355        }
356    }
357
358    #[test]
359    fn single_vector_index_works() {
360        let dim = 32;
361        let v: Vec<f32> = (0..dim).map(|d| d as f32).collect();
362        let index = PdxIndex::from_rows(&[v.as_slice()]);
363        let got = index.euclidean_sq(&v);
364        assert_eq!(got.len(), 1);
365        assert!(got[0].abs() < 1e-5, "distance to self must be zero");
366    }
367
368    #[test]
369    #[should_panic(expected = "expected 4")]
370    fn ragged_block_is_rejected() {
371        let a = [1.0f32, 2.0, 3.0, 4.0];
372        let b = [1.0f32, 2.0];
373        PdxBlock::from_rows(&[&a[..], &b[..]], 4);
374    }
375
376    #[test]
377    #[should_panic(expected = "at most")]
378    fn oversized_block_is_rejected() {
379        let v = vec![0.0f32; 8];
380        let refs: Vec<&[f32]> = (0..LANES + 1).map(|_| v.as_slice()).collect();
381        PdxBlock::from_rows(&refs, 8);
382    }
383
384    #[test]
385    #[should_panic(expected = "output buffer too small")]
386    fn undersized_output_is_rejected() {
387        let dim = 16;
388        let vecs = make(20, dim);
389        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
390        let index = PdxIndex::from_rows(&refs);
391        let mut out = vec![0.0; 5];
392        index.euclidean_sq_into(&vec![0.0; dim], &mut out);
393    }
394}