Skip to main content

thermite_special/specialized/generic/
quadrature.rs

1//! Gauss-Legendre nodes and weights, one root per lane.
2//!
3//! The `n`-point rule integrates polynomials through degree `2n - 1` exactly on `[-1, 1]`:
4//! `int f ~ sum_k w_k f(x_k)` with `x_k` the roots of `P_n` and `w_k = 2 / ((1 - x_k^2) P_n'(x_k)^2)`.
5//!
6//! # Shape
7//!
8//! The root index `k` is the lane's input (`0` is the largest root, `n - 1` the smallest,
9//! `x_{n-1-k} = -x_k`), and `n` is uniform, so a packet of consecutive indices _is_ the rule:
10//! a caller sweeps `k` in packets and stores nodes and weights as it goes. Every lane runs the
11//! same `O(n)` recurrence per Newton step, so the packet costs one root, not `LANES`.
12//!
13//! # Method
14//!
15//! Tricomi's seed `cos(pi (k + 3/4) / (n + 1/2))` is within `O(1/n)` of the root and inside
16//! its Newton basin (the roots of `P_n` are separated by about `pi/n` and the seed's error
17//! is a fraction of that), then `newtons_method` on `P_n` with `P_n'` from the recurrence,
18//! `P_n'(x) = n (x P_n - P_{n-1}) / (x^2 - 1)`, two to four steps in practice, eight at most.
19//! The recurrence `j P_j = (2j - 1) x P_{j-1} - (j - 1) P_{j-2}` runs with its two scalar
20//! coefficients formed once per `j`, no vector division. The residual tolerance scales with
21//! `n`, which is the recurrence's own rounding. Through `|P_n'|`, that is a few `eps`
22//! absolute on interior nodes and far less at the ends, where `P_n'` is `O(n^2)`.
23
24use thermite::{
25    element::FloatElement,
26    math::{
27        TranscendentalMathWithPolicy as _,
28        algorithms::newtons_method,
29        policy::{
30            Policy,
31            policies::{LessPrecision, MaxIterations},
32        },
33    },
34    prelude::*,
35};
36
37use super::ndtr::residual_tolerance;
38use crate::specialized::SpecializedSpecialMath;
39
40/// `(P_n(x), P_n'(x))` by the three-term recurrence.
41#[inline(always)]
42fn legendre_pair<E, V>(x: V, n: u32) -> (V, V)
43where
44    E: FloatElement,
45    V: FloatVector<Element = E>,
46{
47    let mut p_prev = V::ONE;
48    let mut p = x;
49    let mut j = 2u32;
50    while j <= n {
51        // Scalar coefficients once per step: (2j - 1)/j and (j - 1)/j.
52        let jf = E::from_int(j as i64);
53        let a = V::splat(E::from_int((2 * j - 1) as i64) / jf);
54        let b = V::splat(E::from_int((j - 1) as i64) / jf);
55        let next = (a * x).mul_sube(p, b * p_prev);
56        p_prev = p;
57        p = next;
58        j += 1;
59    }
60    // P_n' = n (x P_n - P_{n-1}) / (x^2 - 1). `x^2 - 1` as `-(1 - x)(1 + x)`: near the
61    // end roots `1 - x` is exact (Sterbenz) where `1 - x*x` would lose `eps / (1 - x^2)`,
62    // 150 ulp of the extreme weight at n = 33.
63    let nf = V::splat(E::from_int(n as i64));
64    let dp = -(nf * (x * p - p_prev)) / ((V::ONE - x) * (V::ONE + x));
65    (p, dp)
66}
67
68/// The `k`-th node and weight of the `n`-point Gauss-Legendre rule, `k` per lane.
69#[inline(always)]
70pub fn gauss_legendre_impl<P, E, V>(k: V, n: u32) -> (V, V)
71where
72    P: Policy,
73    E: FloatElement,
74    V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
75{
76    let nf = V::splat(E::from_int(n as i64));
77    let valid = valid_index(k, nf);
78
79    if n == 1 {
80        // P_1 = x: the one root is 0 with weight 2.
81        return (valid.select(V::ZERO, V::NAN), valid.select(V::TWO, V::NAN));
82    }
83
84    // Tricomi's seed, one tier down.
85    let theta = (k + V::splat(<E as FloatElement>::ConstRatio::<3, 4>::VALUE)) / (nf + V::HALF) * V::PI;
86    let x0 = theta.cos_p::<LessPrecision<P>>();
87
88    let tol = residual_tolerance::<P, E, V>(nf);
89    let (x, _) = newtons_method::<V, MaxIterations<P, 8>, _>(x0, tol, valid, None, |x| legendre_pair::<E, V>(x, n));
90
91    // The tolerance is `n` ulp of residual, but `|P_n'|` at the interior roots is only
92    // about `sqrt(n)`, so Newton may stop `8 sqrt(n)` eps short there (56 eps at n = 64).
93    // The pair is needed for the weight anyway, and one more step from it costs a division and
94    // lands the node at the recurrence's own noise floor. The weight keeps this pair's
95    // `P_n'`: its error is `P''/P'` times the step, which is negligible where the step is
96    // large (interior, `P''/P' ~ 2x`) and the step is negligible where `P''/P'` is large.
97    let (p, dp) = legendre_pair::<E, V>(x, n);
98    let x = x - p / dp;
99
100    let one_m_x2 = (V::ONE - x) * (V::ONE + x);
101    let w = V::TWO / (one_m_x2 * dp * dp);
102
103    (valid.select(x, V::NAN), valid.select(w, V::NAN))
104}
105
106/// `k` is a whole number in `[0, n)`.
107#[inline(always)]
108fn valid_index<E: FloatElement, V: FloatVector<Element = E>>(k: V, nf: V) -> V::Mask {
109    k.cmp_ge(V::ZERO) & k.cmp_lt(nf) & k.cmp_eq(k.round())
110}
111
112/// Newton in `x`-space on a `(value, derivative)` pair, for the polynomial rules whose
113/// magnitude varies too much across the interval for a function-space tolerance: a lane
114/// stops when its own step is under `8 sqrt(n)` eps of `1 + |x|`, at most eight steps. No
115/// polishing step: with an `x`-space stop the iterate is already at the recurrence's noise
116/// floor, and a further step there is a random walk of about an ulp (measured on the
117/// smallest root of `L_16^2`, where the floor itself is 7 ulp of 0.38 because the
118/// intermediate `L_m` are about 150 against `L' = 129`).
119#[inline(always)]
120fn newton_x<E, V, F>(mut x: V, n: u32, active: V::Mask, mut f: F) -> (V, V, V)
121where
122    E: FloatElement,
123    V: FloatVector<Element = E>,
124    F: FnMut(V) -> (V, V),
125{
126    let tol = <V as FloatVector>::EPSILON * V::splat(E::from_int(8)) * V::splat(E::from_int(n as i64)).sqrt();
127    // A lane freezes once its own step is small: extra steps at the noise floor walk it
128    // off its answer, and a lane's count of steps must not depend on its packet-mates.
129    let mut frozen = !active;
130    let mut i = 0;
131    while i < 8 {
132        let (p, dp) = f(x);
133        let dx = p / dp;
134        x = frozen.select(x, x - dx);
135        frozen |= dx.abs().cmp_le(tol * (V::ONE + x.abs()));
136        if frozen.all() {
137            break;
138        }
139        i += 1;
140    }
141    let (p, dp) = f(x);
142    (x, p, dp)
143}
144
145/// The `k`-th node and weight of the `n`-point Gauss-Hermite rule (weight `e^{-x^2}` on
146/// the line), `k` per lane, `k = 0` the largest root.
147///
148/// Seed: the WKB phase of the Hermite equation, `x = sqrt(2n+1) cos(phi)` with
149/// `phi - sin(2 phi)/2 = 2 pi (k + 3/4)/(2n + 1)`, solved per lane by four Newton steps
150/// from `phi = (3c/2)^{1/3}`. At the edge this reproduces the Airy constant
151/// (`x_0 ~ sqrt(2n+1) - 1.856 (2n+1)^{-1/6}`) to three digits. The left half is the mirror
152/// of the right. Newton then runs on `h_m = H_m / m!`, whose recurrence
153/// `h_{m+1} = (2x h_m - 2 h_{m-1})/(m + 1)` has a scalar divisor and stays in range where
154/// the raw `H_m` overflows at degree 48, with `h_n' = 2 h_{n-1}`. Weight
155/// `w = sqrt(pi) 2^{n-1} / ((n-1)! n h_{n-1}(x_k)^2)`, the scalar factor a running product.
156/// That factor underflows past `n = 170` in f64 and `n = 40` in f32, which bounds the rule.
157#[inline(always)]
158pub fn gauss_hermite_impl<P, E, V>(k: V, n: u32) -> (V, V)
159where
160    P: Policy,
161    E: FloatElement,
162    V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
163{
164    let nf = V::splat(E::from_int(n as i64));
165    let valid = valid_index(k, nf);
166
167    if n == 1 {
168        return (valid.select(V::ZERO, V::NAN), valid.select(V::SQRT_PI, V::NAN));
169    }
170
171    // Right-half index and the sign to put back.
172    let kk = k.min(nf - V::ONE - k);
173    let neg = k.cmp_gt(kk);
174
175    // phi from the WKB phase.
176    let two_n_p1 = nf.mul_adde(V::TWO, V::ONE);
177    let c = (kk + V::splat(<E as FloatElement>::ConstRatio::<3, 4>::VALUE)) * (V::TAU / two_n_p1);
178    let mut phi = (c * V::splat(<E as FloatElement>::ConstRatio::<3, 2>::VALUE))
179        .cbrt_p::<LessPrecision<P>>()
180        .min(V::FRAC_PI_2);
181    let mut i = 0;
182    while i < 4 {
183        let (s, co) = (phi + phi).sin_cos_p::<LessPrecision<P>>();
184        phi -= (phi - s * V::HALF - c) / (V::ONE - co).max(<V as FloatVector>::EPSILON);
185        i += 1;
186    }
187    let x0 = two_n_p1.sqrt() * phi.cos_p::<LessPrecision<P>>();
188
189    let pair = |x: V| -> (V, V) {
190        let mut h_prev = V::ONE;
191        let mut h = x + x;
192        let mut m = 1u32;
193        while m < n {
194            // (2x h - 2 h_prev)/(m+1) as 2/(m+1) * (x h - h_prev): one FMA and one multiply.
195            let two_inv = V::splat(E::from_ratio(2, (m + 1) as i64));
196            let next = x.mul_sube(h, h_prev) * two_inv;
197            h_prev = h;
198            h = next;
199            m += 1;
200        }
201        (h, h_prev + h_prev)
202    };
203
204    let (x, _, dp) = newton_x::<E, V, _>(x0, n, valid, pair);
205    // dp = 2 h_{n-1} at the (nearly converged) node.
206    let h_nm1 = dp * V::HALF;
207
208    // 2^{n-1} / (n-1)! as a scalar product.
209    let mut factor = E::ONE;
210    let mut m = 1u32;
211    while m < n {
212        factor = factor * E::from_int(2) / E::from_int(m as i64);
213        m += 1;
214    }
215    let w = V::SQRT_PI * V::splat(factor) / (nf * h_nm1 * h_nm1);
216
217    (valid.select(x.neg_c(neg), V::NAN), valid.select(w, V::NAN))
218}
219
220/// The `k`-th node and weight of the `n`-point Gauss-Laguerre rule (weight `x^alpha e^{-x}`
221/// on `[0, inf)`), `k` per lane, `k = 0` the largest root, `alpha > -1` per lane.
222///
223/// Seed: the WKB phase of the Laguerre equation with `x = nu cos^2(psi/2)`,
224/// `nu = 4n + 2 alpha + 2`, `psi - sin psi = 4 pi (k + 3/4)/nu`, five Newton steps from
225/// `psi = (6c)^{1/3}`. The count of phase between the two turning points is
226/// `n + alpha/2 + 1/2`, which is the Bessel-zero offset `alpha/2 - 1/4` on the left and the
227/// Airy `3/4` on the right, so the seed is uniformly within a fraction of a root spacing.
228/// Newton on the raw `L_m^alpha`, `(m+1) L_{m+1} = (2m + alpha + 1 - x) L_m - (m + alpha) L_{m-1}`,
229/// scalar divisor, with `x L_n' = n L_n - (n + alpha) L_{n-1}`. Weight from Hildebrand's
230/// `w = Gamma(n + alpha + 1) / (n! x L_n'(x_k)^2)`, the Gamma ratio as the running product
231/// `Gamma(alpha + 1) prod (m + alpha)/m`. `L_{n-1}` at the largest root grows like
232/// `e^{x/2}`, which bounds the rule near `n = 170` in f64 and `n = 20` in f32.
233#[inline(always)]
234pub fn gauss_laguerre_impl<P, E, V>(k: V, alpha: V, n: u32) -> (V, V)
235where
236    P: Policy,
237    E: FloatElement,
238    V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
239{
240    let nf = V::splat(E::from_int(n as i64));
241    let valid = valid_index(k, nf) & alpha.cmp_gt(-V::ONE);
242    let gamma_a1 = <V as SpecializedSpecialMath<E>>::tgamma::<P>(alpha + V::ONE);
243
244    if n == 1 {
245        // L_1 = 1 + alpha - x. The weight is the whole mass Gamma(alpha + 1).
246        return (valid.select(alpha + V::ONE, V::NAN), valid.select(gamma_a1, V::NAN));
247    }
248
249    let nu = nf.mul_adde(
250        V::splat(<E as FloatElement>::ConstInt::<4>::VALUE),
251        alpha.mul_adde(V::TWO, V::TWO),
252    );
253    let c = (k + V::splat(<E as FloatElement>::ConstRatio::<3, 4>::VALUE))
254        * (V::splat(<E as FloatElement>::ConstInt::<4>::VALUE) * V::PI / nu);
255    let mut psi = (c * V::splat(<E as FloatElement>::ConstInt::<6>::VALUE))
256        .cbrt_p::<LessPrecision<P>>()
257        .min(V::PI);
258    let mut i = 0;
259    while i < 5 {
260        let (s, co) = psi.sin_cos_p::<LessPrecision<P>>();
261        psi -= (psi - s - c) / (V::ONE - co).max(<V as FloatVector>::EPSILON);
262        i += 1;
263    }
264    // x = nu (1 + cos psi)/2, with 1 + cos psi as 2 cos^2(psi/2) for the small roots.
265    let half_cos = (psi * V::HALF).cos_p::<LessPrecision<P>>();
266    let x0 = nu * half_cos * half_cos;
267
268    let pair = |x: V| -> (V, V) {
269        let mut l_prev = V::ONE;
270        let mut l = alpha + V::ONE - x;
271        // `alpha - x` is loop-invariant, so only the `2m + 1` and `m` move.
272        let amx = alpha - x;
273        let mut m = 1u32;
274        while m < n {
275            let mf = V::splat(E::from_int(m as i64));
276            let c1 = V::splat(E::from_int((2 * m + 1) as i64));
277            let inv = V::splat(E::ONE / E::from_int((m + 1) as i64));
278            let next = (amx + c1).mul_sube(l, (alpha + mf) * l_prev) * inv;
279            l_prev = l;
280            l = next;
281            m += 1;
282        }
283        // x L_n' = n L_n - (n + alpha) L_{n-1}
284        let dl = (nf * l - (nf + alpha) * l_prev) / x;
285        (l, dl)
286    };
287
288    let (x, _, dl) = newton_x::<E, V, _>(x0, n, valid, pair);
289
290    // Gamma(n + alpha + 1)/n! = Gamma(alpha + 1) prod_{m=1}^{n} (m + alpha)/m.
291    let mut ratio = gamma_a1;
292    let mut m = 1u32;
293    while m <= n {
294        // `1/m` is a scalar, so `n` vector divisions become `n` vector multiplies.
295        let mf = V::splat(E::from_int(m as i64));
296        let inv_m = V::splat(E::ONE / E::from_int(m as i64));
297        ratio *= (mf + alpha) * inv_m;
298        m += 1;
299    }
300    let w = ratio / (x * dl * dl);
301
302    (valid.select(x, V::NAN), valid.select(w, V::NAN))
303}
Last built: 2026-09-08 21:35:55 UTC