Skip to main content

csm_core_lib/
hyperdim_simd.rs

1//! SIMD-optimized hypervector operations.
2//!
3//! Provides AVX2, x86-SSE, and ARM NEON paths for common HDC primitives.
4
5#[cfg(all(not(target_arch = "wasm32"), target_arch = "x86_64"))]
6use std::arch::x86_64::{
7    _mm256_add_epi8, _mm256_add_epi64, _mm256_and_si256, _mm256_loadu_si256, _mm256_sad_epu8,
8    _mm256_set1_epi8, _mm256_setr_epi8, _mm256_setzero_si256, _mm256_shuffle_epi8,
9    _mm256_srli_epi16, _mm256_storeu_si256, _mm256_xor_si256,
10};
11
12#[allow(dead_code)]
13pub(crate) fn hamming_distance_optimized(lhs: &[u128; 80], rhs: &[u128; 80]) -> u32 {
14    let mut d0 = 0;
15    let mut d1 = 0;
16    let mut d2 = 0;
17    let mut d3 = 0;
18
19    // Unroll by 4 with independent accumulators to improve ILP
20    for i in (0..80).step_by(4) {
21        d0 += (lhs[i] ^ rhs[i]).count_ones();
22        d1 += (lhs[i + 1] ^ rhs[i + 1]).count_ones();
23        d2 += (lhs[i + 2] ^ rhs[i + 2]).count_ones();
24        d3 += (lhs[i + 3] ^ rhs[i + 3]).count_ones();
25    }
26    d0 + d1 + d2 + d3
27}
28
29#[cfg(all(not(target_arch = "wasm32"), target_arch = "x86_64"))]
30#[inline]
31#[target_feature(enable = "avx2")]
32/// # SAFETY
33/// Caller must ensure AVX2 is supported.
34pub(crate) unsafe fn and_simd_avx2(lhs: &[u128; 80], rhs: &[u128; 80]) -> [u128; 80] {
35    let mut res = [0u128; 80];
36    for i in (0..80).step_by(2) {
37        // SAFETY: lhs, rhs, and res are [u128; 80], which is 1280 bytes.
38        // i goes up to 78, so i+2 (256 bits) is 32 bytes.
39        // 32 bytes * 40 iterations = 1280 bytes. All pointers are valid.
40        unsafe {
41            let l = _mm256_loadu_si256(lhs.as_ptr().add(i).cast());
42            let r = _mm256_loadu_si256(rhs.as_ptr().add(i).cast());
43            _mm256_storeu_si256(res.as_mut_ptr().add(i).cast(), _mm256_and_si256(l, r));
44        }
45    }
46    res
47}
48
49#[cfg(all(not(target_arch = "wasm32"), target_arch = "x86_64"))]
50#[inline]
51#[target_feature(enable = "avx2")]
52/// # SAFETY
53/// Caller must ensure AVX2 is supported.
54pub(crate) unsafe fn bind_simd_avx2(lhs: &[u128; 80], rhs: &[u128; 80]) -> [u128; 80] {
55    let mut res = [0u128; 80];
56    for i in (0..80).step_by(2) {
57        // SAFETY: lhs, rhs, and res are [u128; 80], which is 1280 bytes.
58        // i goes up to 78, so i+2 (256 bits) is 32 bytes.
59        // 32 bytes * 40 iterations = 1280 bytes. All pointers are valid.
60        unsafe {
61            let l = _mm256_loadu_si256(lhs.as_ptr().add(i).cast());
62            let r = _mm256_loadu_si256(rhs.as_ptr().add(i).cast());
63            _mm256_storeu_si256(res.as_mut_ptr().add(i).cast(), _mm256_xor_si256(l, r));
64        }
65    }
66    res
67}
68
69#[cfg(all(not(target_arch = "wasm32"), target_arch = "x86_64"))]
70#[inline]
71#[target_feature(enable = "avx2")]
72/// # SAFETY
73/// Caller must ensure AVX2 is supported.
74pub(crate) unsafe fn hamming_distance_simd_avx2(lhs: &[u128; 80], rhs: &[u128; 80]) -> u32 {
75    const LOADS_PER_FLUSH: usize = 20;
76    const TOTAL_LOADS: usize = 40;
77    const UNROLL_FACTOR: usize = 2;
78    // Compile-time guard for loop structure and overflow safety.
79    // Max bits per byte = 8. 8 * UNROLL_FACTOR * (LOADS_PER_FLUSH / UNROLL_FACTOR) = 8 * 20 = 160.
80    // 160 safely fits in u8 (255) to prevent overflow during deferred accumulation.
81    const _: () = assert!(80 % (LOADS_PER_FLUSH * 2) == 0);
82    const _: () = assert!(LOADS_PER_FLUSH % (UNROLL_FACTOR * 2) == 0);
83
84    let lookup = _mm256_setr_epi8(
85        0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4, 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3,
86        3, 4,
87    );
88    let low_mask = _mm256_set1_epi8(0x0f);
89    let mut acc = _mm256_setzero_si256();
90    let zero = _mm256_setzero_si256();
91
92    // Algorithmic Optimization: Deferred 8-bit accumulation with dual accumulators and unrolling.
93    // 80 words = 40 AVX2 loads. We process in two 20-load flushes to avoid 8-bit overflow.
94    // Dual accumulators (acc_8_low, acc_8_high) and 2x unrolling improve ILP by exposing
95    // independent execution paths to the scheduler.
96    for i in (0..80).step_by(LOADS_PER_FLUSH * 2) {
97        let mut acc_8_low = _mm256_setzero_si256();
98        let mut acc_8_high = _mm256_setzero_si256();
99        for j in (0..LOADS_PER_FLUSH * 2).step_by(UNROLL_FACTOR * 2) {
100            let idx0 = i + j;
101            let idx1 = idx0 + 2;
102
103            // SAFETY: i + j + 2 is at most 40 + 36 + 2 = 78.
104            // _mm256_loadu_si256 reads 32 bytes (2 x u128), so add(78) reads indices [78, 79].
105            // This stays within the bounds of the 80-element input arrays.
106            unsafe {
107                let x0 = _mm256_xor_si256(
108                    _mm256_loadu_si256(lhs.as_ptr().add(idx0).cast()),
109                    _mm256_loadu_si256(rhs.as_ptr().add(idx0).cast()),
110                );
111                let x1 = _mm256_xor_si256(
112                    _mm256_loadu_si256(lhs.as_ptr().add(idx1).cast()),
113                    _mm256_loadu_si256(rhs.as_ptr().add(idx1).cast()),
114                );
115
116                acc_8_low = _mm256_add_epi8(
117                    acc_8_low,
118                    _mm256_add_epi8(
119                        _mm256_shuffle_epi8(lookup, _mm256_and_si256(x0, low_mask)),
120                        _mm256_shuffle_epi8(lookup, _mm256_and_si256(x1, low_mask)),
121                    ),
122                );
123                acc_8_high = _mm256_add_epi8(
124                    acc_8_high,
125                    _mm256_add_epi8(
126                        _mm256_shuffle_epi8(
127                            lookup,
128                            _mm256_and_si256(_mm256_srli_epi16(x0, 4), low_mask),
129                        ),
130                        _mm256_shuffle_epi8(
131                            lookup,
132                            _mm256_and_si256(_mm256_srli_epi16(x1, 4), low_mask),
133                        ),
134                    ),
135                );
136            }
137        }
138        acc = _mm256_add_epi64(
139            acc,
140            _mm256_add_epi64(
141                _mm256_sad_epu8(acc_8_low, zero),
142                _mm256_sad_epu8(acc_8_high, zero),
143            ),
144        );
145    }
146
147    let mut results = [0u64; 4];
148    unsafe {
149        _mm256_storeu_si256(results.as_mut_ptr().cast(), acc);
150    }
151    #[allow(clippy::cast_possible_truncation)]
152    let res = (results[0] + results[1] + results[2] + results[3]) as u32;
153    res
154}
155
156#[cfg(all(
157    not(target_arch = "wasm32"),
158    any(target_arch = "x86_64", target_arch = "x86")
159))]
160#[inline]
161pub(crate) fn and_simd_x86(lhs: &[u128; 80], rhs: &[u128; 80]) -> [u128; 80] {
162    let mut res = [0u128; 80];
163    for i in 0..80 {
164        res[i] = lhs[i] & rhs[i];
165    }
166    res
167}
168
169#[cfg(all(
170    not(target_arch = "wasm32"),
171    any(target_arch = "x86_64", target_arch = "x86")
172))]
173#[inline]
174pub(crate) fn bind_simd_x86(lhs: &[u128; 80], rhs: &[u128; 80]) -> [u128; 80] {
175    let mut res = [0u128; 80];
176    for i in 0..80 {
177        res[i] = lhs[i] ^ rhs[i];
178    }
179    res
180}
181
182#[cfg(all(not(target_arch = "wasm32"), target_arch = "aarch64"))]
183#[inline]
184#[target_feature(enable = "neon")]
185/// # SAFETY
186/// Caller must ensure NEON is supported.
187pub(crate) unsafe fn and_simd_neon(lhs: &[u128; 80], rhs: &[u128; 80]) -> [u128; 80] {
188    use std::arch::aarch64::{vandq_u8, vld1q_u8, vst1q_u8};
189    let mut res = [0u128; 80];
190    for i in 0..80 {
191        // SAFETY: lhs, rhs, and res are [u128; 80]. vld1q_u8 loads 128 bits (16 bytes).
192        // add(i) moves the pointer by i * sizeof(u128), which is exactly 16 bytes.
193        // All accesses are within bounds.
194        unsafe {
195            let l = vld1q_u8(lhs.as_ptr().add(i).cast());
196            let r = vld1q_u8(rhs.as_ptr().add(i).cast());
197            vst1q_u8(res.as_mut_ptr().add(i).cast(), vandq_u8(l, r));
198        }
199    }
200    res
201}
202
203#[cfg(all(not(target_arch = "wasm32"), target_arch = "aarch64"))]
204#[inline]
205#[target_feature(enable = "neon")]
206/// # SAFETY
207/// Caller must ensure NEON is supported.
208pub(crate) unsafe fn bind_simd_neon(lhs: &[u128; 80], rhs: &[u128; 80]) -> [u128; 80] {
209    use std::arch::aarch64::{veorq_u8, vld1q_u8, vst1q_u8};
210    let mut res = [0u128; 80];
211    for i in 0..80 {
212        // SAFETY: lhs, rhs, and res are [u128; 80]. vld1q_u8 loads 128 bits (16 bytes).
213        // add(i) moves the pointer by i * sizeof(u128), which is exactly 16 bytes.
214        // All accesses are within bounds.
215        unsafe {
216            let l = vld1q_u8(lhs.as_ptr().add(i).cast());
217            let r = vld1q_u8(rhs.as_ptr().add(i).cast());
218            vst1q_u8(res.as_mut_ptr().add(i).cast(), veorq_u8(l, r));
219        }
220    }
221    res
222}
223
224#[cfg(all(not(target_arch = "wasm32"), target_arch = "aarch64"))]
225#[inline]
226#[target_feature(enable = "neon")]
227/// # SAFETY
228/// Caller must ensure NEON is supported.
229pub(crate) unsafe fn hamming_distance_simd_neon(lhs: &[u128; 80], rhs: &[u128; 80]) -> u32 {
230    use std::arch::aarch64::{
231        vaddlvq_u16, vaddq_u8, vaddq_u16, vcntq_u8, vdupq_n_u8, vdupq_n_u16, veorq_u8, vld1q_u8,
232        vpaddlq_u8,
233    };
234    const BATCH_SIZE: usize = 10;
235    const WORDS_PER_BATCH: usize = BATCH_SIZE * 2;
236    // Compile-time guard for array alignment
237    const _: () = assert!(80 % WORDS_PER_BATCH == 0);
238
239    let mut acc = vdupq_n_u16(0);
240
241    for i in (0..80).step_by(WORDS_PER_BATCH) {
242        // Algorithmic Optimization: Intermediate 8-bit accumulation for NEON.
243        // We accumulate popcounts in an 8-bit vector for 10 iterations (20 words)
244        // before flushing to the 16-bit accumulator via vpaddlq_u8.
245        // Max bits per byte lane is 8. Over 20 additions (10 iterations * 2 loads),
246        // max sum is 8 * 20 = 160, which safely fits in u8 (255).
247        // This reduces the frequency of vpaddlq_u8 (widening pairwise add) calls by 10x.
248        let mut acc_8 = vdupq_n_u8(0);
249        for j in 0..BATCH_SIZE {
250            let idx = i + j * 2;
251            // SAFETY: idx and idx+1 are in (0..80). vld1q_u8 loads 16 bytes.
252            unsafe {
253                let l0 = vld1q_u8(lhs.as_ptr().add(idx).cast());
254                let r0 = vld1q_u8(rhs.as_ptr().add(idx).cast());
255                let x0 = veorq_u8(l0, r0);
256                let c0 = vcntq_u8(x0);
257                acc_8 = vaddq_u8(acc_8, c0);
258
259                let l1 = vld1q_u8(lhs.as_ptr().add(idx + 1).cast());
260                let r1 = vld1q_u8(rhs.as_ptr().add(idx + 1).cast());
261                let x1 = veorq_u8(l1, r1);
262                let c1 = vcntq_u8(x1);
263                acc_8 = vaddq_u8(acc_8, c1);
264            }
265        }
266        acc = vaddq_u16(acc, vpaddlq_u8(acc_8));
267    }
268    vaddlvq_u16(acc) as u32
269}
270
271#[cfg(test)]
272mod tests {
273    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
274    use super::*;
275
276    #[test]
277    #[cfg(all(not(target_arch = "wasm32"), target_arch = "x86_64"))]
278    fn and_simd_avx2_correctness() {
279        if is_x86_feature_detected!("avx2") {
280            let lhs = [0x5555_5555_5555_5555_5555_5555_5555_5555u128; 80];
281            let rhs = [0xAAAA_AAAA_AAAA_AAAA_AAAA_AAAA_AAAA_AAAAu128; 80];
282            // SAFETY: AVX2 support is checked above.
283            let res = unsafe { and_simd_avx2(&lhs, &rhs) };
284            for word in &res {
285                assert_eq!(*word, 0);
286            }
287        }
288    }
289
290    #[test]
291    #[cfg(all(
292        not(target_arch = "wasm32"),
293        any(target_arch = "x86_64", target_arch = "x86")
294    ))]
295    fn and_simd_x86_correctness() {
296        let lhs = [0x5555_5555_5555_5555_5555_5555_5555_5555u128; 80];
297        let rhs = [0xAAAA_AAAA_AAAA_AAAA_AAAA_AAAA_AAAA_AAAAu128; 80];
298        let res = and_simd_x86(&lhs, &rhs);
299        for word in &res {
300            assert_eq!(*word, 0);
301        }
302    }
303
304    #[test]
305    #[cfg(all(not(target_arch = "wasm32"), target_arch = "x86_64"))]
306    fn bind_simd_avx2_correctness() {
307        if is_x86_feature_detected!("avx2") {
308            let lhs = [0x5555_5555_5555_5555_5555_5555_5555_5555u128; 80];
309            let rhs = [0x5555_5555_5555_5555_5555_5555_5555_5555u128; 80];
310            // SAFETY: AVX2 support is checked above.
311            let res = unsafe { bind_simd_avx2(&lhs, &rhs) };
312            for word in &res {
313                assert_eq!(*word, 0);
314            }
315        }
316    }
317
318    #[test]
319    #[cfg(all(
320        not(target_arch = "wasm32"),
321        any(target_arch = "x86_64", target_arch = "x86")
322    ))]
323    fn bind_simd_x86_correctness() {
324        let lhs = [0x5555_5555_5555_5555_5555_5555_5555_5555u128; 80];
325        let rhs = [0x5555_5555_5555_5555_5555_5555_5555_5555u128; 80];
326        let res = bind_simd_x86(&lhs, &rhs);
327        for word in &res {
328            assert_eq!(*word, 0);
329        }
330    }
331
332    #[test]
333    #[cfg(all(not(target_arch = "wasm32"), target_arch = "x86_64"))]
334    fn hamming_distance_simd_avx2_correctness() {
335        if is_x86_feature_detected!("avx2") {
336            let lhs = [0u128; 80];
337            let rhs = [!0u128; 80];
338            // SAFETY: AVX2 support is checked above.
339            let res = unsafe { hamming_distance_simd_avx2(&lhs, &rhs) };
340            assert_eq!(res, 10240);
341        }
342    }
343
344    #[test]
345    #[cfg(all(not(target_arch = "wasm32"), target_arch = "x86_64"))]
346    fn hamming_distance_simd_avx2_edge_cases() {
347        if is_x86_feature_detected!("avx2") {
348            let lhs = [0u128; 80];
349            let mut rhs = [0u128; 80];
350            rhs[0] = 1;
351            rhs[79] = 1 << 127;
352            // SAFETY: AVX2 support is checked above.
353            let res = unsafe { hamming_distance_simd_avx2(&lhs, &rhs) };
354            assert_eq!(res, 2);
355        }
356    }
357
358    #[test]
359    fn hamming_distance_matches_bit_count() {
360        let lhs = [0u128; 80];
361        let mut rhs = [0u128; 80];
362        for i in 0..80 {
363            rhs[i] = i as u128;
364        }
365        let res = hamming_distance_optimized(&lhs, &rhs);
366        let mut expected = 0;
367        for i in 0..80 {
368            expected += (rhs[i]).count_ones();
369        }
370        assert_eq!(res, expected);
371    }
372
373    #[test]
374    fn hamming_distance_optimized_identical_vectors() {
375        let vec = [0x123456789ABCDEF0u128; 80];
376        assert_eq!(hamming_distance_optimized(&vec, &vec), 0);
377    }
378
379    #[test]
380    fn hamming_distance_optimized_complements() {
381        let vec = [0x5555_5555_5555_5555_5555_5555_5555_5555u128; 80];
382        let complement = [0xAAAA_AAAA_AAAA_AAAA_AAAA_AAAA_AAAA_AAAAu128; 80];
383        assert_eq!(hamming_distance_optimized(&vec, &complement), 10240);
384    }
385
386    #[test]
387    fn hamming_distance_optimized_correctness() {
388        let mut lhs = [0u128; 80];
389        let mut rhs = [0u128; 80];
390        lhs[0] = 0b1010;
391        rhs[0] = 0b1100;
392        // 1010 ^ 1100 = 0110 (2 bits set)
393        assert_eq!(hamming_distance_optimized(&lhs, &rhs), 2);
394    }
395}