Skip to content

Add erfcx function - #3405

Merged
andrjohns merged 28 commits into
developfrom
stable-erfcx
Sep 20, 2026
Merged

andrjohns merged 28 commits into
developfrom
stable-erfcx

Conversation

@avehtari

@avehtari avehtari commented Sep 16, 2026

Copy link
Copy Markdown
Member

Claude and Herbie assisted work. Re-uses existing code from Stan math normal_lcdf.hpp

Summary

Implements #3401: adds erfcx(x) = exp(x²)·erfc(x) to prim, rev, fwd and the OpenCL kernel generator.

#3401 covers the motivation. The final choice of algorithm is a bit different. This text describes the algorithm as it now stands, the measurements behind it, and how it relates to the approximation already in normal_lcdf.

Algorithm

Four branches. Cody (1969) gives rational approximations on three intervals, and on the middle one it is arranged as erfc(x) = exp(-x²)·R(x), so erfcx(x) = R(x) with the exponential cancelled analytically — no exp and no erfc call. erfc is 66 % of the cost of forming exp(x²)·erfc(x) (27.0 ns of 41.1 on [0, 4], measured), and erfc itself computes exp(-x²) internally, which we would then cancel with our own exp(x²). Two exponentials to produce a product that analytically has none.

branch method library calls constants
x >= 4 Cody third-interval rational none 12
0.46875 <= x < 4 Cody second-interval rational none 17
|x| < 0.46875 degree-18 Chebyshev-economized expansion about 0 none 19
x <= -0.46875 2·exp(x²) - erfcx(-x) one exp 0

The whole positive axis is computed without a single library call. On the negative side exp is unavoidable, since erfcx grows like 2·exp(x²) as x → -∞; below x = -6.1, erfcx(-x) is under eps/2 of 2·exp(x²), so the subtraction is skipped.

The small-window expansion. The plain Maclaurin series for erfcx is only usable to about |x| = 0.125. Four further Chebyshev-economized terms extend it to 0.46875 at no measurable cost on CPU (11.00 vs 11.05 ns), which is what removes the last exp from the positive axis. It also covers both signs with no branch. Its low-order coefficients reproduce the Maclaurin coefficients of erfcx exactly (1, -2/√π, 1, -0.752252778063652, 0.5, ...), which is a useful independent check that the fit is right.

fma, not a Dekker split, on the negative branch. The rounding error of is recovered with fma(x, x, -h) and folded back in, because exp amplifies it into roughly x²·eps — 512 ulp at x = -26 if left alone. An earlier revision of this PR used a Veltkamp/Dekker split instead. That is correct on the host but silently wrong on a GPU: t - (t - x) is x over the reals but not in floating point, and NVIDIA's OpenCL compiler simplifies it. Measured on a Tesla V100, the split form returned x_lo == 0 at every one of 4096 test points and scored 512.66 ulp — bit-identical to the uncorrected formula it existed to avoid. fma is a single instruction with nothing to reassociate.

Note that no test in this repository could have caught that: the erfcx tests are CPU-only and CI has no GPU, so the device function is never executed against reference values anywhere. It was found by running the shipped kernel string on real hardware. That is a general gap in OpenCL coverage rather than something specific to this PR.

Relation to normal_lcdf

prim/prob/normal_lcdf.hpp already carries the Cody third-interval coefficients for its cdf value, with the same crossover at 4 and for the same reason: it is R's pnorm cutoff |y| > M_SQRT_32 expressed in the erfc argument, sqrt(32)/sqrt(2) = 4 exactly. That part is shared.

Everything else is different:

  • normal_lcdf uses only the tail rational, and covers the interior with exp/erfc plus, for the gradient, five hand-placed Taylor expansions and four residual fits (~350 lines). Those interior cutoffs are not from the literature; they were derived empirically in Bugfix/issue #1284 numerical precision of normal lcdf #1411 to patch regions where the autodiff tester was failing.
  • erfcx uses all three Cody intervals plus a Chebyshev expansion, and needs no Taylor patches anywhere, because it never forms exp(+x²) on the positive axis at all.

That difference makes this function better. normal_lcdf's own documentation records its worst in-range relative gradient error as 7.6e-06 to 6.1e-05, "just inside the 1e-4 relative gradient tolerance expect_ad applies by default". Measured against mpmath, its gradient is 2.7e+09 to 4.2e+11 ulp, where the erfcx identity √(2/π)/erfcx(-x/√2) gives 3 to 7 ulp — eight to eleven orders of magnitude better. SciPy's log_ndtr takes the same route and needs no rational approximation or Taylor patches below x = -1 for exactly this reason. The normal_lcdf improvement using erfcx is a separate PR.

Accuracy

Worst ulp against an mpmath reference at 40 decimal digits, 40 001 points per range, CPU and the OpenCL device function scored against the same reference. libcerf 3.8 is included as the reference implementation of this function.

range this PR, CPU libcerf, CPU this PR, OpenCL libcerf, OpenCL
[−26.6, −10] 2.82 2.82 2.84 2.84
[−10, −4] 3.18 3.18 3.18 3.18
[−4, −0.5] 3.16 3.16 3.16 3.16
[−0.5, 0] 2.47 3.29 2.32 3.29
[0, 0.5] 3.78 1.46 3.10 1.46
[0.5, 2] 6.78 1.92 4.89 1.92
[2, 4] 7.01 1.65 5.07 1.66
[4, 9] 1.99 1.65 1.96 1.65
[9, 20] 1.95 2.68 1.95 2.68
[20, 50] 1.94 2.60 1.94 2.60
[50, 200] 1.94 2.67 1.94 2.67
worst 7.01 3.29 5.07 3.29

libcerf is more accurate on [0, 4] and this PR is more accurate in both tails. These accuracy differences are negligible. The current std_normal_lcdf gradient is 10⁹–10¹¹ ulp. That's the code erfcx exists to replace, and the erfcx route gives 3–7 ulp — an improvement of 8 to 11 orders of magnitude.

Speed

CPU: ns/call on one dedicated core of a Xeon E5-2680 v3 (Haswell, 2.50 GHz, performance governor), -O3, 4096 pseudo-random arguments × 8000 repetitions, three repetitions, spread under 1 %.

GPU: ns/call on a Tesla V100-SXM2 (OpenCL 3.0 CUDA, 80 CUs), 1 048 576 work-items × 2000 in-register iterations, harness overhead subtracted. A simple map kernel would be bandwidth-bound and would measure the memory system rather than the function, so each work-item walks the range with an in-register PRNG; that also gives neighbouring lanes genuinely different subintervals, which is the case of interest.

range this PR, CPU libcerf, CPU this PR, OpenCL libcerf, OpenCL
[0, 4] 9.68 11.33 0.0147 0.9961
[−4, 4] 18.48 19.81 0.0254 0.8788
[0.125, 12] 12.84 11.44 0.0199 1.0409
[−20, 0] 20.16 19.43 0.0260 0.2444
[5, 20] 14.54 12.40 0.0100 0.1204

On CPU the two are close: this PR is 1.17× faster on [0, 4] and 1.07× on [−4, 4], libcerf is 1.04–1.17× faster elsewhere. On GPU this PR is 12× to 68× faster.

The GPU gap is the reason the table-based approach was not adopted. libcerf indexes a 16 640-byte __constant table at a position derived from the input, so lanes within a warp hit different subdomains and the read serialises. This PR is pure ALU with no memory traffic. The CPU numbers show the table costs nothing there — it is a GPU-specific penalty, and a large one.

Absolute cost is strongly machine-dependent: the same CPU benchmark on a 13th-gen laptop part runs about 2.5× faster and gives noticeably different ratios, so the ratios matter more than the absolute numbers.

Edge cases: erfcx(0) = 1, erfcx(+inf) = 0, erfcx(-inf) = +inf, NaN preserved, erfcx(1e10) collapses to the leading term 1/(x√π), erfcx(-26.9) = inf (the true value has already left binary64).

The derivative reuses the value:

d/dx erfcx(x) = 2·x·erfcx(x) − 2/√π

so rev and fwd need no second exp or erfc call, the gradient is exact wherever the value is, and the autodiff layers are independent of which value algorithm sits underneath.

Tests

Four files, 18 tests.

  • test/unit/math/prim/fun/erfcx_test.cpp — values against the reference across negative, small, moderate and far-tail arguments; erfcxUpperTail asserts std::erfc(30.0) == 0 alongside a finite erfcx, i.e. the failure this function exists to avoid; erfcxBranchContinuity checks all three internal crossovers (0.46875, 4, −0.46875) against the analytic slope, so a future change to any cutoff cannot silently introduce a step; erfcxReflectionSkippedTerm asserts that the term skipped below x = -6.1 really is under half an ulp of 2·exp(x²); erfcxEdgeCases; erfcxVectorized; and erfcxNormalTailIdentities, which verifies that the two identities in Add erfcx (scaled complementary error function) as a Stan Math primitive #3401 reproduce std_normal_lcdf and the inverse Mills ratio.
  • test/unit/math/rev/fun/erfcx_test.cpp — value and gradient against 2x·erfcx(x) − 2/√π, both the Eigen::Matrix<var, -1, 1> and var_value<Eigen::VectorXd> overloads, and an upper-tail gradient at x = 30 where the unscaled route cannot produce one.
  • test/unit/math/fwd/fun/erfcx_test.cppfvar<double> value/tangent, chain rule, fvar<fvar<double>> second order.
  • test/unit/math/mix/fun/erfcx_test.cppexpect_ad and expect_ad_vector_matvar, with arguments straddling the crossovers.

The mix test uses explicit finite arguments rather than expect_common_unary_vectorized, because erfcx(-inf) = +inf and the function grows like 2exp(x²) to the left, so the common argument set is not finite-differenceable here.

erfcxReflectionSkippedTerm deliberately does not sweep for continuity at x = -6.1: erfcx has slope about -3.5e17 there, so the function's own variation swamps any fixed relative tolerance. It asserts the design claim directly instead.

