Skip to main content

nodedb_codec/vector_quant/
hamming.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared SIMD Hamming-distance kernel with runtime CPU-feature dispatch.
4//!
5//! Used by 1-bit quantizers ([`bbq`](super::bbq), [`rabitq`](super::rabitq))
6//! and any future binary code that needs to count differing bits between
7//! two equal-length packed-bit slices.
8//!
9//! Dispatch order (resolved once via [`OnceLock`]):
10//!
11//! 1. `x86_64` + `avx512f` + `avx512vpopcntdq` → AVX-512 VPOPCNTDQ kernel
12//! 2. `x86_64` + `avx2`                       → 64-bit popcnt unrolled 4×
13//! 3. `aarch64`                                → NEON `vcntq_u8`
14//! 4. fallback                                  → scalar 8-byte popcnt
15
16#![allow(unsafe_op_in_unsafe_fn)]
17
18use std::sync::OnceLock;
19
20type HammingFn = fn(a: &[u8], b: &[u8]) -> u32;
21
22static DISPATCH: OnceLock<HammingFn> = OnceLock::new();
23
24fn scalar(a: &[u8], b: &[u8]) -> u32 {
25    let chunks = a.len() / 8;
26    let rem = a.len() % 8;
27    let mut count = 0u32;
28    for i in 0..chunks {
29        let av = u64::from_ne_bytes(a[i * 8..i * 8 + 8].try_into().unwrap_or([0u8; 8]));
30        let bv = u64::from_ne_bytes(b[i * 8..i * 8 + 8].try_into().unwrap_or([0u8; 8]));
31        count += (av ^ bv).count_ones();
32    }
33    let base = chunks * 8;
34    for i in 0..rem {
35        count += (a[base + i] ^ b[base + i]).count_ones();
36    }
37    count
38}
39
40#[cfg(target_arch = "x86_64")]
41#[target_feature(enable = "avx512f,avx512vpopcntdq")]
42unsafe fn avx512(a: &[u8], b: &[u8]) -> u32 {
43    use std::arch::x86_64::*;
44    let mut count = 0u32;
45    let chunks = a.len() / 64;
46    let rem = a.len() % 64;
47    for i in 0..chunks {
48        let va = _mm512_loadu_si512(a.as_ptr().add(i * 64) as *const __m512i);
49        let vb = _mm512_loadu_si512(b.as_ptr().add(i * 64) as *const __m512i);
50        let xored = _mm512_xor_si512(va, vb);
51        let popcnt = _mm512_popcnt_epi64(xored);
52        let lo = _mm512_extracti64x4_epi64(popcnt, 0);
53        let hi = _mm512_extracti64x4_epi64(popcnt, 1);
54        let sum4 = _mm256_add_epi64(lo, hi);
55        let sum2 = _mm256_add_epi64(sum4, _mm256_permute4x64_epi64(sum4, 0b0100_1110));
56        let sum1 = _mm256_add_epi64(sum2, _mm256_permute4x64_epi64(sum2, 0b0001_0001));
57        count += _mm256_extract_epi64(sum1, 0) as u32;
58    }
59    let base = chunks * 64;
60    for i in 0..rem {
61        count += (a[base + i] ^ b[base + i]).count_ones();
62    }
63    count
64}
65
66#[cfg(target_arch = "x86_64")]
67#[target_feature(enable = "avx2")]
68unsafe fn avx2(a: &[u8], b: &[u8]) -> u32 {
69    let mut count = 0u32;
70    let n8 = a.len() / 8;
71    let mut i = 0usize;
72    while i + 4 <= n8 {
73        for word in i..i + 4 {
74            let start = word * 8;
75            let end = start + 8;
76            let av = u64::from_ne_bytes(a[start..end].try_into().unwrap_or([0u8; 8]));
77            let bv = u64::from_ne_bytes(b[start..end].try_into().unwrap_or([0u8; 8]));
78            count += (av ^ bv).count_ones();
79        }
80        i += 4;
81    }
82    while i < n8 {
83        let start = i * 8;
84        let end = start + 8;
85        let av = u64::from_ne_bytes(a[start..end].try_into().unwrap_or([0u8; 8]));
86        let bv = u64::from_ne_bytes(b[start..end].try_into().unwrap_or([0u8; 8]));
87        count += (av ^ bv).count_ones();
88        i += 1;
89    }
90    let tail_start = n8 * 8;
91    for j in tail_start..a.len() {
92        count += (a[j] ^ b[j]).count_ones();
93    }
94    count
95}
96
97#[cfg(target_arch = "x86_64")]
98fn avx512_trampoline(a: &[u8], b: &[u8]) -> u32 {
99    unsafe { avx512(a, b) }
100}
101
102#[cfg(target_arch = "x86_64")]
103fn avx2_trampoline(a: &[u8], b: &[u8]) -> u32 {
104    unsafe { avx2(a, b) }
105}
106
107#[cfg(target_arch = "aarch64")]
108fn neon(a: &[u8], b: &[u8]) -> u32 {
109    use std::arch::aarch64::*;
110    let mut count = 0u32;
111    let chunks = a.len() / 16;
112    let rem = a.len() % 16;
113    unsafe {
114        for i in 0..chunks {
115            let va = vld1q_u8(a.as_ptr().add(i * 16));
116            let vb = vld1q_u8(b.as_ptr().add(i * 16));
117            let xored = veorq_u8(va, vb);
118            let popcnt = vcntq_u8(xored);
119            count += vaddvq_u8(popcnt) as u32;
120        }
121    }
122    let base = chunks * 16;
123    for i in 0..rem {
124        count += (a[base + i] ^ b[base + i]).count_ones();
125    }
126    count
127}
128
129fn resolve() -> HammingFn {
130    #[cfg(target_arch = "x86_64")]
131    {
132        if std::is_x86_feature_detected!("avx512f")
133            && std::is_x86_feature_detected!("avx512vpopcntdq")
134        {
135            return avx512_trampoline;
136        }
137        if std::is_x86_feature_detected!("avx2") {
138            return avx2_trampoline;
139        }
140    }
141    #[cfg(target_arch = "aarch64")]
142    {
143        return neon;
144    }
145    #[allow(unreachable_code)]
146    {
147        scalar
148    }
149}
150
151/// Count the number of differing bits between two equal-length packed-bit
152/// slices, dispatching to the best available SIMD kernel at runtime.
153///
154/// In debug builds, panics if `a.len() != b.len()`. In release builds, the
155/// shorter length is used and the tail of the longer slice is ignored.
156#[inline]
157pub fn hamming_distance(a: &[u8], b: &[u8]) -> u32 {
158    debug_assert_eq!(a.len(), b.len(), "hamming_distance: slice length mismatch");
159    let len = a.len().min(b.len());
160    let f = DISPATCH.get_or_init(resolve);
161    f(&a[..len], &b[..len])
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn zero_distance_to_self() {
170        let bits = vec![0b1010_1010u8, 0b1100_1100, 0b1111_0000];
171        assert_eq!(hamming_distance(&bits, &bits), 0);
172    }
173
174    #[test]
175    fn full_inversion_byte() {
176        let a = vec![0xFFu8];
177        let b = vec![0x00u8];
178        assert_eq!(hamming_distance(&a, &b), 8);
179    }
180
181    #[test]
182    fn full_inversion_multibyte() {
183        let dim = 64;
184        let a: Vec<u8> = (0..dim as u8).collect();
185        let b: Vec<u8> = a.iter().map(|&x| !x).collect();
186        assert_eq!(hamming_distance(&a, &b), 512);
187    }
188
189    #[test]
190    fn unaligned_subslices_match_scalar() {
191        let a_storage: Vec<u8> = (0..258).map(|i| (i * 31 + 7) as u8).collect();
192        let b_storage: Vec<u8> = (0..259).map(|i| (i * 17 + 3) as u8).collect();
193        let a = &a_storage[1..257];
194        let b = &b_storage[2..258];
195        assert_eq!(hamming_distance(a, b), scalar(a, b));
196    }
197
198    #[cfg(target_arch = "x86_64")]
199    #[test]
200    fn avx2_directly_handles_unaligned_subslices() {
201        if !std::is_x86_feature_detected!("avx2") {
202            return;
203        }
204        let a_storage: Vec<u8> = (0..258).map(|i| (i * 31 + 7) as u8).collect();
205        let b_storage: Vec<u8> = (0..259).map(|i| (i * 17 + 3) as u8).collect();
206        let a = &a_storage[1..257];
207        let b = &b_storage[2..258];
208        assert_eq!(unsafe { avx2(a, b) }, scalar(a, b));
209    }
210
211    #[test]
212    fn agrees_across_lengths() {
213        // Cross-check dispatched kernel against scalar reference for various
214        // lengths so any SIMD path divergence is caught.
215        for len in [1usize, 7, 8, 9, 15, 16, 31, 32, 63, 64, 65, 127, 128, 255] {
216            let a: Vec<u8> = (0..len).map(|i| (i * 31 + 7) as u8).collect();
217            let b: Vec<u8> = (0..len).map(|i| (i * 17 + 3) as u8).collect();
218            assert_eq!(hamming_distance(&a, &b), scalar(&a, &b), "len={len}");
219        }
220    }
221}