Skip to main content

rssn_advanced/simd/
arithmetic.rs

1//! SIMD-accelerated batch arithmetic operations.
2//!
3//! All public functions in this module are **slice-iterating wrappers**
4//! over the 4-lane kernels in [`crate::asm_presets`]. The inner kernels
5//! emit explicit AVX2 / FMA instructions via `core::arch::asm!` — there
6//! is no reliance on the compiler's auto-vectorizer (`simd_review §1`).
7//!
8//! Each wrapper:
9//!
10//! 1. Checks AVX2 availability once per process via `HAS_AVX2`.
11//! 2. Splits the input into 4-element chunks aligned to the kernel width.
12//! 3. Calls the kernel per chunk.
13//! 4. Processes the trailing 0..3 elements with the scalar fallback,
14//!    guaranteeing bit-identical results across vectorized / scalar paths.
15//!
16//! Feature detection is hoisted out of the per-chunk inner loop using a
17//! `OnceLock<bool>` — `is_x86_feature_detected!` touches an MMIO-mapped
18//! CPUID leaf on `x86_64` and should not be called on every inner iteration.
19
20use std::sync::OnceLock;
21
22use crate::asm_presets::{
23    abs_f64x2, abs_f64x4, add_f64x2, add_f64x4, cmp_eq_f64x4, coef_merge_f64x4, div_f64x2,
24    div_f64x4, fma_f64x4, mul_f64x2, mul_f64x4, neg_f64x2, neg_f64x4, sqrt_f64x2, sqrt_f64x4,
25    sub_f64x2, sub_f64x4,
26};
27use crate::rssn_error;
28
29// =========================================================================
30// Private scalar lane helpers (Change 7: deduplicate scalar fallback)
31// =========================================================================
32
33#[inline(always)]
34fn scalar_add_lanes(lhs: &[f64], rhs: &[f64], out: &mut [f64]) {
35    debug_assert_eq!(lhs.len(), out.len());
36    debug_assert_eq!(rhs.len(), out.len());
37    for i in 0..out.len() {
38        out[i] = lhs[i] + rhs[i];
39    }
40}
41
42#[inline(always)]
43fn scalar_mul_lanes(lhs: &[f64], rhs: &[f64], out: &mut [f64]) {
44    debug_assert_eq!(lhs.len(), out.len());
45    debug_assert_eq!(rhs.len(), out.len());
46    for i in 0..out.len() {
47        out[i] = lhs[i] * rhs[i];
48    }
49}
50
51#[inline(always)]
52fn scalar_sub_lanes(lhs: &[f64], rhs: &[f64], out: &mut [f64]) {
53    debug_assert_eq!(lhs.len(), out.len());
54    debug_assert_eq!(rhs.len(), out.len());
55    for i in 0..out.len() {
56        out[i] = lhs[i] - rhs[i];
57    }
58}
59
60#[inline(always)]
61fn scalar_div_lanes(lhs: &[f64], rhs: &[f64], out: &mut [f64]) {
62    debug_assert_eq!(lhs.len(), out.len());
63    debug_assert_eq!(rhs.len(), out.len());
64    for i in 0..out.len() {
65        out[i] = lhs[i] / rhs[i];
66    }
67}
68
69#[inline(always)]
70fn scalar_sqrt_lanes(inp: &[f64], out: &mut [f64]) {
71    debug_assert_eq!(inp.len(), out.len());
72    for i in 0..out.len() {
73        out[i] = inp[i].sqrt();
74    }
75}
76
77#[inline(always)]
78fn scalar_neg_lanes(inp: &[f64], out: &mut [f64]) {
79    debug_assert_eq!(inp.len(), out.len());
80    for i in 0..out.len() {
81        out[i] = -inp[i];
82    }
83}
84
85#[inline(always)]
86fn scalar_abs_lanes(inp: &[f64], out: &mut [f64]) {
87    debug_assert_eq!(inp.len(), out.len());
88    for i in 0..out.len() {
89        out[i] = inp[i].abs();
90    }
91}
92
93/// Cached result of AVX2 detection. Populated on first use; subsequent
94/// reads are a single atomic load (Acquire). The `OnceLock` value is a
95/// `bool` so the hot path is a branch-predictor-friendly compare.
96static HAS_AVX2: OnceLock<bool> = OnceLock::new();
97
98/// Cached result of NEON detection.
99static HAS_NEON: OnceLock<bool> = OnceLock::new();
100
101/// Returns `true` if AVX2 is available on the current host CPU.
102///
103/// The first call probes `is_x86_feature_detected!`; all subsequent calls
104/// return the cached result with a single Acquire load.
105#[inline]
106fn avx2_available() -> bool {
107    *HAS_AVX2.get_or_init(crate::simd::detect::has_avx2)
108}
109
110/// Returns `true` if NEON is available on the current host CPU.
111#[inline]
112fn neon_available() -> bool {
113    *HAS_NEON.get_or_init(crate::simd::detect::has_neon)
114}
115
116rssn_error! {
117    /// Reasons a batch arithmetic operation cannot proceed.
118    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
119    pub enum BatchError {
120        /// One or more slice lengths disagree.
121        LengthMismatch,
122    }
123}
124
125/// Width (in `f64` lanes) of one AVX2-256 register.
126const LANES: usize = 4;
127
128// =========================================================================
129// f64 element-wise primitives
130// =========================================================================
131
132/// Batch element-wise addition: `result[i] = lhs[i] + rhs[i]`.
133///
134/// # Errors
135///
136/// Returns [`BatchError::LengthMismatch`] when any of the three slices
137/// has a different length.
138pub fn batch_add(lhs: &[f64], rhs: &[f64], result: &mut [f64]) -> Result<(), BatchError> {
139    let n = lhs.len();
140    if n != rhs.len() || n != result.len() {
141        return cold_batch_error_length_mismatch();
142    }
143
144    // Detect once per batch call rather than once per chunk call.
145    let use_avx2 = avx2_available();
146    let use_neon = neon_available();
147    let mut chunk_idx = 0;
148    // 2× unrolled path: two AVX2/scalar chunks per loop body.
149    while chunk_idx + LANES * 2 <= n {
150        let (o1, o2) = result[chunk_idx..chunk_idx + LANES * 2].split_at_mut(LANES);
151        if use_avx2 {
152            add_f64x4::apply(
153                &lhs[chunk_idx..chunk_idx + LANES],
154                &rhs[chunk_idx..chunk_idx + LANES],
155                o1,
156            );
157            add_f64x4::apply(
158                &lhs[chunk_idx + LANES..chunk_idx + LANES * 2],
159                &rhs[chunk_idx + LANES..chunk_idx + LANES * 2],
160                o2,
161            );
162        } else {
163            scalar_add_lanes(
164                &lhs[chunk_idx..chunk_idx + LANES],
165                &rhs[chunk_idx..chunk_idx + LANES],
166                o1,
167            );
168            scalar_add_lanes(
169                &lhs[chunk_idx + LANES..chunk_idx + LANES * 2],
170                &rhs[chunk_idx + LANES..chunk_idx + LANES * 2],
171                o2,
172            );
173        }
174        chunk_idx += LANES * 2;
175    }
176    // Handle any remaining full chunk.
177    while chunk_idx + LANES <= n {
178        let o = &mut result[chunk_idx..chunk_idx + LANES];
179        if use_avx2 {
180            add_f64x4::apply(
181                &lhs[chunk_idx..chunk_idx + LANES],
182                &rhs[chunk_idx..chunk_idx + LANES],
183                o,
184            );
185        } else {
186            scalar_add_lanes(
187                &lhs[chunk_idx..chunk_idx + LANES],
188                &rhs[chunk_idx..chunk_idx + LANES],
189                o,
190            );
191        }
192        chunk_idx += LANES;
193    }
194    // 2-lane NEON path for aarch64 when AVX2 is not available.
195    while chunk_idx + 2 <= n && !use_avx2 && use_neon {
196        if let (Ok(o2), Ok(l2), Ok(r2)) = (
197            <&mut [f64; 2]>::try_from(&mut result[chunk_idx..chunk_idx + 2]),
198            <&[f64; 2]>::try_from(&lhs[chunk_idx..chunk_idx + 2]),
199            <&[f64; 2]>::try_from(&rhs[chunk_idx..chunk_idx + 2]),
200        ) {
201            add_f64x2::apply(l2, r2, o2);
202        }
203        chunk_idx += 2;
204    }
205    while chunk_idx < n {
206        result[chunk_idx] = lhs[chunk_idx] + rhs[chunk_idx];
207        chunk_idx += 1;
208    }
209    Ok(())
210}
211
212/// Batch element-wise multiplication: `result[i] = lhs[i] * rhs[i]`.
213///
214/// # Errors
215///
216/// Returns [`BatchError::LengthMismatch`] when any of the three slices
217/// has a different length.
218pub fn batch_mul(lhs: &[f64], rhs: &[f64], result: &mut [f64]) -> Result<(), BatchError> {
219    let n = lhs.len();
220    if n != rhs.len() || n != result.len() {
221        return cold_batch_error_length_mismatch();
222    }
223
224    let use_avx2 = avx2_available();
225    let use_neon = neon_available();
226    let mut i = 0;
227    while i + LANES * 2 <= n {
228        let (o1, o2) = result[i..i + LANES * 2].split_at_mut(LANES);
229        if use_avx2 {
230            mul_f64x4::apply(&lhs[i..i + LANES], &rhs[i..i + LANES], o1);
231            mul_f64x4::apply(
232                &lhs[i + LANES..i + LANES * 2],
233                &rhs[i + LANES..i + LANES * 2],
234                o2,
235            );
236        } else {
237            scalar_mul_lanes(&lhs[i..i + LANES], &rhs[i..i + LANES], o1);
238            scalar_mul_lanes(
239                &lhs[i + LANES..i + LANES * 2],
240                &rhs[i + LANES..i + LANES * 2],
241                o2,
242            );
243        }
244        i += LANES * 2;
245    }
246    while i + LANES <= n {
247        let o = &mut result[i..i + LANES];
248        if use_avx2 {
249            mul_f64x4::apply(&lhs[i..i + LANES], &rhs[i..i + LANES], o);
250        } else {
251            scalar_mul_lanes(&lhs[i..i + LANES], &rhs[i..i + LANES], o);
252        }
253        i += LANES;
254    }
255    // 2-lane NEON path for aarch64 when AVX2 is not available.
256    while i + 2 <= n && !use_avx2 && use_neon {
257        if let (Ok(o2), Ok(l2), Ok(r2)) = (
258            <&mut [f64; 2]>::try_from(&mut result[i..i + 2]),
259            <&[f64; 2]>::try_from(&lhs[i..i + 2]),
260            <&[f64; 2]>::try_from(&rhs[i..i + 2]),
261        ) {
262            mul_f64x2::apply(l2, r2, o2);
263        }
264        i += 2;
265    }
266    while i < n {
267        result[i] = lhs[i] * rhs[i];
268        i += 1;
269    }
270    Ok(())
271}
272
273/// Batch scalar addition: `result[i] = lhs[i] + scalar`.
274///
275/// # Errors
276///
277/// Returns [`BatchError::LengthMismatch`] when `lhs` and `result`
278/// have different lengths.
279pub fn batch_add_scalar(lhs: &[f64], scalar: f64, result: &mut [f64]) -> Result<(), BatchError> {
280    let n = lhs.len();
281    if n != result.len() {
282        return cold_batch_error_length_mismatch();
283    }
284
285    let use_simd = avx2_available();
286    let rhs_splat = [scalar; LANES];
287    let mut i = 0;
288    while i + LANES * 2 <= n {
289        let (o1, o2) = result[i..i + LANES * 2].split_at_mut(LANES);
290        if use_simd {
291            add_f64x4::apply(&lhs[i..i + LANES], &rhs_splat, o1);
292            add_f64x4::apply(&lhs[i + LANES..i + LANES * 2], &rhs_splat, o2);
293        } else {
294            for j in 0..LANES {
295                o1[j] = lhs[i + j] + scalar;
296            }
297            for j in 0..LANES {
298                o2[j] = lhs[i + LANES + j] + scalar;
299            }
300        }
301        i += LANES * 2;
302    }
303    while i + LANES <= n {
304        let o = &mut result[i..i + LANES];
305        if use_simd {
306            add_f64x4::apply(&lhs[i..i + LANES], &rhs_splat, o);
307        } else {
308            for j in 0..LANES {
309                o[j] = lhs[i + j] + scalar;
310            }
311        }
312        i += LANES;
313    }
314    while i < n {
315        result[i] = lhs[i] + scalar;
316        i += 1;
317    }
318    Ok(())
319}
320
321// =========================================================================
322// Remaining element-wise binary primitives
323// =========================================================================
324
325/// Batch element-wise subtraction: `result[i] = lhs[i] - rhs[i]`.
326///
327/// # Errors
328///
329/// Returns [`BatchError::LengthMismatch`] when any of the three slices
330/// has a different length.
331pub fn batch_sub(lhs: &[f64], rhs: &[f64], result: &mut [f64]) -> Result<(), BatchError> {
332    let n = lhs.len();
333    if n != rhs.len() || n != result.len() {
334        return cold_batch_error_length_mismatch();
335    }
336
337    let use_avx2 = avx2_available();
338    let use_neon = neon_available();
339    let mut i = 0;
340    while i + LANES * 2 <= n {
341        let (o1, o2) = result[i..i + LANES * 2].split_at_mut(LANES);
342        if use_avx2 {
343            sub_f64x4::apply(&lhs[i..i + LANES], &rhs[i..i + LANES], o1);
344            sub_f64x4::apply(
345                &lhs[i + LANES..i + LANES * 2],
346                &rhs[i + LANES..i + LANES * 2],
347                o2,
348            );
349        } else {
350            scalar_sub_lanes(&lhs[i..i + LANES], &rhs[i..i + LANES], o1);
351            scalar_sub_lanes(
352                &lhs[i + LANES..i + LANES * 2],
353                &rhs[i + LANES..i + LANES * 2],
354                o2,
355            );
356        }
357        i += LANES * 2;
358    }
359    while i + LANES <= n {
360        let o = &mut result[i..i + LANES];
361        if use_avx2 {
362            sub_f64x4::apply(&lhs[i..i + LANES], &rhs[i..i + LANES], o);
363        } else {
364            scalar_sub_lanes(&lhs[i..i + LANES], &rhs[i..i + LANES], o);
365        }
366        i += LANES;
367    }
368    while i + 2 <= n && !use_avx2 && use_neon {
369        if let (Ok(o2), Ok(l2), Ok(r2)) = (
370            <&mut [f64; 2]>::try_from(&mut result[i..i + 2]),
371            <&[f64; 2]>::try_from(&lhs[i..i + 2]),
372            <&[f64; 2]>::try_from(&rhs[i..i + 2]),
373        ) {
374            sub_f64x2::apply(l2, r2, o2);
375        }
376        i += 2;
377    }
378    while i < n {
379        result[i] = lhs[i] - rhs[i];
380        i += 1;
381    }
382    Ok(())
383}
384
385/// Batch element-wise division: `result[i] = lhs[i] / rhs[i]`.
386///
387/// Division-by-zero follows IEEE-754: `x / 0 → ±Inf`, `0 / 0 → NaN`.
388///
389/// # Errors
390///
391/// Returns [`BatchError::LengthMismatch`] when any of the three slices
392/// has a different length.
393pub fn batch_div(lhs: &[f64], rhs: &[f64], result: &mut [f64]) -> Result<(), BatchError> {
394    let n = lhs.len();
395    if n != rhs.len() || n != result.len() {
396        return cold_batch_error_length_mismatch();
397    }
398
399    let use_avx2 = avx2_available();
400    let use_neon = neon_available();
401    let mut i = 0;
402    while i + LANES * 2 <= n {
403        let (o1, o2) = result[i..i + LANES * 2].split_at_mut(LANES);
404        if use_avx2 {
405            div_f64x4::apply(&lhs[i..i + LANES], &rhs[i..i + LANES], o1);
406            div_f64x4::apply(
407                &lhs[i + LANES..i + LANES * 2],
408                &rhs[i + LANES..i + LANES * 2],
409                o2,
410            );
411        } else {
412            scalar_div_lanes(&lhs[i..i + LANES], &rhs[i..i + LANES], o1);
413            scalar_div_lanes(
414                &lhs[i + LANES..i + LANES * 2],
415                &rhs[i + LANES..i + LANES * 2],
416                o2,
417            );
418        }
419        i += LANES * 2;
420    }
421    while i + LANES <= n {
422        let o = &mut result[i..i + LANES];
423        if use_avx2 {
424            div_f64x4::apply(&lhs[i..i + LANES], &rhs[i..i + LANES], o);
425        } else {
426            scalar_div_lanes(&lhs[i..i + LANES], &rhs[i..i + LANES], o);
427        }
428        i += LANES;
429    }
430    while i + 2 <= n && !use_avx2 && use_neon {
431        if let (Ok(o2), Ok(l2), Ok(r2)) = (
432            <&mut [f64; 2]>::try_from(&mut result[i..i + 2]),
433            <&[f64; 2]>::try_from(&lhs[i..i + 2]),
434            <&[f64; 2]>::try_from(&rhs[i..i + 2]),
435        ) {
436            div_f64x2::apply(l2, r2, o2);
437        }
438        i += 2;
439    }
440    while i < n {
441        result[i] = lhs[i] / rhs[i];
442        i += 1;
443    }
444    Ok(())
445}
446
447// =========================================================================
448// Unary element-wise primitives
449// =========================================================================
450
451/// Batch element-wise square root: `result[i] = sqrt(inp[i])`.
452///
453/// `sqrt` of a negative value produces `NaN` per IEEE-754.
454///
455/// # Errors
456///
457/// Returns [`BatchError::LengthMismatch`] when `inp` and `result` differ
458/// in length.
459pub fn batch_sqrt(inp: &[f64], result: &mut [f64]) -> Result<(), BatchError> {
460    let n = inp.len();
461    if n != result.len() {
462        return cold_batch_error_length_mismatch();
463    }
464
465    let use_avx2 = avx2_available();
466    let use_neon = neon_available();
467    let mut i = 0;
468    while i + LANES * 2 <= n {
469        let (o1, o2) = result[i..i + LANES * 2].split_at_mut(LANES);
470        if use_avx2 {
471            sqrt_f64x4::apply(&inp[i..i + LANES], o1);
472            sqrt_f64x4::apply(&inp[i + LANES..i + LANES * 2], o2);
473        } else {
474            scalar_sqrt_lanes(&inp[i..i + LANES], o1);
475            scalar_sqrt_lanes(&inp[i + LANES..i + LANES * 2], o2);
476        }
477        i += LANES * 2;
478    }
479    while i + LANES <= n {
480        let o = &mut result[i..i + LANES];
481        if use_avx2 {
482            sqrt_f64x4::apply(&inp[i..i + LANES], o);
483        } else {
484            scalar_sqrt_lanes(&inp[i..i + LANES], o);
485        }
486        i += LANES;
487    }
488    while i + 2 <= n && !use_avx2 && use_neon {
489        if let (Ok(o2), Ok(l2)) = (
490            <&mut [f64; 2]>::try_from(&mut result[i..i + 2]),
491            <&[f64; 2]>::try_from(&inp[i..i + 2]),
492        ) {
493            sqrt_f64x2::apply(l2, o2);
494        }
495        i += 2;
496    }
497    while i < n {
498        result[i] = inp[i].sqrt();
499        i += 1;
500    }
501    Ok(())
502}
503
504/// Batch element-wise negation: `result[i] = -inp[i]`.
505///
506/// # Errors
507///
508/// Returns [`BatchError::LengthMismatch`] when `inp` and `result` differ
509/// in length.
510pub fn batch_neg(inp: &[f64], result: &mut [f64]) -> Result<(), BatchError> {
511    let n = inp.len();
512    if n != result.len() {
513        return cold_batch_error_length_mismatch();
514    }
515
516    let use_avx2 = avx2_available();
517    let use_neon = neon_available();
518    let mut i = 0;
519    while i + LANES * 2 <= n {
520        let (o1, o2) = result[i..i + LANES * 2].split_at_mut(LANES);
521        if use_avx2 {
522            neg_f64x4::apply(&inp[i..i + LANES], o1);
523            neg_f64x4::apply(&inp[i + LANES..i + LANES * 2], o2);
524        } else {
525            scalar_neg_lanes(&inp[i..i + LANES], o1);
526            scalar_neg_lanes(&inp[i + LANES..i + LANES * 2], o2);
527        }
528        i += LANES * 2;
529    }
530    while i + LANES <= n {
531        let o = &mut result[i..i + LANES];
532        if use_avx2 {
533            neg_f64x4::apply(&inp[i..i + LANES], o);
534        } else {
535            scalar_neg_lanes(&inp[i..i + LANES], o);
536        }
537        i += LANES;
538    }
539    while i + 2 <= n && !use_avx2 && use_neon {
540        if let (Ok(o2), Ok(l2)) = (
541            <&mut [f64; 2]>::try_from(&mut result[i..i + 2]),
542            <&[f64; 2]>::try_from(&inp[i..i + 2]),
543        ) {
544            neg_f64x2::apply(l2, o2);
545        }
546        i += 2;
547    }
548    while i < n {
549        result[i] = -inp[i];
550        i += 1;
551    }
552    Ok(())
553}
554
555/// Batch element-wise absolute value: `result[i] = |inp[i]|`.
556///
557/// # Errors
558///
559/// Returns [`BatchError::LengthMismatch`] when `inp` and `result` differ
560/// in length.
561pub fn batch_abs(inp: &[f64], result: &mut [f64]) -> Result<(), BatchError> {
562    let n = inp.len();
563    if n != result.len() {
564        return cold_batch_error_length_mismatch();
565    }
566
567    let use_avx2 = avx2_available();
568    let use_neon = neon_available();
569    let mut i = 0;
570    while i + LANES * 2 <= n {
571        let (o1, o2) = result[i..i + LANES * 2].split_at_mut(LANES);
572        if use_avx2 {
573            abs_f64x4::apply(&inp[i..i + LANES], o1);
574            abs_f64x4::apply(&inp[i + LANES..i + LANES * 2], o2);
575        } else {
576            scalar_abs_lanes(&inp[i..i + LANES], o1);
577            scalar_abs_lanes(&inp[i + LANES..i + LANES * 2], o2);
578        }
579        i += LANES * 2;
580    }
581    while i + LANES <= n {
582        let o = &mut result[i..i + LANES];
583        if use_avx2 {
584            abs_f64x4::apply(&inp[i..i + LANES], o);
585        } else {
586            scalar_abs_lanes(&inp[i..i + LANES], o);
587        }
588        i += LANES;
589    }
590    while i + 2 <= n && !use_avx2 && use_neon {
591        if let (Ok(o2), Ok(l2)) = (
592            <&mut [f64; 2]>::try_from(&mut result[i..i + 2]),
593            <&[f64; 2]>::try_from(&inp[i..i + 2]),
594        ) {
595            abs_f64x2::apply(l2, o2);
596        }
597        i += 2;
598    }
599    while i < n {
600        result[i] = inp[i].abs();
601        i += 1;
602    }
603    Ok(())
604}
605
606// =========================================================================
607// New batch operators (T3.1: batch_pow, batch_cmp_eq, batch_coef_merge)
608// =========================================================================
609
610/// Batch element-wise `pow`: `result[i] = base[i] ^ exp[i]`.
611///
612/// `pow` has no AVX2 intrinsic, so this stays per-element via
613/// `f64::powf`. Kept in this module for API symmetry with the other
614/// batch operations; callers that want SIMD speed should fold the
615/// common case `pow(_, 2.0)` into `batch_mul(x, x)`.
616///
617/// # Errors
618///
619/// Returns [`BatchError::LengthMismatch`] when any of the three slices
620/// has a different length.
621pub fn batch_pow(base: &[f64], exp: &[f64], result: &mut [f64]) -> Result<(), BatchError> {
622    let n = base.len();
623    if n != exp.len() || n != result.len() {
624        return cold_batch_error_length_mismatch();
625    }
626    for ((b, e), o) in base.iter().zip(exp.iter()).zip(result.iter_mut()) {
627        *o = b.powf(*e);
628    }
629    Ok(())
630}
631
632/// Batch IEEE-754 equality: `mask[i] = 0xFF` if `lhs[i] == rhs[i]`,
633/// else `0x00`. NaN never equals NaN.
634///
635/// # Errors
636///
637/// Returns [`BatchError::LengthMismatch`] when any of the three slices
638/// has a different length.
639pub fn batch_cmp_eq(lhs: &[f64], rhs: &[f64], mask: &mut [u8]) -> Result<(), BatchError> {
640    let n = lhs.len();
641    if n != rhs.len() || n != mask.len() {
642        return cold_batch_error_length_mismatch();
643    }
644
645    let use_simd = avx2_available();
646    let mut i = 0;
647    while i + LANES * 2 <= n {
648        if use_simd {
649            cmp_eq_f64x4::apply(
650                &lhs[i..i + LANES],
651                &rhs[i..i + LANES],
652                &mut mask[i..i + LANES],
653            );
654            cmp_eq_f64x4::apply(
655                &lhs[i + LANES..i + LANES * 2],
656                &rhs[i + LANES..i + LANES * 2],
657                &mut mask[i + LANES..i + LANES * 2],
658            );
659        } else {
660            #[allow(clippy::float_cmp)]
661            for j in 0..LANES * 2 {
662                mask[i + j] = if lhs[i + j] == rhs[i + j] { 0xFF } else { 0x00 };
663            }
664        }
665        i += LANES * 2;
666    }
667    while i + LANES <= n {
668        if use_simd {
669            cmp_eq_f64x4::apply(
670                &lhs[i..i + LANES],
671                &rhs[i..i + LANES],
672                &mut mask[i..i + LANES],
673            );
674        } else {
675            #[allow(clippy::float_cmp)]
676            for j in 0..LANES {
677                mask[i + j] = if lhs[i + j] == rhs[i + j] { 0xFF } else { 0x00 };
678            }
679        }
680        i += LANES;
681    }
682    #[allow(clippy::float_cmp)]
683    while i < n {
684        mask[i] = if lhs[i] == rhs[i] { 0xFF } else { 0x00 };
685        i += 1;
686    }
687    Ok(())
688}
689
690/// Batch symbolic coefficient merge:
691/// `out[i] = (coef_a[i] * coef_b[i]) * (var_x[i] * var_y[i])`.
692///
693/// This is the kernel the JIT peephole pass invokes when it fuses
694/// nested products `(coef_a*var_x)*(coef_b*var_y)` (`plan.md §3.1`).
695///
696/// # Errors
697///
698/// Returns [`BatchError::LengthMismatch`] when any of the five slices
699/// has a different length.
700pub fn batch_coef_merge(
701    coef_a: &[f64],
702    coef_b: &[f64],
703    var_x: &[f64],
704    var_y: &[f64],
705    out: &mut [f64],
706) -> Result<(), BatchError> {
707    let n = coef_a.len();
708    if n != coef_b.len() || n != var_x.len() || n != var_y.len() || n != out.len() {
709        return cold_batch_error_length_mismatch();
710    }
711
712    let use_simd = avx2_available();
713    let mut i = 0;
714    while i + LANES <= n {
715        if use_simd {
716            coef_merge_f64x4::apply(
717                &coef_a[i..i + LANES],
718                &coef_b[i..i + LANES],
719                &var_x[i..i + LANES],
720                &var_y[i..i + LANES],
721                &mut out[i..i + LANES],
722            );
723        } else {
724            for j in 0..LANES {
725                out[i + j] = (coef_a[i + j] * coef_b[i + j]) * (var_x[i + j] * var_y[i + j]);
726            }
727        }
728        i += LANES;
729    }
730    while i < n {
731        out[i] = (coef_a[i] * coef_b[i]) * (var_x[i] * var_y[i]);
732        i += 1;
733    }
734    Ok(())
735}
736
737/// Batch fused multiply-add: `out[i] = lhs[i] * rhs[i] + addend[i]`
738/// with single-rounding semantics when FMA is available.
739///
740/// # Errors
741///
742/// Returns [`BatchError::LengthMismatch`] when any of the four slices
743/// has a different length.
744pub fn batch_fma(
745    lhs: &[f64],
746    rhs: &[f64],
747    addend: &[f64],
748    out: &mut [f64],
749) -> Result<(), BatchError> {
750    let n = lhs.len();
751    if n != rhs.len() || n != addend.len() || n != out.len() {
752        return cold_batch_error_length_mismatch();
753    }
754
755    let use_simd = avx2_available();
756    let mut i = 0;
757    while i + LANES * 2 <= n {
758        let (o1, o2) = out[i..i + LANES * 2].split_at_mut(LANES);
759        if use_simd {
760            fma_f64x4::apply(
761                &lhs[i..i + LANES],
762                &rhs[i..i + LANES],
763                &addend[i..i + LANES],
764                o1,
765            );
766            fma_f64x4::apply(
767                &lhs[i + LANES..i + LANES * 2],
768                &rhs[i + LANES..i + LANES * 2],
769                &addend[i + LANES..i + LANES * 2],
770                o2,
771            );
772        } else {
773            for j in 0..LANES {
774                o1[j] = lhs[i + j].mul_add(rhs[i + j], addend[i + j]);
775            }
776            for j in 0..LANES {
777                o2[j] = lhs[i + LANES + j].mul_add(rhs[i + LANES + j], addend[i + LANES + j]);
778            }
779        }
780        i += LANES * 2;
781    }
782    while i + LANES <= n {
783        let o = &mut out[i..i + LANES];
784        if use_simd {
785            fma_f64x4::apply(
786                &lhs[i..i + LANES],
787                &rhs[i..i + LANES],
788                &addend[i..i + LANES],
789                o,
790            );
791        } else {
792            for j in 0..LANES {
793                o[j] = lhs[i + j].mul_add(rhs[i + j], addend[i + j]);
794            }
795        }
796        i += LANES;
797    }
798    while i < n {
799        out[i] = lhs[i].mul_add(rhs[i], addend[i]);
800        i += 1;
801    }
802    Ok(())
803}
804
805// =========================================================================
806// JIT + SIMD bridge (simd_review §3.1)
807// =========================================================================
808
809/// Evaluates a JIT-compiled expression for each row of `var_sets`.
810///
811/// `var_sets` is a **row-major** matrix with `results.len()` rows and
812/// `num_vars` columns. `results[i]` receives `func(&var_sets[i * num_vars..])`.
813///
814/// This bridges the JIT single-eval path with batch processing patterns:
815/// callers can tabulate a compiled expression over a grid of inputs, run
816/// Monte Carlo sampling, or compare the JIT path to a SIMD-vectorized path
817/// by stacking this with [`batch_add`] / [`batch_mul`] on the result slice.
818///
819/// # Errors
820///
821/// Returns [`BatchError::LengthMismatch`] when
822/// `var_sets.len() != results.len() * num_vars`.
823#[cfg(feature = "cranelift-jit")]
824pub fn batch_eval(
825    func: crate::jit::compiler::CompiledExprFn,
826    var_sets: &[f64],
827    num_vars: usize,
828    results: &mut [f64],
829) -> Result<(), BatchError> {
830    let n = results.len();
831    if var_sets.len() != n.saturating_mul(num_vars) {
832        return cold_batch_error_length_mismatch();
833    }
834    for (i, out) in results.iter_mut().enumerate() {
835        *out = func(var_sets[i * num_vars..].as_ptr());
836    }
837    Ok(())
838}
839
840/// Non-JIT stub so `batch_eval` compiles without the `cranelift-jit` feature.
841#[cfg(not(feature = "cranelift-jit"))]
842pub fn batch_eval(
843    _func: *const (),
844    _var_sets: &[f64],
845    _num_vars: usize,
846    results: &mut [f64],
847) -> Result<(), BatchError> {
848    let _ = results;
849    cold_batch_error_length_mismatch()
850}
851
852/// Parallel variant of [`batch_eval`] that splits rows into chunks and
853/// evaluates each chunk on a separate fiber from the `dtact` runtime pool.
854///
855/// `chunk_size` controls how many rows each fiber processes. A `chunk_size`
856/// of 0 or a total row count ≤ `chunk_size` falls back to serial [`batch_eval`].
857///
858/// # Errors
859///
860/// Returns [`BatchError::LengthMismatch`] when
861/// `var_sets.len() != results.len() * num_vars` or `chunk_size == 0` and
862/// the non-JIT stub is active.
863#[cfg(feature = "cranelift-jit")]
864pub fn batch_eval_parallel(
865    func: crate::jit::compiler::CompiledExprFn,
866    var_sets: &[f64],
867    num_vars: usize,
868    results: &mut [f64],
869    chunk_size: usize,
870) -> Result<(), BatchError> {
871    let n = results.len();
872    if var_sets.len() != n.saturating_mul(num_vars) {
873        return cold_batch_error_length_mismatch();
874    }
875    if chunk_size == 0 || n <= chunk_size {
876        return batch_eval(func, var_sets, num_vars, results);
877    }
878
879    use std::cell::UnsafeCell;
880    use std::sync::Arc;
881
882    struct SendSync<T>(T);
883    // SAFETY: The raw pointer is partitioned into disjoint per-row ranges
884    // before distribution to fibers, so concurrent writes never alias.
885    unsafe impl<T> Send for SendSync<T> {}
886    unsafe impl<T> Sync for SendSync<T> {}
887
888    let gate = crate::runtime::ensure_runtime();
889    let results_ptr: Arc<SendSync<UnsafeCell<*mut f64>>> =
890        Arc::new(SendSync(UnsafeCell::new(results.as_mut_ptr())));
891    let var_ptr: *const f64 = var_sets.as_ptr();
892    let var_ptr_send: usize = var_ptr as usize;
893
894    let mut handles = Vec::new();
895    let mut offset = 0usize;
896    while offset < n {
897        let end = (offset + chunk_size).min(n);
898        let count = end - offset;
899        let out_offset = offset;
900        let results_arc = Arc::clone(&results_ptr);
901        handles.push(crate::runtime::spawn_task(gate, move || {
902            for i in 0..count {
903                let row = out_offset + i;
904                let vars = unsafe {
905                    std::slice::from_raw_parts(
906                        (var_ptr_send as *const f64).add(row * num_vars),
907                        num_vars,
908                    )
909                };
910                let val = func(vars.as_ptr());
911                unsafe {
912                    let base = *results_arc.0.get();
913                    *base.add(row) = val;
914                }
915            }
916        }));
917        offset = end;
918    }
919    for h in handles {
920        crate::runtime::join(h);
921    }
922    Ok(())
923}
924
925/// Non-JIT stub for `batch_eval_parallel`.
926#[cfg(not(feature = "cranelift-jit"))]
927pub fn batch_eval_parallel(
928    _func: *const (),
929    _var_sets: &[f64],
930    _num_vars: usize,
931    results: &mut [f64],
932    _chunk_size: usize,
933) -> Result<(), BatchError> {
934    let _ = results;
935    cold_batch_error_length_mismatch()
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941
942    #[test]
943    fn batch_add_matches_scalar() {
944        let lhs: Vec<f64> = (0..27).map(|i| f64::from(i) * 0.5).collect();
945        let rhs: Vec<f64> = (0..27).map(|i| f64::from(i) * 1.5).collect();
946        let mut result = vec![0.0_f64; 27];
947        batch_add(&lhs, &rhs, &mut result).expect("ok");
948        for i in 0..27 {
949            assert!((result[i] - (lhs[i] + rhs[i])).abs() < 1e-12);
950        }
951    }
952
953    #[test]
954    fn batch_mul_handles_unaligned_tail() {
955        // 7 elements = 1 chunk of 4 + 3-element tail.
956        let lhs = [2.0_f64, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
957        let rhs = [10.0_f64; 7];
958        let mut result = [0.0_f64; 7];
959        batch_mul(&lhs, &rhs, &mut result).expect("ok");
960        assert_eq!(result, [20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0]);
961    }
962
963    #[test]
964    fn batch_add_scalar_splats_correctly() {
965        let lhs: Vec<f64> = (0..16).map(f64::from).collect();
966        let mut result = vec![0.0_f64; 16];
967        batch_add_scalar(&lhs, 1.5, &mut result).expect("ok");
968        for i in 0..16 {
969            assert!((result[i] - (f64::from(i as i32) + 1.5)).abs() < 1e-12);
970        }
971    }
972
973    #[test]
974    fn batch_pow_per_element() {
975        let base = [2.0_f64, 3.0, 4.0, 5.0, 6.0];
976        let exp = [2.0_f64, 3.0, 0.5, 0.0, 1.0];
977        let mut out = [0.0_f64; 5];
978        batch_pow(&base, &exp, &mut out).expect("ok");
979        assert!((out[0] - 4.0).abs() < 1e-12, "2^2");
980        assert!((out[1] - 27.0).abs() < 1e-12, "3^3");
981        // 4^0.5 = sqrt(4) = 2.0
982        assert!((out[2] - 2.0).abs() < 1e-12, "sqrt(4)");
983        assert_eq!(out[3], 1.0, "5^0");
984        assert_eq!(out[4], 6.0, "6^1");
985    }
986
987    #[test]
988    fn batch_cmp_eq_handles_chunks_and_tail() {
989        // 5 elements: one chunk + 1 tail.
990        let lhs = [1.0_f64, 2.0, 3.0, 4.0, 5.0];
991        let rhs = [1.0_f64, 0.0, 3.0, 0.0, 5.0];
992        let mut mask = [0_u8; 5];
993        batch_cmp_eq(&lhs, &rhs, &mut mask).expect("ok");
994        assert_eq!(mask, [0xFF, 0x00, 0xFF, 0x00, 0xFF]);
995    }
996
997    #[test]
998    fn batch_coef_merge_matches_naive() {
999        let coef_a = [2.0_f64, 3.0, 4.0, 5.0, 6.0];
1000        let coef_b = [0.5_f64, 0.5, 0.5, 0.5, 0.5];
1001        let var_x = [10.0_f64, 20.0, 30.0, 40.0, 50.0];
1002        let var_y = [1.0_f64, 1.0, 1.0, 1.0, 1.0];
1003        let mut out = [0.0_f64; 5];
1004        batch_coef_merge(&coef_a, &coef_b, &var_x, &var_y, &mut out).expect("ok");
1005        for i in 0..5 {
1006            let expected = (coef_a[i] * coef_b[i]) * (var_x[i] * var_y[i]);
1007            assert!((out[i] - expected).abs() < 1e-12);
1008        }
1009    }
1010
1011    #[test]
1012    fn batch_fma_single_rounding() {
1013        let lhs = [1.0_f64, 2.0, 3.0, 4.0, 5.0];
1014        let rhs = [10.0_f64; 5];
1015        let addend = [100.0_f64; 5];
1016        let mut out = [0.0_f64; 5];
1017        batch_fma(&lhs, &rhs, &addend, &mut out).expect("ok");
1018        for i in 0..5 {
1019            assert!((out[i] - (lhs[i] * rhs[i] + addend[i])).abs() < 1e-12);
1020        }
1021    }
1022
1023    #[test]
1024    fn length_mismatch_returns_error() {
1025        let lhs = [1.0_f64, 2.0];
1026        let rhs = [1.0_f64; 3];
1027        let mut out = [0.0_f64; 2];
1028        assert_eq!(
1029            batch_add(&lhs, &rhs, &mut out),
1030            Err(BatchError::LengthMismatch)
1031        );
1032    }
1033
1034    #[test]
1035    fn batch_sub_matches_scalar() {
1036        let lhs: Vec<f64> = (0..27).map(|i| f64::from(i) * 2.0).collect();
1037        let rhs: Vec<f64> = (0..27).map(f64::from).collect();
1038        let mut result = vec![0.0_f64; 27];
1039        batch_sub(&lhs, &rhs, &mut result).expect("ok");
1040        for i in 0..27 {
1041            assert!((result[i] - f64::from(i as i32)).abs() < 1e-12);
1042        }
1043    }
1044
1045    #[test]
1046    fn batch_div_matches_scalar() {
1047        let lhs: Vec<f64> = (1..=27).map(|i| f64::from(i) * 2.0).collect();
1048        let rhs = vec![2.0_f64; 27];
1049        let mut result = vec![0.0_f64; 27];
1050        batch_div(&lhs, &rhs, &mut result).expect("ok");
1051        for i in 0..27 {
1052            let expected = f64::from((i + 1) as i32);
1053            assert!((result[i] - expected).abs() < 1e-12, "i={i}");
1054        }
1055    }
1056
1057    #[test]
1058    fn batch_sqrt_known_values() {
1059        let inp: Vec<f64> = [0.0, 1.0, 4.0, 9.0, 16.0, 25.0, 36.0, 49.0, 64.0].to_vec();
1060        let expected: Vec<f64> = (0..9).map(f64::from).collect();
1061        let mut result = vec![0.0_f64; inp.len()];
1062        batch_sqrt(&inp, &mut result).expect("ok");
1063        for i in 0..inp.len() {
1064            assert!((result[i] - expected[i]).abs() < 1e-12, "i={i}");
1065        }
1066    }
1067
1068    #[test]
1069    fn batch_neg_flips_signs() {
1070        let inp: Vec<f64> = (0..19).map(|i| f64::from(i) - 9.0).collect();
1071        let mut result = vec![0.0_f64; 19];
1072        batch_neg(&inp, &mut result).expect("ok");
1073        for i in 0..19 {
1074            assert!((result[i] + inp[i]).abs() < 1e-12, "i={i}");
1075        }
1076    }
1077
1078    #[test]
1079    fn batch_abs_removes_sign() {
1080        let inp: Vec<f64> = (0..19).map(|i| f64::from(i) - 9.0).collect();
1081        let mut result = vec![0.0_f64; 19];
1082        batch_abs(&inp, &mut result).expect("ok");
1083        for i in 0..19 {
1084            assert!((result[i] - inp[i].abs()).abs() < 1e-12, "i={i}");
1085        }
1086    }
1087}