Skip to main content

lattice_embed/simd/
normalize.rs

1//! SIMD in-place L2 normalization kernels.
2//!
3//! See docs/simd.md for the two-pass algorithm and backend differences.
4
5#[cfg(target_arch = "x86_64")]
6use std::arch::x86_64::*;
7
8#[cfg(target_arch = "aarch64")]
9use std::arch::aarch64::*;
10
11#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
12use std::arch::wasm32::*;
13
14use super::simd_config;
15
16#[cfg(target_arch = "x86_64")]
17use super::dot_product::{horizontal_sum_avx2, horizontal_sum_avx512};
18
19#[cfg(target_arch = "aarch64")]
20use super::dot_product::horizontal_sum_neon;
21
22#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
23use super::dot_product::horizontal_sum_simd128;
24
25/// **Unstable**: SIMD dispatch layer; use `lattice_embed::utils::normalize` for the stable wrapper.
26#[inline]
27pub fn normalize(vector: &mut [f32]) {
28    let config = simd_config();
29
30    #[cfg(target_arch = "x86_64")]
31    {
32        if config.avx512f_enabled {
33            // SAFETY: Runtime feature detection verified AVX-512F. The mutable
34            // slice is valid for the call lifetime; the callee uses unaligned
35            // loads/stores and chunk/remainder bounds that stay inside the slice.
36            return unsafe { normalize_avx512_unrolled(vector) };
37        }
38        if config.avx2_enabled && config.fma_enabled {
39            // SAFETY: Runtime feature detection verified AVX2+FMA. The mutable
40            // slice is valid for the call lifetime; the callee uses unaligned
41            // loads/stores and chunk/remainder bounds that stay inside the slice.
42            return unsafe { normalize_avx2_unrolled(vector) };
43        }
44    }
45
46    #[cfg(target_arch = "aarch64")]
47    {
48        if config.neon_enabled {
49            // SAFETY: NEON is available on aarch64. The mutable slice is valid
50            // for the call lifetime; the callee uses unaligned loads/stores and
51            // bounded chunk/remainder loops that stay inside the slice.
52            return unsafe { normalize_neon_unrolled(vector) };
53        }
54    }
55
56    #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
57    {
58        if config.simd128_enabled() {
59            // SAFETY: compiled with wasm32 simd128 (compile-time gate, see
60            // `SimdConfig::simd128_enabled`). The mutable slice is valid for
61            // the call lifetime; the callee uses alignment-free loads/stores
62            // and bounded chunk/remainder loops that stay inside the slice.
63            return unsafe { normalize_simd128_unrolled(vector) };
64        }
65    }
66
67    normalize_scalar(vector)
68}
69
70/// Scalar normalization.
71pub(crate) fn normalize_scalar(vector: &mut [f32]) {
72    let norm: f32 = vector.iter().map(|x| x * x).sum::<f32>().sqrt();
73    if norm > 0.0 {
74        let inv_norm = 1.0 / norm;
75        vector.iter_mut().for_each(|x| *x *= inv_norm);
76    }
77}
78
79/// Normalizes in two passes with AVX-512F.
80///
81/// # Safety
82/// Caller must provide AVX-512F; chunked unaligned access stays in bounds.
83/// See [`docs/simd.md`](../../docs/simd.md#kernel-safety-boundary) for the shared kernel invariant.
84#[cfg(target_arch = "x86_64")]
85#[target_feature(enable = "avx512f")]
86unsafe fn normalize_avx512_unrolled(vector: &mut [f32]) {
87    const SIMD_WIDTH: usize = 16;
88    const UNROLL: usize = 4;
89    const CHUNK_SIZE: usize = SIMD_WIDTH * UNROLL;
90
91    let n = vector.len();
92    let chunks = n / CHUNK_SIZE;
93    let main_processed = chunks * CHUNK_SIZE;
94    let remaining = n - main_processed;
95    let remaining_chunks = remaining / SIMD_WIDTH;
96
97    // First pass: compute L2 norm with 4 accumulators
98    let mut norm0 = _mm512_setzero_ps();
99    let mut norm1 = _mm512_setzero_ps();
100    let mut norm2 = _mm512_setzero_ps();
101    let mut norm3 = _mm512_setzero_ps();
102
103    for i in 0..chunks {
104        let base = i * CHUNK_SIZE;
105
106        let v0 = _mm512_loadu_ps(vector.as_ptr().add(base));
107        norm0 = _mm512_fmadd_ps(v0, v0, norm0);
108
109        let v1 = _mm512_loadu_ps(vector.as_ptr().add(base + SIMD_WIDTH));
110        norm1 = _mm512_fmadd_ps(v1, v1, norm1);
111
112        let v2 = _mm512_loadu_ps(vector.as_ptr().add(base + SIMD_WIDTH * 2));
113        norm2 = _mm512_fmadd_ps(v2, v2, norm2);
114
115        let v3 = _mm512_loadu_ps(vector.as_ptr().add(base + SIMD_WIDTH * 3));
116        norm3 = _mm512_fmadd_ps(v3, v3, norm3);
117    }
118
119    let norm_vec = _mm512_add_ps(_mm512_add_ps(norm0, norm1), _mm512_add_ps(norm2, norm3));
120
121    // Remainder for norm calculation with single-register AVX-512F loop
122    let mut norm_remainder = _mm512_setzero_ps();
123    for i in 0..remaining_chunks {
124        let offset = main_processed + i * SIMD_WIDTH;
125        let v = _mm512_loadu_ps(vector.as_ptr().add(offset));
126        norm_remainder = _mm512_fmadd_ps(v, v, norm_remainder);
127    }
128
129    let mut norm_sq = horizontal_sum_avx512(norm_vec) + horizontal_sum_avx512(norm_remainder);
130
131    // Scalar tail for norm (recomputed inline to avoid cross-pass variable dependency)
132    for i in (main_processed + remaining_chunks * SIMD_WIDTH)..n {
133        norm_sq += vector[i] * vector[i];
134    }
135
136    let norm = norm_sq.sqrt();
137    // Match `normalize_scalar`, which only scales when `norm > 0.0`. Rejecting
138    // NaN here too (a NaN element makes `norm` NaN) leaves the vector
139    // byte-identical to the scalar path instead of scaling by a NaN inv_norm.
140    // `is_nan() || <= 0.0` is the lint-clean equivalent of `!(norm > 0.0)`.
141    if norm.is_nan() || norm <= 0.0 {
142        return;
143    }
144
145    let inv_norm = 1.0 / norm;
146    let inv_norm_vec = _mm512_set1_ps(inv_norm);
147
148    // Second pass: scale by inverse norm with 4x unrolling
149    for i in 0..chunks {
150        let base = i * CHUNK_SIZE;
151
152        let v0 = _mm512_loadu_ps(vector.as_ptr().add(base));
153        _mm512_storeu_ps(
154            vector.as_mut_ptr().add(base),
155            _mm512_mul_ps(v0, inv_norm_vec),
156        );
157
158        let v1 = _mm512_loadu_ps(vector.as_ptr().add(base + SIMD_WIDTH));
159        _mm512_storeu_ps(
160            vector.as_mut_ptr().add(base + SIMD_WIDTH),
161            _mm512_mul_ps(v1, inv_norm_vec),
162        );
163
164        let v2 = _mm512_loadu_ps(vector.as_ptr().add(base + SIMD_WIDTH * 2));
165        _mm512_storeu_ps(
166            vector.as_mut_ptr().add(base + SIMD_WIDTH * 2),
167            _mm512_mul_ps(v2, inv_norm_vec),
168        );
169
170        let v3 = _mm512_loadu_ps(vector.as_ptr().add(base + SIMD_WIDTH * 3));
171        _mm512_storeu_ps(
172            vector.as_mut_ptr().add(base + SIMD_WIDTH * 3),
173            _mm512_mul_ps(v3, inv_norm_vec),
174        );
175    }
176
177    // Remainder for scaling with single-register AVX-512F loop
178    for i in 0..remaining_chunks {
179        let offset = main_processed + i * SIMD_WIDTH;
180        let v = _mm512_loadu_ps(vector.as_ptr().add(offset));
181        _mm512_storeu_ps(
182            vector.as_mut_ptr().add(offset),
183            _mm512_mul_ps(v, inv_norm_vec),
184        );
185    }
186
187    // Final scalar remainder (recomputed inline to avoid cross-pass variable dependency)
188    for i in (main_processed + remaining_chunks * SIMD_WIDTH)..n {
189        vector[i] *= inv_norm;
190    }
191}
192
193/// Normalizes in two passes with AVX2 and FMA.
194///
195/// # Safety
196/// Caller must provide AVX2 and FMA; chunked unaligned access stays in bounds.
197/// See [`docs/simd.md`](../../docs/simd.md#kernel-safety-boundary) for the shared kernel invariant.
198#[cfg(target_arch = "x86_64")]
199#[target_feature(enable = "avx2", enable = "fma")]
200unsafe fn normalize_avx2_unrolled(vector: &mut [f32]) {
201    const SIMD_WIDTH: usize = 8;
202    const UNROLL: usize = 4;
203    const CHUNK_SIZE: usize = SIMD_WIDTH * UNROLL;
204    let n = vector.len();
205    let chunks = n / CHUNK_SIZE;
206
207    // First pass: compute L2 norm with 4 accumulators
208    let mut norm0 = _mm256_setzero_ps();
209    let mut norm1 = _mm256_setzero_ps();
210    let mut norm2 = _mm256_setzero_ps();
211    let mut norm3 = _mm256_setzero_ps();
212
213    for i in 0..chunks {
214        let base = i * CHUNK_SIZE;
215
216        let v0 = _mm256_loadu_ps(vector.as_ptr().add(base));
217        norm0 = _mm256_fmadd_ps(v0, v0, norm0);
218
219        let v1 = _mm256_loadu_ps(vector.as_ptr().add(base + SIMD_WIDTH));
220        norm1 = _mm256_fmadd_ps(v1, v1, norm1);
221
222        let v2 = _mm256_loadu_ps(vector.as_ptr().add(base + SIMD_WIDTH * 2));
223        norm2 = _mm256_fmadd_ps(v2, v2, norm2);
224
225        let v3 = _mm256_loadu_ps(vector.as_ptr().add(base + SIMD_WIDTH * 3));
226        norm3 = _mm256_fmadd_ps(v3, v3, norm3);
227    }
228
229    let norm_vec = _mm256_add_ps(_mm256_add_ps(norm0, norm1), _mm256_add_ps(norm2, norm3));
230    let mut norm_sq = horizontal_sum_avx2(norm_vec);
231
232    // Remainder for norm calculation
233    for i in (chunks * CHUNK_SIZE)..n {
234        norm_sq += vector[i] * vector[i];
235    }
236
237    let norm = norm_sq.sqrt();
238    // Match `normalize_scalar`: reject 0.0 and NaN alike so a NaN-containing
239    // vector is left unchanged rather than scaled by NaN. `is_nan() || <= 0.0`
240    // is the lint-clean equivalent of `!(norm > 0.0)`.
241    if norm.is_nan() || norm <= 0.0 {
242        return;
243    }
244
245    let inv_norm = 1.0 / norm;
246    let inv_norm_vec = _mm256_set1_ps(inv_norm);
247
248    // Second pass: divide by norm with 4x unrolling
249    for i in 0..chunks {
250        let base = i * CHUNK_SIZE;
251
252        let v0 = _mm256_loadu_ps(vector.as_ptr().add(base));
253        _mm256_storeu_ps(
254            vector.as_mut_ptr().add(base),
255            _mm256_mul_ps(v0, inv_norm_vec),
256        );
257
258        let v1 = _mm256_loadu_ps(vector.as_ptr().add(base + SIMD_WIDTH));
259        _mm256_storeu_ps(
260            vector.as_mut_ptr().add(base + SIMD_WIDTH),
261            _mm256_mul_ps(v1, inv_norm_vec),
262        );
263
264        let v2 = _mm256_loadu_ps(vector.as_ptr().add(base + SIMD_WIDTH * 2));
265        _mm256_storeu_ps(
266            vector.as_mut_ptr().add(base + SIMD_WIDTH * 2),
267            _mm256_mul_ps(v2, inv_norm_vec),
268        );
269
270        let v3 = _mm256_loadu_ps(vector.as_ptr().add(base + SIMD_WIDTH * 3));
271        _mm256_storeu_ps(
272            vector.as_mut_ptr().add(base + SIMD_WIDTH * 3),
273            _mm256_mul_ps(v3, inv_norm_vec),
274        );
275    }
276
277    // Remainder for scaling
278    for i in (chunks * CHUNK_SIZE)..n {
279        vector[i] *= inv_norm;
280    }
281}
282
283/// Normalizes in two passes with NEON reciprocal-square-root refinement.
284///
285/// # Safety
286/// Caller must run on aarch64; chunked unaligned access stays in bounds.
287/// See [`docs/simd.md`](../../docs/simd.md#kernel-safety-boundary) for convergence and fallback semantics.
288#[cfg(target_arch = "aarch64")]
289#[inline]
290unsafe fn normalize_neon_unrolled(vector: &mut [f32]) {
291    const SIMD_WIDTH: usize = 4;
292    const UNROLL: usize = 4;
293    const CHUNK_SIZE: usize = SIMD_WIDTH * UNROLL;
294    let n = vector.len();
295    let chunks = n / CHUNK_SIZE;
296
297    // First pass: compute L2 norm with 4 accumulators
298    let mut norm0 = vdupq_n_f32(0.0);
299    let mut norm1 = vdupq_n_f32(0.0);
300    let mut norm2 = vdupq_n_f32(0.0);
301    let mut norm3 = vdupq_n_f32(0.0);
302
303    for i in 0..chunks {
304        let base = i * CHUNK_SIZE;
305
306        let v0 = vld1q_f32(vector.as_ptr().add(base));
307        norm0 = vfmaq_f32(norm0, v0, v0);
308
309        let v1 = vld1q_f32(vector.as_ptr().add(base + SIMD_WIDTH));
310        norm1 = vfmaq_f32(norm1, v1, v1);
311
312        let v2 = vld1q_f32(vector.as_ptr().add(base + SIMD_WIDTH * 2));
313        norm2 = vfmaq_f32(norm2, v2, v2);
314
315        let v3 = vld1q_f32(vector.as_ptr().add(base + SIMD_WIDTH * 3));
316        norm3 = vfmaq_f32(norm3, v3, v3);
317    }
318
319    let norm_vec = vaddq_f32(vaddq_f32(norm0, norm1), vaddq_f32(norm2, norm3));
320    let mut norm_sq = horizontal_sum_neon(norm_vec);
321
322    for val in vector.iter().skip(chunks * CHUNK_SIZE) {
323        norm_sq += val * val;
324    }
325
326    // Match `normalize_scalar`: reject 0.0 and NaN alike. A subnormal-but-positive
327    // norm_sq still passes here and is handled by the finite-fallback below; only
328    // zero/NaN short-circuit to leave the vector unchanged, keeping NEON
329    // byte-consistent with the scalar path. `is_nan() || <= 0.0` is the lint-clean
330    // equivalent of `!(norm_sq > 0.0)`.
331    if norm_sq.is_nan() || norm_sq <= 0.0 {
332        return;
333    }
334
335    // vrsqrteq_f32 gives ~8-bit estimate; two Newton–Raphson steps reach full f32
336    // precision (~23 bits), eliminating any residual above the 1e-5 accuracy gate.
337    // vrsqrtsq_f32(a, b) = (3 - a*b) / 2  →  y' = y * vrsqrtsq_f32(x, y*y)
338    let norm_sq_v = vdupq_n_f32(norm_sq);
339    let y0 = vrsqrteq_f32(norm_sq_v);
340    let y1 = vmulq_f32(y0, vrsqrtsq_f32(norm_sq_v, vmulq_f32(y0, y0)));
341    let y2 = vmulq_f32(y1, vrsqrtsq_f32(norm_sq_v, vmulq_f32(y1, y1)));
342    // SAFETY: y2 has 4 identical lanes (norm_sq_v is a broadcast), so lane 0 is the
343    // scalar inv_norm used for both the NEON broadcast and the 1-3 element tail.
344    let mut inv_norm = vgetq_lane_f32(y2, 0);
345    // vrsqrte/Newton overflow to inf for a subnormal-but-nonzero norm_sq (‖v‖ ≲ 7e-20),
346    // where the AVX2/scalar lanes stay finite via 1.0/sqrt; fall back to keep NEON
347    // byte-consistent with them rather than scaling the vector to inf/NaN.
348    if !inv_norm.is_finite() {
349        inv_norm = 1.0 / norm_sq.sqrt();
350    }
351    let inv_norm_vec = vdupq_n_f32(inv_norm);
352
353    // Second pass: scale by inverse norm with 4x unrolling
354    for i in 0..chunks {
355        let base = i * CHUNK_SIZE;
356
357        let v0 = vld1q_f32(vector.as_ptr().add(base));
358        vst1q_f32(vector.as_mut_ptr().add(base), vmulq_f32(v0, inv_norm_vec));
359
360        let v1 = vld1q_f32(vector.as_ptr().add(base + SIMD_WIDTH));
361        vst1q_f32(
362            vector.as_mut_ptr().add(base + SIMD_WIDTH),
363            vmulq_f32(v1, inv_norm_vec),
364        );
365
366        let v2 = vld1q_f32(vector.as_ptr().add(base + SIMD_WIDTH * 2));
367        vst1q_f32(
368            vector.as_mut_ptr().add(base + SIMD_WIDTH * 2),
369            vmulq_f32(v2, inv_norm_vec),
370        );
371
372        let v3 = vld1q_f32(vector.as_ptr().add(base + SIMD_WIDTH * 3));
373        vst1q_f32(
374            vector.as_mut_ptr().add(base + SIMD_WIDTH * 3),
375            vmulq_f32(v3, inv_norm_vec),
376        );
377    }
378
379    // Remainder for scaling
380    for val in vector.iter_mut().skip(chunks * CHUNK_SIZE) {
381        *val *= inv_norm;
382    }
383}
384
385/// Normalizes in two passes with wasm32 SIMD128.
386///
387/// # Safety
388/// This function requires the compile-time `simd128` target feature; bounds are chunked.
389/// See [`docs/simd.md`](../../docs/simd.md#kernel-safety-boundary) for wasm alignment semantics.
390#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
391#[inline]
392unsafe fn normalize_simd128_unrolled(vector: &mut [f32]) {
393    const SIMD_WIDTH: usize = 4;
394    const UNROLL: usize = 4;
395    const CHUNK_SIZE: usize = SIMD_WIDTH * UNROLL;
396    let n = vector.len();
397    let chunks = n / CHUNK_SIZE;
398
399    // First pass: compute L2 norm with 4 accumulators
400    let mut norm0 = f32x4_splat(0.0);
401    let mut norm1 = f32x4_splat(0.0);
402    let mut norm2 = f32x4_splat(0.0);
403    let mut norm3 = f32x4_splat(0.0);
404
405    for i in 0..chunks {
406        let base = i * CHUNK_SIZE;
407
408        let v0 = v128_load(vector.as_ptr().add(base) as *const v128);
409        norm0 = f32x4_add(norm0, f32x4_mul(v0, v0));
410
411        let v1 = v128_load(vector.as_ptr().add(base + SIMD_WIDTH) as *const v128);
412        norm1 = f32x4_add(norm1, f32x4_mul(v1, v1));
413
414        let v2 = v128_load(vector.as_ptr().add(base + SIMD_WIDTH * 2) as *const v128);
415        norm2 = f32x4_add(norm2, f32x4_mul(v2, v2));
416
417        let v3 = v128_load(vector.as_ptr().add(base + SIMD_WIDTH * 3) as *const v128);
418        norm3 = f32x4_add(norm3, f32x4_mul(v3, v3));
419    }
420
421    let norm_vec = f32x4_add(f32x4_add(norm0, norm1), f32x4_add(norm2, norm3));
422    let mut norm_sq = horizontal_sum_simd128(norm_vec);
423
424    // Remainder for norm calculation
425    for i in (chunks * CHUNK_SIZE)..n {
426        norm_sq += vector[i] * vector[i];
427    }
428
429    let norm = norm_sq.sqrt();
430    // Match `normalize_scalar`: reject 0.0 and NaN alike so a NaN-containing
431    // vector is left unchanged rather than scaled by NaN. `is_nan() || <= 0.0`
432    // is the lint-clean equivalent of `!(norm > 0.0)`.
433    if norm.is_nan() || norm <= 0.0 {
434        return;
435    }
436
437    let inv_norm = 1.0 / norm;
438    let inv_norm_vec = f32x4_splat(inv_norm);
439
440    // Second pass: divide by norm with 4x unrolling
441    for i in 0..chunks {
442        let base = i * CHUNK_SIZE;
443
444        let v0 = v128_load(vector.as_ptr().add(base) as *const v128);
445        v128_store(
446            vector.as_mut_ptr().add(base) as *mut v128,
447            f32x4_mul(v0, inv_norm_vec),
448        );
449
450        let v1 = v128_load(vector.as_ptr().add(base + SIMD_WIDTH) as *const v128);
451        v128_store(
452            vector.as_mut_ptr().add(base + SIMD_WIDTH) as *mut v128,
453            f32x4_mul(v1, inv_norm_vec),
454        );
455
456        let v2 = v128_load(vector.as_ptr().add(base + SIMD_WIDTH * 2) as *const v128);
457        v128_store(
458            vector.as_mut_ptr().add(base + SIMD_WIDTH * 2) as *mut v128,
459            f32x4_mul(v2, inv_norm_vec),
460        );
461
462        let v3 = v128_load(vector.as_ptr().add(base + SIMD_WIDTH * 3) as *const v128);
463        v128_store(
464            vector.as_mut_ptr().add(base + SIMD_WIDTH * 3) as *mut v128,
465            f32x4_mul(v3, inv_norm_vec),
466        );
467    }
468
469    // Remainder for scaling
470    for i in (chunks * CHUNK_SIZE)..n {
471        vector[i] *= inv_norm;
472    }
473}