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}