Skip to main content

threecrate_algorithms/
simd_distance.rs

1//! SIMD-accelerated distance computations for nearest-neighbor search.
2//!
3//! # Strategy
4//!
5//! Points are stored in **Structure-of-Arrays (SoA)** layout — three separate
6//! `f32` slices for X, Y, and Z — so that SIMD lanes map directly onto
7//! multiple points at once.
8//!
9//! ```text
10//! AoS (cache-unfriendly for distance):   [(x0,y0,z0), (x1,y1,z1), …]
11//! SoA (SIMD-friendly):                   xs=[x0,x1,…]  ys=[y0,y1,…]  zs=[z0,z1,…]
12//! ```
13//!
14//! Given a query `q = (qx, qy, qz)` the squared distance to point `i` is:
15//!
16//! ```text
17//! d²ᵢ = (xᵢ−qx)² + (yᵢ−qy)² + (zᵢ−qz)²
18//! ```
19//!
20//! SIMD processes 4 (SSE2) or 8 (AVX2) distances per instruction cycle.
21//!
22//! # Dispatch
23//!
24//! ```text
25//! has AVX2?  → avx2_distances_squared  (8-wide f32)
26//! else SSE2? → sse2_distances_squared  (4-wide f32, always present on x86-64)
27//! else       → scalar_distances_squared (portable fallback)
28//! ```
29//!
30//! The dispatch is resolved **at runtime** using `is_x86_feature_detected!`, so
31//! the same binary runs correctly on all hardware while using the widest SIMD
32//! available.
33
34use std::cmp::Ordering;
35use threecrate_core::{NearestNeighborSearch, Point3f};
36
37// ---------------------------------------------------------------------------
38// SoA point store
39// ---------------------------------------------------------------------------
40
41/// Point cloud stored in Structure-of-Arrays format for SIMD-friendly access.
42///
43/// ```
44/// # use threecrate_algorithms::SoaPoints;
45/// # use threecrate_core::Point3f;
46/// let pts = vec![Point3f::new(1.0, 2.0, 3.0), Point3f::new(4.0, 5.0, 6.0)];
47/// let soa = SoaPoints::from_points(&pts);
48/// assert_eq!(soa.xs(), &[1.0, 4.0]);
49/// ```
50#[derive(Debug, Clone)]
51pub struct SoaPoints {
52    xs: Vec<f32>,
53    ys: Vec<f32>,
54    zs: Vec<f32>,
55}
56
57impl SoaPoints {
58    /// Build an SoA store from an AoS slice.
59    pub fn from_points(points: &[Point3f]) -> Self {
60        let mut xs = Vec::with_capacity(points.len());
61        let mut ys = Vec::with_capacity(points.len());
62        let mut zs = Vec::with_capacity(points.len());
63        for p in points {
64            xs.push(p.x);
65            ys.push(p.y);
66            zs.push(p.z);
67        }
68        Self { xs, ys, zs }
69    }
70
71    /// Number of stored points.
72    #[inline]
73    pub fn len(&self) -> usize {
74        self.xs.len()
75    }
76
77    /// Returns `true` if there are no stored points.
78    #[inline]
79    pub fn is_empty(&self) -> bool {
80        self.xs.is_empty()
81    }
82
83    /// X coordinates slice.
84    #[inline]
85    pub fn xs(&self) -> &[f32] {
86        &self.xs
87    }
88
89    /// Y coordinates slice.
90    #[inline]
91    pub fn ys(&self) -> &[f32] {
92        &self.ys
93    }
94
95    /// Z coordinates slice.
96    #[inline]
97    pub fn zs(&self) -> &[f32] {
98        &self.zs
99    }
100}
101
102// ---------------------------------------------------------------------------
103// Public batch-distance API
104// ---------------------------------------------------------------------------
105
106/// Compute squared Euclidean distances from `query` to every point in `pts`.
107///
108/// Results are written into `out`, which must have the same length as `pts`.
109/// Uses the widest SIMD available (AVX2 → SSE2 → scalar).
110pub fn batch_distances_squared(query: &Point3f, pts: &SoaPoints, out: &mut [f32]) {
111    debug_assert_eq!(out.len(), pts.len());
112
113    #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
114    {
115        if is_x86_feature_detected!("avx2") {
116            // SAFETY: feature was detected at runtime.
117            return unsafe { avx2_distances_squared(query, pts, out) };
118        }
119        if is_x86_feature_detected!("sse2") {
120            return unsafe { sse2_distances_squared(query, pts, out) };
121        }
122    }
123
124    scalar_distances_squared(query, pts, out);
125}
126
127/// Portable, vectorisation-friendly fallback (also the reference implementation).
128///
129/// This loop is written so that LLVM can auto-vectorise it on any target.
130#[inline]
131pub fn scalar_distances_squared(query: &Point3f, pts: &SoaPoints, out: &mut [f32]) {
132    let (qx, qy, qz) = (query.x, query.y, query.z);
133    let n = pts.len();
134    let xs = pts.xs();
135    let ys = pts.ys();
136    let zs = pts.zs();
137    for i in 0..n {
138        let dx = xs[i] - qx;
139        let dy = ys[i] - qy;
140        let dz = zs[i] - qz;
141        out[i] = dx * dx + dy * dy + dz * dz;
142    }
143}
144
145// ---------------------------------------------------------------------------
146// SSE2 implementation (4-wide, always available on x86-64)
147// ---------------------------------------------------------------------------
148
149/// Compute distances using SSE2 (4 × f32 per cycle).
150///
151/// # Safety
152/// Caller must ensure the `sse2` CPU feature is present.
153#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
154#[target_feature(enable = "sse2")]
155unsafe fn sse2_distances_squared(query: &Point3f, pts: &SoaPoints, out: &mut [f32]) {
156    #[cfg(target_arch = "x86")]
157    use std::arch::x86::*;
158    #[cfg(target_arch = "x86_64")]
159    use std::arch::x86_64::*;
160
161    let n = pts.len();
162    let xs = pts.xs();
163    let ys = pts.ys();
164    let zs = pts.zs();
165
166    let qx_v = _mm_set1_ps(query.x);
167    let qy_v = _mm_set1_ps(query.y);
168    let qz_v = _mm_set1_ps(query.z);
169
170    let chunks = n / 4;
171    let remainder = n % 4;
172
173    for c in 0..chunks {
174        let base = c * 4;
175        let xs_v = _mm_loadu_ps(xs.as_ptr().add(base));
176        let ys_v = _mm_loadu_ps(ys.as_ptr().add(base));
177        let zs_v = _mm_loadu_ps(zs.as_ptr().add(base));
178
179        let dx = _mm_sub_ps(xs_v, qx_v);
180        let dy = _mm_sub_ps(ys_v, qy_v);
181        let dz = _mm_sub_ps(zs_v, qz_v);
182
183        let d2 = _mm_add_ps(
184            _mm_add_ps(_mm_mul_ps(dx, dx), _mm_mul_ps(dy, dy)),
185            _mm_mul_ps(dz, dz),
186        );
187
188        _mm_storeu_ps(out.as_mut_ptr().add(base), d2);
189    }
190
191    // Handle remainder with scalar code.
192    let rem_start = chunks * 4;
193    scalar_remainder(query, xs, ys, zs, out, rem_start, remainder);
194}
195
196// ---------------------------------------------------------------------------
197// AVX2 implementation (8-wide)
198// ---------------------------------------------------------------------------
199
200/// Compute distances using AVX2 (8 × f32 per cycle).
201///
202/// # Safety
203/// Caller must ensure the `avx2` CPU feature is present.
204#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
205#[target_feature(enable = "avx2")]
206unsafe fn avx2_distances_squared(query: &Point3f, pts: &SoaPoints, out: &mut [f32]) {
207    #[cfg(target_arch = "x86")]
208    use std::arch::x86::*;
209    #[cfg(target_arch = "x86_64")]
210    use std::arch::x86_64::*;
211
212    let n = pts.len();
213    let xs = pts.xs();
214    let ys = pts.ys();
215    let zs = pts.zs();
216
217    let qx_v = _mm256_set1_ps(query.x);
218    let qy_v = _mm256_set1_ps(query.y);
219    let qz_v = _mm256_set1_ps(query.z);
220
221    let chunks = n / 8;
222    let remainder_start = chunks * 8;
223    let remainder = n - remainder_start;
224
225    for c in 0..chunks {
226        let base = c * 8;
227        let xs_v = _mm256_loadu_ps(xs.as_ptr().add(base));
228        let ys_v = _mm256_loadu_ps(ys.as_ptr().add(base));
229        let zs_v = _mm256_loadu_ps(zs.as_ptr().add(base));
230
231        let dx = _mm256_sub_ps(xs_v, qx_v);
232        let dy = _mm256_sub_ps(ys_v, qy_v);
233        let dz = _mm256_sub_ps(zs_v, qz_v);
234
235        let d2 = _mm256_add_ps(
236            _mm256_add_ps(_mm256_mul_ps(dx, dx), _mm256_mul_ps(dy, dy)),
237            _mm256_mul_ps(dz, dz),
238        );
239
240        _mm256_storeu_ps(out.as_mut_ptr().add(base), d2);
241    }
242
243    // Use SSE2 for the leftover 4-element block (if any), then scalar for the rest.
244    let mut rem = remainder;
245    let mut rem_base = remainder_start;
246
247    if rem >= 4 {
248        // We already ensured avx2 implies sse2 on x86-64, so this is safe.
249        #[cfg(target_arch = "x86_64")]
250        use std::arch::x86_64::*;
251        let qx_s = _mm_set1_ps(query.x);
252        let qy_s = _mm_set1_ps(query.y);
253        let qz_s = _mm_set1_ps(query.z);
254
255        let xs_v = _mm_loadu_ps(xs.as_ptr().add(rem_base));
256        let ys_v = _mm_loadu_ps(ys.as_ptr().add(rem_base));
257        let zs_v = _mm_loadu_ps(zs.as_ptr().add(rem_base));
258
259        let dx = _mm_sub_ps(xs_v, qx_s);
260        let dy = _mm_sub_ps(ys_v, qy_s);
261        let dz = _mm_sub_ps(zs_v, qz_s);
262
263        let d2 = _mm_add_ps(
264            _mm_add_ps(_mm_mul_ps(dx, dx), _mm_mul_ps(dy, dy)),
265            _mm_mul_ps(dz, dz),
266        );
267        _mm_storeu_ps(out.as_mut_ptr().add(rem_base), d2);
268
269        rem_base += 4;
270        rem -= 4;
271    }
272
273    scalar_remainder(query, xs, ys, zs, out, rem_base, rem);
274}
275
276/// Scalar tail processing for SIMD functions.
277#[cfg_attr(
278    not(any(target_arch = "x86", target_arch = "x86_64")),
279    allow(dead_code)
280)]
281#[inline(always)]
282fn scalar_remainder(
283    query: &Point3f,
284    xs: &[f32],
285    ys: &[f32],
286    zs: &[f32],
287    out: &mut [f32],
288    start: usize,
289    count: usize,
290) {
291    let (qx, qy, qz) = (query.x, query.y, query.z);
292    for i in 0..count {
293        let idx = start + i;
294        let dx = xs[idx] - qx;
295        let dy = ys[idx] - qy;
296        let dz = zs[idx] - qz;
297        out[idx] = dx * dx + dy * dy + dz * dz;
298    }
299}
300
301// ---------------------------------------------------------------------------
302// SimdBruteForceSearch
303// ---------------------------------------------------------------------------
304
305/// Brute-force nearest-neighbor search with SIMD-accelerated distance computation.
306///
307/// All N squared distances are computed in a single SIMD-vectorised pass before
308/// selection, which is more cache-friendly than repeated point-by-point comparison
309/// and exploits the full width of the CPU's SIMD units.
310///
311/// | Method            | Complexity | Notes                                |
312/// |-------------------|------------|--------------------------------------|
313/// | `find_k_nearest`  | O(N + k log k) | compute-all-then-partial-select  |
314/// | `find_radius_neighbors` | O(N) | compute-all-then-filter           |
315///
316/// # Example
317/// ```
318/// # use threecrate_algorithms::SimdBruteForceSearch;
319/// # use threecrate_core::{Point3f, NearestNeighborSearch};
320/// let pts = vec![Point3f::new(0.0, 0.0, 0.0), Point3f::new(1.0, 0.0, 0.0)];
321/// let searcher = SimdBruteForceSearch::new(&pts);
322/// let result = searcher.find_k_nearest(&Point3f::new(0.1, 0.0, 0.0), 1);
323/// assert_eq!(result[0].0, 0); // index of nearest point
324/// ```
325pub struct SimdBruteForceSearch {
326    soa: SoaPoints,
327}
328
329impl SimdBruteForceSearch {
330    /// Construct a new searcher from a point slice (O(N) time and space).
331    pub fn new(points: &[Point3f]) -> Self {
332        Self {
333            soa: SoaPoints::from_points(points),
334        }
335    }
336
337    /// Number of indexed points.
338    pub fn len(&self) -> usize {
339        self.soa.len()
340    }
341
342    /// Returns `true` if the index is empty.
343    pub fn is_empty(&self) -> bool {
344        self.soa.is_empty()
345    }
346
347    /// Return the SoA representation (useful for benchmarks / inspection).
348    pub fn soa(&self) -> &SoaPoints {
349        &self.soa
350    }
351}
352
353impl NearestNeighborSearch for SimdBruteForceSearch {
354    fn find_k_nearest(&self, query: &Point3f, k: usize) -> Vec<(usize, f32)> {
355        if k == 0 || self.soa.is_empty() {
356            return Vec::new();
357        }
358
359        let n = self.soa.len();
360        let k = k.min(n);
361
362        // ---- 1. Compute all squared distances in one SIMD pass ----
363        let mut dist_sq = vec![0.0f32; n];
364        batch_distances_squared(query, &self.soa, &mut dist_sq);
365
366        // ---- 2. Partial sort: find the k smallest using a max-heap ----
367        let mut heap: std::collections::BinaryHeap<DistEntry> =
368            std::collections::BinaryHeap::with_capacity(k + 1);
369
370        for (idx, &d2) in dist_sq.iter().enumerate() {
371            if heap.len() < k {
372                heap.push(DistEntry {
373                    dist_sq: d2,
374                    index: idx,
375                });
376            } else if let Some(farthest) = heap.peek() {
377                if d2 < farthest.dist_sq {
378                    heap.pop();
379                    heap.push(DistEntry {
380                        dist_sq: d2,
381                        index: idx,
382                    });
383                }
384            }
385        }
386
387        // ---- 3. Extract and sort ascending by distance ----
388        let mut result: Vec<(usize, f32)> = heap
389            .into_iter()
390            .map(|e| (e.index, e.dist_sq.sqrt()))
391            .collect();
392        result.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
393        result
394    }
395
396    fn find_radius_neighbors(&self, query: &Point3f, radius: f32) -> Vec<(usize, f32)> {
397        if radius <= 0.0 || self.soa.is_empty() {
398            return Vec::new();
399        }
400
401        let n = self.soa.len();
402        let radius_sq = radius * radius;
403
404        // ---- 1. Compute all squared distances ----
405        let mut dist_sq = vec![0.0f32; n];
406        batch_distances_squared(query, &self.soa, &mut dist_sq);
407
408        // ---- 2. Filter and convert ----
409        let mut result: Vec<(usize, f32)> = dist_sq
410            .iter()
411            .enumerate()
412            .filter_map(|(idx, &d2)| {
413                if d2 <= radius_sq {
414                    Some((idx, d2.sqrt()))
415                } else {
416                    None
417                }
418            })
419            .collect();
420
421        result.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
422        result
423    }
424}
425
426// ---------------------------------------------------------------------------
427// Internal helper: max-heap entry ordered by dist_sq (largest = top of heap)
428// ---------------------------------------------------------------------------
429
430#[derive(Debug, Clone, Copy, PartialEq)]
431struct DistEntry {
432    dist_sq: f32,
433    index: usize,
434}
435
436impl Eq for DistEntry {}
437
438impl PartialOrd for DistEntry {
439    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
440        Some(self.cmp(other))
441    }
442}
443
444impl Ord for DistEntry {
445    fn cmp(&self, other: &Self) -> Ordering {
446        // Larger dist_sq → higher priority in max-heap → root = farthest of the k candidates.
447        // `total_cmp` handles NaN consistently and avoids floating-point comparison UB.
448        self.dist_sq
449            .total_cmp(&other.dist_sq)
450            .then(self.index.cmp(&other.index))
451    }
452}
453
454// ---------------------------------------------------------------------------
455// Tests
456// ---------------------------------------------------------------------------
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use threecrate_core::Point3f;
462
463    fn cube_points() -> Vec<Point3f> {
464        vec![
465            Point3f::new(0.0, 0.0, 0.0),
466            Point3f::new(1.0, 0.0, 0.0),
467            Point3f::new(0.0, 1.0, 0.0),
468            Point3f::new(0.0, 0.0, 1.0),
469            Point3f::new(1.0, 1.0, 0.0),
470            Point3f::new(1.0, 0.0, 1.0),
471            Point3f::new(0.0, 1.0, 1.0),
472            Point3f::new(1.0, 1.0, 1.0),
473        ]
474    }
475
476    // ---- SoaPoints -------------------------------------------------------
477
478    #[test]
479    fn test_soa_layout() {
480        let pts = cube_points();
481        let soa = SoaPoints::from_points(&pts);
482        assert_eq!(soa.len(), pts.len());
483        for (i, p) in pts.iter().enumerate() {
484            assert_eq!(soa.xs()[i], p.x);
485            assert_eq!(soa.ys()[i], p.y);
486            assert_eq!(soa.zs()[i], p.z);
487        }
488    }
489
490    // ---- batch_distances_squared -----------------------------------------
491
492    fn reference_dist_sq(query: &Point3f, pts: &[Point3f]) -> Vec<f32> {
493        pts.iter()
494            .map(|p| {
495                let dx = p.x - query.x;
496                let dy = p.y - query.y;
497                let dz = p.z - query.z;
498                dx * dx + dy * dy + dz * dz
499            })
500            .collect()
501    }
502
503    #[test]
504    fn test_scalar_distances_match_reference() {
505        let pts = cube_points();
506        let soa = SoaPoints::from_points(&pts);
507        let query = Point3f::new(0.5, 0.5, 0.5);
508        let reference = reference_dist_sq(&query, &pts);
509        let mut out = vec![0.0f32; pts.len()];
510        scalar_distances_squared(&query, &soa, &mut out);
511        for (got, expected) in out.iter().zip(reference.iter()) {
512            assert!(
513                (got - expected).abs() < 1e-6,
514                "got={got}, expected={expected}"
515            );
516        }
517    }
518
519    #[test]
520    fn test_batch_distances_match_scalar() {
521        let pts = cube_points();
522        let soa = SoaPoints::from_points(&pts);
523        let query = Point3f::new(0.3, 0.7, 0.2);
524
525        let mut scalar_out = vec![0.0f32; pts.len()];
526        scalar_distances_squared(&query, &soa, &mut scalar_out);
527
528        let mut simd_out = vec![0.0f32; pts.len()];
529        batch_distances_squared(&query, &soa, &mut simd_out);
530
531        for (got, expected) in simd_out.iter().zip(scalar_out.iter()) {
532            assert!(
533                (got - expected).abs() < 1e-5,
534                "SIMD={got}, scalar={expected}"
535            );
536        }
537    }
538
539    /// Exhaustively test various point counts including non-multiples of 4 and 8
540    /// to verify that the remainder handling is correct.
541    #[test]
542    fn test_batch_distances_various_sizes() {
543        for n in [1, 3, 4, 5, 7, 8, 9, 15, 16, 17, 31, 32, 33, 100] {
544            let pts: Vec<Point3f> = (0..n)
545                .map(|i| Point3f::new(i as f32, (i * 2) as f32, (i * 3) as f32))
546                .collect();
547            let soa = SoaPoints::from_points(&pts);
548            let query = Point3f::new(5.0, 10.0, 15.0);
549            let reference = reference_dist_sq(&query, &pts);
550
551            let mut simd_out = vec![0.0f32; n];
552            batch_distances_squared(&query, &soa, &mut simd_out);
553
554            for (i, (got, expected)) in simd_out.iter().zip(reference.iter()).enumerate() {
555                assert!(
556                    (got - expected).abs() < 1e-4,
557                    "n={n} i={i}: SIMD={got}, ref={expected}"
558                );
559            }
560        }
561    }
562
563    // ---- SimdBruteForceSearch --------------------------------------------
564
565    #[test]
566    fn test_simd_knn_matches_brute_force() {
567        use crate::nearest_neighbor::BruteForceSearch;
568        let pts = cube_points();
569        let query = Point3f::new(0.5, 0.5, 0.5);
570        let k = 3;
571
572        let simd = SimdBruteForceSearch::new(&pts);
573        let scalar = BruteForceSearch::new(&pts);
574
575        let mut simd_res = simd.find_k_nearest(&query, k);
576        let mut scalar_res = scalar.find_k_nearest(&query, k);
577
578        simd_res.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then(a.0.cmp(&b.0)));
579        scalar_res.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then(a.0.cmp(&b.0)));
580
581        assert_eq!(simd_res.len(), k);
582        for ((si, sd), (_, bd)) in simd_res.iter().zip(scalar_res.iter()) {
583            assert!(
584                (sd - bd).abs() < 1e-5,
585                "dist mismatch: simd={sd} scalar={bd}"
586            );
587            let _ = si; // index ties are allowed at equal distance
588        }
589    }
590
591    #[test]
592    fn test_simd_radius_matches_brute_force() {
593        use crate::nearest_neighbor::BruteForceSearch;
594        let pts = cube_points();
595        let query = Point3f::new(0.5, 0.5, 0.5);
596        let radius = 1.0;
597
598        let simd = SimdBruteForceSearch::new(&pts);
599        let scalar = BruteForceSearch::new(&pts);
600
601        let mut simd_res = simd.find_radius_neighbors(&query, radius);
602        let mut scalar_res = scalar.find_radius_neighbors(&query, radius);
603
604        simd_res.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then(a.0.cmp(&b.0)));
605        scalar_res.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then(a.0.cmp(&b.0)));
606
607        assert_eq!(simd_res.len(), scalar_res.len(), "result count mismatch");
608        for ((_, sd), (_, bd)) in simd_res.iter().zip(scalar_res.iter()) {
609            assert!((sd - bd).abs() < 1e-5);
610        }
611    }
612
613    #[test]
614    fn test_empty_cloud() {
615        let simd = SimdBruteForceSearch::new(&[]);
616        let q = Point3f::new(0.0, 0.0, 0.0);
617        assert!(simd.find_k_nearest(&q, 5).is_empty());
618        assert!(simd.find_radius_neighbors(&q, 10.0).is_empty());
619    }
620
621    #[test]
622    fn test_k_larger_than_cloud() {
623        let pts = cube_points();
624        let simd = SimdBruteForceSearch::new(&pts);
625        let q = Point3f::new(0.0, 0.0, 0.0);
626        let result = simd.find_k_nearest(&q, 100);
627        assert_eq!(result.len(), pts.len());
628    }
629
630    #[test]
631    fn test_exact_origin_distance() {
632        let pts = vec![Point3f::new(3.0, 4.0, 0.0)]; // dist from origin = 5
633        let soa = SoaPoints::from_points(&pts);
634        let query = Point3f::origin();
635        let mut out = vec![0.0f32; 1];
636        batch_distances_squared(&query, &soa, &mut out);
637        assert!((out[0] - 25.0).abs() < 1e-6);
638    }
639}