Separately, the Horner chains in both the host and device functions are written flat (p = c + x * p; repeated) rather than nested, because cpplint's line-length rule rejects deep nesting. Flattening is an easy place to introduce an off-by-one, so the flattened forms were checked bit-identical against the loop forms they came from over 500 000 points per interval.

Side Effects

Adds one include to each of the prim/rev/fwd fun.hpp aggregators and a kernel-generator registration; no existing function changes behaviour.

Release notes

Added erfcx(x), the scaled complementary error function exp(x^2)*erfc(x), with reverse- and forward-mode derivatives and OpenCL support.

Checklist

  • Copyright holder: Aalto University

    The copyright holder is typically you or your assignee, such as a university or company. By submitting this pull request, the copyright holder is agreeing to the license the submitted work under the following licenses:
    - Code: BSD 3-clause (https://opensource.org/licenses/BSD-3-Clause)
    - Documentation: CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/)

  • the basic tests are passing

    • unit tests pass (to run, use: ./runTests.py test/unit)
    • header checks pass, (make test-headers)
    • dependencies checks pass, (make test-math-dependencies)
    • docs build, (make doxygen)
    • code passes the built in C++ standards checks (make cpplint)
  • the code is written in idiomatic C++ and changes are documented in the doxygen

  • the new changes are tested

@SteveBronder SteveBronder self-assigned this Sep 16, 2026
@SteveBronder

Copy link
Copy Markdown
Collaborator

So I'm fine with this version. My one question is whether we want to backport something like libcerf's erfcx functions to here. It might be overkill, but that is going to be the most updated version. We do not have to backport all of it, but if we can backport some reasonable approximation I think that would be nice

https://jugit.fz-juelich.de/mlz/lib/cerf/-/blob/main/lib/erfcx.c?ref_type=heads

@avehtari

Copy link
Copy Markdown
Member Author

I'll make a comparison

@SteveBronder

Copy link
Copy Markdown
Collaborator

@WardBrian just pointed out that the file I pointed to pulls in this file of a huge array of precomputed chebyshev polynomials. Not sure if we want that. I'll explore this a bit, but my default is that the PR as of now seems reasonable and good.

@avehtari

Copy link
Copy Markdown
Member Author

Fetched and benchmarked it against the same long double reference. Summary: accuracy is a draw, libcerf is better on [0, 4] (1.9 vs 5.0 ulp, where we call std::erfc), this PR is better above x = 9 and in the far negative tail, worst case 5.42 vs 6.27 ulp. Speed splits the same way: libcerf 2.3× faster on [0, 4], this 1.3× faster on [5, 20].

erfcx.c pulls in cerf.h, defs.h, c.h, double_word.h, double_word.c (476 lines) and the generated auto_cheb_erfcx.c (464 lines) — about 1,400 lines, including machine-generated coefficient tables with no in-tree derivation.

@avehtari

Copy link
Copy Markdown
Member Author

It seems it would be possible to get maybe 20% speedup for this PR, but to get that 2.3 faster on [0, 4] would require adopting that 464-line generated coefficient table

@SteveBronder

Copy link
Copy Markdown
Collaborator

Thinking about it I think I'm okay with backporting the chebyshev polynomial code as well. If we are going to use this in a lot of places then I think it makes sense to have that lookup table.

@avehtari

Copy link
Copy Markdown
Member Author

I'll create another branch using libcerf code and make more comparisons

@avehtari

Copy link
Copy Markdown
Member Author

I did some studying (with help from Claude). One additional consideration is that that huge array of precomputed chebyshev polynomials is GPU-hostile

GPU results — Tesla V100-SXM2-16GB, OpenCL 3.0 CUDA, 80 CUs, cl_khr_fp64 yes

range cody libcerf libcerf is
[0, 4] 0.0373 0.9692 26.0× slower
[5, 20] 0.0102 0.1204 11.8× slower
[-20, 0] 0.0233 0.2439 10.5× slower
[-4, 4] 0.0360 0.8558 23.8× slower
[0.125, 12] 0.0436 1.0252 23.5× slower

Where cody is the algorithm used in this PR.

Do we want to use different algorithm for CPU (libcerf with Chebyshev) and GPU (cody)?

@stan-buildbot

Copy link
Copy Markdown
Contributor
Name Old Result New Result Ratio Performance change( 1 - new / old )
stat_comp_benchmarks/benchmarks/gp_regr/gp_regr.stan 0.23 0.22 1.01 1.13% faster
stat_comp_benchmarks/benchmarks/gp_regr/gen_gp_data.stan 0.06 0.06 1.0 -0.06% slower
stat_comp_benchmarks/benchmarks/low_dim_gauss_mix/low_dim_gauss_mix.stan 6.41 6.41 1.0 0.01% faster
stat_comp_benchmarks/benchmarks/low_dim_corr_gauss/low_dim_corr_gauss.stan 0.02 0.02 0.98 -1.8% slower
stat_comp_benchmarks/benchmarks/irt_2pl/irt_2pl.stan 8.52 8.53 1.0 -0.1% slower
stat_comp_benchmarks/benchmarks/gp_pois_regr/gp_pois_regr.stan 4.55 4.54 1.0 0.19% faster
stat_comp_benchmarks/benchmarks/sir/sir.stan 168.56 168.14 1.0 0.25% faster
stat_comp_benchmarks/benchmarks/garch/garch.stan 0.9 0.9 1.0 0.17% faster
stat_comp_benchmarks/benchmarks/arma/arma.stan 0.71 0.7 1.01 0.61% faster
stat_comp_benchmarks/benchmarks/pkpd/one_comp_mm_elim_abs.stan 42.85 45.94 0.93 -7.2% slower
stat_comp_benchmarks/benchmarks/pkpd/sim_one_comp_mm_elim_abs.stan 0.6 0.59 1.0 0.44% faster
stat_comp_benchmarks/benchmarks/low_dim_gauss_mix_collapse/low_dim_gauss_mix_collapse.stan 21.1 21.03 1.0 0.35% faster
stat_comp_benchmarks/benchmarks/eight_schools/eight_schools.stan 0.11 0.11 1.01 0.76% faster
stat_comp_benchmarks/benchmarks/arK/arK.stan 3.19 3.18 1.0 0.2% faster
performance.compilation 388.51 384.94 1.01 0.92% faster
Mean result: 0.9976216825191226

Jenkins Console Log
Jenkins Build Stages
Commit hash: 98c64f32efad1c4821e30c9f76801a0d0c89d086

Machine information
Distributor ID:	Ubuntu
Description:	Ubuntu 20.04.3 LTS
Release:	20.04
Codename:	focal

CPU:

Architecture:                            x86_64
CPU op-mode(s):                          32-bit, 64-bit
Byte Order:                              Little Endian
Address sizes:                           43 bits physical, 48 bits virtual
CPU(s):                                  256
On-line CPU(s) list:                     0-255
Thread(s) per core:                      2
Core(s) per socket:                      64
Socket(s):                               2
NUMA node(s):                            2
Vendor ID:                               AuthenticAMD
CPU family:                              23
Model:                                   49
Model name:                              AMD EPYC 7742 64-Core Processor
Stepping:                                0
Frequency boost:                         enabled
CPU MHz:                                 1497.269
CPU max MHz:                             3416.0681
CPU min MHz:                             1500.0000
BogoMIPS:                                4491.85
Virtualization:                          AMD-V
L1d cache:                               4 MiB
L1i cache:                               4 MiB
L2 cache:                                64 MiB
L3 cache:                                512 MiB
NUMA node0 CPU(s):                       0-63,128-191
NUMA node1 CPU(s):                       64-127,192-255
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Old microcode:             Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Mitigation; untrained return thunk; SMT enabled with STIBP protection
Vulnerability Spec rstack overflow:      Mitigation; Safe RET
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; usercopy/swapgs barriers and __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; Retpolines; IBPB conditional; STIBP always-on; RSB filling; PBRSB-eIBRS Not affected; BHI Not affected
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Mitigation; IBPB before exit to userspace
Flags:                                   fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm constant_tsc rep_good nopl xtopology nonstop_tsc cpuid extd_apicid aperfmperf rapl pni pclmulqdq monitor ssse3 fma cx16 sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm cmp_legacy svm extapic cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw ibs skinit wdt tce topoext perfctr_core perfctr_nb bpext perfctr_llc mwaitx cpb cat_l3 cdp_l3 hw_pstate ssbd mba ibrs ibpb stibp vmmcall fsgsbase bmi1 avx2 smep bmi2 cqm rdt_a rdseed adx smap clflushopt clwb sha_ni xsaveopt xsavec xgetbv1 xsaves cqm_llc cqm_occup_llc cqm_mbm_total cqm_mbm_local clzero irperf xsaveerptr rdpru wbnoinvd amd_ppin arat npt lbrv svm_lock nrip_save tsc_scale vmcb_clean flushbyasid decodeassists pausefilter pfthreshold avic v_vmsave_vmload vgif v_spec_ctrl umip rdpid overflow_recov succor smca sev sev_es

G++:

g++ (Ubuntu 9.4.0-1ubuntu1~20.04) 9.4.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

Clang:

clang version 10.0.0-4ubuntu1 
Target: x86_64-pc-linux-gnu
Thread model: posix
InstalledDir: /usr/bin

@SteveBronder

Copy link
Copy Markdown
Collaborator

I did some studying (with help from Claude). One additional consideration is that that huge array of precomputed chebyshev polynomials is GPU-hostile

Oh yeah if we do not leave the table over there that is annoying to transfer.

It would be nice to have the more accurate CPU version, but I'm also fine with the cody algorithm this PR uses. Let me have one more look at this over the morning.

@avehtari

Copy link
Copy Markdown
Member Author

I think we can have a Cody version which is

  • about the same speed as libcerf on CPU and much faster on GPU
  • only slightly less accurate than libcerf, but that accuracy is also eleven orders of magnitude inside Stan Math's own default AD tolerance
  • value accuracy similar to the current Cody variant in normal_lcdf
  • gradient accuracy improvemen of 8 to 11 orders of magnitude compared to to the current Cody variant in normal_lcdf

I'll update the PR

@SteveBronder

