thermite_special/specialized/generic/fresnel.rs
1//! The Fresnel integrals `C(x) = int_0^x cos(pi t^2/2) dt` and
2//! `S(x) = int_0^x sin(pi t^2/2) dt`.
3//!
4//! # Two regions
5//!
6//! Below a crossover (2.5265 at both precisions) the integrands are summed
7//! directly: `C(x)/x` and `S(x)/x^3` are both smooth functions of `w = x^4`, fitted
8//! as Chebyshev series and summed by Clenshaw. Above it the standard auxiliary
9//! form
10//!
11//! ```text
12//! C = 1/2 + f sin t - g cos t, S = 1/2 - f cos t - g sin t, t = pi x^2 / 2
13//! ```
14//!
15//! with `f = P(u)/(pi x)` and `g = Q(u)/(pi^2 x^3)` for `u = 1/(pi x^2)^2`, both
16//! `P` and `Q` plain Horner polynomials tending to 1.
17//!
18//! # Why Chebyshev below and Horner above
19//!
20//! Measurement, not symmetry. The small-argument fit in the monomial basis has an
21//! error amplification (`sum |c_k| |w|^k / |f|`) of 3482 at this crossover, and
22//! 1.26e5 if the crossover moves to 3. The same fit in the Chebyshev basis summed
23//! by Clenshaw sits at 5.8 and 7.0. Monomial Horner would cap the f64 kernel at
24//! about 4e-13. Clenshaw costs two operations per term against Horner's one and
25//! buys three orders of magnitude, which is also what lets the crossover sit far
26//! enough out for the auxiliaries to be well conditioned. Their own amplification
27//! is 1.02 there, so they keep Horner.
28//!
29//! # The phase
30//!
31//! `t = pi x^2 / 2` computed as `x*x*0.5` is worthless long before the function is:
32//! measured against a 45-digit oracle, `sin(pi*(x*x*0.5))` in binary64 is 5.2e-13
33//! off at `x = 123`, 9.8e-11 at 1234, **5.3e-6 at 98765**, and returns the wrong
34//! sign by `x ~ 1e9`. Since `C` and `S` are `1/2` plus a term of size `1/(pi x)`,
35//! that error lands directly on the result.
36//!
37//! [`phase_half_x2`] fixes it in about ten operations, and the fix is exact: `x*x`
38//! splits as `p + e` with `e` always representable, halving is exact, and each half
39//! reduces mod 2 exactly by Sterbenz. Both halves must be reduced _before_ being
40//! added. `|e/2|` reaches `ulp(x^2)/4`, which is 32 at `x = 1e9`, and adding that
41//! to an already-reduced `p/2` rounds the latter's low bits straight off. Measured
42//! 2.80 ulp (`C`) and 2.64 (`S`) in f64 over `x` from 1e-4 to 1e15, and 2.14 / 3.40
43//! in f32 out to 1e7. The naive phase alone is worth thousands of ulp there.
44//!
45//! Above `x = 1.147e16` (f64) / `2.136e7` (f32) the correction has fallen under
46//! half an ulp of `1/2` and both functions are exactly `1/2`. Below that `x^2`
47//! cannot overflow, so the phase needs no range guard.
48
49use thermite::{
50 element::FloatElement,
51 math::{
52 CoreMathWithPolicy as _, TranscendentalMathWithPolicy as _,
53 policy::{Policy, PrecisionPolicy},
54 specialized::SpecializedTranscendentalMath,
55 },
56 prelude::*,
57};
58
59use super::chebyshev::chebyshev_series;
60
61/// `x^2/2 mod 2`, the argument for `sincos_pi`, to full precision for every `x`
62/// whose square is finite.
63///
64/// See the module docs for why the two words are reduced separately. Below
65/// `Average` the residual is dropped entirely and this is the naive `x*x/2`,
66/// which is accurate only while `x^2` is exact.
67#[inline(always)]
68pub fn phase_half_x2<P, E, V>(x: V) -> V
69where
70 E: FloatElement,
71 V: FloatVector<Element = E>,
72 P: Policy,
73{
74 // v - 2*round(v/2). Exact: `round` is ties-to-even and the subtraction is
75 // Sterbenz-exact whenever |v - 2k| <= 1 <= |v|/2.
76 let rem2 = |v: V| -> V { (v * V::HALF).round().nmul_adde(V::TWO, v) };
77
78 let p = x * x;
79
80 if const { P::POLICY.precision.le(PrecisionPolicy::Medium) } {
81 return rem2(p * V::HALF);
82 }
83
84 // `mul_sub`, deliberately, not `mul_sube`. The residual has to be EXACT: it is
85 // multiplied by nothing and added to a quantity of size 1, so any error in it is
86 // an error in the phase, and only a fused (or correctly-rounded emulated)
87 // multiply-add produces it. On a backend with hardware FMA the two spellings emit
88 // the same instruction. On one without, this is the one place in the kernel that
89 // pays for the emulation, which buys the entire large-argument accuracy claim.
90 // A Veltkamp split would be the same result in about eight ordinary operations,
91 // but needs a per-format splitting constant, and the emulation is already
92 // correctly rounded here.
93 let e = x.mul_sub(x, p);
94
95 rem2(rem2(p * V::HALF) + rem2(e * V::HALF))
96}
97
98/// `(S(x), C(x))`, in SciPy's order.
99///
100/// `cheb_c` and `cheb_s` are Chebyshev coefficients for `C(x)/x` and `S(x)/x^3` in
101/// `w = x^4` mapped onto `[-1, 1]` by `w*map - 1`. `aux_p` and `aux_q` are ascending
102/// monomial coefficients in `u = 1/(pi x^2)^2`. `x0` is the crossover and `cutoff`
103/// the point above which both functions are `1/2`.
104#[inline(always)]
105pub fn fresnel_with<P, E, V, const NC: usize, const NS: usize, const NP: usize, const NQ: usize>(
106 x: V,
107 x0: E,
108 map: E,
109 cutoff: E,
110 cheb_c: &[E; NC],
111 cheb_s: &[E; NS],
112 aux_p: &[E; NP],
113 aux_q: &[E; NQ],
114) -> (V, V)
115where
116 E: FloatElement,
117 V: FloatVector<Element = E> + SpecializedTranscendentalMath<E>,
118 P: Policy,
119{
120 let ax = x.abs();
121 let q = ax * ax;
122 let is_small = ax.cmp_le(V::splat(x0));
123
124 if !const { P::POLICY.avoid_branching } && is_small.all() {
125 let (c, s) = fresnel_small::<P, E, V, NC, NS>(ax, q, map, cheb_c, cheb_s);
126 // Both functions are odd and both are positive for x > 0, so the sign is a
127 // copysign rather than a branch. It carries -0.0 through unchanged.
128 return (s.copysign(x), c.copysign(x));
129 }
130
131 // One division for the whole branch: r = 1/(pi x^2), and then
132 // f = P(u) r x = P(u)/(pi x) and g = Q(u) u x = Q(u)/(pi^2 x^3).
133 let r = V::FRAC_1_PI.approx_div_p::<P>(q);
134 let u = r * r;
135 let f = (u.poly_n_p::<P, NP>(aux_p) * r) * ax;
136 let g = (u.poly_n_p::<P, NQ>(aux_q) * u) * ax;
137
138 let (sin_t, cos_t) = phase_half_x2::<P, E, V>(ax).sincos_pi_p::<P>();
139
140 // Two chained FMAs apiece: 1/2 + f sin t - g cos t and 1/2 - f cos t - g sin t.
141 let mut c = g.nmul_adde(cos_t, f.mul_adde(sin_t, V::HALF));
142 let mut s = g.nmul_adde(sin_t, f.nmul_adde(cos_t, V::HALF));
143
144 if const { P::POLICY.avoid_branching } || thermite::unlikely(is_small.any()) {
145 let (cs, ss) = fresnel_small::<P, E, V, NC, NS>(ax, q, map, cheb_c, cheb_s);
146 c = is_small.select(cs, c);
147 s = is_small.select(ss, s);
148 }
149
150 // Past the cutoff `f` and `g` have fallen under half an ulp of 1/2. Naming it is
151 // a shortcut and also what keeps the infinities right, since `q` is
152 // +inf there and the auxiliary branch would otherwise take the trig of a NaN.
153 let done = ax.cmp_gt(V::splat(cutoff));
154 c = done.select(V::HALF, c);
155 s = done.select(V::HALF, s);
156
157 (s.copysign(x), c.copysign(x))
158}
159
160/// `(C, S)` from the Chebyshev series in `w = x^4`, for `|x|` under the crossover.
161#[inline(always)]
162fn fresnel_small<P, E, V, const NC: usize, const NS: usize>(
163 ax: V,
164 q: V,
165 map: E,
166 cheb_c: &[E; NC],
167 cheb_s: &[E; NS],
168) -> (V, V)
169where
170 E: FloatElement,
171 V: FloatVector<Element = E>,
172 P: Policy,
173{
174 // w = x^4, mapped onto [-1, 1] by a single FMA.
175 let w = q * q;
176 let t = w.mul_sube(V::splat(map), V::ONE);
177 let a = chebyshev_series::<P, E, V, 1, NC, true>(t, cheb_c);
178 let b = chebyshev_series::<P, E, V, 1, NS, true>(t, cheb_s);
179 (ax * a, (ax * q) * b)
180}