Skip to main content

thermite_special/specialized/generic/
zeta.rs

1//! The Riemann zeta function, as `$\zeta(s) - 1$` with `$\zeta$` built on top.
2//!
3//! # Which one is the primitive
4//!
5//! `$\zeta(s) \to 1$` fast: `$\zeta(40) - 1$` is about `$9.1\times10^{-13}$`, already far below
6//! the mantissa of `$\zeta$` itself, and by `$s = 80$` the complement is `8.3e-25`. So a caller
7//! who wants the complement cannot get it by subtracting: measured at `s = 80`, forming
8//! `zeta(s)` and taking away 1 is **100% wrong**, and by `s = 200` it returns a flat zero
9//! where the true value is `1e-61`.
10//!
11//! The Euler-Maclaurin sum below opens with the `$n = 1$` term, which _is_ that 1, so the
12//! complement comes from **omitting** it rather than cancelling it (exact, with no subtraction
13//! anywhere), and still carries digits at `s = 700` where the value is around `1e-211`. That
14//! makes [`zetac`](Self) the primitive here and `$\zeta = 1 + \zeta_c$` the derived form, the
15//! same relationship `exp_m1` has to `exp`.
16//!
17//! # Algorithm
18//!
19//! Euler-Maclaurin, truncated at [`N`] direct terms with [`bernoulli_terms`] correction terms:
20//!
21//! ```math
22//! \zeta(s) = \sum_{n=1}^{N-1} n^{-s} + \frac{N^{1-s}}{s-1} + \frac{N^{-s}}{2}
23//!          + \sum_{k\ge1} \frac{B_{2k}}{(2k)!}\,(s)_{2k-1}\,N^{-(s+2k-1)}
24//! ```
25//!
26//! The usual alternative is a table of minimax rationals over five or six intervals in `s`
27//! (this is what Boost does, in about a thousand lines). That is excellent scalar code and the
28//! wrong shape for a vector unit, where selecting a coefficient _table_ per lane means either a
29//! gather or evaluating every interval and discarding all but one. Euler-Maclaurin is one
30//! straight-line expression for the whole positive axis instead.
31//!
32//! Borwein's accelerated eta series was the other candidate, and is the more famous one because
33//! it converges in the critical strip and over the complex plane. Measured, it needs **22 terms
34//! to match this at 10**, more than twice the transcendental calls for the same answer, so it
35//! lost on cost. If complex `s` is ever wanted, it becomes interesting again.
36//!
37//! # Four exponentials, not nine
38//!
39//! Every direct term is `$n^{-s} = 2^{-s\log_2 n}$` with `$\log_2 n$` a compile-time constant,
40//! which reads as one `exp2` per term. But the Dirichlet terms **factor over the primes**: with
41//! `$p_n = n^{-s}$` evaluated for `n` in 2, 3, 5, 7, the rest are products:
42//! `$p_4 = p_2^2$`, `$p_6 = p_2p_3$`, `$p_8 = p_2^3$`, `$p_9 = p_3^2$`, and
43//! `$N^{-s} = p_2p_5$` needs no call of its own. Four transcendentals and five multiplies cover
44//! all of `n = 2..10`, at measured accuracy indistinguishable from nine separate calls
45//! (4.89e-16 against 4.41e-16).
46//!
47//! The count is `$\pi(N)$`, the prime-counting function, not `N`, so raising `N` to tighten
48//! the critical strip is cheaper than it looks: `N = 16` costs six, `N = 20` costs eight.
49//!
50//! The correction sum needs **no transcendentals at all**. `$N^{-(s+2k-1)}$` is `$N^{-s}$` times
51//! a constant, and `$(s)_{2k-1}/(2k)!$` advances by a two-factor recurrence whose denominator is
52//! a compile-time integer, so the whole tail is one multiply-accumulate ladder over the shipped
53//! Bernoulli table.
54//!
55//! # Accuracy
56//!
57//! Against mpmath at 40 digits, worst relative error with `N = 10` and 8 correction terms:
58//! 4.4e-16 for `s` in `[1.5, 5]`, 4.3e-16 for `[5, 40]`, 2.3e-15 through the critical strip
59//! `[0.1, 0.9]`, and 4.6e-16 approaching the pole. The strip is the weak region, and `N` is the
60//! lever if it ever matters.
61//!
62//! Negative `s` is **not** reachable by adding terms. The expansion is asymptotic, and its
63//! error there gets _worse_ with larger `N` (measured 3.7e-9 at `N = 10`, 5.4e-8 at `N = 16`).
64//! It takes the functional equation instead, which lands at `$1 - s > 1$`, back in the region
65//! where the series is at its best.
66
67use thermite::{
68    const_splat,
69    element::FloatElement,
70    math::{
71        CoreMathWithPolicy as _, TranscendentalMathWithPolicy as _,
72        policy::{Policy, PrecisionPolicy},
73        specialized::SpecializedTranscendentalMath,
74    },
75    prelude::*,
76};
77
78use crate::specialized::SpecializedSpecialMath;
79use crate::tables::bernoulli::BernoulliNumbers;
80
81/// The truncation point of the direct sum. Ten is the knee: eight direct terms and six
82/// correction terms leave 2.4e-14, ten and eight reach 4.4e-16, and more of either buys nothing
83/// (12 and 8 measured 2.7e-16). Because the terms factor over the primes, the transcendental
84/// cost is `pi(10) = 4` rather than 9.
85#[allow(dead_code)] // named in the docs as the truncation point; the value is inlined below
86pub const N: usize = 10;
87
88/// Correction terms by precision tier. The dropped term bounds the error directly, and the
89/// series is convergent-then-asymptotic in this range, so the tiers are: 8 terms is 4.4e-16, 4
90/// is around 1e-11, and 2 is around 1e-7, which straddles f32's floor, where the whole tail is
91/// nearly free anyway.
92#[inline(always)]
93pub const fn bernoulli_terms(precision: PrecisionPolicy) -> usize {
94    match precision {
95        PrecisionPolicy::Worst => 2,
96        PrecisionPolicy::Medium => 4,
97        _ => 8,
98    }
99}
100
101/// Per-element constants: the base-2 logarithms of the primes under `N`. `log2(pi)` for
102/// the functional equation comes from `FloatConsts`. Declared for `f32`/`f64`. Add more as needed.
103pub trait ZetaConsts {
104    /// `log2(3)`, for `3^-s = exp2(-s log2 3)`.
105    const LOG2_3: Self;
106    /// `log2(5)`.
107    const LOG2_5: Self;
108    /// `log2(7)`.
109    const LOG2_7: Self;
110}
111
112impl ZetaConsts for f32 {
113    const LOG2_3: f32 = 1.5849624872207642;
114    const LOG2_5: f32 = 2.321928024291992;
115    const LOG2_7: f32 = 2.8073549270629883;
116}
117
118impl ZetaConsts for f64 {
119    const LOG2_3: f64 = 1.584962500721156;
120    const LOG2_5: f64 = 2.321928094887362;
121    const LOG2_7: f64 = 2.807354922057604;
122}
123
124/// `zeta(s) - 1` by Euler-Maclaurin for `s > 0`, and optionally `zeta'(s)` alongside it. The
125/// leading `n = 1` term is simply never added, which is what makes the complement exact rather
126/// than a cancellation. Since the two functions differ by a constant, one derivative
127/// serves both.
128///
129/// `DERIV` is a compile-time flag, so the whole derivative half folds away when it is not
130/// wanted. It shares every transcendental with the value: the `log n` weights are constants
131/// (`log n = log2 n * ln 2`, and the composite ones are sums of the prime ones), and the
132/// correction sum's derivative rides the same ladder with a second accumulator.
133///
134/// That second accumulator is not optional. Dropping the correction sum's own
135/// `d/ds (s)_{2k-1}` term (the tempting simplification, since the correction is already tiny)
136/// was measured at **8.9e-5** relative against **2.4e-15** for the full form. The tail is small
137/// but its derivative is not small in the same way.
138#[inline(always)]
139fn zetac_positive<P, E, V, const DERIV: bool>(s: V) -> (V, V)
140where
141    E: FloatElement + ZetaConsts + BernoulliNumbers,
142    V: FloatVector<Element = E> + SpecializedTranscendentalMath<E> + SpecializedSpecialMath<E>,
143    P: Policy,
144{
145    let neg_s = -s;
146
147    // The four primes below N. Everything else is a product of these.
148    let p2 = neg_s.exp2_p::<P>();
149    let p3 = (neg_s * V::splat(<E as ZetaConsts>::LOG2_3)).exp2_p::<P>();
150    let p5 = (neg_s * V::splat(<E as ZetaConsts>::LOG2_5)).exp2_p::<P>();
151    let p7 = (neg_s * V::splat(<E as ZetaConsts>::LOG2_7)).exp2_p::<P>();
152
153    let p4 = p2 * p2;
154    let p6 = p2 * p3;
155    let p8 = p4 * p2;
156    let p9 = p3 * p3;
157    let n_s = p2 * p5; // 10^-s, and the fifth call it saves
158
159    // n = 2..9, ordered small-to-large so the accumulation adds like magnitudes together.
160    let direct = ((p9 + p8) + (p7 + p6)) + ((p5 + p4) + (p3 + p2));
161
162    // N^{1-s}/(s-1) + N^-s/2, sharing the single N^-s.
163    let ten: V = const_splat!(int <E>: 10);
164    let boundary = n_s * (ten.approx_div_p::<P>(s - V::ONE) + V::HALF);
165
166    // The correction sum. `u` carries (s)_{2k-1}/(2k)! * N^-(s+2k-1) and advances by
167    //   u_{k+1} = u_k (s + 2k - 1)(s + 2k) / ((2k+1)(2k+2) N^2),
168    // whose denominator is a compile-time integer, so no transcendental appears here at all.
169    let recur: [E; 8] = [
170        <E as FloatElement>::ConstRatio::<1, 1200>::VALUE,
171        <E as FloatElement>::ConstRatio::<1, 3000>::VALUE,
172        <E as FloatElement>::ConstRatio::<1, 5600>::VALUE,
173        <E as FloatElement>::ConstRatio::<1, 9000>::VALUE,
174        <E as FloatElement>::ConstRatio::<1, 13200>::VALUE,
175        <E as FloatElement>::ConstRatio::<1, 18200>::VALUE,
176        <E as FloatElement>::ConstRatio::<1, 24000>::VALUE,
177        <E as FloatElement>::ConstRatio::<1, 30600>::VALUE,
178    ];
179    let terms = const { bernoulli_terms(P::POLICY.precision) };
180
181    // u_1 = (s)_1/2! * N^-(s+1) = s N^-s / 20, and its derivative.
182    let ln_n = V::LN_10;
183    let twentieth: V = const_splat!(ratio <E>: 1 / 20);
184    let mut u = s * n_s * twentieth;
185    let mut du = (n_s - s * ln_n * n_s) * twentieth;
186    let mut a = s + V::ONE; // s + 2k - 1 at k = 1
187    let mut tail = V::ZERO;
188    let mut dtail = V::ZERO;
189
190    let mut k = 0;
191    while k < terms {
192        V::_loop_hint();
193
194        let b = V::splat(E::B2N[k]);
195        tail = u.mul_adde(b, tail);
196        if const { DERIV } {
197            dtail = du.mul_adde(b, dtail);
198        }
199
200        // u_{k+1} = u_k a(a+1) r_k, so du_{k+1} = [du_k a(a+1) + u_k (2a+1)] r_k. The r_k are
201        // pure constants (every bit of the s-dependence lives in u_1), so nothing else enters.
202        let step = a * (a + V::ONE);
203        let r = V::splat(recur[k]);
204        if const { DERIV } {
205            du = du.mul_adde(step, u * (a + a + V::ONE)) * r;
206        }
207        u *= step * r;
208        a += V::TWO;
209        k += 1;
210    }
211
212    let value = (direct + boundary) + tail;
213
214    let deriv = if const { DERIV } {
215        // d/ds n^-s = -(ln n) n^-s. Every log is a constant, and the composite ones are sums of
216        // the prime ones: ln 4 = 2 ln 2, ln 6 = ln 2 + ln 3, and so on.
217        let l2 = V::LN_2;
218        let l3 = V::splat(<E as ZetaConsts>::LOG2_3) * l2;
219        let l5 = V::splat(<E as ZetaConsts>::LOG2_5) * l2;
220        let l7 = V::splat(<E as ZetaConsts>::LOG2_7) * l2;
221
222        let d_direct = -((l3 * p3 + l2 * p2)
223            + ((l2 + l2) * p4 + l5 * p5)
224            + ((l2 + l3) * p6 + l7 * p7)
225            + ((l2 + l2 + l2) * p8 + (l3 + l3) * p9));
226
227        // d/ds [N^-s (N/(s-1) + 1/2)] = -ln(N) * boundary - N^-s N/(s-1)^2.
228        let sm1 = s - V::ONE;
229        let d_boundary = -(ln_n * boundary) - n_s * ten.approx_div_p::<P>(sm1 * sm1);
230
231        (d_direct + d_boundary) + dtail
232    } else {
233        V::ZERO
234    };
235
236    (value, deriv)
237}
238
239/// `zeta(s)` (`ZETAC = false`) or `zeta(s) - 1` (`ZETAC = true`), and its derivative when
240/// `DERIV` is set. `zeta` and `zetac` differ by a constant, so the one derivative serves both.
241#[inline(always)]
242pub fn zeta_core<P, E, V, const ZETAC: bool, const DERIV: bool>(s: V) -> (V, V)
243where
244    E: FloatElement + ZetaConsts + BernoulliNumbers,
245    V: FloatVector<Element = E> + SpecializedTranscendentalMath<E> + SpecializedSpecialMath<E>,
246    P: Policy,
247{
248    // Negative arguments reflect. The expansion is asymptotic, so this is not a matter of
249    // taking more terms. Its error there grows with N. The functional equation maps s < 0 to
250    // 1 - s > 1, which is where the series is at its best.
251    let reflect = s.cmp_lt(V::ZERO);
252    let arg = reflect.select(V::ONE - s, s);
253
254    let (zc, dz_arg) = zetac_positive::<P, E, V, DERIV>(arg);
255    let full = V::ONE + zc;
256
257    let mut result = if const { ZETAC } { zc } else { full };
258    // The reflected lanes hold d/d(arg), and arg = 1 - s there, so the chain rule's -1 is
259    // applied inside the reflected branch rather than here.
260    let mut deriv = dz_arg;
261
262    if const { P::POLICY.avoid_branching } || thermite::unlikely(reflect.any()) {
263        // zeta(s) = chi(s) zeta(1-s),  chi(s) = 2^s pi^(s-1) sin(pi s/2) Gamma(1-s).
264        //
265        // 2^s pi^(s-1) is one exp2, not two powers: 2^(s + (s-1) log2 pi).
266        let base = (s - V::ONE).mul_adde(V::LOG2_PI, s).exp2_p::<P>()
267            * <V as SpecializedSpecialMath<E>>::tgamma::<P>(V::ONE - s);
268        let (sin_h, cos_h) = s
269            .scale(<E as FloatElement>::ConstRatio::<1, 2>::VALUE)
270            .sincos_pi_p::<P>();
271
272        let reflected = base * sin_h * full;
273
274        // Away from the positive axis zeta is nowhere near 1, so taking the complement here is
275        // an ordinary subtraction rather than the cancellation `zetac` exists to avoid.
276        let out = if const { ZETAC } { reflected - V::ONE } else { reflected };
277        result = reflect.select(out, result);
278
279        if const { DERIV } {
280            // Differentiating chi(s) zeta(1-s) gives
281            //   zeta'(s) = chi(s)[ln 2 + ln pi + (pi/2)cot(pi s/2) - psi(1-s)] zeta(1-s)
282            //              - chi(s) zeta'(1-s).
283            //
284            // Written that way the cotangent blows up at every even negative integer: exactly
285            // the trivial zeros, where zeta(s) is 0, so the product is 0 * inf. Folding chi's
286            // own sine into it instead leaves `base * (pi/2) * cos(pi s/2)`, which is finite
287            // there and needs no guard.
288            let logs = V::LN_2 + V::LN_PI - <V as SpecializedSpecialMath<E>>::digamma::<P>(V::ONE - s);
289            // `dz_arg` is zeta'(1-s), the derivative with respect to its own argument. The
290            // chain rule's d(1-s)/ds = -1 is what makes this term subtract.
291            let d_reflected = base * ((logs * sin_h + V::FRAC_PI_2 * cos_h) * full - sin_h * dz_arg);
292            deriv = reflect.select(d_reflected, deriv);
293        }
294    }
295
296    if const { P::POLICY.check_overflow } {
297        // The simple pole. The boundary term already divides by s - 1 and produces the correct
298        // infinity from the right. This pins the exact hit, where the two-sided limit does not
299        // exist and the sign would otherwise come from the zero's.
300        let pole = s.cmp_eq(V::ONE);
301        result = pole.select(V::INFINITY, result);
302        if const { DERIV } {
303            deriv = pole.select(V::NEG_INFINITY, deriv);
304        }
305    }
306
307    (result, deriv)
308}
309
310/// `zeta(s)` (`ZETAC = false`) or `zeta(s) - 1` (`ZETAC = true`), for real `s`.
311#[inline(always)]
312pub fn zeta_impl<P, E, V, const ZETAC: bool>(s: V) -> V
313where
314    E: FloatElement + ZetaConsts + BernoulliNumbers,
315    V: FloatVector<Element = E> + SpecializedTranscendentalMath<E> + SpecializedSpecialMath<E>,
316    P: Policy,
317{
318    zeta_core::<P, E, V, ZETAC, false>(s).0
319}
Last built: 2026-09-08 21:35:55 UTC