Skip to main content

semiflow/
truncated_exp4_cached.rs

1//! Wave B2 cache types for `TruncatedExp4thDiffusionChernoff` (v0.13.0, ADR-0034 Amendment 1).
2//!
3//! Kept in a separate module so that `truncated_exp4.rs` stays within the
4//! 700-line constitution cap (Override #1).
5//!
6//! ## What is here
7//!
8//! - [`HalfNodeCoeffCache`]: pre-evaluated `a(x)` at the four G⁴ half-node offsets.
9//! - [`TruncatedExp4WithCache`]: wrapper that substitutes cache lookups for closure
10//!   dispatch in the stencil hot loop, enabling Wave B3 SIMD lane saturation.
11
12use alloc::vec::Vec;
13
14// Wave B3: SIMD dispatch imports (ADR-0019 Amendment 2).
15// Re-exported from simd/mod.rs; arch-specific impls in simd/x86_64.rs / aarch64.rs.
16#[cfg(all(feature = "simd", target_arch = "x86_64", target_feature = "avx2"))]
17use crate::simd::apply_g4_stencil_avx2_4nodes;
18#[cfg(all(feature = "simd", target_arch = "aarch64", target_feature = "neon"))]
19use crate::simd::apply_g4_stencil_neon_4nodes;
20use crate::{
21    chernoff::{ChernoffFunction, Growth},
22    error::SemiflowError,
23    grid::Grid1D,
24    grid_fn::GridFn1D,
25    scratch::ScratchPool,
26    truncated_exp4::{
27        apply_power_series_f64_at_node, validate_a_norm_bound_f64, validate_cfl_4th,
28        validate_tau_f64, TruncatedExp4thDiffusionChernoff, TRUNC_ORDER_USIZE,
29    },
30};
31
32// ---------------------------------------------------------------------------
33// HalfNodeCoeffCache
34// ---------------------------------------------------------------------------
35
36/// Pre-evaluated `a(x)` at the four half-node offsets required by the G⁴ stencil.
37///
38/// Layout (N = number of grid nodes):
39/// - `ar3h[i]` = `a(x_i + 3·dx/2)` for i in 0..N
40/// - `ar1h[i]` = `a(x_i + dx/2)`   for i in 0..N
41/// - `al1h[i]` = `a(x_i - dx/2)`   for i in 0..N
42/// - `al3h[i]` = `a(x_i - 3·dx/2)` for i in 0..N
43///
44/// Eliminates four `a(x)` function-pointer calls per node per stencil application.
45/// `precompute_g4_grids_f64` calls the stencil K=4 times, so Wave B2 removes
46/// `4 × K × N = 16N` function-pointer dispatches per `apply()`.
47///
48/// # Caller contract
49///
50/// The function pointer `a` passed to [`HalfNodeCoeffCache::new`] MUST be **pure
51/// and deterministic** (same input always yields the same output).  If `a` has
52/// side effects or non-deterministic behaviour the cached values will diverge
53/// from the live-dispatch path and `TEXP4_CACHED_COEFF_BIT_EQUAL` will fail.
54///
55/// See ADR-0034 Amendment 1.
56#[derive(Clone)]
57pub struct HalfNodeCoeffCache {
58    /// `a(x_i + 3·dx/2)` for i in 0..N
59    pub ar3h: Vec<f64>,
60    /// `a(x_i + dx/2)` for i in 0..N
61    pub ar1h: Vec<f64>,
62    /// `a(x_i - dx/2)` for i in 0..N
63    pub al1h: Vec<f64>,
64    /// `a(x_i - 3·dx/2)` for i in 0..N
65    pub al3h: Vec<f64>,
66}
67
68impl HalfNodeCoeffCache {
69    /// Populate cache by evaluating `a(x)` at all half-node offsets for grid `g`.
70    ///
71    /// Boundary half-nodes (`i=0` left side, `i=N-1` right side) are evaluated by
72    /// calling `a()` at the extrapolated coordinate — matching the live-closure path
73    /// in `apply_g4_at_node_f64` which calls `(mc.a)(x_i ± offset)` without clamping
74    /// (ADR-0007 boundary policy applies at the `GridFn` level, not here).
75    // ar3h/ar1h/al1h/al3h: a×right/left×3/1×half — mathematically unambiguous.
76    #[allow(clippy::similar_names)]
77    pub fn new(a: fn(f64) -> f64, grid: Grid1D<f64>) -> Self {
78        let n = grid.n;
79        let dx = grid.dx();
80        let mut ar3h = Vec::with_capacity(n);
81        let mut ar1h = Vec::with_capacity(n);
82        let mut al1h = Vec::with_capacity(n);
83        let mut al3h = Vec::with_capacity(n);
84        for i in 0..n {
85            let x = grid.x_at(i);
86            ar3h.push(a(x + 1.5 * dx));
87            ar1h.push(a(x + 0.5 * dx));
88            al1h.push(a(x - 0.5 * dx));
89            al3h.push(a(x - 1.5 * dx));
90        }
91        Self {
92            ar3h,
93            ar1h,
94            al1h,
95            al3h,
96        }
97    }
98}
99
100// ---------------------------------------------------------------------------
101// TruncatedExp4WithCache
102// ---------------------------------------------------------------------------
103
104/// [`TruncatedExp4thDiffusionChernoff`] with pre-evaluated `a(x)` half-node cache.
105///
106/// Implements [`ChernoffFunction`] identically to the wrapped type but replaces
107/// the 4-per-node function-pointer dispatches in `apply_g4_at_node_f64` with
108/// direct `Vec<f64>` index reads.
109///
110/// # Construction
111///
112/// ```rust
113/// use semiflow::{chernoff::ApplyChernoffExt, Grid1D, GridFn1D, TruncatedExp4WithCache};
114/// let grid = Grid1D::new(-4.0, 4.0, 64).unwrap();
115/// let me4c = TruncatedExp4WithCache::with_cached_coefficients(
116///     |_| 1.0, |_| 0.0, |_| 0.0, 1.0, grid,
117/// );
118/// let u0 = GridFn1D::from_fn(grid, |x| (-x * x).exp());
119/// let u1 = me4c.apply_chernoff(0.001, &u0).unwrap();
120/// assert_eq!(u1.values.len(), 64);
121/// ```
122///
123/// # Caller invariants
124///
125/// Same as [`TruncatedExp4thDiffusionChernoff`].  Additionally, `a(x)` MUST be
126/// **pure and deterministic** — see [`HalfNodeCoeffCache`] doc.
127///
128/// See ADR-0034 Amendment 1.
129#[derive(Clone)]
130pub struct TruncatedExp4WithCache {
131    /// Wrapped Chernoff operator (closure path, unchanged, for validation reuse).
132    pub(crate) inner: TruncatedExp4thDiffusionChernoff<f64>,
133    /// Pre-evaluated half-node coefficient cache.
134    pub(crate) cache: HalfNodeCoeffCache,
135}
136
137impl TruncatedExp4WithCache {
138    /// Construct with pre-evaluated `a(x)` half-node coefficients.
139    ///
140    /// The hot loop indexes the cache instead of calling the closure, enabling
141    /// SIMD lane saturation in Wave B3.
142    ///
143    /// Memory cost: 4N × 8 bytes ≈ 16 KB at N=512, negligible vs the 2.8 MB
144    /// per-call baseline measured in iter-3 benchmarks.
145    ///
146    /// # Panics
147    ///
148    /// Does not panic. All grid operations are deterministic.
149    #[must_use]
150    pub fn with_cached_coefficients(
151        a: fn(f64) -> f64,
152        a_prime: fn(f64) -> f64,
153        a_double_prime: fn(f64) -> f64,
154        a_norm_bound: f64,
155        grid: Grid1D<f64>,
156    ) -> Self {
157        let inner =
158            TruncatedExp4thDiffusionChernoff::new(a, a_prime, a_double_prime, a_norm_bound, grid);
159        let cache = HalfNodeCoeffCache::new(a, grid);
160        Self { inner, cache }
161    }
162}
163
164impl ChernoffFunction<f64> for TruncatedExp4WithCache {
165    type S = GridFn1D<f64>;
166
167    /// Consistency order **2**.
168    fn order(&self) -> u32 {
169        2
170    }
171
172    /// Growth `(M, ω) = (1.0, 0.0)`.
173    fn growth(&self) -> Growth<f64> {
174        Growth::contraction()
175    }
176
177    /// Allocation-free output override using pre-evaluated coefficient cache.
178    ///
179    /// Uses the same `precompute_g4_grids_cached` stencil path (including SIMD
180    /// dispatch) as `apply` to guarantee bit-identical results. The only savings vs
181    /// the default bridge: output is written directly into `dst` instead of returning
182    /// a new `GridFn1D` allocation.
183    ///
184    /// The four g-grid `GridFn1D` intermediates are still heap-allocated per call.
185    /// Scratch is accepted but unused (`GridFn1D` state cannot be stored in `ScratchPool`).
186    fn apply_into(
187        &self,
188        tau: f64,
189        src: &GridFn1D<f64>,
190        dst: &mut GridFn1D<f64>,
191        _scratch: &mut ScratchPool<f64>,
192    ) -> Result<(), SemiflowError> {
193        validate_tau_f64(tau)?;
194        validate_a_norm_bound_f64(self.inner.a_norm_bound)?;
195        let dx = self.inner.grid.dx();
196        validate_cfl_4th(tau, self.inner.a_norm_bound, dx)?;
197        let n = src.values.len();
198        let g_grids = precompute_g4_grids_cached(&self.inner, &self.cache, src)?;
199        dst.values.resize(n, 0.0);
200        // No drift conjugation — direct index (bit-identical to apply).
201        for i in 0..n {
202            dst.values[i] = apply_power_series_f64_at_node(tau, &g_grids, i);
203        }
204        Ok(())
205    }
206}
207
208// ---------------------------------------------------------------------------
209// Cached stencil helpers
210// ---------------------------------------------------------------------------
211
212/// Precompute G⁴ power-series grids using cache lookups instead of closure dispatch.
213fn precompute_g4_grids_cached(
214    mc: &TruncatedExp4thDiffusionChernoff<f64>,
215    cache: &HalfNodeCoeffCache,
216    f: &GridFn1D<f64>,
217) -> Result<[GridFn1D<f64>; TRUNC_ORDER_USIZE + 1], SemiflowError> {
218    let g0 = f.clone();
219    let g1 = apply_g4_stencil_cached(mc, cache, &g0)?;
220    let g2 = apply_g4_stencil_cached(mc, cache, &g1)?;
221    let g3 = apply_g4_stencil_cached(mc, cache, &g2)?;
222    let g4 = apply_g4_stencil_cached(mc, cache, &g3)?;
223    Ok([g0, g1, g2, g3, g4])
224}
225
226/// Apply the G⁴ stencil once; reads coefficients from cache instead of closure.
227///
228/// Dispatches to the 4-node lane path (Wave B3) for interior chunks in every
229/// build: AVX2/NEON intrinsics under `feature = "simd"`, a scalar mirror of the
230/// same arithmetic otherwise (ADR-0200).
231/// Boundary nodes (first/last 2) always use the scalar path (requires `sample()`
232/// for out-of-bounds extrapolation).
233fn apply_g4_stencil_cached(
234    mc: &TruncatedExp4thDiffusionChernoff<f64>,
235    cache: &HalfNodeCoeffCache,
236    prev: &GridFn1D<f64>,
237) -> Result<GridFn1D<f64>, SemiflowError> {
238    let n = prev.values.len();
239    let dx = mc.grid.dx();
240    let dx_sq = dx * dx;
241    let mut out = prev.zeroed_like();
242
243    #[cfg(test)]
244    if crate::simd::FORCE_SCALAR.with(core::cell::Cell::get) {
245        apply_g4_stencil_cached_scalar(mc, cache, prev, &mut out, n, dx, dx_sq)?;
246        return Ok(out);
247    }
248    apply_g4_stencil_cached_simd(mc, cache, prev, &mut out, n, dx, dx_sq)
249}
250
251/// Scalar implementation of the G⁴ stencil (all nodes, no SIMD).
252#[allow(dead_code)] // used by the test force-scalar hook
253#[allow(clippy::too_many_arguments)]
254fn apply_g4_stencil_cached_scalar(
255    mc: &TruncatedExp4thDiffusionChernoff<f64>,
256    cache: &HalfNodeCoeffCache,
257    prev: &GridFn1D<f64>,
258    out: &mut GridFn1D<f64>,
259    n: usize,
260    dx: f64,
261    dx_sq: f64,
262) -> Result<(), SemiflowError> {
263    for i in 0..n {
264        let x_i = mc.grid.x_at(i);
265        out.values[i] = apply_g4_at_node_cached(cache, prev, i, n, x_i, dx, dx_sq)?;
266    }
267    Ok(())
268}
269
270/// SIMD dispatch path: boundary nodes scalar, interior chunks of 4 via arch SIMD.
271///
272/// Boundary invariant: the first/last 2 nodes may require `prev.sample()` for
273/// out-of-bounds extrapolation — those are always processed by the scalar path.
274/// Interior nodes [2 .. n-2] are safe for the 5-point stencil without extrapolation.
275#[allow(clippy::too_many_arguments)]
276fn apply_g4_stencil_cached_simd(
277    mc: &TruncatedExp4thDiffusionChernoff<f64>,
278    cache: &HalfNodeCoeffCache,
279    prev: &GridFn1D<f64>,
280    out: &mut GridFn1D<f64>,
281    n: usize,
282    dx: f64,
283    dx_sq: f64,
284) -> Result<GridFn1D<f64>, SemiflowError> {
285    let dx_sq_inv = 1.0 / dx_sq;
286    let scalar_left = 2_usize.min(n);
287    let simd_end = n.saturating_sub(2); // exclusive: last 2 nodes stay scalar
288
289    // Scalar left boundary (nodes 0, 1).
290    for i in 0..scalar_left {
291        let x_i = mc.grid.x_at(i);
292        out.values[i] = apply_g4_at_node_cached(cache, prev, i, n, x_i, dx, dx_sq)?;
293    }
294
295    // SIMD interior: chunks of 4 where all 5-point stencil reads are in-bounds.
296    // Condition: base >= 2 (scalar_left) and base+5 <= n (i.e. base <= n-5).
297    // simd_end = n-2, so base+4 <= n-2 → base <= n-6 → base+5 <= n-1 < n. Safe.
298    let mut i = scalar_left;
299    while i + 4 <= simd_end {
300        apply_g4_4nodes_simd(i, prev, cache, dx_sq_inv, out);
301        i += 4;
302    }
303
304    // Scalar tail + right boundary (remainder and last 2 nodes).
305    while i < n {
306        let x_i = mc.grid.x_at(i);
307        out.values[i] = apply_g4_at_node_cached(cache, prev, i, n, x_i, dx, dx_sq)?;
308        i += 1;
309    }
310    Ok(out.clone())
311}
312
313/// Dispatch 4-node lane kernel: AVX2 or NEON under `feature = "simd"`, the
314/// scalar mirror of the same arithmetic otherwise.
315///
316/// Interior invariant: `base >= 2` and `base + 5 <= n` — caller ensures this.
317#[allow(clippy::similar_names)]
318fn apply_g4_4nodes_simd(
319    base: usize,
320    prev: &GridFn1D<f64>,
321    cache: &HalfNodeCoeffCache,
322    dx_sq_inv: f64,
323    out: &mut GridFn1D<f64>,
324) {
325    // AVX2 path.
326    #[cfg(all(feature = "simd", target_arch = "x86_64", target_feature = "avx2"))]
327    apply_g4_stencil_avx2_4nodes(
328        base,
329        &prev.values,
330        &cache.ar3h,
331        &cache.ar1h,
332        &cache.al1h,
333        &cache.al3h,
334        dx_sq_inv,
335        &mut out.values,
336    );
337
338    // NEON path.
339    #[cfg(all(feature = "simd", target_arch = "aarch64", target_feature = "neon"))]
340    apply_g4_stencil_neon_4nodes(
341        base,
342        &prev.values,
343        &cache.ar3h,
344        &cache.ar1h,
345        &cache.al1h,
346        &cache.al3h,
347        dx_sq_inv,
348        &mut out.values,
349    );
350
351    // Scalar mirror for builds/arches without AVX2/NEON intrinsics.
352    #[cfg(not(all(
353        feature = "simd",
354        any(
355            all(target_arch = "x86_64", target_feature = "avx2"),
356            all(target_arch = "aarch64", target_feature = "neon")
357        )
358    )))]
359    apply_g4_4nodes_scalar_fallback(base, prev, cache, dx_sq_inv, out);
360}
361
362/// Pure-scalar 4-node G⁴ stencil — used when AVX2/NEON intrinsics are not compiled in.
363///
364/// Mirrors `avx2_g4_fluxes` / `neon_g4_lane_pair` operation for operation
365/// (`× 0.25`, `× (1/12)`, `× dx_sq_inv`, same addition order), so every build
366/// produces the same bits (ADR-0200).
367///
368/// Interior invariant: `base >= 2` and `base + 5 <= n` — caller ensures this.
369#[cfg(not(all(
370    feature = "simd",
371    any(
372        all(target_arch = "x86_64", target_feature = "avx2"),
373        all(target_arch = "aarch64", target_feature = "neon")
374    )
375)))]
376#[allow(clippy::similar_names)]
377fn apply_g4_4nodes_scalar_fallback(
378    base: usize,
379    prev: &GridFn1D<f64>,
380    cache: &HalfNodeCoeffCache,
381    dx_sq_inv: f64,
382    out: &mut GridFn1D<f64>,
383) {
384    let one_over_12 = 1.0 / 12.0;
385    for j in base..base + 4 {
386        // Interior invariant guarantees prev[j-2..j+3] are all in-bounds.
387        let rp2 = prev.values[j + 2];
388        let rp1 = prev.values[j + 1];
389        let ctr = prev.values[j];
390        let lm1 = prev.values[j - 1];
391        let lm2 = prev.values[j - 2];
392        let ar3h = cache.ar3h[j];
393        let ar1h = cache.ar1h[j];
394        let al1h = cache.al1h[j];
395        let al3h = cache.al3h[j];
396        let flux_right = ((5.0 * ar1h) * (rp1 - ctr)) * 0.25;
397        let flux_right_far = ((-ar3h) * (rp2 - rp1)) * one_over_12;
398        let flux_left = ((-5.0 * al1h) * (ctr - lm1)) * 0.25;
399        let flux_left_far = (al3h * (lm1 - lm2)) * one_over_12;
400        out.values[j] = (((flux_right_far + flux_right) + flux_left) + flux_left_far) * dx_sq_inv;
401    }
402}
403
404#[allow(clippy::similar_names, clippy::too_many_arguments)]
405fn apply_g4_at_node_cached(
406    cache: &HalfNodeCoeffCache,
407    prev: &GridFn1D<f64>,
408    i: usize,
409    n: usize,
410    x_i: f64,
411    dx: f64,
412    dx_sq: f64,
413) -> Result<f64, SemiflowError> {
414    let rp2 = if i + 2 < n {
415        prev.values[i + 2]
416    } else {
417        prev.sample(x_i + 2.0 * dx)?
418    };
419    let rp1 = if i + 1 < n {
420        prev.values[i + 1]
421    } else {
422        prev.sample(x_i + dx)?
423    };
424    let ctr = prev.values[i];
425    let lm1 = if i >= 1 {
426        prev.values[i - 1]
427    } else {
428        prev.sample(x_i - dx)?
429    };
430    let lm2 = if i >= 2 {
431        prev.values[i - 2]
432    } else {
433        prev.sample(x_i - 2.0 * dx)?
434    };
435
436    // Cache lookup — eliminates function-pointer dispatch.
437    let ar3h = cache.ar3h[i];
438    let ar1h = cache.ar1h[i];
439    let al1h = cache.al1h[i];
440    let al3h = cache.al3h[i];
441
442    let flux_right = 5.0 * ar1h * (rp1 - ctr) / 4.0;
443    let flux_right_far = -ar3h * (rp2 - rp1) / 12.0;
444    let flux_left = -5.0 * al1h * (ctr - lm1) / 4.0;
445    let flux_left_far = al3h * (lm1 - lm2) / 12.0;
446    Ok((flux_right_far + flux_right + flux_left + flux_left_far) / dx_sq)
447}