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}