Skip to main content

thermite_compensated/specialized/special/
mod.rs

1//! The gamma family's backend for [`Compensated`], one rung below
2//! [`SpecializedSpecialMath`](thermite_special::specialized::SpecializedSpecialMath).
3//!
4//! See [the parent module](super) for why this rung exists. In short: the gamma family
5//! is the part of `thermite-special` that is driven by fitted coefficients, and the
6//! width of a `Compensated` decides which coefficients are correct - so it needs a
7//! dispatch axis that `Compensated`'s single blanket backend impl does not have.
8//!
9//! # Implementing
10//!
11//! Every method has a default, so the minimal impl is empty:
12//!
13//! ```ignore
14//! impl<V: FloatVector<Element = f64>> SpecializedCompensatedSpecialMath<Compensated<f64>>
15//!     for Compensated<V> {}
16//! ```
17//!
18//! Override a method when this width can do better than the generic series - typically
19//! by carrying a table tuned to it.
20//!
21//! # The generic algorithm
22//!
23//! The defaults are Stirling and its derivatives, which is one expansion in one set of
24//! constants for the whole family. Shift the argument up by the recurrences until it is
25//! large (`$x \gtrsim 30$` for double-double), then:
26//!
27//! ```math
28//! \ln\Gamma(x) \sim (x - \tfrac{1}{2})\ln x - x + \tfrac{1}{2}\ln 2\pi
29//!     + \sum_{n \ge 1} \frac{B_{2n}}{2n(2n-1)x^{2n-1}}
30//! ```
31//! ```math
32//! \psi(x) \sim \ln x - \frac{1}{2x} - \sum_{n \ge 1} \frac{B_{2n}}{2n\,x^{2n}}
33//! \qquad
34//! \psi_1(x) \sim \frac{1}{x} + \frac{1}{2x^2} + \sum_{n \ge 1} \frac{B_{2n}}{x^{2n+1}}
35//! ```
36//!
37//! The Bernoulli numbers are exact rationals, so unlike the minimax rationals the real
38//! `f32`/`f64` paths use, they extend to any precision without refitting - there is no
39//! oracle to chase and no table to source. Roughly 13 terms clear `$2^{-106}$` at
40//! `$x > 30$`, about half that for double-single, so the term count is a `const` off the
41//! mantissa width rather than a fixed loop.
42//!
43//! This is why `Compensated` may never want Lanczos. The real paths use it because it
44//! skips the shift loop; here every operation is already an order of magnitude more
45//! expensive, so the loop costs relatively less and a 24-coefficient table at 32 digits
46//! costs a lot to source and validate.
47
48mod pd;
49mod ps;
50
51use thermite::math::policy::Policy;
52use thermite::math::{FloatConsts, TranscendentalMathWithPolicy};
53use thermite::prelude::*;
54
55use crate::Compensated;
56
57/// What a default body needs of `Compensated<V>` in order to do compensated arithmetic.
58///
59/// Requested per method rather than as a supertrait of
60/// [`SpecializedCompensatedSpecialMath`] - see that trait's docs for why the difference
61/// matters.
62pub trait CompensatedGammaOps: FloatVector + TranscendentalMathWithPolicy {}
63impl<T> CompensatedGammaOps for T where T: FloatVector + TranscendentalMathWithPolicy {}
64
65/// Argument the shift loop drives `z` up to before the asymptotic series is used.
66///
67/// 30 rather than 20 trades ten more shift steps for two fewer series terms: the
68/// double-double case needs 13 coefficients at 20 and 11 at 30. Shift steps are one
69/// multiply or divide each, series terms are a multiply-add plus a constant, and going
70/// further out (40) starts costing more in the loop than it saves in the tail.
71///
72/// Numerator size is *not* a constraint on this choice - `CompensatedConstRatio`
73/// evaluates the ratio in f64 before splitting it across the two limbs, so a coefficient
74/// like B_24's 236364091 is carried exactly even at double-single width.
75const SHIFT_TARGET: i64 = 30;
76
77/// Compile-time rational `N/D`, split across both limbs.
78///
79/// `CompensatedConstRatio` does the splitting at const time, so the coefficients cost no
80/// runtime division and carry the full double-double value of the ratio - not the ratio
81/// rounded to the element type first.
82#[inline(always)]
83fn frac<C: FloatVector, const N: i64, const D: i64>() -> C {
84    C::splat(const { <C::Element as FloatElement>::ConstRatio::<N, D>::VALUE })
85}
86
87/// Compile-time integer `N`, same mechanism.
88#[inline(always)]
89fn int_frac<C: FloatVector, const N: i64>() -> C {
90    C::splat(const { <C::Element as FloatElement>::ConstInt::<N>::VALUE })
91}
92
93/// `$\sum_{n\ge1} rac{B_{2n}}{2n(2n-1)} w^{n-1}$`, Horner in `$w = 1/z^2$`.
94///
95/// The coefficients are exact rationals of small integers - no floating-point literals
96/// anywhere - so one table serves every width. Emitted highest-order first, which is
97/// what Horner wants and also what makes truncating cheap: a narrower type only needs
98/// the last few, and dropping leading terms is exactly what starting the accumulator at
99/// zero does.
100///
101/// Double-double needs all eleven at `z >= 30`; double-single needs three. Both are
102/// evaluated for now, which costs the narrow case a few multiply-adds it does not need.
103#[inline(always)]
104fn stirling_series<C: FloatVector>(w: C) -> C {
105    let mut acc = <C as NumericVector>::ZERO;
106
107    macro_rules! horner {
108        ($(($n:literal, $d:literal)),* $(,)?) => {
109            $( acc = acc.mul_add(w, frac::<C, $n, $d>()); )*
110        };
111    }
112
113    horner!(
114        (77683, 5796),
115        (-174611, 125400),
116        (43867, 244188),
117        (-3617, 122400),
118        (1, 156),
119        (-691, 360360),
120        (1, 1188),
121        (-1, 1680),
122        (1, 1260),
123        (-1, 360),
124        (1, 12),
125    );
126
127    acc
128}
129
130/// `$\sum_{n\ge1} rac{B_{2n}}{2n} w^{n-1}$` for digamma, Horner in `$w = 1/z^2$`.
131///
132/// Eleven terms, the same reach as the Stirling series at the same shift target.
133#[inline(always)]
134fn digamma_series<C: FloatVector>(w: C) -> C {
135    let mut acc = <C as NumericVector>::ZERO;
136
137    macro_rules! horner {
138        ($(($n:literal, $d:literal)),* $(,)?) => {
139            $( acc = acc.mul_add(w, frac::<C, $n, $d>()); )*
140        };
141    }
142
143    horner!(
144        (77683, 276),
145        (-174611, 6600),
146        (43867, 14364),
147        (-3617, 8160),
148        (1, 12),
149        (-691, 32760),
150        (1, 132),
151        (-1, 240),
152        (1, 252),
153        (-1, 120),
154        (1, 12),
155    );
156
157    acc
158}
159
160/// `$\sum_{n\ge1} B_{2n} w^{n-1}$` for trigamma, Horner in `$w = 1/z^2$`.
161///
162/// Twelve terms rather than eleven: trigamma's coefficients are the bare Bernoulli
163/// numbers, without the `$1/2n$` or `$1/2n(2n-1)$` damping the other two series get, so
164/// the tail decays one term slower.
165#[inline(always)]
166fn trigamma_series<C: FloatVector>(w: C) -> C {
167    let mut acc = <C as NumericVector>::ZERO;
168
169    macro_rules! horner {
170        ($(($n:literal, $d:literal)),* $(,)?) => {
171            $( acc = acc.mul_add(w, frac::<C, $n, $d>()); )*
172        };
173    }
174
175    horner!(
176        (-236364091, 2730),
177        (854513, 138),
178        (-174611, 330),
179        (43867, 798),
180        (-3617, 510),
181        (7, 6),
182        (-691, 2730),
183        (5, 66),
184        (-1, 30),
185        (1, 42),
186        (-1, 30),
187        (1, 6),
188    );
189
190    acc
191}
192
193/// Backend for the coefficient-bearing part of `Compensated`'s special math.
194///
195/// # Implemented on the inner vector, not on `Compensated`
196///
197/// This is implemented for `V`, with `E = V::Element` (`f32` or `f64`) as the dispatch
198/// tag, and its methods take `Compensated<Self>` by argument rather than by `self`. That
199/// looks backwards for a math trait and is load-bearing.
200///
201/// The natural spelling - implement it for `Compensated<V>`, take `self`, and give it
202/// `FloatVector<Element = E>` as a supertrait so that default bodies can do arithmetic -
203/// does not work. Naming that supertrait asserts the projection
204/// `<Compensated<V> as GenericVector>::Element == Compensated<V::Element>` at every use
205/// of the bound, and `crate::special`'s seam then normalizes through *that* rather than
206/// through `CompensatedFloatVector`, losing the `Mask: CastMask<..>` obligations its
207/// `erf` / `erfinv` / `lambert_w` bodies depend on. It surfaces a hundred lines away as
208/// unrelated `mismatched types` errors in code that was never touched.
209///
210/// Hanging the trait off `V` avoids that entirely: the seam then constrains `V`, which
211/// cannot say anything about `Compensated<V>`'s projections. What a default body needs
212/// is requested per method via [`CompensatedGammaOps`], scoped to that method alone -
213/// which is what makes real default bodies possible here at all.
214///
215/// The element parameter is also what keeps the two per-width impls from colliding:
216/// without it both would be `impl<V> .. for V`, differing only in `V::Element`, which
217/// coherence does not accept as disjoint.
218pub trait SpecializedCompensatedSpecialMath<E>: Sized {
219    /// `$\Gamma(x)$`.
220    ///
221    /// Exponentiates [`compensated_lgamma_r`](Self::compensated_lgamma_r) rather than
222    /// running its own reduction, which costs a few bits and saves a second copy of the
223    /// reflection: an absolute error `d` in `$\ln\Gamma$` is a *relative* error `d` in
224    /// `$\Gamma$`, so the loss is `$\log_2|\ln\Gamma(x)|$` bits - about 6 near x = 30 and
225    /// 10 at the overflow edge, out of 106. Avoiding it entirely means a direct Stirling
226    /// for `$\Gamma$`, which is only worth writing if those bits are ever missed.
227    #[inline(always)]
228    fn compensated_tgamma<P: Policy>(x: Compensated<Self>) -> Compensated<Self>
229    where
230        Compensated<Self>: CompensatedGammaOps,
231    {
232        let (lg, sign) = Self::compensated_lgamma_r::<P>(x);
233
234        sign * lg.exp_p::<P>()
235    }
236
237    /// `$(\ln|\Gamma(x)|, \operatorname{sign}\Gamma(x))$`.
238    ///
239    /// The sign is carried separately because `lgamma` discards it and `beta` needs it.
240    ///
241    /// Shift-and-Stirling, with the reflection below `1/2`. See the module docs for the
242    /// expansion and for why the shift target is 30.
243    #[inline(always)]
244    fn compensated_lgamma_r<P: Policy>(x: Compensated<Self>) -> (Compensated<Self>, Compensated<Self>)
245    where
246        Compensated<Self>: CompensatedGammaOps,
247    {
248        let one = <Compensated<Self> as NumericVector>::ONE;
249        let half = frac::<Compensated<Self>, 1, 2>();
250
251        // Below 1/2 the series is useless, so evaluate at 1 - x and reflect afterwards.
252        let reflect = x.cmp_lt(half);
253        let z0 = reflect.select(one - x, x);
254
255        // Shift up to the target, accumulating the divided-out product rather than its
256        // log: one `ln` at the end instead of thirty. z0 >= 1/2 here, so 30 steps always
257        // suffice, and the product tops out around 3e31 - nowhere near overflow.
258        let target = int_frac::<Compensated<Self>, SHIFT_TARGET>();
259        let mut z = z0;
260        let mut prod = one;
261
262        let mut i = 0;
263        while i < SHIFT_TARGET {
264            let shifting = z.cmp_lt(target);
265            prod = prod.mul_c(shifting, z);
266            z = z.add_c(shifting, one);
267            i += 1;
268        }
269
270        // Stirling: (z - 1/2) ln z - z + ln(2pi)/2 + poly(1/z^2)/z
271        let w = one / (z * z);
272        let poly = stirling_series::<Compensated<Self>>(w);
273
274        let half_ln_tau = (<Compensated<Self> as FloatConsts>::LN_2 + <Compensated<Self> as FloatConsts>::LN_PI) * half;
275        let stirling = (z - half).mul_add(z.ln_p::<P>(), half_ln_tau - z) + poly / z;
276
277        let lg = stirling - prod.ln_p::<P>();
278
279        // Reflection: ln|Gamma(x)| = ln(pi) - ln|sin(pi x)| - ln|Gamma(1 - x)|, and
280        // sign(Gamma(x)) = sign(sin(pi x)) since Gamma(1 - x) > 0 for x < 1/2. The poles
281        // at the non-positive integers fall out on their own: sin(pi x) is zero there, so
282        // the log is -inf and the result is +inf.
283        let sp = x.sin_pi_p::<P>();
284        let reflected = (<Compensated<Self> as FloatConsts>::LN_PI - sp.abs().ln_p::<P>()) - lg;
285
286        let mut value = reflect.select(reflected, lg);
287        let sign = one.neg_c(reflect & sp.is_negative());
288
289        // The poles at the non-positive integers have to be selected in rather than left
290        // to `ln(0) = -inf` propagating through the reflection. Infinities do not survive
291        // compensated arithmetic: `two_sum(finite, inf)` evaluates `inf - inf` while
292        // forming the error word, so the pair normalizes to NaN rather than to infinity.
293        let zero = <Compensated<Self> as NumericVector>::ZERO;
294        let is_pole = reflect & x.cmp_le(zero) & x.cmp_eq(x.floor());
295        value = is_pole.select(<Compensated<Self> as FloatVector>::INFINITY, value);
296
297        (value, sign)
298    }
299
300    /// `$\psi(x)$`, the digamma function.
301    ///
302    /// Same shape as [`compensated_lgamma_r`](Self::compensated_lgamma_r) - shift up,
303    /// then the asymptotic series - but the recurrence `$\psi(x) = \psi(x+1) - 1/x$`
304    /// accumulates a *sum* of reciprocals rather than a product, so it cannot be deferred
305    /// to a single log at the end.
306    #[inline(always)]
307    fn compensated_digamma<P: Policy>(x: Compensated<Self>) -> Compensated<Self>
308    where
309        Compensated<Self>: CompensatedGammaOps,
310    {
311        let one = <Compensated<Self> as NumericVector>::ONE;
312        let half = frac::<Compensated<Self>, 1, 2>();
313
314        let reflect = x.cmp_lt(half);
315        let z0 = reflect.select(one - x, x);
316
317        let target = int_frac::<Compensated<Self>, SHIFT_TARGET>();
318        let mut z = z0;
319        let mut acc = <Compensated<Self> as NumericVector>::ZERO;
320
321        let mut i = 0;
322        while i < SHIFT_TARGET {
323            let shifting = z.cmp_lt(target);
324            acc = acc.add_c(shifting, one / z);
325            z = z.add_c(shifting, one);
326            i += 1;
327        }
328
329        // psi(z) ~ ln z - 1/(2z) - sum B_2n/(2n z^2n), the sum being w * horner(w).
330        let w = one / (z * z);
331        let psi = (z.ln_p::<P>() - half / z) - w * digamma_series::<Compensated<Self>>(w);
332
333        let value = psi - acc;
334
335        // psi(x) = psi(1 - x) - pi cot(pi x). Both halves of the cotangent come out of one
336        // reduction, and it is exactly the poles of `sin_pi` that carry psi's own poles.
337        let (sp, cp) = x.sincos_pi_p::<P>();
338        let reflected = value - <Compensated<Self> as FloatConsts>::PI * (cp / sp);
339
340        reflect.select(reflected, value)
341    }
342
343    /// `$\psi_1(x)$`, the trigamma function.
344    ///
345    /// As [`compensated_digamma`](Self::compensated_digamma), with the recurrence
346    /// `$\psi_1(x) = \psi_1(x+1) + 1/x^2$` and the reflection
347    /// `$\psi_1(x) + \psi_1(1-x) = \pi^2/\sin^2(\pi x)$`. Note the reflection *adds*
348    /// rather than subtracting, unlike digamma's.
349    #[inline(always)]
350    fn compensated_trigamma<P: Policy>(x: Compensated<Self>) -> Compensated<Self>
351    where
352        Compensated<Self>: CompensatedGammaOps,
353    {
354        let one = <Compensated<Self> as NumericVector>::ONE;
355        let half = frac::<Compensated<Self>, 1, 2>();
356
357        let reflect = x.cmp_lt(half);
358        let z0 = reflect.select(one - x, x);
359
360        let target = int_frac::<Compensated<Self>, SHIFT_TARGET>();
361        let mut z = z0;
362        let mut acc = <Compensated<Self> as NumericVector>::ZERO;
363
364        let mut i = 0;
365        while i < SHIFT_TARGET {
366            let shifting = z.cmp_lt(target);
367            acc = acc.add_c(shifting, one / (z * z));
368            z = z.add_c(shifting, one);
369            i += 1;
370        }
371
372        // psi_1(z) ~ (1 + 1/(2z) + w*horner(w)) / z, with w = 1/z^2.
373        let w = one / (z * z);
374        let psi1 = (one + half / z + w * trigamma_series::<Compensated<Self>>(w)) / z;
375
376        let value = psi1 + acc;
377
378        let sp = x.sin_pi_p::<P>();
379        let reflected = <Compensated<Self> as FloatConsts>::PI_SQUARED / (sp * sp) - value;
380
381        reflect.select(reflected, value)
382    }
383
384    /// `$B(a, b) = \Gamma(a)\Gamma(b)/\Gamma(a+b)$`.
385    ///
386    /// Through logs rather than as a ratio of gammas, which overflows for arguments the
387    /// beta function itself handles perfectly well. Rides entirely on
388    /// [`compensated_lgamma_r`](Self::compensated_lgamma_r), so a width that overrides
389    /// that one gets this for free and should never need to touch this.
390    #[inline(always)]
391    fn compensated_beta<P: Policy>(a: Compensated<Self>, b: Compensated<Self>) -> Compensated<Self>
392    where
393        Compensated<Self>: CompensatedGammaOps,
394    {
395        let (la, sa) = Self::compensated_lgamma_r::<P>(a);
396        let (lb, sb) = Self::compensated_lgamma_r::<P>(b);
397        let (lab, sab) = Self::compensated_lgamma_r::<P>(a + b);
398
399        ((la + lb) - lab).exp_p::<P>() * ((sa * sb) / sab)
400    }
401}