Copy link
Copy Markdown
Collaborator

but that accuracy is also eleven orders of magnitude inside Stan Math's own default AD tolerance

Yes and just better than what we are currently so idt we need to chase something perfect

@avehtari

Copy link
Copy Markdown
Member Author

I just updated the PR and the PR text which explains the current PR algorithm version which beats libcerf for our purposes

@avehtari

Copy link
Copy Markdown
Member Author

This PR is still missing some OpenCL support and OpenCL unit tests. Working on it, and pushing commits tomorrow

@stan-buildbot

Copy link
Copy Markdown
Contributor
Name Old Result New Result Ratio Performance change( 1 - new / old )
stat_comp_benchmarks/benchmarks/gp_regr/gp_regr.stan 0.23 0.23 1.0 0.3% faster
stat_comp_benchmarks/benchmarks/gp_regr/gen_gp_data.stan 0.06 0.06 0.97 -2.62% slower
stat_comp_benchmarks/benchmarks/low_dim_gauss_mix/low_dim_gauss_mix.stan 6.4 6.44 0.99 -0.57% slower
stat_comp_benchmarks/benchmarks/low_dim_corr_gauss/low_dim_corr_gauss.stan 0.02 0.02 0.97 -3.37% slower
stat_comp_benchmarks/benchmarks/irt_2pl/irt_2pl.stan 8.5 8.51 1.0 -0.11% slower
stat_comp_benchmarks/benchmarks/gp_pois_regr/gp_pois_regr.stan 4.54 4.51 1.01 0.62% faster
stat_comp_benchmarks/benchmarks/sir/sir.stan 171.41 175.54 0.98 -2.41% slower
stat_comp_benchmarks/benchmarks/garch/garch.stan 0.9 0.9 1.0 -0.36% slower
stat_comp_benchmarks/benchmarks/arma/arma.stan 0.71 0.71 1.0 0.37% faster
stat_comp_benchmarks/benchmarks/pkpd/one_comp_mm_elim_abs.stan 43.11 43.08 1.0 0.06% faster
stat_comp_benchmarks/benchmarks/pkpd/sim_one_comp_mm_elim_abs.stan 0.59 0.59 1.0 -0.03% slower
stat_comp_benchmarks/benchmarks/low_dim_gauss_mix_collapse/low_dim_gauss_mix_collapse.stan 21.06 21.05 1.0 0.03% faster
stat_comp_benchmarks/benchmarks/eight_schools/eight_schools.stan 0.11 0.11 0.98 -1.6% slower
stat_comp_benchmarks/benchmarks/arK/arK.stan 3.19 3.19 1.0 -0.12% slower
performance.compilation 375.15 371.23 1.01 1.05% faster
Mean result: 0.9943416452299286

Jenkins Console Log
Jenkins Build Stages
Commit hash: 98c64f32efad1c4821e30c9f76801a0d0c89d086

Machine information
Distributor ID:	Ubuntu
Description:	Ubuntu 20.04.3 LTS
Release:	20.04
Codename:	focal

CPU:

Architecture:                            x86_64
CPU op-mode(s):                          32-bit, 64-bit
Byte Order:                              Little Endian
Address sizes:                           43 bits physical, 48 bits virtual
CPU(s):                                  256
On-line CPU(s) list:                     0-255
Thread(s) per core:                      2
Core(s) per socket:                      64
Socket(s):                               2
NUMA node(s):                            2
Vendor ID:                               AuthenticAMD
CPU family:                              23
Model:                                   49
Model name:                              AMD EPYC 7742 64-Core Processor
Stepping:                                0
Frequency boost:                         enabled
CPU MHz:                                 1496.978
CPU max MHz:                             3416.0681
CPU min MHz:                             1500.0000
BogoMIPS:                                4491.85
Virtualization:                          AMD-V
L1d cache:                               4 MiB
L1i cache:                               4 MiB
L2 cache:                                64 MiB
L3 cache:                                512 MiB
NUMA node0 CPU(s):                       0-63,128-191
NUMA node1 CPU(s):                       64-127,192-255
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Old microcode:             Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Mitigation; untrained return thunk; SMT enabled with STIBP protection
Vulnerability Spec rstack overflow:      Mitigation; Safe RET
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; usercopy/swapgs barriers and __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; Retpolines; IBPB conditional; STIBP always-on; RSB filling; PBRSB-eIBRS Not affected; BHI Not affected
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Mitigation; IBPB before exit to userspace
Flags:                                   fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm constant_tsc rep_good nopl xtopology nonstop_tsc cpuid extd_apicid aperfmperf rapl pni pclmulqdq monitor ssse3 fma cx16 sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm cmp_legacy svm extapic cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw ibs skinit wdt tce topoext perfctr_core perfctr_nb bpext perfctr_llc mwaitx cpb cat_l3 cdp_l3 hw_pstate ssbd mba ibrs ibpb stibp vmmcall fsgsbase bmi1 avx2 smep bmi2 cqm rdt_a rdseed adx smap clflushopt clwb sha_ni xsaveopt xsavec xgetbv1 xsaves cqm_llc cqm_occup_llc cqm_mbm_total cqm_mbm_local clzero irperf xsaveerptr rdpru wbnoinvd amd_ppin arat npt lbrv svm_lock nrip_save tsc_scale vmcb_clean flushbyasid decodeassists pausefilter pfthreshold avic v_vmsave_vmload vgif v_spec_ctrl umip rdpid overflow_recov succor smca sev sev_es

G++:

g++ (Ubuntu 9.4.0-1ubuntu1~20.04) 9.4.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

Clang:

clang version 10.0.0-4ubuntu1 
Target: x86_64-pc-linux-gnu
Thread model: posix
InstalledDir: /usr/bin

@andrjohns andrjohns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple suggestions for reducing duplication and squeezing out a bit more performance

