Skip to main content

thermite_special/specialized/generic/
legendre.rs

1use thermite::{element::FloatElement, prelude::*};
2
3/// Runtime-length form of [`legendre_series`].
4///
5/// A genuine port of the recurrence rather than a fold over the const kernel: a series
6/// carries `k`-dependent state and does not partition the way the slice reductions in
7/// `thermite` do. Both forms must be edited together.
8///
9/// `chebyshev_series` no longer has a twin like this, its two bodies having been merged
10/// behind an `N = 0` sentinel and an `assert_unchecked`. **This one has not been merged,
11/// and the reason is `a` and `b_next` below.** They are `E::from_ratio` calls, not
12/// `const fn`s, so they become literals only when LLVM fully unrolls the loop. Chebyshev's
13/// only per-step quantity is `coeffs[k]` and has nothing to lose, while merging here would
14/// put a division per step behind an `assume` that nothing would detect. Wants an asm or
15/// llvm-mca check before anyone tries it.
16///
17/// What the runtime length costs here is more than the lost unrolling: `a_k` and `b_{k+1}`
18/// are no longer compile-time constants, so each step pays a division to form them. If the
19/// degree is known, [`legendre_series`] is meaningfully cheaper, not just tidier.
20///
21/// The empty series is `0`, where the const form refuses to compile.
22#[inline(always)]
23pub fn legendre_series_slice<E, V>(x: V, coeffs: &[E]) -> V
24where
25    E: FloatElement,
26    V: FloatVector<Element = E>,
27{
28    let n = coeffs.len();
29
30    if n == 0 {
31        return V::ZERO;
32    }
33
34    if n == 1 {
35        return V::splat(coeffs[0]);
36    }
37
38    let cn1 = V::splat(coeffs[n - 1]);
39
40    if n == 2 {
41        return x.mul_adde(cn1, V::splat(coeffs[0]));
42    }
43
44    let mut y2 = cn1;
45    let mut y1 = (x * V::splat(a::<E>(n - 2))).mul_adde(cn1, V::splat(coeffs[n - 2]));
46
47    let mut k = n - 2;
48    while k > 1 {
49        k -= 1;
50        let ax = x * V::splat(a::<E>(k));
51        let yk = ax.mul_adde(y1, y2.mul_adde(V::splat(b_next::<E>(k)), V::splat(coeffs[k])));
52        y2 = y1;
53        y1 = yk;
54    }
55
56    x.mul_adde(
57        y1,
58        y2.mul_adde(
59            V::splat(<E as FloatElement>::ConstRatio::<{ -1 }, 2>::VALUE),
60            V::splat(coeffs[0]),
61        ),
62    )
63}
64
65/// Clenshaw summation of a Legendre series, `$\sum_{k=0}^{N-1} c_k P_k(x)$`.
66///
67/// The Legendre recurrence `(k+1) P_{k+1} = (2k+1) x P_k - k P_{k-1}` is
68/// `P_{k+1} = a_k x P_k + b_k P_{k-1}` with `a_k = (2k+1)/(k+1)` and `b_k = -k/(k+1)`, so
69/// Clenshaw's adjoint recurrence is
70///
71/// ```text
72/// y_k = c_k + a_k x y_{k+1} + b_{k+1} y_{k+2}      k = N-1 down to 1,  y_N = y_{N+1} = 0
73/// S   = c_0 + x y_1 + b_1 y_2 = c_0 + x y_1 - y_2 / 2
74/// ```
75///
76/// Both ratios depend only on `k`, which is a compile-time constant at every step of the
77/// unrolled loop, so they fold to literals. The per-step critical path is the one FMA
78/// that carries `y_{k+1}`, exactly as in the Chebyshev kernel. Unlike that kernel there is
79/// no endpoint-cancellation variant here: `P_n(1) = 1` for every `n` makes the same
80/// degeneracy exist at `$x = \pm 1$`, but its Reinsch-style rewrite has not been derived
81/// or measured, so this is plain Clenshaw at every policy, which is why, unlike its
82/// siblings, this kernel takes no policy parameter.
83#[inline(always)]
84pub fn legendre_series<E, V, const N: usize>(x: V, coeffs: &[E; N]) -> V
85where
86    E: FloatElement,
87    V: FloatVector<Element = E>,
88{
89    const {
90        assert!(N >= 1, "legendre_series: N must be at least 1");
91    }
92
93    // S = c_0 P_0 = c_0.
94    if const { N == 1 } {
95        return V::splat(coeffs[0]);
96    }
97
98    let cn1 = V::splat(coeffs[N - 1]);
99
100    // S = c_0 + c_1 x.
101    if const { N == 2 } {
102        return x.mul_adde(cn1, V::splat(coeffs[0]));
103    }
104
105    // Hoist the top two steps, whose y_{k+2} (and y_{k+1}) terms are zero:
106    //     k = N-1:  y = c_{N-1}
107    //     k = N-2:  y = c_{N-2} + a_{N-2} x c_{N-1}
108    let mut y2 = cn1;
109    let mut y1 = (x * V::splat(a::<E>(N - 2))).mul_adde(cn1, V::splat(coeffs[N - 2]));
110
111    // k = N-3 down to 1.
112    let mut k = N - 2;
113    while k > 1 {
114        k -= 1;
115        // y_k = a_k x y_{k+1} + (c_k + b_{k+1} y_{k+2}). b_{k+1} is negative, so the sign
116        // lives in the constant and the addend is a single FMA off the critical path.
117        let ax = x * V::splat(a::<E>(k));
118        let yk = ax.mul_adde(y1, y2.mul_adde(V::splat(b_next::<E>(k)), V::splat(coeffs[k])));
119        y2 = y1;
120        y1 = yk;
121    }
122
123    // S = x y_1 + (c_0 - y_2 / 2)
124    x.mul_adde(
125        y1,
126        y2.mul_adde(
127            V::splat(<E as FloatElement>::ConstRatio::<{ -1 }, 2>::VALUE),
128            V::splat(coeffs[0]),
129        ),
130    )
131}
132
133/// `a_k = (2k+1)/(k+1)`.
134#[inline(always)]
135fn a<E: FloatElement>(k: usize) -> E {
136    E::from_ratio((2 * k + 1) as thermite::LargeInt, (k + 1) as thermite::LargeInt)
137}
138
139/// `b_{k+1} = -(k+1)/(k+2)`.
140#[inline(always)]
141fn b_next<E: FloatElement>(k: usize) -> E {
142    E::from_ratio(-((k + 1) as thermite::LargeInt), (k + 2) as thermite::LargeInt)
143}
Last built: 2026-09-08 21:35:55 UTC