Skip to main content

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}
Last built: 2026-09-08 21:35:55 UTC