gam_gpu/numerics_host.rs
1//! Host-side scalar special functions shared by the CPU parity references of
2//! the GPU backends.
3//!
4//! The CUDA kernels emit their own NVRTC-visible numerics (see
5//! [`crate::numerics_device`]); this module is the matching **host** side
6//! used by the CPU parity oracles (`bms_flex_row`'s test oracle) and the
7//! CPU reference path (`pirls_row`'s probit CDF). Keeping a single definition
8//! here means the host `erfc` cannot drift between backends.
9
10/// Complementary error function `erfc(x) = 1 − erf(x)` evaluated on the host.
11///
12/// Routes to `libm::erfc`, the SunOS msun double-precision implementation
13/// (accurate to within ~1 ulp across the entire real line). The CUDA kernel
14/// side calls device `erfc`, which is itself msun-derived, so the host CPU
15/// reference matches the device path to within a ULP. The previous
16/// branchless Cody 1969 Chebyshev rational here was only ~1.2e-7 accurate
17/// in relative terms; that ate seven digits of every probit `Mills =
18/// φ/Φ = pdf / (½·erfc(-x/√2))` evaluation and made any sufficiently
19/// tight finite-difference probe of `∂neglog/∂e = -w·s·Mills` (which the
20/// analytic side computes from this same `cdf`, while the FD side
21/// differences `log cdf` and cancels the erfc bias) break against itself
22/// at the ~2e-7 floor instead of the genuine 5-point-stencil truncation
23/// floor near 1e-12.
24pub fn erfc(x: f64) -> f64 {
25 libm::erfc(x)
26}
27
28// ── Host oracle for the shared device probit numerics (issue #1175) ──────────
29//
30// The functions below are the CPU-side, device-free mirror of the CUDA source
31// in [`crate::numerics_device::PROBIT_NUMERICS_CU`]. They are written
32// LINE-FOR-LINE against that kernel source — the SAME branch structure, the
33// SAME asymptotic `erfcx` polynomial, and the SAME constants — differing only
34// in that they call the host `libm`
35// transcendentals (`erfc`/`exp`/`log`) where the kernel calls the device
36// `erfc`/`exp`/`log`. Both sides are the SunOS *msun* double-precision
37// implementations, so the host oracle matches the device to within ~1 ULP per
38// transcendental (issue #1175 items 4–5). This mirrors the #1017
39// `emulate_certified_encode_row` pattern: a CPU emulator that is BOTH the
40// fallback and the exactness oracle a device launch is pinned to.
41//
42// Correctness *without a GPU* (CPU-verifiable): the test harness below asserts
43// (a) these constants are bit-identical to the literals in the kernel source
44// (the "constants cannot drift" lock, #1175 item 4), (b) the kernel source uses
45// only msun transcendentals and no fast-math intrinsics (transcendental-parity
46// intent), and (c) the host oracle satisfies the defining probit identities to
47// a stated ULP bound. Confirming a *device launch* reproduces this oracle to
48// round-off still needs CUDA hardware.
49
50/// `1/√(2π)`, matching `INV_SQRT_2PI` in the kernel source bit-for-bit.
51pub const INV_SQRT_2PI: f64 = 0.3989422804014327;
52/// `√2`, matching `SQRT_2` in the kernel source bit-for-bit.
53pub const SQRT_2: f64 = 1.4142135623730951;
54/// `ln(2)`, matching `LN_2` in the kernel source bit-for-bit.
55pub const LN_2: f64 = 0.6931471805599453;
56/// `1/√π`, matching `inv_sqrt_pi` in the kernel source bit-for-bit.
57pub const INV_SQRT_PI: f64 = 0.5641895835477563;
58/// `√(2/π)`, matching `sqrt_2_over_pi` in the kernel source bit-for-bit.
59pub const SQRT_2_OVER_PI: f64 = 0.7978845608028654;
60
61/// Scaled complementary error function `erfcx(x) = exp(x²)·erfc(x)` for `x ≥ 0`,
62/// the host oracle for the device `erfcx_nonnegative`. Returns `0.0` at `+∞`;
63/// negative inputs and `NaN` return `NaN` because they violate the restricted
64/// domain. For `0 ≤ x < 26` evaluates `exp(x²)·erfc(x)` directly; beyond that
65/// it switches to the same six-correction asymptotic expansion as the kernel.
66pub fn erfcx_nonnegative(x: f64) -> f64 {
67 if x.is_nan() || x < 0.0 {
68 return f64::NAN;
69 }
70 if x == f64::INFINITY {
71 return 0.0;
72 }
73 if x < 26.0 {
74 return libm::exp(x * x) * erfc(x);
75 }
76 let inv = 1.0 / x;
77 let inv2 = inv * inv;
78 let poly = 1.0
79 + inv2
80 * (-0.5
81 + inv2
82 * (0.75
83 + inv2
84 * (-1.875 + inv2 * (6.5625 + inv2 * (-29.53125 + inv2 * 162.421875)))));
85 inv * poly * INV_SQRT_PI
86}
87
88/// `log Φ(x)` for the standard normal CDF, the host oracle for the device
89/// `log_ndtr`. For `x < 0` uses the `erfcx` representation
90/// `log Φ(x) = −u² + log(½·erfcx(u))`, `u = −x/√2`, keeping digits into the
91/// deep left tail; for `x ≥ 0` uses `log1p(−½·erfc(x/√2))`, retaining the
92/// negative tail after the CDF rounds to one. Propagates `±∞`/`NaN` exactly as
93/// the device path does.
94pub fn log_ndtr(x: f64) -> f64 {
95 if x == f64::INFINITY {
96 return 0.0;
97 }
98 if x == f64::NEG_INFINITY {
99 return f64::NEG_INFINITY;
100 }
101 if x.is_nan() {
102 return x;
103 }
104 if x < 0.0 {
105 let u = -x / SQRT_2;
106 let ex = erfcx_nonnegative(u);
107 -u * u + libm::log(ex) - LN_2
108 } else {
109 let upper_tail = 0.5 * erfc(x / SQRT_2);
110 libm::log1p(-upper_tail)
111 }
112}
113
114/// Joint `(log Φ(x), Mills ratio φ(x)/Φ(x))`, the host oracle for the device
115/// `log_ndtr_and_mills`. The `x < 0` branch computes the Mills ratio as
116/// `√(2/π)/erfcx(u)`, which stays finite even when `Φ(x)` underflows; the
117/// `x ≥ 0` branch forms `pdf/cdf` directly. Boundary values mirror the kernel:
118/// `(+0, +0)` at `+∞`, `(−∞, +∞)` at `−∞`, `(NaN, NaN)` at `NaN`.
119pub fn log_ndtr_and_mills(x: f64) -> (f64, f64) {
120 if x == f64::INFINITY {
121 return (0.0, 0.0);
122 }
123 if x == f64::NEG_INFINITY {
124 return (f64::NEG_INFINITY, f64::INFINITY);
125 }
126 if x.is_nan() {
127 return (x, x);
128 }
129 if x < 0.0 {
130 let u = -x / SQRT_2;
131 let ex = erfcx_nonnegative(u);
132 let log_cdf = -u * u + libm::log(ex) - LN_2;
133 let lambda = SQRT_2_OVER_PI / ex;
134 (log_cdf, lambda)
135 } else {
136 let upper_tail = 0.5 * erfc(x / SQRT_2);
137 let cdf = 1.0 - upper_tail;
138 let pdf = INV_SQRT_2PI * libm::exp(-0.5 * x * x);
139 let log_cdf = libm::log1p(-upper_tail);
140 let lambda = pdf / cdf;
141 (log_cdf, lambda)
142 }
143}
144
145/// Joint `(log Φ(x), φ(x)/Φ(x), −d²log Φ(x)/dx²)` host oracle.
146///
147/// The curvature's deep-left branch differentiates the same 32-level Laplace
148/// continued fraction as the CPU model kernel, retaining its unit limit without
149/// subtracting the nearly equal `x` and Mills ratio. The CUDA source mirrors
150/// this operation order; host/device transcendental channels are ULP-close,
151/// while the rational curvature branch is operation-for-operation identical
152/// when device FMA contraction is disabled.
153pub fn log_ndtr_mills_curvature(x: f64) -> (f64, f64, f64) {
154 let (log_cdf, lambda) = log_ndtr_and_mills(x);
155 if x.is_nan() {
156 return (log_cdf, lambda, x);
157 }
158 if x.is_infinite() {
159 return (
160 log_cdf,
161 lambda,
162 if x.is_sign_positive() { 0.0 } else { 1.0 },
163 );
164 }
165 let curvature = if x <= -4.0 {
166 let t = -x;
167 let mut q = 0.0;
168 let mut q_first = 0.0;
169 for n in (1..=32).rev() {
170 let denominator = t + q;
171 let value = f64::from(n) / denominator;
172 q_first = -value * (1.0 + q_first) / denominator;
173 q = value;
174 }
175 1.0 + q_first
176 } else {
177 lambda * (x + lambda)
178 };
179 (log_cdf, lambda, curvature)
180}
181
182#[cfg(test)]
183mod probit_parity_tests {
184 //! CPU-verifiable floating-point-order & transcendental parity harness for
185 //! the shared probit numerics (issue #1175). Everything here runs without a
186 //! GPU: it pins the host oracle constants to the kernel-source literals,
187 //! audits the kernel source for msun-only transcendentals (no fast-math),
188 //! and checks the host oracle against the defining probit identities within
189 //! stated ULP bounds. A *device* reproducing this oracle to round-off still
190 //! requires CUDA hardware and is asserted by the on-device parity gates.
191 use super::*;
192 use crate::numerics_device::PROBIT_NUMERICS_CU;
193
194 const EPS: f64 = f64::EPSILON; // 2.220446049250313e-16
195
196 /// Relative error of `got` vs `want`, expressed in ULP of `want`.
197 fn ulp(got: f64, want: f64) -> f64 {
198 if want == 0.0 {
199 (got - want).abs() / EPS
200 } else {
201 (got - want).abs() / (EPS * want.abs())
202 }
203 }
204
205 /// Extract the first f64 literal appearing after `needle` in `src`.
206 fn literal_after(src: &str, needle: &str) -> f64 {
207 let start = src
208 .find(needle)
209 .unwrap_or_else(|| panic!("kernel source is missing marker {needle:?}"))
210 + needle.len();
211 let tail = &src[start..];
212 // Skip separators between the marker and the number ('=', whitespace).
213 let num_start = tail
214 .find(|c: char| c == '-' || c == '.' || c.is_ascii_digit())
215 .unwrap_or_else(|| panic!("no numeric literal follows {needle:?}"));
216 let rest = &tail[num_start..];
217 let end = rest
218 .find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-')))
219 .unwrap_or(rest.len());
220 rest[..end]
221 .parse::<f64>()
222 .unwrap_or_else(|e| panic!("failed to parse literal after {needle:?}: {e}"))
223 }
224
225 /// #1175 item 4 pattern ("constants cannot drift"): every constant the host
226 /// oracle uses is bit-identical to the literal baked into the kernel source.
227 /// A one-bit edit on either side fails this immediately.
228 #[test]
229 fn host_constants_match_kernel_source_bit_for_bit() {
230 for (needle, host) in [
231 ("#define INV_SQRT_2PI", INV_SQRT_2PI),
232 ("#define SQRT_2", SQRT_2),
233 ("#define LN_2", LN_2),
234 ("inv_sqrt_pi =", INV_SQRT_PI),
235 ("sqrt_2_over_pi =", SQRT_2_OVER_PI),
236 ] {
237 let device = literal_after(PROBIT_NUMERICS_CU, needle);
238 assert_eq!(
239 device.to_bits(),
240 host.to_bits(),
241 "constant {needle:?} drifted: kernel={device:?} host={host:?}"
242 );
243 }
244 }
245
246 /// Transcendental-parity intent: the kernel evaluates its transcendentals
247 /// through the msun `erfc`/`exp`/`log` (which the host `libm` mirrors) and
248 /// contains NO fast-math intrinsic or single-precision variant. FMA
249 /// contraction is separately disabled at compile time via
250 /// `device_cache`'s `--fmad=false`; this guards the source itself.
251 #[test]
252 fn kernel_source_uses_msun_transcendentals_only() {
253 for good in ["erfc(", "exp(", "log(", "log1p("] {
254 assert!(
255 PROBIT_NUMERICS_CU.contains(good),
256 "kernel source should call msun `{good}`"
257 );
258 }
259 for bad in [
260 "__expf",
261 "__logf",
262 "expf(",
263 "logf(",
264 "erfcf(",
265 "__fdividef",
266 "__frcp",
267 "use_fast_math",
268 "ffast-math",
269 "__dmul_",
270 "__dadd_",
271 "__fmaf",
272 ] {
273 assert!(
274 !PROBIT_NUMERICS_CU.contains(bad),
275 "kernel source must not use fast-math / single-precision `{bad}`"
276 );
277 }
278 }
279
280 /// `erfc` boundary + symmetry: `erfc(0)=1` exactly and
281 /// `erfc(-x) = 2 - erfc(x)` to ≤ 2 ULP across a moderate grid.
282 #[test]
283 fn erfc_boundary_and_symmetry() {
284 assert_eq!(erfc(0.0), 1.0);
285 let mut worst = 0.0_f64;
286 for i in 0..300 {
287 let x = i as f64 * 0.01;
288 worst = worst.max(ulp(erfc(-x), 2.0 - erfc(x)));
289 }
290 assert!(worst <= 2.0, "erfc symmetry drift {worst:.3} ULP > 2");
291 }
292
293 /// Defining identity `erfcx(x)·exp(-x²) = erfc(x)` to ≤ 4 ULP for
294 /// `0 < x < 26` (the direct branch of the host oracle).
295 #[test]
296 fn erfcx_matches_definition() {
297 assert_eq!(erfcx_nonnegative(0.0), 1.0);
298 assert!(erfcx_nonnegative(-3.0).is_nan());
299 assert!(erfcx_nonnegative(f64::NEG_INFINITY).is_nan());
300 assert!(erfcx_nonnegative(f64::NAN).is_nan());
301 assert_eq!(erfcx_nonnegative(f64::INFINITY), 0.0);
302 let mut worst = 0.0_f64;
303 let mut x = 0.1;
304 while x < 25.0 {
305 worst = worst.max(ulp(erfcx_nonnegative(x) * libm::exp(-x * x), erfc(x)));
306 x += 0.1;
307 }
308 assert!(worst <= 4.0, "erfcx definition drift {worst:.3} ULP > 4");
309 }
310
311 #[test]
312 fn erfcx_asymptotic_switch_and_subnormal_contract_match_device_source() {
313 let switch = 26.0_f64;
314 let direct = libm::exp(switch * switch) * erfc(switch);
315 assert!(
316 (erfcx_nonnegative(switch) / direct - 1.0).abs() < 5.0e-14,
317 "erfcx switch disagrees with direct finite identity"
318 );
319 let tail = erfcx_nonnegative(f64::MAX);
320 assert!(tail > 0.0 && tail.is_subnormal(), "erfcx(MAX)={tail:e}");
321
322 for required in [
323 "isnan(x) || x < 0.0",
324 "inv2 * 162.421875",
325 "log1p(-upper_tail)",
326 "log_ndtr_mills_curvature",
327 ] {
328 assert!(
329 PROBIT_NUMERICS_CU.contains(required),
330 "device source lost shared tail contract `{required}`"
331 );
332 }
333 for forbidden in ["1e-300", "if (xx > 700.0)", "if (cdf > 1.0)", "1.0 / 0.0"] {
334 assert!(
335 !PROBIT_NUMERICS_CU.contains(forbidden),
336 "device source reintroduced numerical projection `{forbidden}`"
337 );
338 }
339 }
340
341 /// `log_ndtr` boundary + stable bulk Gaussian identity to ≤ 2 ULP for
342 /// `|x| ≤ 3`, and `Φ(x)+Φ(-x)=1` to ≤ 4e-16.
343 #[test]
344 fn log_ndtr_matches_log_cdf_and_reflects() {
345 assert_eq!(log_ndtr(0.0), libm::log(0.5));
346 assert_eq!(log_ndtr(f64::INFINITY), 0.0);
347 assert_eq!(log_ndtr(f64::NEG_INFINITY), f64::NEG_INFINITY);
348 assert!(log_ndtr(f64::NAN).is_nan());
349 assert!(log_ndtr(10.0) < 0.0);
350
351 let mut worst_bulk = 0.0_f64;
352 for i in -30..=30 {
353 let x = i as f64 * 0.1;
354 let expected = if x < 0.0 {
355 libm::log(0.5 * erfc(-x / SQRT_2))
356 } else {
357 libm::log1p(-0.5 * erfc(x / SQRT_2))
358 };
359 worst_bulk = worst_bulk.max(ulp(log_ndtr(x), expected));
360 }
361 assert!(
362 worst_bulk <= 2.0,
363 "log_ndtr vs log-cdf drift {worst_bulk:.3} ULP > 2"
364 );
365
366 let mut worst_refl = 0.0_f64;
367 for i in 0..60 {
368 let x = i as f64 * 0.1;
369 let s = libm::exp(log_ndtr(x)) + libm::exp(log_ndtr(-x));
370 worst_refl = worst_refl.max((s - 1.0).abs());
371 }
372 assert!(
373 worst_refl <= 4e-16,
374 "Φ(x)+Φ(-x) reflection drift {worst_refl:e} > 4e-16"
375 );
376 }
377
378 /// `log_ndtr_and_mills` agrees with `log_ndtr` on the log-CDF channel and
379 /// satisfies the Mills identity `λ(x)·Φ(x) = φ(x)` to ≤ 32 ULP for
380 /// `|x| ≤ 5`; the deep left tail stays finite (no `-∞`/`NaN`).
381 #[test]
382 fn log_ndtr_and_mills_identity_and_deep_tail() {
383 for i in -50..=50 {
384 let x = i as f64 * 0.1;
385 let (log_cdf, lambda) = log_ndtr_and_mills(x);
386 assert_eq!(
387 log_cdf.to_bits(),
388 log_ndtr(x).to_bits(),
389 "joint log-CDF channel diverged from log_ndtr at x={x}"
390 );
391 let phi = libm::exp(log_cdf);
392 let pdf = INV_SQRT_2PI * libm::exp(-0.5 * x * x);
393 assert!(
394 ulp(lambda * phi, pdf) <= 32.0,
395 "Mills identity drift {:.3} ULP > 32 at x={x}",
396 ulp(lambda * phi, pdf)
397 );
398 }
399 for &x in &[-10.0, -20.0, -30.0, -38.0] {
400 let (log_cdf, lambda) = log_ndtr_and_mills(x);
401 assert!(
402 log_cdf.is_finite() && log_cdf < 0.0,
403 "deep-tail log Φ({x}) not finite-negative: {log_cdf}"
404 );
405 assert!(
406 lambda.is_finite() && lambda > x.abs() * 0.9,
407 "deep-tail Mills({x}) should track |x|: {lambda}"
408 );
409 }
410 assert_eq!(log_ndtr_and_mills(f64::INFINITY), (0.0, 0.0));
411 assert_eq!(
412 log_ndtr_and_mills(f64::NEG_INFINITY),
413 (f64::NEG_INFINITY, f64::INFINITY)
414 );
415 }
416
417 #[test]
418 fn log_ndtr_curvature_keeps_unit_left_tail_and_matches_mills_derivative() {
419 let (_, lambda, curvature) = log_ndtr_mills_curvature(-1.0e100);
420 // Deep-left Mills tracks |x| to rounding: the value is assembled from
421 // a short transcendental chain (x/sqrt2, erfcx, reciprocal), so exact
422 // bit equality with 1e100 is one spurious ulp away on some hosts
423 // (a real A10 box measured 1.0000000000000002e100). The contract is
424 // "λ(-1e100) = 1e100 up to a few ulps", not a bit pattern.
425 let ulp = 1.0e100_f64.next_up() - 1.0e100;
426 assert!(
427 (lambda - 1.0e100).abs() <= 4.0 * ulp,
428 "deep-left Mills must track |x| to rounding: lambda={lambda:e}"
429 );
430 assert_eq!(curvature, 1.0);
431 assert_eq!(log_ndtr_mills_curvature(f64::INFINITY), (0.0, 0.0, 0.0));
432 assert_eq!(
433 log_ndtr_mills_curvature(f64::NEG_INFINITY),
434 (f64::NEG_INFINITY, f64::INFINITY, 1.0)
435 );
436 let (nan_log, nan_mills, nan_curv) = log_ndtr_mills_curvature(f64::NAN);
437 assert!(nan_log.is_nan() && nan_mills.is_nan() && nan_curv.is_nan());
438
439 let h = 1.0e-5;
440 for x in [-8.0_f64, -4.0, -2.0, 0.0, 3.0] {
441 let (_, _, analytic) = log_ndtr_mills_curvature(x);
442 let (_, left) = log_ndtr_and_mills(x - h);
443 let (_, right) = log_ndtr_and_mills(x + h);
444 let finite_difference = -(right - left) / (2.0 * h);
445 let relative = (finite_difference - analytic).abs() / analytic.abs().max(1.0e-300);
446 assert!(
447 relative < 2.0e-8,
448 "curvature fd mismatch x={x}: analytic={analytic:e}, fd={finite_difference:e}, rel={relative:e}"
449 );
450 }
451 }
452}