Skip to main content

edge_rs/utils/
simd.rs

1//! SIMD kernels behind the `EdgeSimd` trait.
2//!
3//! The optimiser core (`glm::levenberg`) calls `f64::dot_simd(..)` and
4//! `f64::exp_in_place_simd(..)` directly, since likelihoods and optimisers work
5//! in f64 regardless of the crate's `T: EdgeFloat` data layer. The vector width
6//! lives here and nowhere else; no intrinsics or `wide` types appear outside
7//! this file.
8//!
9//! Each operation exists at four widths: scalar, 128-bit, 256-bit and 512-bit.
10//! [`detect_simd_level`] caches what the machine supports and the trait impls
11//! dispatch on it. A tier vectorises what it can at its own width, then hands
12//! the remainder down to the tier below. That ladder matters here because the
13//! hot caller is `linear_predictor`, which dots design rows of two to ten
14//! elements: a single-width kernel would drop those straight to scalar.
15//!
16//! The reductions run one accumulator, not several. Multiple accumulators break
17//! the `fadd` dependency chain and pay from a few hundred elements up, but the
18//! block loop that drives them needs `lanes * accumulators` elements before it
19//! can fire at all, which at two to ten coefficients it never does. Dropping the
20//! four-accumulator version moved `glm_fit` by under three per cent either way
21//! at two and four coefficients and made it five per cent faster at eight, so it
22//! was buying code and nothing else.
23
24use std::sync::OnceLock;
25
26use wide::{f32x4, f32x8, f64x2, f64x4};
27
28#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))]
29use wide::{f32x16, f64x8};
30
31//////////////
32// Dispatch //
33//////////////
34
35/// Widest vector width available on the running machine.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum SimdLevel {
38    /// No vector unit worth dispatching to.
39    Scalar,
40    /// 128-bit. Also covers NEON, which every aarch64 target has.
41    Sse,
42    /// 256-bit.
43    Avx2,
44    /// 512-bit.
45    Avx512,
46}
47
48/// Cached result of the CPU feature probe.
49static SIMD_LEVEL: OnceLock<SimdLevel> = OnceLock::new();
50
51/// Detects the widest vector width this machine supports.
52///
53/// The probe runs once and is cached, so the `match` in the trait impls costs a
54/// load and a branch rather than a `cpuid`.
55///
56/// ### Returns
57///
58/// The [`SimdLevel`] to dispatch on.
59pub fn detect_simd_level() -> SimdLevel {
60    *SIMD_LEVEL.get_or_init(|| {
61        #[cfg(target_arch = "x86_64")]
62        {
63            if is_x86_feature_detected!("avx512f") {
64                return SimdLevel::Avx512;
65            }
66            if is_x86_feature_detected!("avx2") {
67                return SimdLevel::Avx2;
68            }
69            if is_x86_feature_detected!("sse4.1") {
70                return SimdLevel::Sse;
71            }
72            SimdLevel::Scalar
73        }
74
75        #[cfg(target_arch = "aarch64")]
76        {
77            SimdLevel::Sse
78        }
79
80        #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
81        {
82            SimdLevel::Scalar
83        }
84    })
85}
86
87/// SIMD-accelerated primitives over slices of a float type.
88///
89/// Implemented once per concrete float type; algorithm code never dispatches on
90/// the type itself.
91pub trait EdgeSimd: Sized + Copy {
92    /// Dot product of two equal-length slices.
93    ///
94    /// ### Params
95    ///
96    /// * `a` - Left operand
97    /// * `b` - Right operand, must be the same length as `a`
98    ///
99    /// ### Returns
100    ///
101    /// The sum of the elementwise products. Excess elements beyond `b`'s length
102    /// are ignored, so a length mismatch truncates rather than panics.
103    fn dot_simd(a: &[Self], b: &[Self]) -> Self;
104
105    /// Exponentiates a slice in place.
106    ///
107    /// This is the `mu = exp(eta)` step of every GLM fit in the crate, so it
108    /// sits on the hottest path there is.
109    ///
110    /// ### Params
111    ///
112    /// * `x` - Values to exponentiate, modified in place
113    ///
114    /// Note that each tier vectorises what it can and hands the remainder down,
115    /// and the last tier calls libm. `wide`'s polynomial and libm disagree in
116    /// the last ulp, so the same input value can come back with two different
117    /// bit patterns depending on where in the slice it sits, on the slice
118    /// length, and on which tier the machine dispatched to: `vec![0.37; n]`
119    /// gives two distinct patterns for every odd `n`. This is elementwise rather
120    /// than a reassociation, so it is a stronger caveat than the one on
121    /// [`EdgeSimd::dot_simd`], but the magnitude is still one ulp and nothing in
122    /// the crate depends on it. The vector lanes also flush a deep underflow to
123    /// zero where libm returns a subnormal: `exp(-745.0)` is exactly 0 in a
124    /// lane, 5e-324 in the tail.
125    fn exp_in_place_simd(x: &mut [Self]);
126}
127
128/////////////////
129// Dot product //
130/////////////////
131
132//-----------//
133// f64 tiers //
134//-----------//
135
136/// Dot product of two `f64` slices, scalar.
137///
138/// ### Params
139///
140/// * `a` - Left operand
141/// * `b` - Right operand
142///
143/// ### Returns
144///
145/// The sum of elementwise products, truncated to the shorter operand.
146#[inline]
147fn dot_f64_scalar(a: &[f64], b: &[f64]) -> f64 {
148    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
149}
150
151/// Dot product of two `f64` slices, 128-bit.
152///
153/// ### Params
154///
155/// * `a` - Left operand
156/// * `b` - Right operand
157///
158/// ### Returns
159///
160/// The sum of elementwise products, truncated to the shorter operand.
161#[inline]
162fn dot_f64_sse(a: &[f64], b: &[f64]) -> f64 {
163    const LANES: usize = 2;
164
165    let n = a.len().min(b.len());
166    let mut total = f64x2::ZERO;
167    let mut i = 0;
168
169    while i + LANES <= n {
170        total += f64x2::from(&a[i..i + LANES]) * f64x2::from(&b[i..i + LANES]);
171        i += LANES;
172    }
173
174    total.reduce_add() + dot_f64_scalar(&a[i..n], &b[i..n])
175}
176
177/// Dot product of two `f64` slices, 256-bit.
178///
179/// ### Params
180///
181/// * `a` - Left operand
182/// * `b` - Right operand
183///
184/// ### Returns
185///
186/// The sum of elementwise products, truncated to the shorter operand.
187#[inline]
188fn dot_f64_avx2(a: &[f64], b: &[f64]) -> f64 {
189    const LANES: usize = 4;
190
191    let n = a.len().min(b.len());
192    let mut total = f64x4::ZERO;
193    let mut i = 0;
194
195    while i + LANES <= n {
196        total += f64x4::from(&a[i..i + LANES]) * f64x4::from(&b[i..i + LANES]);
197        i += LANES;
198    }
199
200    total.reduce_add() + dot_f64_sse(&a[i..n], &b[i..n])
201}
202
203/// Dot product of two `f64` slices, 512-bit.
204///
205/// ### Params
206///
207/// * `a` - Left operand
208/// * `b` - Right operand
209///
210/// ### Returns
211///
212/// The sum of elementwise products, truncated to the shorter operand.
213#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))]
214#[inline]
215fn dot_f64_avx512(a: &[f64], b: &[f64]) -> f64 {
216    const LANES: usize = 8;
217
218    let n = a.len().min(b.len());
219    let mut total = f64x8::ZERO;
220    let mut i = 0;
221
222    while i + LANES <= n {
223        total += f64x8::from(&a[i..i + LANES]) * f64x8::from(&b[i..i + LANES]);
224        i += LANES;
225    }
226
227    total.reduce_add() + dot_f64_avx2(&a[i..n], &b[i..n])
228}
229
230/// Fallback when the crate is not built with AVX-512, so `f64x8` would be two
231/// 256-bit halves and buy nothing over the 256-bit tier.
232///
233/// ### Params
234///
235/// * `a` - Left operand
236/// * `b` - Right operand
237///
238/// ### Returns
239///
240/// The sum of elementwise products, truncated to the shorter operand.
241#[cfg(not(all(target_arch = "x86_64", target_feature = "avx512f")))]
242#[inline]
243fn dot_f64_avx512(a: &[f64], b: &[f64]) -> f64 {
244    dot_f64_avx2(a, b)
245}
246
247//-----------//
248// f32 tiers //
249//-----------//
250
251/// Dot product of two `f32` slices, scalar.
252///
253/// ### Params
254///
255/// * `a` - Left operand
256/// * `b` - Right operand
257///
258/// ### Returns
259///
260/// The sum of elementwise products, truncated to the shorter operand.
261#[inline]
262fn dot_f32_scalar(a: &[f32], b: &[f32]) -> f32 {
263    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
264}
265
266/// Dot product of two `f32` slices, 128-bit.
267///
268/// ### Params
269///
270/// * `a` - Left operand
271/// * `b` - Right operand
272///
273/// ### Returns
274///
275/// The sum of elementwise products, truncated to the shorter operand.
276#[inline]
277fn dot_f32_sse(a: &[f32], b: &[f32]) -> f32 {
278    const LANES: usize = 4;
279
280    let n = a.len().min(b.len());
281    let mut total = f32x4::ZERO;
282    let mut i = 0;
283
284    while i + LANES <= n {
285        total += f32x4::from(&a[i..i + LANES]) * f32x4::from(&b[i..i + LANES]);
286        i += LANES;
287    }
288
289    total.reduce_add() + dot_f32_scalar(&a[i..n], &b[i..n])
290}
291
292/// Dot product of two `f32` slices, 256-bit.
293///
294/// ### Params
295///
296/// * `a` - Left operand
297/// * `b` - Right operand
298///
299/// ### Returns
300///
301/// The sum of elementwise products, truncated to the shorter operand.
302#[inline]
303fn dot_f32_avx2(a: &[f32], b: &[f32]) -> f32 {
304    const LANES: usize = 8;
305
306    let n = a.len().min(b.len());
307    let mut total = f32x8::ZERO;
308    let mut i = 0;
309
310    while i + LANES <= n {
311        total += f32x8::from(&a[i..i + LANES]) * f32x8::from(&b[i..i + LANES]);
312        i += LANES;
313    }
314
315    total.reduce_add() + dot_f32_sse(&a[i..n], &b[i..n])
316}
317
318/// Dot product of two `f32` slices, 512-bit.
319///
320/// ### Params
321///
322/// * `a` - Left operand
323/// * `b` - Right operand
324///
325/// ### Returns
326///
327/// The sum of elementwise products, truncated to the shorter operand.
328#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))]
329#[inline]
330fn dot_f32_avx512(a: &[f32], b: &[f32]) -> f32 {
331    const LANES: usize = 16;
332
333    let n = a.len().min(b.len());
334    let mut total = f32x16::ZERO;
335    let mut i = 0;
336
337    while i + LANES <= n {
338        total += f32x16::from(&a[i..i + LANES]) * f32x16::from(&b[i..i + LANES]);
339        i += LANES;
340    }
341
342    total.reduce_add() + dot_f32_avx2(&a[i..n], &b[i..n])
343}
344
345/// Fallback when the crate is not built with AVX-512.
346///
347/// ### Params
348///
349/// * `a` - Left operand
350/// * `b` - Right operand
351///
352/// ### Returns
353///
354/// The sum of elementwise products, truncated to the shorter operand.
355#[cfg(not(all(target_arch = "x86_64", target_feature = "avx512f")))]
356#[inline]
357fn dot_f32_avx512(a: &[f32], b: &[f32]) -> f32 {
358    dot_f32_avx2(a, b)
359}
360
361/////////////////
362// Exponential //
363/////////////////
364
365//-----------//
366// f64 tiers //
367//-----------//
368
369/// In-place exponential over an `f64` slice, scalar.
370///
371/// ### Params
372///
373/// * `x` - Values to exponentiate
374#[inline]
375fn exp_f64_scalar(x: &mut [f64]) {
376    for v in x.iter_mut() {
377        *v = v.exp();
378    }
379}
380
381/// In-place exponential over an `f64` slice, 128-bit.
382///
383/// Worth taking down to two lanes: `wide`'s polynomial beats a pair of libm
384/// calls, and `n_samples` in the GLM path is often odd and small.
385///
386/// ### Params
387///
388/// * `x` - Values to exponentiate
389#[inline]
390fn exp_f64_sse(x: &mut [f64]) {
391    const LANES: usize = 2;
392
393    let n = x.len();
394    let mut i = 0;
395
396    while i + LANES <= n {
397        let v = f64x2::from(&x[i..i + LANES]).exp();
398        x[i..i + LANES].copy_from_slice(v.as_array());
399        i += LANES;
400    }
401
402    exp_f64_scalar(&mut x[i..]);
403}
404
405/// In-place exponential over an `f64` slice, 256-bit.
406///
407/// ### Params
408///
409/// * `x` - Values to exponentiate
410#[inline]
411fn exp_f64_avx2(x: &mut [f64]) {
412    const LANES: usize = 4;
413
414    let n = x.len();
415    let mut i = 0;
416
417    while i + LANES <= n {
418        let v = f64x4::from(&x[i..i + LANES]).exp();
419        x[i..i + LANES].copy_from_slice(v.as_array());
420        i += LANES;
421    }
422
423    exp_f64_sse(&mut x[i..]);
424}
425
426/// In-place exponential over an `f64` slice, 512-bit.
427///
428/// ### Params
429///
430/// * `x` - Values to exponentiate
431#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))]
432#[inline]
433fn exp_f64_avx512(x: &mut [f64]) {
434    const LANES: usize = 8;
435
436    let n = x.len();
437    let mut i = 0;
438
439    while i + LANES <= n {
440        let v = f64x8::from(&x[i..i + LANES]).exp();
441        x[i..i + LANES].copy_from_slice(v.as_array());
442        i += LANES;
443    }
444
445    exp_f64_avx2(&mut x[i..]);
446}
447
448/// Fallback when the crate is not built with AVX-512.
449///
450/// ### Params
451///
452/// * `x` - Values to exponentiate
453#[cfg(not(all(target_arch = "x86_64", target_feature = "avx512f")))]
454#[inline]
455fn exp_f64_avx512(x: &mut [f64]) {
456    exp_f64_avx2(x)
457}
458
459//-----------//
460// f32 tiers //
461//-----------//
462
463/// In-place exponential over an `f32` slice, scalar.
464///
465/// ### Params
466///
467/// * `x` - Values to exponentiate
468#[inline]
469fn exp_f32_scalar(x: &mut [f32]) {
470    for v in x.iter_mut() {
471        *v = v.exp();
472    }
473}
474
475/// In-place exponential over an `f32` slice, 128-bit.
476///
477/// ### Params
478///
479/// * `x` - Values to exponentiate
480#[inline]
481fn exp_f32_sse(x: &mut [f32]) {
482    const LANES: usize = 4;
483
484    let n = x.len();
485    let mut i = 0;
486
487    while i + LANES <= n {
488        let v = f32x4::from(&x[i..i + LANES]).exp();
489        x[i..i + LANES].copy_from_slice(v.as_array());
490        i += LANES;
491    }
492
493    exp_f32_scalar(&mut x[i..]);
494}
495
496/// In-place exponential over an `f32` slice, 256-bit.
497///
498/// ### Params
499///
500/// * `x` - Values to exponentiate
501#[inline]
502fn exp_f32_avx2(x: &mut [f32]) {
503    const LANES: usize = 8;
504
505    let n = x.len();
506    let mut i = 0;
507
508    while i + LANES <= n {
509        let v = f32x8::from(&x[i..i + LANES]).exp();
510        x[i..i + LANES].copy_from_slice(v.as_array());
511        i += LANES;
512    }
513
514    exp_f32_sse(&mut x[i..]);
515}
516
517/// In-place exponential over an `f32` slice, 512-bit.
518///
519/// ### Params
520///
521/// * `x` - Values to exponentiate
522#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))]
523#[inline]
524fn exp_f32_avx512(x: &mut [f32]) {
525    const LANES: usize = 16;
526
527    let n = x.len();
528    let mut i = 0;
529
530    while i + LANES <= n {
531        let v = f32x16::from(&x[i..i + LANES]).exp();
532        x[i..i + LANES].copy_from_slice(v.as_array());
533        i += LANES;
534    }
535
536    exp_f32_avx2(&mut x[i..]);
537}
538
539/// Fallback when the crate is not built with AVX-512.
540///
541/// ### Params
542///
543/// * `x` - Values to exponentiate
544#[cfg(not(all(target_arch = "x86_64", target_feature = "avx512f")))]
545#[inline]
546fn exp_f32_avx512(x: &mut [f32]) {
547    exp_f32_avx2(x)
548}
549
550/////////////////////
551// Implementations //
552/////////////////////
553
554impl EdgeSimd for f64 {
555    #[inline]
556    fn dot_simd(a: &[f64], b: &[f64]) -> f64 {
557        match detect_simd_level() {
558            SimdLevel::Avx512 => dot_f64_avx512(a, b),
559            SimdLevel::Avx2 => dot_f64_avx2(a, b),
560            SimdLevel::Sse => dot_f64_sse(a, b),
561            SimdLevel::Scalar => dot_f64_scalar(a, b),
562        }
563    }
564
565    #[inline]
566    fn exp_in_place_simd(x: &mut [f64]) {
567        match detect_simd_level() {
568            SimdLevel::Avx512 => exp_f64_avx512(x),
569            SimdLevel::Avx2 => exp_f64_avx2(x),
570            SimdLevel::Sse => exp_f64_sse(x),
571            SimdLevel::Scalar => exp_f64_scalar(x),
572        }
573    }
574}
575
576impl EdgeSimd for f32 {
577    #[inline]
578    fn dot_simd(a: &[f32], b: &[f32]) -> f32 {
579        match detect_simd_level() {
580            SimdLevel::Avx512 => dot_f32_avx512(a, b),
581            SimdLevel::Avx2 => dot_f32_avx2(a, b),
582            SimdLevel::Sse => dot_f32_sse(a, b),
583            SimdLevel::Scalar => dot_f32_scalar(a, b),
584        }
585    }
586
587    #[inline]
588    fn exp_in_place_simd(x: &mut [f32]) {
589        match detect_simd_level() {
590            SimdLevel::Avx512 => exp_f32_avx512(x),
591            SimdLevel::Avx2 => exp_f32_avx2(x),
592            SimdLevel::Sse => exp_f32_sse(x),
593            SimdLevel::Scalar => exp_f32_scalar(x),
594        }
595    }
596}
597
598///////////
599// Tests //
600///////////
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use approx::assert_relative_eq;
606
607    /// Lengths chosen to straddle every rung of the ladder: below a vector, one
608    /// vector, and vectors plus awkward remainders that force the delegation
609    /// down through 256, 128 and scalar.
610    const SIZES: [usize; 16] = [0, 1, 2, 3, 5, 6, 7, 8, 9, 15, 16, 17, 31, 32, 33, 45];
611
612    fn ramp_f64(n: usize) -> Vec<f64> {
613        (0..n).map(|i| (i as f64) * 0.5 - 3.0).collect()
614    }
615
616    fn ramp_f32(n: usize) -> Vec<f32> {
617        (0..n).map(|i| (i as f32) * 0.5 - 3.0).collect()
618    }
619
620    /// Every tier must agree with the scalar reference. Dispatch on this host
621    /// only ever reaches one tier, so the tier functions are called directly.
622    #[test]
623    fn test_dot_tiers_match_scalar_f64() {
624        for n in SIZES {
625            let a = ramp_f64(n);
626            let b: Vec<f64> = ramp_f64(n).iter().map(|v| v * 1.7 + 0.3).collect();
627            let expected = dot_f64_scalar(&a, &b);
628            assert_relative_eq!(dot_f64_sse(&a, &b), expected, epsilon = 1e-12);
629            assert_relative_eq!(dot_f64_avx2(&a, &b), expected, epsilon = 1e-12);
630            assert_relative_eq!(dot_f64_avx512(&a, &b), expected, epsilon = 1e-12);
631            assert_relative_eq!(f64::dot_simd(&a, &b), expected, epsilon = 1e-12);
632        }
633    }
634
635    #[test]
636    fn test_dot_tiers_match_scalar_f32() {
637        for n in SIZES {
638            let a = ramp_f32(n);
639            let b: Vec<f32> = ramp_f32(n).iter().map(|v| v * 1.7 + 0.3).collect();
640            let expected = dot_f32_scalar(&a, &b);
641            assert_relative_eq!(dot_f32_sse(&a, &b), expected, epsilon = 1e-4);
642            assert_relative_eq!(dot_f32_avx2(&a, &b), expected, epsilon = 1e-4);
643            assert_relative_eq!(dot_f32_avx512(&a, &b), expected, epsilon = 1e-4);
644            assert_relative_eq!(f32::dot_simd(&a, &b), expected, epsilon = 1e-4);
645        }
646    }
647
648    #[test]
649    fn test_exp_tiers_match_scalar() {
650        for n in SIZES {
651            let mut expected = ramp_f64(n);
652            exp_f64_scalar(&mut expected);
653
654            for kernel in [
655                exp_f64_sse as fn(&mut [f64]),
656                exp_f64_avx2,
657                exp_f64_avx512,
658                f64::exp_in_place_simd,
659            ] {
660                let mut x = ramp_f64(n);
661                kernel(&mut x);
662                for (got, want) in x.iter().zip(expected.iter()) {
663                    assert_relative_eq!(got, want, max_relative = 1e-12);
664                }
665            }
666
667            let mut expected = ramp_f32(n);
668            exp_f32_scalar(&mut expected);
669
670            for kernel in [
671                exp_f32_sse as fn(&mut [f32]),
672                exp_f32_avx2,
673                exp_f32_avx512,
674                f32::exp_in_place_simd,
675            ] {
676                let mut x = ramp_f32(n);
677                kernel(&mut x);
678                for (got, want) in x.iter().zip(expected.iter()) {
679                    assert_relative_eq!(got, want, max_relative = 1e-5);
680                }
681            }
682        }
683    }
684
685    /// A dot product of unequal lengths truncates to the shorter operand rather
686    /// than reading out of bounds. Documented behaviour, and the delegation has
687    /// to preserve it at every rung, so pin all tiers.
688    #[test]
689    fn test_dot_truncates_on_length_mismatch() {
690        let a = vec![1.0_f64; 20];
691        let b = vec![2.0_f64; 5];
692        assert_relative_eq!(dot_f64_scalar(&a, &b), 10.0, epsilon = 1e-12);
693        assert_relative_eq!(dot_f64_sse(&a, &b), 10.0, epsilon = 1e-12);
694        assert_relative_eq!(dot_f64_avx2(&a, &b), 10.0, epsilon = 1e-12);
695        assert_relative_eq!(dot_f64_avx512(&a, &b), 10.0, epsilon = 1e-12);
696        assert_relative_eq!(f64::dot_simd(&a, &b), 10.0, epsilon = 1e-12);
697    }
698
699    /// The probe is cached, so repeated calls must agree.
700    #[test]
701    fn test_detect_simd_level_is_stable() {
702        assert_eq!(detect_simd_level(), detect_simd_level());
703    }
704}