Skip to main content

thermite_special/specialized/generic/
pochhammer.rs

1//! The Pochhammer symbol `$(z)_m = \Gamma(z+m)/\Gamma(z)$`.
2//!
3//! # Three paths, and why the obvious one is not enough
4//!
5//! Written out, this is a ratio of two Gamma functions, and the obvious spelling
6//! `exp(lgamma(z+m) - lgamma(z))` is a disaster in exactly the region the function is most
7//! used. The two logarithms are large and nearly equal whenever `m` is small next to `z`,
8//! so the subtraction sheds the digits that carry the answer: measured against mpmath at
9//! `z = 1e8, m = 1e-4`, that form has **no correct digits at all** (2.8e-7 relative, where
10//! the true value is within 1e-3 of 1). Everything below exists to avoid forming that
11//! difference.
12//!
13//! **Integer `m`, small (`|m| <= `[`PRODUCT_CAP`]).** The definition collapses to a plain
14//! product `z(z+1)...(z+m-1)`, which forms no logarithm at all and is therefore exact to
15//! within its own multiplications: measured worst 4.1 ulp across the sweep, and 0 to 0.2
16//! ulp on most of it. This is the dominant case, not a fast path bolted on. Hypergeometric
17//! series, binomial-style coefficients and Taylor coefficients of special functions all
18//! advance `m` by whole numbers. A negative integer `m` is the reciprocal
19//! of the same product started at `z + m`, which is why the sign of `m` only chooses a
20//! starting point and a final reciprocal. The product is also indifferent to the sign of
21//! `z`, so this path covers the negative half of the domain for free, poles included:
22//! `(-2)_3` contains a zero factor and correctly returns 0.
23//!
24//! It runs at `Average` and above, and within that is gated at runtime on any lane wanting
25//! it, returning early when every lane does. That is the shape [`gamma`](super::gamma)'s
26//! exact-integer branch uses, one tier lower. The tier differs because the trade does: for
27//! `gamma` the Lanczos path is fast and runs regardless, so its integer branch is pure
28//! accuracy spend, while here the product is _also the cheaper route_ for integer-heavy data
29//! (a handful of multiplies and an early return, against the full Stirling evaluation). With
30//! the runtime guard skipping it outright when no lane wants it, the only shape that pays for
31//! having it is a genuinely mixed vector.
32//!
33//! Below `Average` it is compiled out and integer `m` goes through the Stirling difference
34//! like anything else. Measured over 231 points with `m` in `0..20` and `z` across eleven
35//! magnitudes, that is 4.2 ulp median and 172 worst, against 0.00 median and 4.2 worst for
36//! the product. The visible difference is the exactness rather than the ulp count:
37//! `(3)_1` is `3.0` on the product path and `3.0000000000000018` without it.
38//!
39//! **Everything else with both arguments positive.** Take the Stirling difference instead of
40//! the logarithm difference. With
41//! `$\ln\Gamma(x) = (x - \tfrac12)\ln x - x + \tfrac12\ln 2\pi + \mathrm{stirlerr}(x)$`,
42//! the `$\tfrac12 \ln 2\pi$` cancels exactly and the rest regroups so that nothing large is
43//! ever subtracted from anything large:
44//!
45//! ```math
46//! \ln\frac{\Gamma(x+m)}{\Gamma(x)}
47//!   = \left(x - \tfrac12\right)\ln\!\left(1 + \frac{m}{x}\right)
48//!   + m\left(\ln(x+m) - 1\right)
49//!   + \mathrm{stirlerr}(x+m) - \mathrm{stirlerr}(x)
50//! ```
51//!
52//! Every term is `O(m)` as `m -> 0`, which is what makes the small-`m` region well behaved.
53//! The `log1p` is doing the work the naive subtraction failed at. [`stirlerr`] is only valid
54//! at or above [`STIRLERR_MIN`], so an argument below it is first walked up by a whole number
55//! of steps and the exact product of those steps divided back out, the same shifted-product
56//! trick [`pmf_parts`](super::poisson::pmf_parts) uses, and for the same reason.
57//!
58//! **Each argument is shifted independently**, which is what keeps every intermediate in
59//! range: a product is built only for an argument that is _below_ 9, so its factors are under
60//! 18 and it can never exceed `18^9`. Shifting both by a shared amount instead (the obvious
61//! spelling) walks an argument that was already fine and overflows it, and the resulting
62//! `inf * 0` is a NaN sitting exactly where the answer is a perfectly good infinity. The
63//! independent shift is also the more accurate of the two, because the product it skips is a
64//! string of roundings that never happens. See the comments on the shift for the numbers.
65//!
66//! Its accuracy is the floor of anything that exponentiates a logarithm: the relative error
67//! of the result is the _absolute_ error of the exponent, so it tracks
68//! `$|\ln (z)_m| \cdot \epsilon$` and is bounded below by nothing else. Measured against
69//! mpmath over 6924 points with `z` in `[0.1, 8.9]` and non-integer `m`, the median is 2.6
70//! ulp, the 99th percentile 25 ulp and the worst 51 ulp. Individual points scale with the
71//! result's own logarithm, reaching 259 ulp at `z = 3.7, m = 100` where the value is near
72//! `1e163` and `|ln| = 375`. It falls to **zero** error where the answer approaches 1, which
73//! is precisely where the naive form was worst.
74//!
75//! **The residue.** A non-integer `m` (or one past the cap) with `z` or `z + m` non-positive
76//! reaches neither path above, and falls back to the logarithmic form with the sign taken
77//! from [`lgamma_r`](crate::RealSpecialMath::lgamma_r). It inherits that form's
78//! cancellation. This is the region where `(z)_m` is a ratio across Gamma's poles and no
79//! cheap rearrangement is available. It is documented rather than fixed.
80
81use thermite::{
82    const_splat,
83    element::FloatElement,
84    mask::GenericMask,
85    math::{
86        CoreMathWithPolicy as _, TranscendentalMathWithPolicy as _,
87        policy::{Policy, PrecisionPolicy},
88        specialized::SpecializedTranscendentalMath,
89    },
90    prelude::*,
91};
92
93use crate::specialized::SpecializedRealSpecialMath;
94
95use super::poisson::{STIRLERR_MIN, stirlerr};
96
97/// Largest `|m|` taken by the exact product path.
98///
99/// The product costs one multiply per step and buys two orders of magnitude of accuracy over
100/// the logarithmic route, so the cap is about where the multiplications stop being free
101/// rather than about where they stop being better. Integer `m` past this falls to the
102/// Stirling difference, which is continuous with it.
103pub const PRODUCT_CAP: usize = 20;
104
105/// `$(z)_m = \Gamma(z+m)/\Gamma(z)$`, the Pochhammer symbol, for real `z` and real `m`.
106#[inline(always)]
107pub fn pochhammer<P, E, V>(z: V, m: V) -> V
108where
109    E: FloatElement,
110    V: FloatVector<Element = E> + SpecializedTranscendentalMath<E> + SpecializedRealSpecialMath<E>,
111    P: Policy,
112{
113    let zm = z + m;
114
115    // --- Path A: integer m within the cap, the case nearly every caller is in. -----------
116    //
117    // For m >= 0 the product runs up from z. For m < 0 it runs up from z + m and is
118    // reciprocated, since (z)_{-n} = 1 / (z-n)_n.
119    //
120    // Compiled out below `Average`. The product is the _accurate_ route rather than the fast
121    // one. The Stirling difference below is already inside the tolerance those tiers ask
122    // for, so at that point this is a second path earning nothing, and on a mixed vector it
123    // is paid for on every lane.
124    let mut use_product = GenericMask::FALSY;
125    let mut by_product = V::ONE;
126
127    if const { P::POLICY.precision.ge(PrecisionPolicy::Average) } {
128        let n = m.abs();
129        let negative_m = m.cmp_lt(V::ZERO);
130        use_product = m.cmp_eq(m.round()) & n.cmp_le(const_splat!(int <E>: 20));
131
132        if const { P::POLICY.avoid_branching } || thermite::unlikely(use_product.any()) {
133            let base = negative_m.select(zm, z);
134            let mut product = V::ONE;
135            let mut step = V::ZERO;
136            let mut i = 0;
137            while i < PRODUCT_CAP {
138                V::_loop_hint();
139
140                // Masked by `use_product`, not just by `|m|`: a lane with a large _non-integer_
141                // m would otherwise keep the loop alive for a product it never reads. Same
142                // reason `gamma`'s integer branch masks its own condition by `is_int`.
143                let active = use_product & step.cmp_lt(n);
144                if const { !P::POLICY.avoid_branching } && !active.any() {
145                    break;
146                }
147
148                // Lanes past their own m multiply by one, so a single trip count serves every lane.
149                product *= active.select(base + step, V::ONE);
150                step += V::ONE;
151                i += 1;
152            }
153            by_product = negative_m.select(product.approx_reciprocal_p::<P>(), product);
154
155            if const { !P::POLICY.avoid_branching } && use_product.all() {
156                return by_product;
157            }
158        }
159    }
160
161    // --- Path B: both arguments positive, any real m. ------------------------------------
162    //
163    // Walk each argument up to STIRLERR_MIN with a whole number of unit steps and divide the
164    // exact product of those steps back out.
165    //
166    // **Each argument gets its own shift.** Walking both by the shared `9 - min(z, z+m)` is
167    // the obvious spelling and is wrong twice over: it walks an argument that is already
168    // above 9, and `(z+m)^9` then overflows for `z + m` past ~1e34, leaving `inf * 0`, a NaN
169    // where the answer is a perfectly good infinity. Shifting each only as far as it needs
170    // means a product is built _only_ for an argument below 9, so its factors are under 18
171    // and it is bounded by `18^9`, about 2e11 (nowhere near overflow, for any input at all).
172    // Everything stays in range, so nothing has to be repaired afterwards.
173    //
174    // It is also more accurate, because the skipped product is a string of roundings that
175    // never happens. Measured against mpmath over 6924 points with `z` in `[0.1, 8.9]`, the
176    // median goes 3.94 -> 2.57 ulp and the 99th percentile 31.9 -> 24.9, with individual
177    // large-`m` points improving much more (118 -> 10.9 ulp at `z = 0.5, m = 50.5`).
178    let nine: V = const_splat!(int <E>: 9);
179    let s_z = (nine - z).ceil().max(V::ZERO);
180    let s_zm = (nine - zm).ceil().max(V::ZERO);
181
182    // The one place the shifts must agree: when _both_ arguments are below 9, an unequal pair
183    // makes the shifted difference `m + (s_zm - s_z)` instead of `m`, and everything staying
184    // `O(m)` as `m -> 0` is the whole point of this path. Measured, letting them differ there
185    // costs the small-`m` region an order of magnitude (0.48 -> 9.52 ulp at `z = 3, m = 1e-6`).
186    let both_shifted = s_z.cmp_gt(V::ZERO) & s_zm.cmp_gt(V::ZERO);
187    let common = s_z.max(s_zm);
188    let s_z = both_shifted.select(common, s_z);
189    let s_zm = both_shifted.select(common, s_zm);
190
191    // One loop, two masked products: each argument multiplies only on the steps it needs, so
192    // a lane whose `z + m` is already past 9 builds nothing for it.
193    let mut num = V::ONE;
194    let mut den = V::ONE;
195    let mut step = V::ZERO;
196    let mut i = 0;
197    while i < STIRLERR_MIN as usize {
198        V::_loop_hint();
199
200        let want_num = step.cmp_lt(s_z);
201        let want_den = step.cmp_lt(s_zm);
202        if const { !P::POLICY.avoid_branching } && !(want_num | want_den).any() {
203            break;
204        }
205
206        num *= want_num.select(z + step, V::ONE);
207        den *= want_den.select(zm + step, V::ONE);
208        step += V::ONE;
209        i += 1;
210    }
211
212    let y = z + s_z;
213    let x = zm + s_zm;
214
215    // The shifted difference. `s_zm - s_z` is an exact small integer, and is exactly zero
216    // wherever both arguments were shifted, which keeps this equal to `m` in the
217    // small-`m` region rather than recovering it from `x - y` and cancelling it away.
218    let mm = m + (s_zm - s_z);
219
220    // (y - 1/2) ln(1 + mm/y) + mm (ln x - 1) + stirlerr(x) - stirlerr(y).
221    // Nothing large is subtracted from anything large, and every term vanishes with mm.
222    let ln_ratio = (y - V::HALF).mul_adde(
223        mm.approx_div_p::<P>(y).ln_1p_p::<P>(),
224        mm.mul_adde(x.ln_p::<P>() - V::ONE, stirlerr::<P, E, V>(x) - stirlerr::<P, E, V>(y)),
225    );
226
227    // Both products are bounded by 18^9, so `exp` saturating to infinity or zero is already
228    // the right answer and there is nothing to guard.
229    let by_stirling = ln_ratio.exp_p::<P>() * num.approx_div_p::<P>(den);
230
231    let positive = z.cmp_gt(V::ZERO) & zm.cmp_gt(V::ZERO);
232    let mut result = use_product.select(by_product, by_stirling);
233
234    // --- Path C: the residue, where neither of the above applies. ------------------------
235    let residue = !(use_product | positive);
236    if const { P::POLICY.avoid_branching } || thermite::unlikely(residue.any()) {
237        // The ORIGINAL arguments, not the shifted ones: the shift above is only valid where
238        // `stirlerr` is, which is the case this branch exists to escape.
239        let (lg_num, sign_num) = <V as SpecializedRealSpecialMath<E>>::lgamma_r::<P>(zm);
240        let (lg_den, sign_den) = <V as SpecializedRealSpecialMath<E>>::lgamma_r::<P>(z);
241        let by_log = (lg_num - lg_den).exp_p::<P>() * (sign_num * sign_den);
242        result = residue.select(by_log, result);
243    }
244
245    result
246}
Last built: 2026-09-08 21:35:55 UTC