thermite_special/specialized/generic/chebyshev.rs
1use thermite::{
2 element::FloatElement,
3 math::policy::{Policy, PrecisionPolicy},
4 prelude::*,
5};
6
7/// Shared Chebyshev series summation for all element types, all four kinds, and both the
8/// compile-time and runtime coefficient counts.
9///
10/// Evaluates `$\sum_{k=0}^{N-1} c_k P_k(x)$` where `P_k` is `T_k`, `U_k`, `V_k`, or `W_k`
11/// for `K` of 1, 2, 3, or 4. All four share the recurrence
12/// `$P_{k+1}(x) = 2x P_k(x) - P_{k-1}(x)$` with `P_0 = 1`, differing only in `P_1`, so
13/// the `b_k` loop below is common to every kind.
14///
15/// `REINSCH` says whether the *arithmetic* admits the endpoint form (see below): it needs
16/// a real `copysign` and a meaningful nearest endpoint, so real vectors pass `true` and
17/// `Complex` and the composites pass `false`. It is a capability, not a request: the
18/// form is taken only when the policy also asks for `Best` precision or better.
19///
20/// # The `N` parameter
21///
22/// `N` is the coefficient count when the caller knows it and **`0` when it does not**, the
23/// same sentinel `fast_polynomial::poly_f_internal` uses. At a nonzero `N` the length is
24/// handed to LLVM as an `assert_unchecked`, so the `n == 1`/`n == 2` shortcuts fold away
25/// and the loop unrolls exactly as it did when the bound was the const generic itself. At
26/// `N = 0` every one of those becomes an ordinary runtime branch.
27///
28/// This replaces a hand-ported `chebyshev_series_slice` that duplicated the whole
29/// recurrence, Reinsch arm included, under a doc comment reading "both forms must be edited
30/// together". A series is still not a reduction, since it carries `k`-dependent state and
31/// cannot be folded over chunks the way a norm can, so sharing the *body* is the only way
32/// to share anything here, and it is what removes the drift.
33///
34/// Chebyshev is the merge's safe case on purpose: the only per-step quantity is
35/// `coeffs[k]`, so nothing here depends on `k` becoming a literal. The Legendre, Hermite
36/// and Laguerre series do (a division or a square root per step folds away only if the
37/// loop unrolls), which is why they have not been merged.
38///
39/// # Safety
40///
41/// `N != 0` promises `coeffs.len() == N`. The const-length entry point is the only caller
42/// that passes a nonzero `N`, and it takes a `&[E; N]`, so the promise is the array's.
43///
44/// The empty series is `0`. `N = 0` is therefore both "unknown length" and "empty", which
45/// agree: an empty slice returns `V::ZERO` down the runtime path. The rejection of an empty
46/// *const* count lives on the `chebyshev_n` entry point, where it is still a compile error.
47#[inline(always)]
48pub fn chebyshev_series<P, E, V, const K: usize, const N: usize, const REINSCH: bool>(x: V, coeffs: &[E]) -> V
49where
50 P: Policy,
51 E: FloatElement,
52 V: FloatVector<Element = E>,
53{
54 const {
55 assert!(K >= 1 && K <= 4, "chebyshev: K must be 1, 2, 3, or 4");
56 }
57
58 let n = coeffs.len();
59
60 // SAFETY: IFF N != 0, `n` is guaranteed to be == N by this function's contract, so this
61 // is an optimization hint rather than a check. It is what keeps the const-length caller
62 // generating the code it did when `N` was the loop bound directly.
63 if const { N != 0 } {
64 unsafe { core::hint::assert_unchecked(n == N) };
65 }
66
67 if n == 0 {
68 return V::ZERO;
69 }
70
71 // S = Σ c_k P_0 = c_0 when n = 1; skip the whole recurrence.
72 if n == 1 {
73 return V::splat(coeffs[0]);
74 }
75
76 let x2 = x + x;
77
78 // P_1: T_1 = x, U_1 = 2x, V_1 = 2x - 1, W_1 = 2x + 1.
79 let p1 = if const { K == 1 } {
80 x
81 } else if const { K == 2 } {
82 x2
83 } else if const { K == 3 } {
84 x2 - V::ONE
85 } else if const { K == 4 } {
86 x2 + V::ONE
87 } else {
88 unsafe { core::hint::unreachable_unchecked() }
89 };
90
91 let cn1 = V::splat(coeffs[n - 1]);
92 let cn2 = V::splat(coeffs[n - 2]);
93
94 // S = c_0 + c_1*P_1(x) when n = 2.
95 if n == 2 {
96 return p1.mul_adde(cn1, cn2);
97 }
98
99 // Reinsch's modification. The plain recurrence below forms `2x*b - b` with consecutive
100 // b_k of nearly equal magnitude as x -> +-1, and cancels. Measured against a 60-digit
101 // oracle that costs up to 37 ulp on a sum whose own condition number is ~1. Recurring
102 // instead on the differences (near +1) or the sums (near -1) forms the small quantity
103 // directly:
104 //
105 // d_k = b_k - b_{k+1} = 2(x-1)*b_{k+1} + d_{k+1} + c_k, b_k = b_{k+1} + d_k
106 // d_k = b_k + b_{k+1} = 2(x+1)*b_{k+1} - d_{k+1} + c_k, b_k = d_k - b_{k+1}
107 //
108 // The two differ only in the sign of d_{k+1} and of b_{k+1}, so s = copysign(1, x) folds
109 // them into one branchless recurrence, which matters because the endpoint is a per-lane
110 // property and a scalar branch is not available. x - s is exact by Sterbenz for
111 // |x| >= 1/2, so the cancellation happens once, exactly, instead of once per step.
112 //
113 // Costs roughly 2x on the dependency chain (two FMAs deep per step instead of one), hence
114 // the policy gate. Always using the +1 form to dodge the copysign was measured and is
115 // WORSE than plain Clenshaw at x -> -1 (q 85 vs 53). Do not "simplify" it away.
116 if const { REINSCH && P::POLICY.precision.ge(PrecisionPolicy::Best) } {
117 let s = V::ONE.copysign(x);
118 let step = (x - s) + (x - s);
119
120 let mut b_1 = V::ZERO; // b_{k+1}
121 let mut b_2 = V::ZERO; // b_{k+2}
122 let mut d_1 = V::ZERO; // d_{k+1}
123
124 // k = n-1 down to 1. The first two steps fold away against the zero seeds.
125 let mut k = n - 1;
126 while k >= 1 {
127 let d = step.mul_adde(b_1, s.mul_adde(d_1, V::splat(coeffs[k])));
128 let b = s.mul_adde(b_1, d);
129 b_2 = b_1;
130 b_1 = b;
131 d_1 = d;
132 k -= 1;
133 }
134
135 return b_1.mul_adde(p1, V::splat(coeffs[0]) - b_2);
136 }
137
138 // Clenshaw's backward recurrence:
139 //
140 // b_{n+1} = b_n = 0
141 // for k = n-1 down to 1: b_k = 2x*b_{k+1} - b_{k+2} + c_k
142 // S = (c_0 - b_2) + b_1 * P_1(x)
143 //
144 // This is more numerically stable than the forward sum (especially when the partial sums
145 // of Σ c_k P_k are much smaller than max|c_k P_k|) and uses only two running scalars
146 // instead of three.
147 //
148 // Hoist the first two iterations to eliminate the b_2 = 0 subtraction in the loop:
149 // k = n-1: b_{n-1} = 2x*0 + c_{n-1} - 0 = c_{n-1}
150 // k = n-2: b_{n-2} = 2x*c_{n-1} + c_{n-2} - 0 = 2x*c_{n-1} + c_{n-2}
151 let mut b1 = x2.mul_adde(cn1, cn2); // b_{k+1} = b_{n-2}
152 let mut b2 = cn1; // b_{k+2} = b_{n-1}
153
154 // Iterate k = n-3, n-4, ..., 1.
155 let mut k = n - 2;
156 while k > 1 {
157 k -= 1;
158 // b_k = (2x*b_{k+1} + c_k) - b_{k+2}
159 let bk = x2.mul_adde(b1, V::splat(coeffs[k]) - b2);
160 b2 = b1;
161 b1 = bk;
162 }
163
164 // S = b_1 * P_1(x) + (c_0 - b_2)
165 b1.mul_adde(p1, V::splat(coeffs[0]) - b2)
166}