Skip to main content

lattice_embed/simd/
normalize.rs

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