Comment on lines +24 to +39
inline double erfcx_cody_tail(double x) {
const double u = 1.0 / (x * x);
double p = 0.0163153871373020978498;
p = 0.305326634961232344035 + u * p;
p = 0.360344899949804439429 + u * p;
p = 0.125781726111229246204 + u * p;
p = 0.0160837851487422766278 + u * p;
p = 0.000658749161529837803157 + u * p;
double q = -1.0;
q = -2.56852019228982242072 + u * q;
q = -1.87295284992346047209 + u * q;
q = -0.527905102951428412248 + u * q;
q = -0.0605183413124413191178 + u * q;
q = -0.00233520497626869185443 + u * q;
return (INV_SQRT_PI + (p / q) * u) / x;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you split the tail correction from the calculation then it can be reused elsewhere (both in this PR and others):

I also find the arrays+looping expression for the coefficients more readable (and compiles to the same code), but that's not a hard requirement!

Suggested change
inline double erfcx_cody_tail(double x) {
const double u = 1.0 / (x * x);
double p = 0.0163153871373020978498;
p = 0.305326634961232344035 + u * p;
p = 0.360344899949804439429 + u * p;
p = 0.125781726111229246204 + u * p;
p = 0.0160837851487422766278 + u * p;
p = 0.000658749161529837803157 + u * p;
double q = -1.0;
q = -2.56852019228982242072 + u * q;
q = -1.87295284992346047209 + u * q;
q = -0.527905102951428412248 + u * q;
q = -0.0605183413124413191178 + u * q;
q = -0.00233520497626869185443 + u * q;
return (INV_SQRT_PI + (p / q) * u) / x;
}
/**
* Correction factor of the Cody (1969) third-interval rational:
* `erfcx(x) = (INV_SQRT_PI + u * correction(u)) / x` with `u = 1 / x^2`.
*
* @tparam T scalar type
* @param u inverse square of the argument, `0 <= u <= 1/16`
* @return `P(u) / Q(u)`
*/
template <typename T>
inline T erfcx_tail_correction(const T& u) {
static constexpr double p[]
= {0.000658749161529837803157, 0.0160837851487422766278,
0.125781726111229246204, 0.360344899949804439429,
0.305326634961232344035, 0.0163153871373020978498};
static constexpr double q[]
= {-0.00233520497626869185443, -0.0605183413124413191178,
-0.527905102951428412248, -1.87295284992346047209,
-2.56852019228982242072, -1.0};
T numerator = p[5];
T denominator = q[5];
for (int i = 4; i >= 0; --i) {
numerator = p[i] + u * numerator;
denominator = q[i] + u * denominator;
}
return numerator / denominator;
}
inline double erfcx_cody_tail(double x) {
const double u = 1.0 / (x * x);
return (INV_SQRT_PI + u * erfcx_tail_correction(u)) / x;
}

q = -0.00233520497626869185443 + u * q;
return (INV_SQRT_PI + (p / q) * u) / x;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the tail correction split out you can create a reusable helper for the derivative:

/**
 * Derivative of `erfcx`, `2 * x * erfcx(x) - 2 / sqrt(pi)`. That difference
 * cancels for large `x`; there the tail rational gives it directly.
 *
 * @tparam T scalar type
 * @param x argument
 * @param value `erfcx(x)`
 * @return derivative of `erfcx` at `x`
 */
template <typename T>
inline T erfcx_derivative(const T& x, const T& value) {
  if (x >= 4.0) {
    const T u = 1.0 / (x * x);
    return 2.0 * u * erfcx_tail_correction(u);
  }
  return 2.0 * x * value - TWO_OVER_SQRT_PI;
}

Comment on lines +87 to +107
inline double erfcx_small(double x) {
double p = 3.05977060678449757e-06;
p = -9.35890030086883823e-06 + x * p;
p = 2.46655529768908249e-05 + x * p;
p = -7.08163358203131886e-05 + x * p;
p = 1.98445679338826757e-04 + x * p;
p = -5.34506929034156810e-04 + x * p;
p = 1.38888415444527033e-03 + x * p;
p = -3.47359067853470795e-03 + x * p;
p = 8.33333374332981443e-03 + x * p;
p = -1.91048337772546720e-02 + x * p;
p = 4.16666666458337179e-02 + x * p;
p = -8.59717459974174147e-02 + x * p;
p = 1.66666666667239644e-01 + x * p;
p = -3.00901111227312890e-01 + x * p;
p = 4.99999999999992839e-01 + x * p;
p = -7.52252778063651983e-01 + x * p;
p = 1.0 + x * p;
p = -1.12837916709551256 + x * p;
return 1.0 + x * p;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By splitting the polynomial into the odd & even powers you can split the dependency chain and enable a bit more vectorisation in the compiler:

Suggested change
inline double erfcx_small(double x) {
double p = 3.05977060678449757e-06;
p = -9.35890030086883823e-06 + x * p;
p = 2.46655529768908249e-05 + x * p;
p = -7.08163358203131886e-05 + x * p;
p = 1.98445679338826757e-04 + x * p;
p = -5.34506929034156810e-04 + x * p;
p = 1.38888415444527033e-03 + x * p;
p = -3.47359067853470795e-03 + x * p;
p = 8.33333374332981443e-03 + x * p;
p = -1.91048337772546720e-02 + x * p;
p = 4.16666666458337179e-02 + x * p;
p = -8.59717459974174147e-02 + x * p;
p = 1.66666666667239644e-01 + x * p;
p = -3.00901111227312890e-01 + x * p;
p = 4.99999999999992839e-01 + x * p;
p = -7.52252778063651983e-01 + x * p;
p = 1.0 + x * p;
p = -1.12837916709551256 + x * p;
return 1.0 + x * p;
}
inline double erfcx_small(double x) {
static constexpr double even[]
= {1.0,
1.0,
4.99999999999992839e-01,
1.66666666667239644e-01,
4.16666666458337179e-02,
8.33333374332981443e-03,
1.38888415444527033e-03,
1.98445679338826757e-04,
2.46655529768908249e-05,
3.05977060678449757e-06};
static constexpr double odd[]
= {-1.12837916709551256, -7.52252778063651983e-01,
-3.00901111227312890e-01, -8.59717459974174147e-02,
-1.91048337772546720e-02, -3.47359067853470795e-03,
-5.34506929034156810e-04, -7.08163358203131886e-05,
-9.35890030086883823e-06};
const double x2 = x * x;
double e = even[9];
double o = odd[8];
for (int i = 8; i >= 1; --i) {
e = even[i] + x2 * e;
o = odd[i - 1] + x2 * o;
}
return even[0] + x2 * e + x * o;
}

Comment thread stan/math/rev/fun/erfcx.hpp Outdated
Comment on lines +44 to +66
inline var erfcx(const var& a) {
double val = erfcx(a.val());
return make_callback_var(val, [a, val](auto& vi) mutable {
a.adj() += vi.adj() * (2.0 * a.val() * val - TWO_OVER_SQRT_PI);
});
}

/**
* The scaled complementary error function for matrix variables.
*
* @tparam T a matrix type
* @param a The variable.
* @return Scaled complementary error function applied elementwise.
*/
template <typename T, require_matrix_t<T>* = nullptr>
inline auto erfcx(const var_value<T>& a) {
auto val = to_arena(erfcx(a.val()));
return make_callback_var(val, [a, val](auto& vi) mutable {
a.adj().array()
+= vi.adj().array()
* (2.0 * a.val().array() * val.array() - TWO_OVER_SQRT_PI);
});
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can combine these and then call the pre-defined helper:

template <
    typename T, require_var_t<T>* = nullptr,
    require_all_not_nonscalar_prim_or_rev_kernel_expression_t<T>* = nullptr>
inline auto erfcx(T&& a) {
  auto val = to_arena(erfcx(a.val()));
  return make_callback_var(val, [a](auto& vi) mutable {
    const auto& deriv = apply_scalar_binary(
      [](double x, double v) { return internal::erfcx_derivative(x, v); },
      a.val(), val);
    as_array_or_scalar(a.adj())
        += as_array_or_scalar(vi.adj()) * as_array_or_scalar(deriv);
  });
}

Comment thread stan/math/fwd/fun/erfcx.hpp Outdated
template <typename T>
inline fvar<T> erfcx(const fvar<T>& x) {
T v = erfcx(x.val_);
return fvar<T>(v, x.d_ * (2.0 * x.val_ * v - TWO_OVER_SQRT_PI));

@andrjohns andrjohns Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After introducing a reusable erfcx_derivative helper:

Suggested change
return fvar<T>(v, x.d_ * (2.0 * x.val_ * v - TWO_OVER_SQRT_PI));
return fvar<T>(v, x.d_ * internal::erfcx_derivative(x.val_, v));

@avehtari

Copy link
Copy Markdown
Member Author

Thanks, @andrjohns, I included these. The second one found a real defect.

The derivative suggestion revealed a defect

2 * x * erfcx(x) - 2 / sqrt(pi) cancels. Both terms approach 2 / sqrt(pi) while the result decays like 1 / (sqrt(pi) * x^2), so the subtraction loses the whole answer in the tail. Against a 50-digit reference:

x 2x*erfcx(x) - 2/sqrt(pi) 2*u*C(u)
4 6.1 ulp 0.1 ulp
10 71.8 1.8
100 6 788 31.1
1 000 177 164 23.7
10 000 1.07e+08 29.1
1e+06 2.55e+11 23.8

The cancellation is exact and removable: 2 * INV_SQRT_PI is 2 / sqrt(pi), so for x >= 4

2 * x * (INV_SQRT_PI + u * C(u)) / x - 2 / sqrt(pi) = 2 * u * C(u)

with no subtraction left. internal::erfcx_derivative does this, and both the forward and the reverse mode now call it.

I missed this because of accidentally not measuring accuracy of d/dx erfcx(x). The existing suites could not catch it either: expect_ad compares against finite differences, whose own error is far larger than 2.55e+11 ulp. I have added test/unit/math/mix/fun/erfcx_derivative_test.cpp with fixed references at x = 4, 6, 10, 100, 1000, 10000, plus two interior points as a control and a continuity check at the branch. Against the previous implementation 2 of its 5 tests fail; against the current one all 5 pass.

Splitting out the tail correction

Adopted, in the array form. I checked it is bit-identical over 2 million points in [4, 50].

Splitting the small-branch polynomial into even and odd powers

Adopted. It is a real speed win and a small accuracy cost, so here are both numbers. Over [-0.46875, 0.46875], 10 000 points scored against mpmath:

form worst ulp latency throughput
one degree-18 Horner chain 1.49 47.4 ns 13.8 ns
even and odd split 2.21 26.4 ns 8.3 ns

1.80 times faster by latency, 1.67 by throughput, for 0.7 ulp. The whole function is 7.0 ulp, set by the middle Cody interval, so the small branch was never the limit and the trade is clearly worth taking.

Effect on the whole function. CPU, Xeon E5-2680 v3, one dedicated core, throughput, ns per call:

range before after ratio
[-0.46875, 0.46875] 14.924 9.515 1.57
[0, 4] 8.899 7.962 1.12
[-4, 4] 14.183 13.528 1.05
[0.125, 12] 11.673 11.552 1.01
[-20, 0] 13.976 13.803 1.01
[5, 20] 13.321 13.311 1.00

The same derivative defect was in the OpenCL path, and is now fixed

Your suggestions are all CPU-side, but the second one applies to OpenCL as well. opencl/rev/erfcx.hpp computed the same cancelling expression:

A.adj() += elt_multiply(res.adj(),
    elt_multiply(2.0, elt_multiply(A.val(), res.val())) - TWO_OVER_SQRT_PI);

so the OpenCL reverse mode had the same 2.55e+11 ulp error. The OpenCL device function now has erfcx_tail_correction and erfcx_derivative, mirroring the CPU split, and opencl/rev calls the latter.

The existing OpenCL suite never reached the tail at all. Its inputs were -2.6 ... 2.6 and MatrixXd::Random, which is uniform on [-1, 1], so x >= 4 never reached the OpenCL kernels, for the value or the derivative.

Speed on the GPU

The three CPU-side suggestions do not change the OpenCL device function, so the GPU timings are the same before and after. Tesla V100-SXM2-32GB, ns per call:

range before after
[0, 4] 0.0124 0.0124
[5, 20] 0.0100 0.0100
[-20, 0] 0.0265 0.0265
[-4, 4] 0.0254 0.0254
[-0.46875, 0.46875] 0.0059 0.0059

I then measured whether the even and odd split is worth porting to the OpenCL device function as well. It is not:

range flat even/odd ratio
small [-0.46875, 0.46875] 0.0055 0.0053 1.04
small [0, 0.46875] 0.0050 0.0053 0.95

Nothing outside noise, against 1.67 to 1.80 on the CPU. The split shortens the dependency chain but does not reduce the instruction count; it raises it slightly, to two chains of 9 plus a combine. A GPU hides chain latency across warps and is limited by the instruction count, so it gains nothing.

The OpenCL device function therefore keeps the single chain and stays at 1.5 ulp on that branch while the CPU is at 2.2 ulp. That 0.7 ulp difference is far inside the EXPECT_NEAR_REL tolerance of 1e-8 that the CPU-versus-OpenCL tests use, and inside the 2.0 to 2.6 ulp agreement already measured between the two paths in #3398. Porting the split would cost the OpenCL path 0.7 ulp and buy nothing.

Merging the reverse-mode overloads

Adopted. Two notes on the snippet.

The lambda captures [a] but uses val in its body, so it needs [a, val].

Test status

Both configurations pass.

Plain CPU, no STAN_OPENCL, on a Xeon E5-2680 v4: prim 8, rev 5, fwd 3, mix 2, and the new derivative suite 5.

With STAN_OPENCL=true on a Tesla V100-SXM2-32GB: those same suites, plus opencl/rev 5 and elt_function_cl_test 62.

Question

To call the derivative from the kernel generator I registered erfcx_derivative with ADD_BINARY_FUNCTION_WITH_INCLUDES. That creates stan::math::erfcx_derivative for kernel expressions with no CPU counterpart, since the CPU one is internal::. I therefore did not add a line to elt_function_cl_test.cpp, because TEST_BINARY_FUNCTION calls the CPU function of the same name. Would you prefer a public CPU erfcx_derivative, or a different way to reach the OpenCL device function?

@spinkney

Copy link
Copy Markdown
Member

Thank you @avehtari for putting this together!

@SteveBronder SteveBronder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One note, generally anywhere we have static constexpr double {NAME}[] I would prefer we use an std::array instead of double {NAME}[] for a raw array

Overall looks good though!

Comment thread stan/math/prim/fun/erfcx.hpp Outdated
Comment thread stan/math/prim/fun/erfcx.hpp Outdated
Comment thread stan/math/prim/fun/erfcx.hpp Outdated
Comment thread stan/math/opencl/kernels/device_functions/erfcx.hpp
Comment thread stan/math/opencl/kernels/device_functions/erfcx.hpp
Comment thread stan/math/opencl/kernels/device_functions/erfcx.hpp
@avehtari

Copy link
Copy Markdown
Member Author

@SteveBronder I committed your suggestions, but they are not actually valid. Fixing...

@avehtari

Copy link
Copy Markdown
Member Author

@SteveBronder

Two bugs in the applied batch

erfcx_small read past the end of both arrays. AddressSanitizer reports a global-buffer-overflow, and the values were wrong by up to 16 percent. I fixed it by deriving the bound from the container.

static_assert(even.size() == odd.size(), "the two chains are stepped by one loop");
for (int i = static_cast<int>(even.size()) - 1; i >= 0; --i) {

That is bit-identical to the previous version at all 200 001 points in the branch interval, and clean under ASan.

The OpenCL device function had C++ in it. erfcx_small and erfcx_cody_middle sit inside the STRINGIFY(...) block, whose contents become the OpenCL C kernel source. After the batch that source contained 33 occurrences of std::fma and 3 of constexpr std::array. OpenCL C has no namespaces and no std::array, so the kernel could not build.

erfcx_tail_correction was correct as suggested, and I kept it.

Speed, measured

Xeon E5-2680 v3, one dedicated core, minimum of three repetitions. Tesla V100-SXM2-32GB, 1048576 work items, best of five, baseline kernel subtracted.

The CPU changes are neutral. Every range comes out at 1.00, so I kept them: std::array with the bound derived from size() is harder to get wrong than a raw array with a written-out bound, which is what the first bug was.

The OpenCL rewrite costs time, so I reverted that file:

range before after ratio
[-0.46875, 0.46875] 0.0058 0.0067 0.87
[0, 4] 0.0123 0.0137 0.90
[-4, 4] 0.0250 0.0273 0.92
[-20, 0] 0.0256 0.0269 0.95
[5, 20] 0.0100 0.0099 1.01

ns per call. Rewriting it with the coefficients written directly into the fma calls gave 0.0137, 0.0099, 0.0269, 0.0273 and 0.0067, identical to four digits. So the cost is the Estrin-style pairing itself and not the array.

My guess is that the pairing trades a dependency chain for extra live values, which helps a single scalar CPU stream but not a GPU that already hides chain latency across warps. The same thing happened with the even and odd split on the CPU side: 1.67 to 1.80 times faster on the Xeon, and nothing on the V100.

Comment thread stan/math/rev/fun/erfcx.hpp Outdated
Comment on lines +62 to +63
const auto& deriv = apply_scalar_binary(
[](double x, double v) { return internal::erfcx_derivative(x, v); },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const auto& deriv = apply_scalar_binary(
[](double x, double v) { return internal::erfcx_derivative(x, v); },
auto deriv = apply_scalar_binary(
[](double x, double v) { return internal::erfcx_derivative(x, v); },

Comment thread test/unit/math/rev/fun/erfcx_test.cpp Outdated
Comment on lines +28 to +33
struct {
double x;
double d;
} cases[] = {{-2.0, -436.89199672700738}, {-1.0, -11.14633932862008},
{0.0, -1.1283791670955126}, {1.0, -0.27321201478389856},
{5.0, -0.021332789764826311}, {10.0, -0.0055593122190608565}};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace this with an std::pair

Comment on lines +99 to +117
inline double erfcx_cody_middle(double y) {
double p = 2.15311535474403846e-8 * y;
p = (p + 5.64188496988670089e-1) * y;
p = (p + 8.88314979438837594) * y;
p = (p + 66.1191906371416295) * y;
p = (p + 298.635138197400131) * y;
p = (p + 881.952221241769090) * y;
p = (p + 1712.04761263407058) * y;
p = (p + 2051.07837782607147) * y;
double q = y;
q = (q + 15.7449261107098347) * y;
q = (q + 117.693950891312499) * y;
q = (q + 537.181101862009858) * y;
q = (q + 1621.38957456669019) * y;
q = (q + 3290.79923573345963) * y;
q = (q + 4362.61909014324716) * y;
q = (q + 3439.36767414372164) * y;
return (p + 1230.33935479799725) / (q + 1230.33935480374942);
}

@SteveBronder SteveBronder Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I didn't see in my last review comment I was in the opencl and those were meant for the cpu verions

Suggested change
inline double erfcx_cody_middle(double y) {
double p = 2.15311535474403846e-8 * y;
p = (p + 5.64188496988670089e-1) * y;
p = (p + 8.88314979438837594) * y;
p = (p + 66.1191906371416295) * y;
p = (p + 298.635138197400131) * y;
p = (p + 881.952221241769090) * y;
p = (p + 1712.04761263407058) * y;
p = (p + 2051.07837782607147) * y;
double q = y;
q = (q + 15.7449261107098347) * y;
q = (q + 117.693950891312499) * y;
q = (q + 537.181101862009858) * y;
q = (q + 1621.38957456669019) * y;
q = (q + 3290.79923573345963) * y;
q = (q + 4362.61909014324716) * y;
q = (q + 3439.36767414372164) * y;
return (p + 1230.33935479799725) / (q + 1230.33935480374942);
}
inline double erfcx_cody_middle(double y) {
constexpr std::array p{
1230.33935479799725,
2051.07837782607147,
1712.04761263407058,
881.952221241769090,
298.635138197400131,
66.1191906371416295,
8.88314979438837594,
5.64188496988670089e-1
};
constexpr std::array q{
1230.33935480374942,
3439.36767414372164,
4362.61909014324716,
3290.79923573345963,
1621.38957456669019,
537.181101862009858,
117.693950891312499,
15.7449261107098347,
};
const double y2 = y * y;
std::array<double, 4> p_vals;
std::array<double, 4> q_vals;
for (int i = 0, j = 0; i < 4; i++, j+=2) {
p_vals[i] = std::fma(p[j + 1], y, p[j]);
q_vals[i] = std::fma(q[j + 1], y, q[j]);
}
double num = std::fma(2.15311535474403846e-8, y2, p[3]);
double den = y2 + q[3];
for (int i = 2; i >= 0; i--) {
num = std::fma(num, y2, p[i]);
den = std::fma(den, y2, q[i]);
}
return num / den;
}

@andrjohns andrjohns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Aki! A few more speed-optimisations and accuracy improvements and then I think this is just about ready

Comment on lines +13 to +57

/**
* Correction factor of the Cody (1969) third-interval rational:
* `erfcx(x) = (INV_SQRT_PI + u * correction(u)) / x` with `u = 1 / x^2`.
*
* Split out from the value so that the derivative can reuse it. See
* `erfcx_derivative`.
*
* @tparam T scalar type
* @param u inverse square of the argument, `0 <= u <= 1/16`
* @return `P(u) / Q(u)`
*/
template <typename T>
inline T erfcx_tail_correction(const T& u) {
static constexpr std::array p{
0.000658749161529837803157, 0.0160837851487422766278,
0.125781726111229246204, 0.360344899949804439429,
0.305326634961232344035};
static constexpr std::array q{
-0.00233520497626869185443, -0.0605183413124413191178,
-0.527905102951428412248, -1.87295284992346047209,
-2.56852019228982242072};
T numerator = 0.0163153871373020978498;
T denominator = -1.0;
for (int i = 4; i >= 0; --i) {
numerator = numerator * u + p[i];
denominator = denominator * u + q[i];
}
return numerator / denominator;
}

/**
* Cody (1969) third-interval rational, valid for `x >= 4`.
*
* Gives `erfcx` directly. `x * x` is infinite for `x` large enough, which
* correctly collapses this to the leading term `INV_SQRT_PI / x` and, at
* infinity, to zero.
*
* @param x argument, `x >= 4`
* @return scaled complementary error function
*/
inline double erfcx_cody_tail(double x) {
const double u = 1.0 / (x * x);
return (INV_SQRT_PI + u * erfcx_tail_correction(u)) / x;
}

@andrjohns andrjohns Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you declare the coefficients separately in the internal namespace then they can be reused for multiple corrections below.

Also, by parameterising the tail polynomial calculation to work on iterators (from the coefficient arrays) then you can enable another speedup by walking the coefficients in reverse, allowing one division to be dropped:

Suggested change
/**
* Correction factor of the Cody (1969) third-interval rational:
* `erfcx(x) = (INV_SQRT_PI + u * correction(u)) / x` with `u = 1 / x^2`.
*
* Split out from the value so that the derivative can reuse it. See
* `erfcx_derivative`.
*
* @tparam T scalar type
* @param u inverse square of the argument, `0 <= u <= 1/16`
* @return `P(u) / Q(u)`
*/
template <typename T>
inline T erfcx_tail_correction(const T& u) {
static constexpr std::array p{
0.000658749161529837803157, 0.0160837851487422766278,
0.125781726111229246204, 0.360344899949804439429,
0.305326634961232344035};
static constexpr std::array q{
-0.00233520497626869185443, -0.0605183413124413191178,
-0.527905102951428412248, -1.87295284992346047209,
-2.56852019228982242072};
T numerator = 0.0163153871373020978498;
T denominator = -1.0;
for (int i = 4; i >= 0; --i) {
numerator = numerator * u + p[i];
denominator = denominator * u + q[i];
}
return numerator / denominator;
}
/**
* Cody (1969) third-interval rational, valid for `x >= 4`.
*
* Gives `erfcx` directly. `x * x` is infinite for `x` large enough, which
* correctly collapses this to the leading term `INV_SQRT_PI / x` and, at
* infinity, to zero.
*
* @param x argument, `x >= 4`
* @return scaled complementary error function
*/
inline double erfcx_cody_tail(double x) {
const double u = 1.0 / (x * x);
return (INV_SQRT_PI + u * erfcx_tail_correction(u)) / x;
}
// Cody (1969) third interval, ascending powers of `u = 1 / x^2`
inline constexpr std::array<double, 6> erfcx_tail_p{
0.000658749161529837803157, 0.0160837851487422766278,
0.125781726111229246204, 0.360344899949804439429,
0.305326634961232344035, 0.0163153871373020978498};
inline constexpr std::array<double, 6> erfcx_tail_q{
-0.00233520497626869185443, -0.0605183413124413191178,
-0.527905102951428412248, -1.87295284992346047209,
-2.56852019228982242072, -1.0};
// Cody (1969) second interval, ascending powers of `y`
inline constexpr std::array<double, 9> erfcx_middle_p{
1230.33935479799725, 2051.07837782607147, 1712.04761263407058,
881.952221241769090, 298.635138197400131, 66.1191906371416295,
8.88314979438837594, 5.64188496988670089e-1, 2.15311535474403846e-8};
inline constexpr std::array<double, 9> erfcx_middle_q{
1230.33935480374942, 3439.36767414372164, 4362.61909014324716,
3290.79923573345963, 1621.38957456669019, 537.181101862009858,
117.693950891312499, 15.7449261107098347, 1.0};
// `2 * y * P(y) - 2 / sqrt(pi) * Q(y)`, formed at 512 bits
inline constexpr std::array<double, 10> erfcx_middle_dp{
-1.38828929641828509756e+03, -1.42023212188952842156e+03,
-8.20531739638677179304e+02, -2.89174075427329356508e+02,
-6.56377752033711740945e+01, -8.87368790370412020304e+00,
-5.65021004636098339946e-01, 5.29779936004038675702e-05,
-2.17311817239589615890e-06, 4.30623070948807692000e-08};
/**
* The two tail polynomials, taking the coefficients in the order the
* iterators give them as descending powers of `t`.
*
* @tparam T scalar type
* @tparam It coefficient iterator
* @param p first numerator coefficient
* @param q first denominator coefficient
* @param t argument
* @return numerator and denominator
*/
template <typename T, typename It>
inline std::pair<T, T> erfcx_tail_polynomials(It p, It q, const T& t) {
T numerator = *p;
T denominator = *q;
for (std::size_t i = 1; i < erfcx_tail_p.size(); ++i) {
numerator = numerator * t + *++p;
denominator = denominator * t + *++q;
}
return {numerator, denominator};
}
/**
* Correction factor of the Cody (1969) third-interval rational:
* `erfcx(x) = (INV_SQRT_PI + u * correction(u)) / x` with `u = 1 / x^2`.
*
* @tparam T scalar type
* @param u inverse square of the argument, `0 <= u <= 1/16`
* @return `P(u) / Q(u)`
*/
template <typename T>
inline T erfcx_tail_correction(const T& u) {
const auto pq = erfcx_tail_polynomials(erfcx_tail_p.crbegin(),
erfcx_tail_q.crbegin(), u);
return pq.first / pq.second;
}
/**
* Cody (1969) third-interval rational, valid for `x >= 4`.
*
* `u * correction(u)` is formed from the reversed coefficients in `x^2`, so
* its division does not wait on `1 / x^2`.
*
* @param x argument, `x >= 4`
* @return scaled complementary error function
*/
inline double erfcx_cody_tail(double x) {
// correction is below eps / 8, and x^12 would overflow further out
constexpr double leading_term_only = 0x1p27;
if (x > leading_term_only) {
return INV_SQRT_PI / x;
}
const double s = x * x;
const auto pq
= erfcx_tail_polynomials(erfcx_tail_p.cbegin(), erfcx_tail_q.cbegin(), s);
return (INV_SQRT_PI + pq.first / (s * pq.second)) / x;
}

Comment on lines +59 to +87
/**
* Derivative of `erfcx`, `2 * x * erfcx(x) - 2 / sqrt(pi)`.
*
* That difference cancels for large `x`: both terms approach
* `2 / sqrt(pi)` while the result decays like `1 / (sqrt(pi) * x^2)`.
* Measured against a 50-digit reference, the difference form gives 6.1 ulp
* at `x = 4` and 2.55e+11 ulp at `x = 1e6`.
*
* For `x >= 4` the tail rational gives the derivative with no subtraction,
* because the constant cancels analytically:
*
* `2 * x * (INV_SQRT_PI + u * C(u)) / x - 2 / sqrt(pi) = 2 * u * C(u)`
*
* since `2 * INV_SQRT_PI` is `2 / sqrt(pi)`. That form measures 0.1 to
* 31 ulp over the same range.
*
* @tparam T scalar type
* @param x argument
* @param value `erfcx(x)`
* @return derivative of `erfcx` at `x`
*/
template <typename T>
inline T erfcx_derivative(const T& x, const T& value) {
if (x >= 4.0) {
const T u = 1.0 / (x * x);
return 2.0 * u * erfcx_tail_correction(u);
}
return 2.0 * x * value - TWO_OVER_SQRT_PI;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gradient calculations are still losing precision in the [0.46875, 4) and >=30 ranges, and can be improved with additional series (at a slight performance cost).

For these series (and the middle-tail calcs) can speed up the evaluation by running over y^2 and evaluating the polynomials in pairs:

y2 = y*y
p = c8
p = p*y2 + (c6 + c7*y)
p = p*y2 + (c4 + c5*y)
p = p*y2 + (c2 + c3*y)
p = p*y2 + (c0 + c1*y)
Suggested change
/**
* Derivative of `erfcx`, `2 * x * erfcx(x) - 2 / sqrt(pi)`.
*
* That difference cancels for large `x`: both terms approach
* `2 / sqrt(pi)` while the result decays like `1 / (sqrt(pi) * x^2)`.
* Measured against a 50-digit reference, the difference form gives 6.1 ulp
* at `x = 4` and 2.55e+11 ulp at `x = 1e6`.
*
* For `x >= 4` the tail rational gives the derivative with no subtraction,
* because the constant cancels analytically:
*
* `2 * x * (INV_SQRT_PI + u * C(u)) / x - 2 / sqrt(pi) = 2 * u * C(u)`
*
* since `2 * INV_SQRT_PI` is `2 / sqrt(pi)`. That form measures 0.1 to
* 31 ulp over the same range.
*
* @tparam T scalar type
* @param x argument
* @param value `erfcx(x)`
* @return derivative of `erfcx` at `x`
*/
template <typename T>
inline T erfcx_derivative(const T& x, const T& value) {
if (x >= 4.0) {
const T u = 1.0 / (x * x);
return 2.0 * u * erfcx_tail_correction(u);
}
return 2.0 * x * value - TWO_OVER_SQRT_PI;
}
/**
* Horner in `y^2` over adjacent coefficient pairs.
*
* @tparam T scalar type
* @param c coefficients, ascending
* @param y argument
* @param y2 `y * y`
* @return polynomial value
*/
template <typename T, std::size_t N>
inline T erfcx_paired_horner(const std::array<double, N>& c, const T& y,
const T& y2) {
int j = static_cast<int>(N) - (N % 2 ? 3 : 4);
T r = N % 2 ? T(c[N - 1]) : T(c[N - 2] + c[N - 1] * y);
for (; j >= 0; j -= 2) {
r = r * y2 + (c[j] + c[j + 1] * y);
}
return r;
}
/**
* Derivative of `erfcx`, `2 * x * erfcx(x) - 2 / sqrt(pi)`.
*
* That difference cancels for `x > 1`, so there the subtraction is done
* analytically instead: in the coefficients on the middle interval, against
* the leading term of the tail rational above 4, and by the asymptotic series
* above 30, where the tail rational's own fit error dominates.
*
* @tparam T scalar type
* @param x argument
* @param value `erfcx(x)`
* @return derivative of `erfcx` at `x`
*/
template <typename T>
inline T erfcx_derivative(const T& x, const T& value) {
if (x < 0.46875) {
return 2.0 * x * value - TWO_OVER_SQRT_PI;
}
const T x2 = x * x;
if (x < 4.0) {
return erfcx_paired_horner(erfcx_middle_dp, x, x2)
/ erfcx_paired_horner(erfcx_middle_q, x, x2);
}
const T u = 1.0 / x2;
if (x < 30.0) {
return 2.0 * u * erfcx_tail_correction(u);
}
// `-sqrt(pi) * x^2 * erfcx'(x)`, asymptotic, ascending powers of `u`
static constexpr std::array<double, 8> series_coefficients{
1.0, -1.5, 3.75, -13.125,
59.0625, -324.84375, 2111.484375, -15836.1328125};
const T series = erfcx_paired_horner(series_coefficients, u, u * u);
return -INV_SQRT_PI * u * series;
}

Comment on lines +99 to +117
inline double erfcx_cody_middle(double y) {
double p = 2.15311535474403846e-8 * y;
p = (p + 5.64188496988670089e-1) * y;
p = (p + 8.88314979438837594) * y;
p = (p + 66.1191906371416295) * y;
p = (p + 298.635138197400131) * y;
p = (p + 881.952221241769090) * y;
p = (p + 1712.04761263407058) * y;
p = (p + 2051.07837782607147) * y;
double q = y;
q = (q + 15.7449261107098347) * y;
q = (q + 117.693950891312499) * y;
q = (q + 537.181101862009858) * y;
q = (q + 1621.38957456669019) * y;
q = (q + 3290.79923573345963) * y;
q = (q + 4362.61909014324716) * y;
q = (q + 3439.36767414372164) * y;
return (p + 1230.33935479799725) / (q + 1230.33935480374942);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After declaring the coefficients separately and specifying the paired-horner implementation function, this can simplify down to:

Suggested change
inline double erfcx_cody_middle(double y) {
double p = 2.15311535474403846e-8 * y;
p = (p + 5.64188496988670089e-1) * y;
p = (p + 8.88314979438837594) * y;
p = (p + 66.1191906371416295) * y;
p = (p + 298.635138197400131) * y;
p = (p + 881.952221241769090) * y;
p = (p + 1712.04761263407058) * y;
p = (p + 2051.07837782607147) * y;
double q = y;
q = (q + 15.7449261107098347) * y;
q = (q + 117.693950891312499) * y;
q = (q + 537.181101862009858) * y;
q = (q + 1621.38957456669019) * y;
q = (q + 3290.79923573345963) * y;
q = (q + 4362.61909014324716) * y;
q = (q + 3439.36767414372164) * y;
return (p + 1230.33935479799725) / (q + 1230.33935480374942);
}
inline double erfcx_cody_middle(double y) {
const double y2 = y * y;
return erfcx_paired_horner(erfcx_middle_p, y, y2)
/ erfcx_paired_horner(erfcx_middle_q, y, y2);
}

Comment on lines +140 to +171
inline double erfcx_small(double x) {
// Split into the even and odd powers of x, so the two Horner chains run
// independently. A single degree-18 chain is 18 dependent operations; two
// chains of 9 halve that latency.
static constexpr std::array even = {1.0,
4.99999999999992839e-01,
1.66666666667239644e-01,
4.16666666458337179e-02,
8.33333374332981443e-03,
1.38888415444527033e-03,
1.98445679338826757e-04,
2.46655529768908249e-05};
static constexpr std::array odd
= {-1.12837916709551256, -7.52252778063651983e-01,
-3.00901111227312890e-01, -8.59717459974174147e-02,
-1.91048337772546720e-02, -3.47359067853470795e-03,
-5.34506929034156810e-04, -7.08163358203131886e-05};
static_assert(even.size() == odd.size(),
"the two chains are stepped by one loop");
// The leading coefficient of each chain seeds the accumulator, and the
// trailing 1.0 is added at the end, so both arrays hold the interior
// coefficients only. Derive the bound from the array rather than writing
// it out, so shortening an array cannot leave the loop reading past it.
const double x2 = x * x;
double e = 3.05977060678449757e-06;
double o = -9.35890030086883823e-06;
for (int i = static_cast<int>(even.size()) - 1; i >= 0; --i) {
e = even[i] + x2 * e;
o = odd[i] + x2 * o;
}
return 1.0 + x2 * e + x * o;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can parallelise/split the evaluation even more by using Estrin's method instead of Horner's:

Suggested change
inline double erfcx_small(double x) {
// Split into the even and odd powers of x, so the two Horner chains run
// independently. A single degree-18 chain is 18 dependent operations; two
// chains of 9 halve that latency.
static constexpr std::array even = {1.0,
4.99999999999992839e-01,
1.66666666667239644e-01,
4.16666666458337179e-02,
8.33333374332981443e-03,
1.38888415444527033e-03,
1.98445679338826757e-04,
2.46655529768908249e-05};
static constexpr std::array odd
= {-1.12837916709551256, -7.52252778063651983e-01,
-3.00901111227312890e-01, -8.59717459974174147e-02,
-1.91048337772546720e-02, -3.47359067853470795e-03,
-5.34506929034156810e-04, -7.08163358203131886e-05};
static_assert(even.size() == odd.size(),
"the two chains are stepped by one loop");
// The leading coefficient of each chain seeds the accumulator, and the
// trailing 1.0 is added at the end, so both arrays hold the interior
// coefficients only. Derive the bound from the array rather than writing
// it out, so shortening an array cannot leave the loop reading past it.
const double x2 = x * x;
double e = 3.05977060678449757e-06;
double o = -9.35890030086883823e-06;
for (int i = static_cast<int>(even.size()) - 1; i >= 0; --i) {
e = even[i] + x2 * e;
o = odd[i] + x2 * o;
}
return 1.0 + x2 * e + x * o;
}
inline double erfcx_small(double x) {
static constexpr std::array<double, 19> c{1.0,
-1.12837916709551256,
1.0,
-7.52252778063651983e-01,
4.99999999999992839e-01,
-3.00901111227312890e-01,
1.66666666667239644e-01,
-8.59717459974174147e-02,
4.16666666458337179e-02,
-1.91048337772546720e-02,
8.33333374332981443e-03,
-3.47359067853470795e-03,
1.38888415444527033e-03,
-5.34506929034156810e-04,
1.98445679338826757e-04,
-7.08163358203131886e-05,
2.46655529768908249e-05,
-9.35890030086883823e-06,
3.05977060678449757e-06};
const double x2 = x * x;
const double x4 = x2 * x2;
const double x8 = x4 * x4;
std::array<double, 4> quad;
for (int i = 0; i < 4; ++i) {
const int j = 4 * i + 2;
quad[i] = (c[j] + c[j + 1] * x) + (c[j + 2] + c[j + 3] * x) * x2;
}
const double rest
= (quad[0] + quad[1] * x4) + (quad[2] + quad[3] * x4 + c[18] * x8) * x8;
return (c[0] + c[1] * x) + x2 * rest;
}

@andrjohns

Copy link
Copy Markdown
Collaborator

@SteveBronder - using std::fma raised a tricky performance q. Without the -mfma compiler flag (or -march), the compiler doesn't use the fused instruction and instead just calls out to libm (claude measured as ~8x slower than just using * and +) and we end up paying a performance hit for the correct rounding when it's not always required.

CPUs without fma instructions would be pretty old (~2010), how risky do you think it would be to enable by default (adding a STAN_NO_FMA flag to allow disabling)?

@avehtari

Copy link
Copy Markdown
Member Author

The time zone difference causes some asynchronity issues in this PR discussion. I had gone through @SteveBronder's latest suggestions, but had not yet pushed new commits as it was too late yesterday. I have solution for fma issue. I usually would use -march, but one of the clusters I'm using has heteregenous nodes and -march causes problems.

@SteveBronder

Copy link
Copy Markdown
Collaborator

CPUs without fma instructions would be pretty old (~2010), how risky do you think it would be to enable by default (adding a STAN_NO_FMA flag to allow disabling)?

I think everything post haswell is going to have an fma instruction. But making sure users have the flag set is very real. We can just use a * b + c since that should still give the fma instruction when march is used

https://godbolt.org/z/8sa43jafP

@avehtari

avehtari commented Sep 19, 2026

Copy link
Copy Markdown
Member Author

@andrjohns with your suggestions the speed drops at least on CPU

Value and derivative together, which is what the reverse pass costs

range before after ratio
small [-0.4, 0.4] 13.566 9.680 1.40
middle [0.46875, 4) 9.942 13.195 0.75
tail [4, 30) 15.233 20.265 0.75
series [30, 1000] 15.253 16.546 0.92
mixed [-4, 40] 15.362 18.825 0.82

So the speed does drop, by 18–33 % on the AD path, in exchange for the accuracy:

before after
derivative, [0.46875, 4) 32.3 ulp 4.2 ulp
derivative, x ≥ 30 3.1–31.1 ulp 0.1–1.1 ulp

What is your preference?

@andrjohns

Copy link
Copy Markdown
Collaborator

@andrjohns with your suggestions the speed drops at least on CPU

Value and derivative together, which is what the reverse pass costs
range before after ratio
small [-0.4, 0.4] 13.566 9.680 1.40
middle [0.46875, 4) 9.942 13.195 0.75
tail [4, 30) 15.233 20.265 0.75
series [30, 1000] 15.253 16.546 0.92
mixed [-4, 40] 15.362 18.825 0.82

So the speed does drop, by 18–33 % on the AD path, in exchange for the accuracy:
before after
derivative, [0.46875, 4) 32.3 ulp 4.2 ulp
derivative, x ≥ 30 3.1–31.1 ulp 0.1–1.1 ulp

What is your preference?

Ahh yeah that's really not worth it, safe to ignore me

@avehtari

Copy link
Copy Markdown
Member Author

All right, took some time to test the effect of each suggestion on speed and accuracy. The decision was to prioritize speed as long as the accuracy is still good. Even 100 ulp is much better than what we had (10^9 to 10^11 ulp).

Speed is the ratio against the code before your suggestions and the version with changes listed below, value and derivative together. A ratio above 1 is faster. Xeon E5-2680 v3 and Tesla V100-SXM2, three repetitions, ns per call.

CPU.

range before after ratio
small [-0.4, 0.4] 13.43 9.64 1.39
middle [0.46875, 4) 11.70 9.95 1.18
tail [4, 30) 15.08 15.12 1.00
series [30, 1000] 15.24 15.39 0.99
mixed [-4, 40] 15.69 15.35 1.02

Value alone: 1.56 on [5, 20], 1.41 on [0.125, 12], 1.38 on [-0.46875, 0.46875], 1.13 on [-4, 4], 1.05 on [-20, 0].

GPU. The device function keeps its own evaluation order, because every candidate was measured on a V100 first and only one of them won there:

change, measured on the device GPU ratio taken
paired Cody middle 0.87–0.99 no
reversed tail walk, Estrin 0.87–0.92 no
derivative, middle dp rational 0.56 no
derivative, series above 30 1.17 yes

So the mathematics is the same on both targets and the evaluation order is not: pairing and Estrin are 1.4–1.8× on the Xeon and 8–13 % slower on the V100.

Accuracy. Worst case per range over 4001 points, against mpmath at 40 digits. Here 1 ulp means a relative error of 2^-53, which is 1.11e-16, the spacing of binary64 in [1, 2). The value and the derivative are scored on the same grid and in the same unit.

range value before value after deriv before deriv after
[-26, -6] 2.4 2.4 3.0 3.0
[-6, -0.46875] 2.8 2.9 3.6 3.6
[-0.46875, 0.46875] 3.4 2.4 3.9 2.9
[0.46875, 4) 6.1 4.3 200 119
[4, 30) 1.9 1.9 8.1 10.4
[30, 1e3] 1.9 1.9 41.9 3.2
[1e3, 1e8] 1.9 1.9 42.4 3.3
[1e8, 1e12] 1.1 1.1 41.8 2.8

The value stays within 4.3 ulp, or 4.8e-16 relative, everywhere. The derivative is worse than the value in two places, for two separate reasons.

On [0.46875, 4) it is formed as 2 * x * erfcx(x) - 2 / sqrt(pi), and that difference cancels: at x = 3.94 the two terms agree to about 1 part in 30, so the value's 4.3 ulp comes out as 119 ulp. In absolute terms 119 ulp is 1.3e-14 relative and 5.2e-16 absolute, and the largest absolute error anywhere above x = -0.47 is 5.5e-16, at x = -0.45. @andrjohns's suggestion improved accuracy but cost in speed (there might be something between).

On [4, 30) there is no subtraction, and what is left is the fit error of the Cody rational itself, 10.4 ulp. It does not show in the value, because there u * C(u) is a small correction added to INV_SQRT_PI: at x = 30 it carries a weight of 5.6e-4, so the same fit error arrives diluted. The derivative is 2 * u * C(u) alone, so it carries the fit error at full weight. Above 30 @andrjohns's asymptotic series replaces the rational and that error goes away: 42 ulp to 3.2 ulp, for 1 % on that range on the Xeon and 1.17 on the device.

Absolute error is not a useful measure on the negative axis: erfcx(-26) is about 1.6e+293, so an error of 2.4 ulp there is 9.1e+277 in absolute terms.

What went in. From @SteveBronder: the paired Cody middle coefficients on CPU, auto deriv, the std::pair test and the else if dispatch. From @andrjohns: the erfcx_paired_horner helper, Estrin's method in erfcx_small, which is where the 1.39 speed increase comes from, and the asymptotic series above 30 on both targets.

What did not. The middle dp rational. On this grid it is 119 → 4.7 ulp, but speed drops to 0.67 on [0.46875, 4), and 0.56 on the device. @andrjohns said it's not worth it.

One change is from neither of you. With the reversed walk the value evaluates the tail rational in x^2 while the derivative still took it in 1/x^2, so they stopped sharing and the reverse pass did the work twice. Writing the derivative's [4, 30) branch in the same form restores the sharing, and is why tail is 1.00 rather than a regression.

Three changes from the suggestions as written. In the 20:03 erfcx_cody_middle, p_vals and q_vals are filled but the Horner loop reads p[3] and p[i], so the paired values are unused. erfcx_tail_polynomials took its loop bound from erfcx_tail_p.size(), correct only for the two 6-element arrays, so the bound is now a template parameter. Every std::fma is written as arithmetic.

All suites pass on a V100: prim 8, rev 5, fwd 3, mix 2, derivative 5, opencl/rev 5, elt_function_cl_test 62. clang-format 10 clean.

@andrjohns andrjohns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@avehtari

Copy link
Copy Markdown
Member Author

I'm investigating one additonal simple idea to reduce the worst ulp (even it is already good)

@avehtari

Copy link
Copy Markdown
Member Author

I was not able to reduce the worst ulp 192 of ercfx derivative (in a narrow range) without drop in speed in some interval. But then I realized to check what does the worst 192 ulp error in ercfx derivative then mean for std_normal_lcdf and other functions using std_normal_lcdf.

std_normal_lcdf gradient, worst ulp over y ∈ [−283, 0):

gradient via AD through erfcx gradient via analytic Mills ratio
5.89 at y = −5.643 6.51 at y = −3.947

The Mills-ratio route never touches erfcx'. d/dy log Φ(y) = SQRT_TWO_OVER_SQRT_PI / erfcx(v) uses value only. Its 6.51 ulp sits at y = −3.95, i.e. v = 2.79, which is exactly where the erfcx value is worst at 6.47 ulp.

The AD route damps erfcx' by (√π/2)·|erfcx'(v)|. That factor is at most 1, and at v ≈ 3.99, where the 192 ulp lives, erfcx' is only −3.25e−02, so the factor is 0.029: 192 × 0.029 ≈ 5.6 ulp. The measurement puts the worst at 5.89 ulp at exactly y = −5.6434, v = 3.9905. Reducing worst ulp of ercfx derivative doesn't provide any benefit.

The error is large in relative terms precisely where the derivative is small, so it never reaches the answer.

For other distributions calling std_normal_lcdf: they chain-rule through it. normal_lcdf(y | mu, sigma) scales the gradient by 1/sigma and truncation bounds scale similarly. Scaling preserves relative error, so they inherit the same ~6 ulp. The operation that would amplify is differencing two lcdf values, as in T[a,b] truncation with log_diff_exp(lcdf(b), lcdf(a)) for close bounds, but that amplifies the lcdf value error, not the derivative, so again that 192 ulp in ercfx derivative doesn't matter.

So the honest conclusion for the PR: the 192 ulp is invisible to the intended consumer. Reducing that 192 ulp is possible, but costs 0.72 on a interval.

I think we can stop trying to optimize this further (at least for now)

@stan-buildbot

Copy link
Copy Markdown
Contributor
Name Old Result New Result Ratio Performance change( 1 - new / old )
stat_comp_benchmarks/benchmarks/gp_regr/gp_regr.stan 0.23 0.23 1.01 0.52% faster
stat_comp_benchmarks/benchmarks/gp_regr/gen_gp_data.stan 0.06 0.06 1.01 1.27% faster
stat_comp_benchmarks/benchmarks/low_dim_gauss_mix/low_dim_gauss_mix.stan 6.31 6.32 1.0 -0.17% slower
stat_comp_benchmarks/benchmarks/low_dim_corr_gauss/low_dim_corr_gauss.stan 0.02 0.02 0.98 -1.62% slower
stat_comp_benchmarks/benchmarks/irt_2pl/irt_2pl.stan 8.47 8.46 1.0 0.12% faster
stat_comp_benchmarks/benchmarks/gp_pois_regr/gp_pois_regr.stan 4.5 4.52 1.0 -0.39% slower
stat_comp_benchmarks/benchmarks/sir/sir.stan 165.83 167.12 0.99 -0.78% slower
stat_comp_benchmarks/benchmarks/garch/garch.stan 0.9 0.88 1.02 1.66% faster
stat_comp_benchmarks/benchmarks/arma/arma.stan 0.7 0.71 0.99 -0.65% slower
stat_comp_benchmarks/benchmarks/pkpd/one_comp_mm_elim_abs.stan 43.74 42.8 1.02 2.13% faster
stat_comp_benchmarks/benchmarks/pkpd/sim_one_comp_mm_elim_abs.stan 0.6 0.6 0.99 -0.69% slower
stat_comp_benchmarks/benchmarks/low_dim_gauss_mix_collapse/low_dim_gauss_mix_collapse.stan 20.87 20.82 1.0 0.22% faster
stat_comp_benchmarks/benchmarks/eight_schools/eight_schools.stan 0.11 0.11 1.0 0.01% faster
stat_comp_benchmarks/benchmarks/arK/arK.stan 3.2 3.2 1.0 0.06% faster
performance.compilation 378.06 384.17 0.98 -1.62% slower
Mean result: 1.0001572170343909

Jenkins Console Log
Jenkins Build Stages
Commit hash: bb466c55b15cd449827e57a514f6525b94324be6

Machine information
Distributor ID:	Ubuntu
Description:	Ubuntu 20.04.3 LTS
Release:	20.04
Codename:	focal

CPU:

Architecture:                            x86_64
CPU op-mode(s):                          32-bit, 64-bit
Byte Order:                              Little Endian
Address sizes:                           43 bits physical, 48 bits virtual
CPU(s):                                  256
On-line CPU(s) list:                     0-255
Thread(s) per core:                      2
Core(s) per socket:                      64
Socket(s):                               2
NUMA node(s):                            2
Vendor ID:                               AuthenticAMD
CPU family:                              23
Model:                                   49
Model name:                              AMD EPYC 7742 64-Core Processor
Stepping:                                0
Frequency boost:                         enabled
CPU MHz:                                 1497.000
CPU max MHz:                             3416.0681
CPU min MHz:                             1500.0000
BogoMIPS:                                4491.85
Virtualization:                          AMD-V
L1d cache:                               4 MiB
L1i cache:                               4 MiB
L2 cache:                                64 MiB
L3 cache:                                512 MiB
NUMA node0 CPU(s):                       0-63,128-191
NUMA node1 CPU(s):                       64-127,192-255
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Old microcode:             Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Mitigation; untrained return thunk; SMT enabled with STIBP protection
Vulnerability Spec rstack overflow:      Mitigation; Safe RET
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; usercopy/swapgs barriers and __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; Retpolines; IBPB conditional; STIBP always-on; RSB filling; PBRSB-eIBRS Not affected; BHI Not affected
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Mitigation; IBPB before exit to userspace
Flags:                                   fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm constant_tsc rep_good nopl xtopology nonstop_tsc cpuid extd_apicid aperfmperf rapl pni pclmulqdq monitor ssse3 fma cx16 sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm cmp_legacy svm extapic cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw ibs skinit wdt tce topoext perfctr_core perfctr_nb bpext perfctr_llc mwaitx cpb cat_l3 cdp_l3 hw_pstate ssbd mba ibrs ibpb stibp vmmcall fsgsbase bmi1 avx2 smep bmi2 cqm rdt_a rdseed adx smap clflushopt clwb sha_ni xsaveopt xsavec xgetbv1 xsaves cqm_llc cqm_occup_llc cqm_mbm_total cqm_mbm_local clzero irperf xsaveerptr rdpru wbnoinvd amd_ppin arat npt lbrv svm_lock nrip_save tsc_scale vmcb_clean flushbyasid decodeassists pausefilter pfthreshold avic v_vmsave_vmload vgif v_spec_ctrl umip rdpid overflow_recov succor smca sev sev_es

G++:

g++ (Ubuntu 9.4.0-1ubuntu1~20.04) 9.4.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

Clang:

clang version 10.0.0-4ubuntu1 
Target: x86_64-pc-linux-gnu
Thread model: posix
InstalledDir: /usr/bin

@andrjohns
andrjohns merged commit 4105b29 into develop Sep 20, 2026
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants