Skip to main content

thermite_special/specialized/generic/
laguerre.rs

1use thermite::{
2    element::FloatElement,
3    math::{CoreMathWithPolicy as _, TranscendentalMathWithPolicy as _, policy::Policy},
4    prelude::*,
5};
6
7use crate::specialized::SpecializedSpecialMath;
8
9use super::poisson;
10
11/// The weight, as a vector, from whichever of the two arguments `INT_ALPHA` selects.
12///
13/// The integer form is a scalar `i32`, so everything derived from it is a scalar constant
14/// that folds to a literal whenever the caller's `alpha` is compile-time known, which the
15/// float form cannot do, because the vector paths it feeds (`lgamma`, `cmp`) are built on
16/// intrinsics LLVM does not constant fold.
17#[inline(always)]
18fn weight<E, V, const INT_ALPHA: bool>(alpha: V, alpha_int: i32) -> V
19where
20    E: FloatElement,
21    V: FloatVector<Element = E>,
22{
23    if const { INT_ALPHA } {
24        V::splat(E::from_int(alpha_int as thermite::LargeInt))
25    } else {
26        alpha
27    }
28}
29
30/// `(s_k, 1/s_k)` for `s_k = sqrt((k+1)(k+alpha+1))`, the factor the recurrence divides by.
31///
32/// Under `INT_ALPHA` the radicand is a scalar product, and both results fold to literals
33/// at a compile-time weight. The product is formed in `E` rather than in `LargeInt` on
34/// purpose: `from_int` panics on a value it cannot represent exactly, and
35/// `(k+1)(k+alpha+1)` leaves 2^53 for large `alpha` while the two factors separately never
36/// do.
37///
38/// The `sqrt` and divide are taken on the *splat*, not in `E`: `FloatElement::sqrt` is
39/// `libm`'s legacy-encoded `sqrtsd` under `no_std`, and one of those inside an AVX body
40/// costs an SSE/AVX transition each way, measured at 30x on the whole function with a
41/// runtime weight (at a literal one LLVM hoists the pure asm out of the loop and hides
42/// it). A vector `sqrt` of a splat is the same one instruction, VEX-encoded, and folds.
43#[inline(always)]
44fn step_scale<P, E, V, const INT_ALPHA: bool>(k: usize, alpha: V, alpha_int: i32) -> (V, V)
45where
46    P: Policy,
47    E: FloatElement,
48    V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
49{
50    if const { INT_ALPHA } {
51        let k1 = E::from_int((k + 1) as thermite::LargeInt);
52        let ka1 = E::from_int(k as thermite::LargeInt + alpha_int as thermite::LargeInt + 1);
53        let s = V::splat(k1 * ka1).sqrt();
54        (s, V::ONE / s)
55    } else {
56        let k1 = V::splat(E::from_int((k + 1) as thermite::LargeInt));
57        let s = (k1 * (k1 + alpha)).sqrt();
58        (s, s.approx_reciprocal_p::<P>())
59    }
60}
61
62/// `2k + alpha + 1`, the `k`-dependent part of the recurrence's leading coefficient.
63#[inline(always)]
64fn two_k_a1<E, V, const INT_ALPHA: bool>(k: usize, a1: V, alpha_int: i32) -> V
65where
66    E: FloatElement,
67    V: FloatVector<Element = E>,
68{
69    if const { INT_ALPHA } {
70        V::splat(E::from_int(
71            2 * k as thermite::LargeInt + alpha_int as thermite::LargeInt + 1,
72        ))
73    } else {
74        V::splat(E::from_int(2 * k as thermite::LargeInt)) + a1
75    }
76}
77
78/// `g = x^{alpha/2} e^{-x/4} / sqrt(alpha!)` for a small positive integer weight, as a
79/// **product** rather than the exponential of a combined log (see [`seed`]).
80///
81/// With `alpha` a scalar integer every piece is cheap and most of it is uniform:
82/// `1/sqrt(alpha!)` is a scalar factorial loop, one `sqrt` and one divide of a splat
83/// (a literal at a compile-time weight), and `x^{alpha/2}` is `powi` by squaring at a
84/// uniform exponent, `log2(alpha/2)` vector multiplies, plus one vector `sqrt` when
85/// `alpha` is odd. That replaces a vector `ln`, `lgamma` and `exp`, and is *more*
86/// accurate, not less: `lgamma(alpha+1)` is `O(alpha ln alpha)` and its half-ulp
87/// absolute error becomes that many ulp of relative error once exponentiated, whereas
88/// the product's error is a handful of roundings.
89///
90/// The price is range, which is why it is capped per arithmetic
91/// (`LAGUERRE_PRODUCT_SEED_CAP`). Two things must hold: `alpha!` is finite (`alpha <= 170`
92/// binary64, `34` binary32), and `x^{alpha/2}` is finite wherever `f = e^{-x/4}` has not
93/// yet underflowed to zero (`x` under about 2980 / 416), so a finite `f` never meets an
94/// infinite power: `2980^88` and `416^14` fit, giving `alpha <= 170` and `alpha <= 29`.
95/// Past those `x` the true `l_0` is zero to the last denormal and `f` is exactly `0`,
96/// so the one place the power *can* overflow is masked to `0` rather than `inf * 0`.
97///
98/// `x = 0` needs no patch here: `0^h = 0` and `sqrt 0 = 0` give the `l_0(0) = 0` limit
99/// for `alpha > 0` directly (`alpha = 0` never reaches this function).
100#[inline(always)]
101fn product_seed<P, E, V>(x: V, f: V, alpha_int: i32) -> V
102where
103    P: Policy,
104    E: FloatElement,
105    V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
106{
107    // Exact up to 22! (binary64) / 13! (binary32), a rounding per step beyond, and the sqrt
108    // halves whatever accumulated. Rooted on the splat, not in E. See `step_scale`.
109    let mut fact = <E as thermite::register::Element>::ONE;
110    let mut i = 2;
111    while i <= alpha_int {
112        fact = fact * E::from_int(i as thermite::LargeInt);
113        i += 1;
114    }
115    let c = V::ONE / V::splat(fact).sqrt();
116
117    let mut p = x.powi_p::<P>(alpha_int / 2);
118    if alpha_int & 1 != 0 {
119        p *= x.sqrt();
120    }
121
122    // (x^{alpha/2} c) f: the power is O(1) or huge, c is O(1) or tiny, f is O(1) or tiny,
123    // so scale the power down before the seed's own decay is applied.
124    let g = (p * c) * f;
125
126    // A zero f is the one place p may be inf (see above); the limit there is 0.
127    f.is_zero().select(V::ZERO, g)
128}
129
130/// The two halves of `$l_0^{(\alpha)}(x) = x^{\alpha/2} e^{-x/2} / \sqrt{\Gamma(\alpha+1)}$`, as
131/// `(f, g)` with `f = e^{-x/4}`, so `l_0 = g * f`.
132///
133/// The same half-split as the Hermite seed and for the same reason (see `hermite::seed`),
134/// though the exponent is linear here so the ranges are far more generous: the seed
135/// underflows and `l_k / f` overflows at `x/4` past the exponent range, i.e. `x` under
136/// about 350 (binary32) or 2800 (binary64) is full accuracy at every degree, which covers
137/// every degree up to roughly 87 / 700 everywhere on the half-line (the turning point of
138/// `l_n^{(\alpha)}` sits near `4n`).
139///
140/// `g` carries the whole parameter dependence `x^{alpha/2} / sqrt(Gamma(alpha+1))`, and
141/// how it is formed decides both range and accuracy. Not as that literal product: near the
142/// peak the two factors are enormous and tiny and cancel to `$O(1)$`, but separately
143/// `x^{alpha/2}` overflows binary64 near `alpha = 250` while `1/sqrt(Gamma(alpha+1))`
144/// underflows near `alpha = 320`, and their overlap is `inf * 0 = NaN`. And not as the
145/// exponential of the combined log `alpha/2 ln x - lgamma(alpha+1)/2 - x/4` either, which
146/// keeps range but turns `lgamma`'s half-ulp *absolute* error, `O(alpha ln alpha)` in size,
147/// into that many ulp of relative error. Instead, by weight:
148///
149/// - `alpha = 0`, the ordinary Laguerre function and the common case: `g = f` exactly.
150/// - integer `alpha` up to `LAGUERRE_PRODUCT_SEED_CAP`: the direct product with an exact
151///   factorial, [`product_seed`]. Cheapest and most accurate, capped where it could overflow.
152/// - any other weight: Loader's saddle-point form of the Poisson mass, [`real_seed`], with
153///   `alpha >= 9` directly and `alpha < 9` after a shift into the Stirling region with an
154///   exact product. No `lgamma` anywhere, and no `ln` at all near the peak.
155///
156/// The Gamma is unavoidable in general: `alpha` is a free parameter of the family and
157/// `Gamma(alpha+1)` is literally the `n = 0` normalization, which is why the Hermite seed
158/// needs no such call (its `n = 0` constant is the parameter-free `pi^{-1/4}`).
159#[inline(always)]
160fn seed<P, E, V, const INT_ALPHA: bool>(x: V, alpha: V, alpha_int: i32) -> (V, V)
161where
162    P: Policy,
163    E: FloatElement,
164    V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
165{
166    let f = (x * V::splat(<E as FloatElement>::ConstRatio::<{ -1 }, 4>::VALUE)).exp_p::<P>();
167
168    // alpha = 0 is the ordinary Laguerre function and by far the common weight, and there
169    // x^{alpha/2} / sqrt(Gamma(alpha+1)) is exactly 1, leaving g = f.
170    if const { INT_ALPHA } {
171        // A scalar test on a scalar argument: no vector compare, no reduction, and one that
172        // disappears outright at a literal weight, taking the ln, lgamma and second exp
173        // with it. This is the case the integer mode exists for.
174        if alpha_int == 0 {
175            return (f, f);
176        }
177        // Small positive integer weights: no ln, lgamma or second exp at all.
178        if alpha_int > 0 && alpha_int <= V::LAGUERRE_PRODUCT_SEED_CAP {
179            return (f, product_seed::<P, E, V>(x, f, alpha_int));
180        }
181    } else {
182        // The vector form has to reduce a mask, which stays a *runtime* branch even at
183        // a visible-constant zero: on AVX2 the compare is `_mm256_cmp_pd`, which LLVM does
184        // not constant fold, so the general path remains compiled behind it.
185        if const { !P::POLICY.avoid_branching } && alpha.is_zero().all() {
186            return (f, f);
187        }
188    }
189
190    let a = weight::<E, V, INT_ALPHA>(alpha, alpha_int);
191
192    // Above the product cap the integer weight is far past STIRLERR_MIN, so the shift into
193    // the Stirling region folds away.
194    if const { INT_ALPHA } {
195        return (f, real_seed::<P, E, V, true>(x, f, a));
196    }
197
198    (f, real_seed::<P, E, V, false>(x, f, alpha))
199}
200
201/// `g = x^{alpha/2} e^{-x/4} / sqrt(Gamma(alpha+1))` for a real weight, every lane
202/// through one shared path: `l_0^2` is the Poisson mass at `k = alpha`, mean `x`, so
203///
204/// ```text
205/// g = l_0 e^{x/4} = sqrt(P(alpha; x)) e^{x/4}
206///   = exp(rest/2 + base) * sqrt(prod) * (2 pi n)^{-1/4}
207/// ```
208///
209/// with `(rest, large, prod, n)` from [`poisson::pmf_parts`]: `alpha >= 9` is Loader's
210/// saddle-point form (`n = alpha`, `prod = 1`, and near the peak `x ~ alpha` a series with
211/// nothing large in it), `alpha < 9` is the same Stirling machinery after a shift
212/// `n = alpha + m` with `prod = (alpha+1)...(alpha+m)`, and no `lgamma` anywhere. On a mixed
213/// vector the two share the one `ln`, `stirlerr(n)`, the one `exp`, the TwoSum and the one
214/// `inverse_sqrt`. Only the shift (plus its `ln x`) and the peak series are
215/// branch-specific, and both are skipped when no lane needs them.
216///
217/// `base` is `+x/4` on `alpha >= 9` lanes (their `rest` already contains `-x`) and `-x/4`
218/// on shifted lanes (their `rest` leaves `-x` out, see `pmf_parts`); either way it is the one
219/// large term, and [`poisson::exp_two_sum`] keeps its rounding out of the result, since the
220/// exponent is up to ~700 and half an ulp of that is hundreds of ulp after the `exp`.
221///
222/// Pins: `alpha = 0` lanes are set to exactly `f` (the shifted form would be `1` only to a
223/// few ulp, and a lane's result must not depend on whether its neighbors let the vector
224/// take the `g = f` shortcut), and `x = 0` lanes are `0` for `alpha > 0` (`ln 0` makes the
225/// exponent `-inf`, but the TwoSum on it is `inf - inf`) and `1` at `alpha = 0`.
226#[inline(always)]
227fn real_seed<P, E, V, const ALL_LARGE: bool>(x: V, f: V, a: V) -> V
228where
229    P: Policy,
230    E: FloatElement,
231    V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
232{
233    let quarter = V::splat(<E as FloatElement>::ConstRatio::<1, 4>::VALUE);
234
235    let (rest, rest_lo, large, prod, n) = poisson::pmf_parts::<P, E, V, ALL_LARGE>(a, x);
236
237    let x4 = x * quarter;
238    // The seed is the square root of the mass, so both exponent words are halved (exact).
239    // Dropping the second word graded ~6 ulp against 0.55 on the `alpha = 0` lanes that
240    // skip the seed, which is how the shared `pmf_parts` defect was found.
241    let g = poisson::exp_sum::<P, E, V>(x4.neg_c(!large), rest * V::HALF, rest_lo * V::HALF);
242    let g = g * (n * V::splat(E::TAU)).sqrt().inverse_sqrt_p::<P>();
243    // sqrt(prod) is 1 wherever no lane was shifted, and a sqrt is not free.
244    let g = if const { ALL_LARGE } || (const { !P::POLICY.avoid_branching } && large.all()) {
245        g
246    } else {
247        g * prod.sqrt()
248    };
249
250    let g = x.is_zero().select(V::ZERO, g);
251    if const { ALL_LARGE } {
252        g
253    } else {
254        a.is_zero().select(f, g)
255    }
256}
257
258/// The orthonormal generalized Laguerre function
259/// `$l_N^{(\alpha)}(x) = \sqrt{N!/\Gamma(N+\alpha+1)}\, x^{\alpha/2} e^{-x/2} L_N^{(\alpha)}(x)$`.
260///
261/// Three-term recurrence on the functions themselves, with `s_k = sqrt((k+1)(k+alpha+1))`:
262///
263/// ```text
264/// l_{k+1} = ((2k + alpha + 1 - x) l_k - s_{k-1} l_{k-1}) / s_k
265/// ```
266///
267/// which keeps every intermediate `O(1)`. The `s_k` depend only on `k` and the weight, not
268/// on the running values, so they sit beside the recurrence rather than on its critical
269/// path. Under `INT_ALPHA` they are scalars and fold to literals at a compile-time weight;
270/// otherwise each step carries a vector `sqrt` and reciprocal. See [`seed`] for the range.
271#[inline(always)]
272pub fn laguerre_function_n<P, E, V, const N: usize, const INT_ALPHA: bool>(x: V, alpha: V, alpha_int: i32) -> V
273where
274    P: Policy,
275    E: FloatElement,
276    V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
277{
278    let (f, g0) = seed::<P, E, V, INT_ALPHA>(x, alpha, alpha_int);
279
280    if const { N == 0 } {
281        return g0 * f;
282    }
283
284    let a = weight::<E, V, INT_ALPHA>(alpha, alpha_int);
285    let a1 = a + V::ONE;
286
287    // s_0 = sqrt(alpha + 1); l_1 = (alpha + 1 - x) l_0 / s_0
288    let (mut s_prev, d0) = step_scale::<P, E, V, INT_ALPHA>(0, alpha, alpha_int);
289    let mut p0 = g0;
290    let mut p1 = ((a1 - x) * g0) * d0;
291
292    let mut k = 1;
293    while k < N {
294        let (s_k, d_k) = step_scale::<P, E, V, INT_ALPHA>(k, alpha, alpha_int);
295
296        // ((2k + alpha + 1 - x) l_k - s_{k-1} l_{k-1}) / s_k
297        let b = two_k_a1::<E, V, INT_ALPHA>(k, a1, alpha_int) - x;
298        let next = b.mul_sube(p1, s_prev * p0) * d_k;
299
300        s_prev = s_k;
301        p0 = p1;
302        p1 = next;
303
304        k += 1;
305    }
306
307    p1 * f
308}
309
310/// The runtime-degree twin of [`laguerre_function_n`].
311///
312/// The same seed, the same recurrence and the same backward `s_k` order, with the degree as a
313/// value. Under `INT_ALPHA` the per-step scales are computed rather than folded, which is the
314/// only cost.
315#[inline(always)]
316pub fn laguerre_function<P, E, V, const INT_ALPHA: bool>(x: V, alpha: V, alpha_int: i32, n: u32) -> V
317where
318    P: Policy,
319    E: FloatElement,
320    V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
321{
322    let (f, g0) = seed::<P, E, V, INT_ALPHA>(x, alpha, alpha_int);
323
324    if n == 0 {
325        return g0 * f;
326    }
327
328    let a = weight::<E, V, INT_ALPHA>(alpha, alpha_int);
329    let a1 = a + V::ONE;
330
331    let (mut s_prev, d0) = step_scale::<P, E, V, INT_ALPHA>(0, alpha, alpha_int);
332    let mut p0 = g0;
333    let mut p1 = ((a1 - x) * g0) * d0;
334
335    let n = n as usize;
336    let mut k = 1;
337    while k < n {
338        let (s_k, d_k) = step_scale::<P, E, V, INT_ALPHA>(k, alpha, alpha_int);
339
340        let b = two_k_a1::<E, V, INT_ALPHA>(k, a1, alpha_int) - x;
341        let next = b.mul_sube(p1, s_prev * p0) * d_k;
342
343        s_prev = s_k;
344        p0 = p1;
345        p1 = next;
346
347        k += 1;
348    }
349
350    p1 * f
351}
352
353/// Runtime-length form of [`laguerre_function_series`].
354///
355/// A genuine port of the recurrence rather than a fold over the const kernel: a series
356/// carries `k`-dependent state and does not partition the way the slice reductions in
357/// `thermite` do. Both forms must be edited together.
358///
359/// Same pre-scaling, same seed, same backward `s_k` order. Read
360/// [`laguerre_function_series`] for the reasoning. `INT_ALPHA` still selects the
361/// integer-weight path, but the weights are no longer folded literals at any `alpha`,
362/// since `k` is not a constant, so the per-step `sqrt` is paid in full here.
363///
364/// The empty series is `0`, where the const form refuses to compile.
365#[inline(always)]
366pub fn laguerre_function_series_slice<P, E, V, const INT_ALPHA: bool>(x: V, alpha: V, alpha_int: i32, coeffs: &[E]) -> V
367where
368    P: Policy,
369    E: FloatElement,
370    V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
371{
372    let n = coeffs.len();
373
374    if n == 0 {
375        return V::ZERO;
376    }
377
378    let (f, g0) = seed::<P, E, V, INT_ALPHA>(x, alpha, alpha_int);
379
380    if n == 1 {
381        return (f * V::splat(coeffs[0])) * g0;
382    }
383
384    let a1 = weight::<E, V, INT_ALPHA>(alpha, alpha_int) + V::ONE;
385
386    let mut y2 = V::ZERO;
387    let mut y1 = f * V::splat(coeffs[n - 1]);
388
389    let mut k = n - 1;
390    while k > 1 {
391        k -= 1;
392        let (s_k, d_k) = step_scale::<P, E, V, INT_ALPHA>(k, alpha, alpha_int);
393        let (_, d_k1) = step_scale::<P, E, V, INT_ALPHA>(k + 1, alpha, alpha_int);
394
395        let alpha_k = (two_k_a1::<E, V, INT_ALPHA>(k, a1, alpha_int) - x) * d_k;
396        let ratio_k1 = s_k * d_k1;
397
398        let yk = alpha_k.mul_adde(y1, y2.nmul_adde(ratio_k1, f * V::splat(coeffs[k])));
399        y2 = y1;
400        y1 = yk;
401    }
402
403    let (s0, d0) = step_scale::<P, E, V, INT_ALPHA>(0, alpha, alpha_int);
404    let (_, d1) = step_scale::<P, E, V, INT_ALPHA>(1, alpha, alpha_int);
405    let alpha_0 = (a1 - x) * d0;
406    let ratio_1 = s0 * d1;
407
408    alpha_0.mul_adde(y1, y2.nmul_adde(ratio_1, f * V::splat(coeffs[0]))) * g0
409}
410
411/// Clenshaw summation of a Laguerre-function series, `$\sum_{k=0}^{N-1} c_k l_k^{(\alpha)}(x)$`.
412///
413/// Clenshaw over `l_k / l_0`, with the coefficients pre-scaled by `f = e^{-x/4}` and the
414/// outer factor reduced to `g = l_0 / f`, the same split as [`laguerre_function`]. With
415/// `alpha_k = (2k + alpha + 1 - x) / s_k` and `beta_k = -s_{k-1} / s_k`:
416///
417/// ```text
418/// y_k = f c_k + alpha_k y_{k+1} + beta_{k+1} y_{k+2}      k = N-1 down to 1
419/// S   = g * (f c_0 + alpha_0 y_1 + beta_1 y_2)
420/// ```
421///
422/// The recurrence runs backward, so `s_k` is needed at step `k` and `s_{k-1}` one step
423/// later, the opposite order from the forward kernel. `s_{k-1}` is recomputed rather than
424/// carried, since it is one `sqrt` off the critical path either way (and a folded literal
425/// under `INT_ALPHA` at a compile-time weight).
426#[inline(always)]
427pub fn laguerre_function_series<P, E, V, const N: usize, const INT_ALPHA: bool>(
428    x: V,
429    alpha: V,
430    alpha_int: i32,
431    coeffs: &[E; N],
432) -> V
433where
434    P: Policy,
435    E: FloatElement,
436    V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
437{
438    const {
439        assert!(N >= 1, "laguerre_function_series: N must be at least 1");
440    }
441
442    let (f, g0) = seed::<P, E, V, INT_ALPHA>(x, alpha, alpha_int);
443
444    // S = c_0 l_0
445    if const { N == 1 } {
446        return (f * V::splat(coeffs[0])) * g0;
447    }
448
449    let a1 = weight::<E, V, INT_ALPHA>(alpha, alpha_int) + V::ONE;
450
451    // Top step, k = N-1: y = f c_{N-1}. Below it, every step needs alpha_k and beta_{k+1}.
452    let mut y2 = V::ZERO;
453    let mut y1 = f * V::splat(coeffs[N - 1]);
454
455    // k = N-2 down to 1.
456    let mut k = N - 1;
457    while k > 1 {
458        k -= 1;
459        let (s_k, d_k) = step_scale::<P, E, V, INT_ALPHA>(k, alpha, alpha_int);
460        let (_, d_k1) = step_scale::<P, E, V, INT_ALPHA>(k + 1, alpha, alpha_int);
461
462        // alpha_k = (2k + alpha + 1 - x) / s_k and beta_{k+1} = -s_k / s_{k+1}, both off
463        // the chain. beta is a genuine runtime value, so its sign is taken by the FMA form.
464        let alpha_k = (two_k_a1::<E, V, INT_ALPHA>(k, a1, alpha_int) - x) * d_k;
465        let ratio_k1 = s_k * d_k1;
466
467        let yk = alpha_k.mul_adde(y1, y2.nmul_adde(ratio_k1, f * V::splat(coeffs[k])));
468        y2 = y1;
469        y1 = yk;
470    }
471
472    // S = g0 * (alpha_0 y_1 + (f c_0 + beta_1 y_2)); s_0 = sqrt(alpha+1), s_1 = sqrt(2(alpha+2)).
473    let (s0, d0) = step_scale::<P, E, V, INT_ALPHA>(0, alpha, alpha_int);
474    let (_, d1) = step_scale::<P, E, V, INT_ALPHA>(1, alpha, alpha_int);
475    let alpha_0 = (a1 - x) * d0;
476    let ratio_1 = s0 * d1;
477
478    alpha_0.mul_adde(y1, y2.nmul_adde(ratio_1, f * V::splat(coeffs[0]))) * g0
479}
Last built: 2026-09-08 21:35:55 UTC