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#[cfg(test)]
62mod probit_parity_tests {
63    //! CPU-verifiable floating-point-order & transcendental parity harness for
64    //! the shared probit numerics (issue #1175). Everything here runs without a
65    //! GPU: it pins the host oracle constants to the kernel-source literals,
66    //! audits the kernel source for msun-only transcendentals (no fast-math),
67    //! and checks the host oracle against the defining probit identities within
68    //! stated ULP bounds. A *device* reproducing this oracle to round-off still
69    //! requires CUDA hardware and is asserted by the on-device parity gates.
70    use super::*;
71    use crate::numerics_device::PROBIT_NUMERICS_CU;
72
73    const EPS: f64 = f64::EPSILON; // 2.220446049250313e-16
74
75    /// Relative error of `got` vs `want`, expressed in ULP of `want`.
76    fn ulp(got: f64, want: f64) -> f64 {
77        if want == 0.0 {
78            (got - want).abs() / EPS
79        } else {
80            (got - want).abs() / (EPS * want.abs())
81        }
82    }
83
84    /// Extract the first f64 literal appearing after `needle` in `src`.
85    fn literal_after(src: &str, needle: &str) -> f64 {
86        let start = src
87            .find(needle)
88            .unwrap_or_else(|| panic!("kernel source is missing marker {needle:?}"))
89            + needle.len();
90        let tail = &src[start..];
91        // Skip separators between the marker and the number ('=', whitespace).
92        let num_start = tail
93            .find(|c: char| c == '-' || c == '.' || c.is_ascii_digit())
94            .unwrap_or_else(|| panic!("no numeric literal follows {needle:?}"));
95        let rest = &tail[num_start..];
96        let end = rest
97            .find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-')))
98            .unwrap_or(rest.len());
99        rest[..end]
100            .parse::<f64>()
101            .unwrap_or_else(|e| panic!("failed to parse literal after {needle:?}: {e}"))
102    }
103
104    /// #1175 item 4 pattern ("constants cannot drift"): every constant the host
105    /// oracle uses is bit-identical to the literal baked into the kernel source.
106    /// A one-bit edit on either side fails this immediately.
107    #[test]
108    fn host_constants_match_kernel_source_bit_for_bit() {
109        for (needle, host) in [
110            ("#define INV_SQRT_2PI", INV_SQRT_2PI),
111            ("#define SQRT_2", SQRT_2),
112            ("#define LN_2", LN_2),
113            ("inv_sqrt_pi =", INV_SQRT_PI),
114            ("sqrt_2_over_pi =", SQRT_2_OVER_PI),
115        ] {
116            let device = literal_after(PROBIT_NUMERICS_CU, needle);
117            assert_eq!(
118                device.to_bits(),
119                host.to_bits(),
120                "constant {needle:?} drifted: kernel={device:?} host={host:?}"
121            );
122        }
123    }
124
125    /// Transcendental-parity intent: the kernel evaluates its transcendentals
126    /// through the msun `erfc`/`exp`/`log` (which the host `libm` mirrors) and
127    /// contains NO fast-math intrinsic or single-precision variant. FMA
128    /// contraction is separately disabled at compile time via
129    /// `device_cache`'s `--fmad=false`; this guards the source itself.
130    #[test]
131    fn kernel_source_uses_msun_transcendentals_only() {
132        for good in ["erfc(", "exp(", "log(", "log1p("] {
133            assert!(
134                PROBIT_NUMERICS_CU.contains(good),
135                "kernel source should call msun `{good}`"
136            );
137        }
138        for bad in [
139            "__expf",
140            "__logf",
141            "expf(",
142            "logf(",
143            "erfcf(",
144            "__fdividef",
145            "__frcp",
146            "use_fast_math",
147            "ffast-math",
148            "__dmul_",
149            "__dadd_",
150            "__fmaf",
151        ] {
152            assert!(
153                !PROBIT_NUMERICS_CU.contains(bad),
154                "kernel source must not use fast-math / single-precision `{bad}`"
155            );
156        }
157    }
158
159    /// `erfc` boundary + symmetry: `erfc(0)=1` exactly and
160    /// `erfc(-x) = 2 - erfc(x)` to ≤ 2 ULP across a moderate grid.
161    #[test]
162    fn erfc_boundary_and_symmetry() {
163        assert_eq!(erfc(0.0), 1.0);
164        let mut worst = 0.0_f64;
165        for i in 0..300 {
166            let x = i as f64 * 0.01;
167            worst = worst.max(ulp(erfc(-x), 2.0 - erfc(x)));
168        }
169        assert!(worst <= 2.0, "erfc symmetry drift {worst:.3} ULP > 2");
170    }
171
172}