thermite_special/specialized/generic/langevin.rs
1//! Langevin function `L(x) = coth(x) - 1/x` and its inverse, shared by every real
2//! element type. The per-precision pieces (the polynomial tables, and Newton vs
3//! Halley for the inverse) come in from `ps.rs`/`pd.rs`.
4//!
5//! # Forward
6//!
7//! `coth(x)` and `1/x` both grow like `1/x` while their difference is only `x/3`, so
8//! the direct form loses relative accuracy as `3u/x^2`. That is not a corner case, at
9//! `x = 0.1` it is already 300 ulp. Below the crossover `X0 = 2` the function is therefore an odd
10//! polynomial `x * p(x^2)` (minimax, fitted to `L(x)/x`), and above it
11//!
12//! ```math
13//! L(x) = 1 - \frac{1}{x} + \frac{2q}{1 - q}, \qquad q = e^{-2x}
14//! ```
15//!
16//! which is `1 - small` and cancels nothing. `q` rather than `expm1(2x)` because it
17//! never overflows (`q -> 0` is the correct limit and `L(inf) = 1` falls out), and it
18//! also gives the derivative for free: `csch^2(x) = 4q/(1-q)^2`.
19//!
20//! # Inverse
21//!
22//! `L^-1` has a simple pole at `y = 1`, and near it `x = 1/(1-y) - 2x^2 e^{-2x}`, so for
23//! `y >= 0.85` the seed is `1/(1-y)` itself (relative error `2.2e-5` at 0.85, `4e-8` at
24//! 0.9, below `u` past 0.95). Below 0.85 the seed is `y * q(y^2) / (1 - y^2)` with `q`
25//! a minimax fit of `L^-1(y)(1-y^2)/y`, the same shape as Cohen's Pade `(3-y^2)/(1-y^2)`,
26//! which is what the vMF literature calls the Banerjee estimator. Both seeds share the
27//! one division `1/(1-y^2)`, since `1/(1-y) = (1+y)/(1-y^2)`.
28//!
29//! The seed is then polished with one step. f32 takes Newton, `x <- x - f/f'` for
30//! `f = L(x) - y`: the error squares with constant ~1, so the `8e-5` seed lands at
31//! `~6e-9`, past f32. f64 takes Halley, `x <- x - 2ff'/(2f'^2 - ff'')`: `L''` is
32//! nearly free on both branches (`2 csch^2 coth - 2/x^3` from the same `q`, and the
33//! differentiated identity below 2), the step still has one division, and the error
34//! cubes with constant `f'''/(6f') - (f''/(2f'))^2` at most ~0.07 (small `y`) and
35//! ~2e-4 at the tail crossover. So f64's `1.1e-6` seed (deg 8 rather than f32's deg 4
36//! exactly for this) reaches full precision in one step where Newton needed two, i.e.
37//! a second exp and division. `L` is monotone and concave on `x > 0`, so from any
38//! positive seed either iteration is safe without safeguards.
39//!
40//! The residual is formed as `((1-y) - 1/x) + 2q/(1-q)` on the large branch rather
41//! than `L(x) - y`: `1 - y` is exact for `y >= 0.5`, and that keeps the step accurate
42//! to `u` even where `L(x)` is within an ulp of 1, which `L(x) - y` cannot do (its
43//! error, `u`, divided by `L' ~ 1/x^2`, would grow as `u x`).
44//!
45//! `L^-1` itself is ill-conditioned near 1 (a relative error `u` in `y` moves the
46//! result by `u/(1-y)`), so callers with `1 - y` in hand should compute it exactly
47//! before rounding, exactly as they would for `acos` near 1.
48
49use thermite::{
50 element::{FloatElement, FloatElementWithBits},
51 math::{
52 CoreMathWithPolicy as _, TranscendentalMathWithPolicy as _,
53 policy::{
54 Policy, PrecisionPolicy,
55 policies::{AveragePrecision, CheckOverflow, CmpLessPrecision},
56 },
57 },
58 prelude::*,
59};
60
61use crate::specialized::SpecializedSpecialMath;
62
63/// Crossover between the odd polynomial and the `1 - 1/x + 2q/(1-q)` form.
64///
65/// The tables in `ps.rs`/`pd.rs` (and the compensated one) are fitted on `[0, X0]`.
66const X0_NUM: thermite::LargeInt = 2;
67
68/// Seed crossover for the inverse: `1/(1-y)` above, `y q(y^2)/(1-y^2)` below.
69const Y1_NUM: thermite::LargeInt = 85;
70const Y1_DEN: thermite::LargeInt = 100;
71
72/// Refinement steps for a given precision policy. One step (Newton from f32's ~8e-5
73/// seed, Halley from f64's ~1e-6 seed, see the module docs) reaches the type's full
74/// precision. `Reference` takes a second for good measure, `Worst` ships the seed.
75#[inline(always)]
76pub const fn refine_steps(precision: PrecisionPolicy) -> usize {
77 match precision {
78 PrecisionPolicy::Worst => 0,
79 PrecisionPolicy::Reference => 2,
80 _ => 1,
81 }
82}
83
84/// The large-branch pieces for `x >= X0`: `q = e^{-2x}`, `1 - q`, and the one
85/// reciprocal `r = 1/(x(1-q))` that everything else is a product with:
86///
87/// ```text
88/// L = 1 - 1/x + 2q/(1-q) = (x - 1 + q(x+1)) r
89/// L' = 1/x^2 - 4q/(1-q)^2 = ((1-q)^2 - 4q x^2) r^2
90/// ```
91///
92/// Both numerators are free of cancellation on `x >= 2` (`q <= e^-4`, so `4qx^2` is at
93/// most 7% of `(1-q)^2` at the crossover and vanishes beyond it), and the whole branch
94/// costs the exp, the division and a handful of FMAs.
95#[inline(always)]
96fn large_parts<P, E, V>(x: V) -> (V, V, V)
97where
98 P: Policy,
99 E: FloatElementWithBits,
100 V: FloatVectorWithBits<Element = E> + SpecializedSpecialMath<E>,
101{
102 // The exp never needs the Best tier: Average is already 1-2 ulp, below what `L`'s
103 // own rounding contributes, and Best's range gate costs more than the whole rest of
104 // the branch (measured 2x on f32x8). Overflow checks are off for the same reason:
105 // the caller owns the edges.
106 //
107 // What this DOES depend on is `exp`'s low end saturating to exactly 0 rather than
108 // wrapping, since `q` wants 0 there. Average takes the two-scale reconstruction, whose
109 // halves are clamped explicitly in `exp_{f,d}_internal` under `!check_overflow`.
110 // Removing that clamp returns NaN here, at x = 100 in f32.
111 type ExpPolicy<P> = CheckOverflow<CmpLessPrecision<P, AveragePrecision<P>>, false>;
112
113 // Argument clamped where q has long underflowed to 0 (x > 52 in f32, 372 in f64):
114 // without the range gate, a huge argument's reduction leaves garbage in the
115 // polynomial that then multiplies the zeroed scale.
116 let xc = x.min(V::splat(<E as FloatElement>::ConstInt::<400>::VALUE));
117 let q = (-(xc + xc)).exp_p::<ExpPolicy<P>>();
118 let omq = V::ONE - q;
119 let r = (x * omq).approx_reciprocal_p::<P>();
120 (q, omq, r)
121}
122
123/// `L'(x)` and `L''(x)` on the large branch from [`large_parts`]:
124///
125/// ```text
126/// L' = 1/x^2 - csch^2 x
127/// L'' = 2 csch^2 x coth x - 2/x^3, coth x = 1 + 2q/(1-q)
128/// ```
129///
130/// with `1/x = (1-q) r`, `1/(1-q) = x r`, `csch^2 = 4q/(1-q)^2`.
131#[inline(always)]
132fn large_derivs<V: FloatVector>(x: V, q: V, omq: V, r: V) -> (V, V) {
133 let rcp = omq * r;
134 let d = x * r;
135 let w = (q + q) * d;
136 let csch2 = w * (d + d);
137 let rcp2 = rcp * rcp;
138 let dl = rcp2 - csch2;
139 let d2l = rcp2.nmul_adde(rcp, csch2.mul_adde(w, csch2));
140 (dl, d2l + d2l)
141}
142
143/// `L(x)` (or, with `ONE_MINUS`, `1 - L(x)`) and `L'(x)` together.
144///
145/// `L'` costs no transcendental of its own: on the small branch it is `1 - L^2 - 2L/x`
146/// (which is exact algebra, and cancels only ~2 bits there since `L ~ x/3`), on the
147/// large branch `1/x^2 - csch^2(x)` from the same `q`.
148///
149/// The complement is not a second kernel: on the large branch `1 - L = (1 - q(2x+1)) r`
150/// with the same `q` and division (no cancellation, `q(2x+1) <= 0.092` at `x = 2`), on
151/// the small one `1 - x p` where `1 - L >= 0.46`. It exists because `1 - L(x)` is what
152/// sits against `L^-1`'s pole in the vMF convolution, and forming it from `L` loses
153/// every digit once `L` rounds to 1 (`x > 1/u`, i.e. sharpness ~1e7 in f32).
154#[inline(always)]
155pub fn langevin_primal<P, E, V, const N: usize, const ONE_MINUS: bool>(x: V, small: &[E; N]) -> (V, V)
156where
157 P: Policy,
158 E: FloatElementWithBits,
159 V: FloatVectorWithBits<Element = E> + SpecializedSpecialMath<E>,
160{
161 let ax = x.abs();
162 let is_small = ax.cmp_le(V::splat(<E as FloatElement>::ConstInt::<X0_NUM>::VALUE));
163
164 // Odd polynomial on the signed input, so the sign rides along for free.
165 let p = (x * x).poly_n_p::<P, _>(small);
166 let l_small = x * p;
167 // 1 - L*(L + 2/x) with L = x p: 2L/x = 2p, no division needed, as -L*L + (1 - 2p).
168 let mut dl = l_small.nmul_adde(l_small, p.nmul_adde(V::TWO, V::ONE));
169 let mut l = if const { ONE_MINUS } { V::ONE - l_small } else { l_small };
170
171 if const { P::POLICY.avoid_branching } || !is_small.all() {
172 let (q, omq, r) = large_parts::<P, E, V>(ax);
173 let lpos = q.mul_adde(ax + V::ONE, ax - V::ONE) * r; // L(|x|)
174 let big = if const { ONE_MINUS } {
175 // 1 - L(x): (1 - q(2x+1)) r for x > 0, and 1 + L(|x|) for x < 0 (no
176 // cancellation either way, so the sign only picks a form).
177 let onem = q.nmul_adde(ax.mul_adde(V::TWO, V::ONE), V::ONE) * r;
178 x.select_negative(V::ONE + lpos, onem)
179 } else {
180 lpos.copysign(x)
181 };
182 l = is_small.select(l, big);
183 dl = is_small.select(dl, large_derivs(ax, q, omq, r).0);
184
185 // x = inf is inf * 0 above (r = 0 against infinite numerators). The limits are
186 // L = ±1 (so 1 - L = 0 or 2), L' = 0.
187 let is_inf = ax.cmp_eq(V::INFINITY);
188 let l_inf = if const { ONE_MINUS } {
189 x.select_negative(V::TWO, V::ZERO)
190 } else {
191 V::ONE.copysign(x)
192 };
193 l = is_inf.select(l_inf, l);
194 dl = dl.nz(is_inf);
195 }
196
197 (l, dl)
198}
199
200/// `L^-1(y)` (or, with `ONE_MINUS`, `L^-1(1 - t)` from `t` directly): seed plus
201/// [`refine_steps`] of Newton (`HALLEY = false`) or Halley, see the module docs. Halley's
202/// `L''` costs a reciprocal and a few FMAs on top of Newton, which buys f64 a whole
203/// second step. f32 is already done after one Newton and would only pay.
204///
205/// The complement is a re-entry point, not a second implementation: the kernel already
206/// works in `t = 1 - y` (the tail seed is `1/t`, the large-branch residual consumes `t`),
207/// so `ONE_MINUS` only changes where `t` comes from, exact from the caller instead of
208/// rounded from `y`. That is the whole difference between a result conditioned by
209/// `1/(1-y)` and one accurate to `u` at any sharpness.
210#[inline(always)]
211pub fn inv_langevin<P, E, V, const NF: usize, const NI: usize, const HALLEY: bool, const ONE_MINUS: bool>(
212 input: V,
213 small: &[E; NF],
214 seed_poly: &[E; NI],
215) -> V
216where
217 P: Policy,
218 E: FloatElementWithBits,
219 V: FloatVectorWithBits<Element = E> + SpecializedSpecialMath<E>,
220{
221 // (signed y, |y|, 1 - |y|). In complement mode t is the input. y = 1 - t is exact
222 // for t in [0.5, 2] and its rounding is harmless below (the result is O(1) there),
223 // and a negative y (t > 1) simply falls back to the rounded 1 - |y|.
224 let (y_in, y, t) = if const { ONE_MINUS } {
225 let y_in = V::ONE - input;
226 let y = y_in.abs();
227 (y_in, y, y_in.select_negative(V::ONE - y, input))
228 } else {
229 let y = input.abs();
230 (input, y, V::ONE - y) // exact for y >= 0.5 (Sterbenz), which is where it matters
231 };
232 let opy = V::ONE + y;
233 // 1/(1-y^2) as the product of the two exact-ish factors, never as 1 - y*y: without
234 // FMA that would lose the whole low half near y = 1.
235 let inv = (t * opy).approx_reciprocal_p::<P>();
236
237 let use_tail = y.cmp_ge(V::splat(<E as FloatElement>::ConstRatio::<Y1_NUM, Y1_DEN>::VALUE));
238 let s = y * y;
239 let num = use_tail.select(opy, y * s.poly_n_p::<P, _>(seed_poly));
240 let mut x = num * inv;
241
242 let x0 = V::splat(<E as FloatElement>::ConstInt::<X0_NUM>::VALUE);
243
244 let steps = const { refine_steps(P::POLICY.precision) };
245 let mut i = 0;
246 while i < steps {
247 let is_small = x.cmp_le(x0);
248
249 // Small branch: L = x p, L' = 1 - L(L + 2p), residual L - y (no cancellation
250 // issue: both are ~y and the quotient is against L' ~ 1/3). L'' from
251 // differentiating that identity: L'' = -2 L L' - 2 (L' - p)/x. It cancels near
252 // 0 (L'' ~ -2x/15), which Halley's correction term does not mind. The clamp
253 // only keeps x = 0 (y = 0, whose step is exactly zero anyway) finite.
254 let p = (x * x).poly_n_p::<P, _>(small);
255 let l = x * p;
256 let mut r = l - y;
257 let mut dl = l.nmul_adde(l, p.nmul_adde(V::TWO, V::ONE));
258 let mut d2l = V::ZERO;
259 if const { HALLEY } {
260 let rcp = x.max(V::MIN_POSITIVE).approx_reciprocal_p::<P>();
261 d2l = (l + l).nmul_sube(dl, (rcp + rcp) * (dl - p));
262 }
263
264 if const { P::POLICY.avoid_branching } || !is_small.all() {
265 let (q, omq, rr) = large_parts::<P, E, V>(x);
266 // The accurate form of L(x) - y (module docs), over the shared denominator:
267 // ((1-y) - 1/x) + 2q/(1-q) = ((1-q)(tx - 1) + 2qx) / (x(1-q))
268 // `tx - 1` is one FMA and both terms are O(q x), so the residual carries no
269 // rounding of an O(1) quantity, which is what keeps the step at ~u even
270 // where the seed is already within an ulp.
271 let rbig = omq.mul_adde(t.mul_sube(x, V::ONE), (q + q) * x) * rr;
272 let (dbig, d2big) = large_derivs(x, q, omq, rr);
273 r = is_small.select(r, rbig);
274 dl = is_small.select(dl, dbig);
275 if const { HALLEY } {
276 d2l = is_small.select(d2l, d2big);
277 }
278 }
279
280 if const { HALLEY } {
281 // One division: x - 2 f f' / (2 f'^2 - f f'').
282 let two_dl = dl + dl;
283 x -= (r * two_dl) / dl.mul_sube(two_dl, r * d2l);
284 } else {
285 x -= r / dl;
286 }
287 i += 1;
288 }
289
290 // y = 1 (t = 0) is the pole (the seed already gives +inf there, but a step on it is
291 // 0/0), and y > 1 (t < 0) is out of the domain. In complement mode these are read off
292 // `t` itself: a `t` below u/2 rounds `1 - t` to exactly 1, and is a perfectly good
293 // finite input. The negative branch (t > 1) went through the rounded `1 - |y|`, so
294 // its pole (t = 2) is read off `y` as before.
295 let (at_pole, out_of_domain) = if const { ONE_MINUS } {
296 let neg = y_in.is_negative();
297 (
298 input.cmp_eq(V::ZERO) | (neg & y.cmp_ge(V::ONE)),
299 input.cmp_lt(V::ZERO) | (neg & y.cmp_gt(V::ONE)),
300 )
301 } else {
302 (y.cmp_ge(V::ONE), y.cmp_gt(V::ONE))
303 };
304 x = at_pole.select(V::INFINITY, x);
305 if const { P::POLICY.check_overflow } {
306 x = (out_of_domain | input.is_nan()).select(V::NAN, x);
307 }
308
309 x.copysign(y_in)
310}