Skip to main content

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}