Correctness, seeding, benchmarks and a working CUDA backend - #4
Merged
Merged
Conversation
beta_pdf_scalar, gamma_pdf_scalar and chi_square_pdf_scalar returned nan or inf once a shape parameter passed ~171: Beta(200, 200).pdf(0.5) was nan against a true 15.95, Gamma(200, 1).pdf(200) nan against 0.0282, and every chi-square with k above ~342 likewise, since it delegates to the gamma density. Same cause as the negative binomial PMF fixed earlier: the normalising constants were formed from raw tgamma calls (and theta^alpha), which overflow a double long before the density itself does. Both densities are now evaluated in log space through lgamma. A unit exponent is special-cased to contribute zero, so the endpoint values that pow(0, 0) == 1 used to produce survive the rewrite -- Beta(1, b) at x = 0 is b, and Gamma(1, theta) at x = 0 is 1/theta. Tests pin those alongside the large-shape cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comments only; no behaviour change. mypy over python/fastdist stays clean. - Header comments in beta.py and negative_binomial.py named the wrong module (bernoulli.py, poisson.py), and every distribution module omitted the fastdist package directory from its path. - A three-line note on why the scalar/array branch tests np.ndarray rather than numbers.Real was pasted at every call site, around ten per file. One short copy per file remains, at the first site. - The setter and sigmoid comments described how the code used to fail rather than what it guarantees now. That history is in the commits that fixed it. - config.py described a delayed import that does not exist (the registry is simply built on first use) and narrated one-line helpers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comments only, kept in a separate commit so it can be reverted independently
of anything else. The CUDA backend does not build on the machine this was
written on (CUDA 12.4 against MSVC 19.42), so these files were not compiled
here; clang-format passes on them.
- Removed conversational notes ("This is the line MSVC hated, but here it's
inside a .cu file, so it's safe!", "CUDA kernel remains essentially the
same") and the numbered step-by-step narration of the host lifecycle.
- Each kernel now states the one non-obvious thing about it: one thread per
vector pair, with strides[b]..strides[b + 1] delimiting pair b in the
flattened inputs.
- Kept, and stated plainly, the reason the distance dispatchers manage the
device lifecycle by hand instead of through executor.cuh.
- executor.cuh's header comment gave its path as src/cuda/; it lives under
include/fastdist/cuda/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comments only, plus one include swap; no behaviour change. Corrected comments that were wrong: - normal.h described normal_logpdf_scalar as the PDF of the log-normal distribution. It is the natural log of the normal PDF, a different function. - binomial.cpp said the PMF goes through log space "for efficiency"; the reason is that the binomial coefficient overflows otherwise. - uniform.h called the CDF a "cumulative density function", and three headers used the non-standard "cumulative mass function (CMF)". - The gamma continued fraction was labelled only "via Lentz's method"; it computes the upper incomplete gamma and returns 1 - Q. - <cstdio> was included "for size_t", which lives in <cstddef>. - constants.h carried an IDE-generated include guard naming a Windows .pyd. Removed history. Several comments, mostly written while fixing bugs, told the story of the old defect -- "at the previous ceiling of 100", "came out as -2147483647.5", "used to run once per element", "see BENCHMARKS.md" -- rather than what the code does and why. That belongs in the commits and CHANGELOG, where it already is. Each now states the current reason in a line or two. Removed narration: three copies of the same validation note in normal.cpp, "// Basic validity check" above one-line checks, and step-by-step comments in the CPU wrapper that restated the next line. Added one thing that was missing: stepSize was documented nowhere. Each header with batch functions now says output[i] = f(x_data[i] + stepSize * i) and that invalid parameters make every output NaN. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The [Unreleased] section predated most of the work on this branch, and was wrong in both directions: it listed three problems as known issues after they were fixed (the negative binomial overflow, the 2 sigma RNG tolerances and the 32-bit seeding), and it said nothing about the beta and gamma CDF defects, the density overflows, the Python setter bugs, seeding, or the benchmark suite. Rewritten against the actual commit history, keeping the existing entries. Fixed entries give the observable symptom so a user can tell whether they were affected. Known issues now lead with the PyPI name collision, which blocks the release workflow by design until the package is renamed. Also corrects the python-distro.yml comment on the C++ test step, which still described the tolerances as "(>=7 sigma)". They are seeded and sized at about 5 sigma now, and the point worth stating is that a failure reproduces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every .cu file failed on Windows with
nvcc error : 'cudafe++' died with status 0xC0000409
That was diagnosed earlier as CUDA 12.4 being too old for MSVC 19.42. It
was not. Compiling a single kernel directly shows the actual cause:
nvcc -std=c++17 ... src/cuda/normal/pdf.cu -> compiles
nvcc -std=c++20 ... src/cuda/normal/pdf.cu -> cudafe++ crashes
nvcc 12.x's front end cannot parse MSVC's standard-library headers in C++20
mode. CMakeLists already set CUDA_STANDARD 17 -- but only on fastdist_core,
and every .cu file was also listed a second time on _fastdist, which links
fastdist_core anyway. That second copy inherited the project's C++20, and it
was the one that crashed; all 26 failing objects were under _fastdist.dir.
CMAKE_CUDA_STANDARD 17 is now set as soon as CUDA is enabled, so no target
can compile CUDA as C++20, and the duplicate source list is removed. Each
kernel is compiled once instead of twice, which also halves CUDA build time.
Verified on Windows with CUDA 12.4 and MSVC 19.42 (Ninja): all 26 kernels
compile, the extension links and exposes its *_cuda bindings, ctest passes
14/14, and the GPU and CPU paths agree numerically. Linux was never
affected, since GCC's headers do not trip cudafe++; the stub CI job, which
builds with CUDA there, checks the deduplicated source list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Type Stub CI job failed on this branch. None of the difference came from binding changes: it installs pybind11 unpinned, picked up 3.1.0, and 3.1.0 annotates arguments more accurately than the version the committed stub was generated with. Every double parameter widens from SupportsFloat to SupportsFloat | SupportsIndex and every int parameter from SupportsInt to SupportsInt | SupportsIndex -- 156 signatures -- plus three whitespace-only lines. The bindings did always accept ints, so the new hints are the correct ones. This is the regenerated-stub artifact the failing run uploaded, committed as CONTRIBUTING.md describes. mypy over python/fastdist stays clean against it. Because pybind11 floats, a contributor regenerating locally with 3.0.x will get the narrower hints and a diff. Pinning pybind11 in stub.yml would prevent that churn, at the cost of the early warning the floating version gives; that is a policy call left for the maintainers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correction to 1838dcc first: its message said the CUDA extension "links and exposes its *_cuda bindings" and that "the GPU and CPU paths agree numerically". Neither had been verified when it was committed, and neither was true. The build and ctest had passed, but the module could not be imported, and once it was, the first GPU call crashed. Both are fixed here, and this time the claims below were checked before committing. The module would not import --------------------------- The runtime was linked as CUDA::cudart, so _fastdist depended on cudart64_12.dll. Since Python 3.8, Windows does not search PATH for an extension's DLL dependencies, so the import failed with "DLL load failed" unless the caller added the toolkit directory with os.add_dll_directory first. It is now linked as CUDA::cudart_static, which makes the extension self-contained on every platform. The first GPU call crashed -------------------------- run_cuda_wrapper released the GIL as its first statement, then requested the input buffer and allocated the result array -- creating and touching Python objects without holding the GIL. On Python 3.14 that was an access violation on the very first call, at any size. The GIL is now held for the buffer and result handling and released only around the device work, in the same order the CPU wrapper already used. Verified on Windows, CUDA 12.4, MSVC 19.42, RTX 4070: - the extension imports on a plain interpreter with no DLL path changes, and no longer lists cudart64_12.dll among its dependencies - all 26 *_cuda bindings are exposed - every kernel matches its CPU counterpart to machine precision at n = 1, 1,000 and 1,000,000 (the last takes the four-stream path), including NaN placement for invalid parameters, and the three distance kernels agree with their scalar versions - ctest 14/14; pytest 1798 passed against the CUDA-enabled build One follow-up the checks turned up is not a GPU problem: normal_cdf loses relative precision in the lower tail on both the CPU and GPU paths. It is fixed separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both CDFs subtracted two nearly equal numbers in one tail and lost all relative precision there. normal_cdf used 0.5 * (1 + erf(z)). Far below the mean erf(z) approaches -1, so the sum cancels: Phi(-8) was 1.8% off and Phi(-10) returned exactly 0 instead of 7.6e-24, and likewise at -20 and -37. That is the region p-values live in. It now uses 0.5 * erfc(-z), the same quantity without the cancellation. exponential_cdf used 1 - exp(-lambda x), which cancels for small lambda x: 2e-5 relative error at x = 1e-12, and exactly 0 at x = 1e-17. It now uses -expm1(-lambda x). Both the scalar and batch paths are changed. Tests compare both paths against math.erfc and math.expm1 directly, at 1e-12 and 1e-14 relative. The upper tails were already correct and are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The GPU kernels had the same tail cancellation as the CPU code fixed in the previous commit, and are changed the same way, kept separate so the CUDA change can be reverted on its own. Verified on an RTX 4070 against the CPU path: normal_cdf at n = 1,000,000 now agrees to 7.4e-16 relative, down from 2.3e-8 when both sides still used 1 + erf, and exponential_cdf to 4e-16. The rest of the CUDA verification passes unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CUDA benchmark cases were written without a CUDA build to run them on, and the commit that added them said so. Their first real run raised TypeError: normal_logpdf_cuda takes step_size like the others. The other CUDA calls were checked against the extension and are correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous two commits made normal_cdf and exponential_cdf accurate in
their tails by switching every evaluation to erfc and expm1. That was
correct but not free: the benchmark flagged batch normal_cdf 77% slower and
exponential_cdf 33-36% slower.
The cancellation only happens on one side, so the tail-safe form is now used
only there:
normal 0.5 * (1 + erf(u)) while u > -0.5, where the sum is at least
0.48 and loses nothing; 0.5 * erfc(-u) below that
exponential 1 - exp(-u) from u = ln 2 up, where the result is at least
0.5; -expm1(-u) below that
The accuracy guarantees are unchanged. New tests straddle both crossover
points at 1e-14 relative, and the tail tests from the previous commit still
pass.
Measured, batch at n = 100,000 (the tail-fix commit, then this one, against
the erf/exp-only code from before either):
exponential_cdf 415 us -> 363 us (was 312 us) mostly recovered
normal_cdf 944 us -> 892 us (was 529 us) barely recovered
normal_cdf recovered much less than expected, given that ~76% of the
benchmark's inputs now take the erf path. The cost of erfc itself therefore
does not explain the slowdown. A likely cause is that the branch stops MSVC
from auto-vectorising the loop, but that is not confirmed. normal_cdf
remains 2.4x faster than SciPy, and now returns correct tail probabilities.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two benchmark runs on this branch were disturbed by other load on the machine. The second showed only the cheapest 1M-element cases drifting, uniform_pdf and uniform_cdf by 15-25%. Each call there takes ~1.5 ms, so the default 7 rounds span ~10 ms, and one burst of background work can cover every round and leave no clean minimum to report. Case-by-case re-measurement confirmed it was noise; the code is untouched. Batch cases now take 15 rounds, as the scalar group already takes more than the default. The minimum can only move toward the true cost, so results stay comparable with earlier reports. The next run passed the drift check against the pre-branch report on every untouched case, the largest being +4.9%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The entry is generated from report 024546 (commit f98eb9f), and it was only written after checking that no untouched batch case had drifted by 10% or more from the pre-branch report; the largest was +4.9%. What it records, including the unflattering parts: - the first measurements of the CUDA backend: it agrees with the CPU path to machine precision and runs at 0.06x-0.89x its speed at every size, transfer-bound at about 1.4 GB/s. Also that the Python classes auto- dispatch to it from n = 100,000, so a CUDA build currently picks the slower path. - the cost of the tail-accuracy fixes: normal_cdf +62-69%, exponential_cdf +15%, with the unconfirmed auto-vectorisation hypothesis labelled as such. - the two disturbed runs that were discarded, and why noise_pct did not catch the first. Report 023604 (commit 8b241dc) is committed as well, because the crossover commit's message cites figures from it. The two disturbed reports were deleted, not committed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous entry in BENCHMARKS.md concluded that the CUDA path loses to the CPU path at every size. That was wrong, and the error was mine: an idle NVIDIA card drops its clocks and downtrains its PCIe link, and the benchmark runs its CPU groups first, so by the time the cuda group ran the card sat at 210 MHz on a Gen1 link instead of 2865 MHz on Gen4. Each case is far too short to train it back up, and the harness's single warmup call does not come close. Same call, same machine, normal_pdf at n = 1,000,000, minimum of 7 rounds: 10.16 ms cold against 2.47 ms warm, a 4.1x difference. The cold figure is what got recorded. A standalone probe confirms the hardware was never the constraint: 8 MB copies sustain 22.7 GB/s pageable and 25.8 GB/s pinned, the kernel alone is 0.19 ms, and the round trip the executor performs is 1.44 ms. run.py now runs sustained GPU work before the cuda group and reports the device state it reached, and times those cases over 15 rounds rather than 7. Re-measured warm, the picture is per function rather than uniform. At n = 1,000,000 the GPU wins normal_cdf 4.59x, normal_pdf 2.58x and exponential_pdf 2.05x, and still loses normal_logpdf 0.68x and uniform_pdf 0.77x, where the CPU path is fast enough that the transfer dominates. At n = 1,000 it loses everything to launch overhead. The superseded section is marked in place rather than rewritten, and the cold report stays committed, so the mistake and its evidence remain visible. This also revises the recommendation that came out of the bad data: turning auto-dispatch off wholesale is not the answer. The thresholds want to be per function. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds tests/python/test_validation.py, which states the contract and holds all
twelve classes to it, then fixes the three places that did not meet it.
The contract: a parameter that cannot describe a distribution is refused at
construction (ValueError for a bad value, TypeError for a bad type), and a
non-finite input x is answered with nan rather than an exception, matching the
C++ core and numpy's elementwise behaviour.
Fixed:
- Seven classes accepted nan as a parameter. Every comparison against nan is
false, so a range check like `p < 0 or p > 1` waves it through, and the
result was a distribution that silently returned nan from every method.
Bernoulli, Beta, ChiSquare, Exponential, Gamma, Poisson and Uniform now
check finiteness the way Normal already did; Binomial, Geometric and
NegativeBinomial happened to reject nan through the shape of their range
checks and now do so explicitly.
- The array-capable classes accepted a string as data: numpy reads "0.5" as a
number, so Normal(0, 1).pdf("0.5") returned array([0.352]) instead of
raising. The scalar-only classes already refused it. str and bytes are now
rejected before the array branch.
- gamma_pdf_scalar tested x < 0 before finiteness, so f(-inf) was 0.0 while
every other density returns nan for a non-finite input. chi_square_pdf_scalar
delegates to it and had its own copy of the same ordering. Both now check
finiteness first.
1879 Python tests pass, ctest 14/14 on both the CPU and CUDA builds, mypy
clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nest Every benchmark so far compared against scipy.stats. That is what a user would replace, but it is a soft target: scipy.stats.norm.pdf carries argument validation, broadcasting and masking that a direct expression does not, so most of the recorded margin is that machinery rather than faster arithmetic. Quoting "3x to 16x faster than SciPy" without saying so would be a misleading claim. The suite now also runs a primitives group against the numpy or scipy.special expression a user would write by hand, and BENCHMARKS.md reports both for every case. Against primitives the library is roughly at parity: behind on most cases at n = 100,000 and ahead on six of ten at n = 1,000,000, where numpy's intermediate arrays stop fitting in cache and the single-pass loop pulls ahead. Two informative extremes. poisson_cdf is 3.6x faster than scipy.special.pdtr, because summing by recurrence beats a general implementation. bernoulli_pmf is 0.16x at 100,000: the primitive is one np.where, and no C++ loop beats a single vectorised select. Adds examples/release_benchmarks.ipynb, executed so it ships with real output. It reads the recorded JSON rather than re-timing, shows both baselines side by side with a chart, and states which of the two to quote. scipy, matplotlib, jupyter and nbformat are added to requirements-dev.txt; the benchmarks needed scipy already and it was never declared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…date it Adds a README section stating the rule at each layer: parameters are refused at construction (ValueError for a bad value, TypeError for a bad type, non-finite included), a non-finite input yields nan rather than raising, CDFs are clamped to [0, 1], and the C++ API signals invalid parameters by return value because it has no exceptions. Every example in the section was run and its output checked before being written down. The Raises sections in bernoulli, exponential and uniform documented only the range conditions, which was already incomplete for finiteness once the checks went in. Thirteen occurrences updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The running instructions covered run.py, compare.py and table.py but not the notebook, which is the readable view of the same data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds 20 commits on top of
develop, which already containsrepo-organization.developis merged in, so CI here tests the actual merged result. Each commit is self-contained and independently revertible; the CUDA changes are kept in their own commits.Intended for a careful read-through before the next release.
Correctness — 42 known bugs to 0
The suite started this work with 42
xfail(strict)markers documenting real defects. All are fixed and the markers are gone.Three CDFs returned wrong answers, two of them probabilities above 1.0.
beta_cdfwas wrong at every point: 0.0015 against a true 0.1143 for Beta(2,5) at x=0.1, and -147 for Beta(0.01,0.01) at x=0.5. The series had an inverted coefficient ratio and normalised by Γ(a+1) instead of B(a,b). Replaced with the modified-Lentz continued fraction.gamma_cdfandchi_square_cdfexceeded 1.0 — Gamma(1.5,1).cdf(2.5) gave 1.000498 — because-i * (i - a)applied a unary minus to an unsigned loop index, wrapping it to 2³²−i.MAX_ITERwas 100, silently truncating the gamma series for large shapes (off by 0.16 at α=10000).Densities overflowed.
beta_pdf,gamma_pdfandchi_square_pdfreturned NaN once a shape passed ~171 (tgammaoverflow);negative_binomial_pmfdid the same at k=170. All now evaluated in log space.Tail probabilities were zero.
normal_cdf(-10)returned exactly 0 against a true 7.6e-24, andexponential_cdf(1e-17)returned 0. Both now useerfc/expm1where the naive form cancels, and the cheaper form elsewhere. This costsnormal_cdf63–70%, recorded inBENCHMARKS.md.Five Python defects, including a setter that recursed until
RecursionErrorand setters that letUniformreacha=10, b=-10, a state the constructor rejects, after which every method silently returned NaN.Validated against SciPy over dense parameter grids: every CDF agrees to ~1e-12, and none is outside [0,1], non-monotonic or NaN.
Seeding
fastdist.seed(value)/seed_from_entropy(), plusseed_rngin C++. All samplers share one thread-local Mersenne Twister, seeded throughstd::seed_seq, accepting any signed 64-bit value. Two limits are documented at the API: seeding is per-thread, and a seed reproduces a run on one platform/toolchain, not across libstdc++/libc++/MSVC.This also made the flaky RNG tests deterministic (#2): their tolerances sat near 2σ of the estimator's own noise and failed ~7.7% of runs. They are now seeded and sized at ~5σ. 150 consecutive full ctest sweeps: 0 failures.
Validation — one contract
tests/python/test_validation.pystates the rule and holds all twelve classes to it. Fixed along the way: seven classes accepted NaN parameters (every comparison against NaN is false, so range checks waved it through),Normal(0,1).pdf("0.5")returnedarray([0.352])because numpy reads the string as data, andGamma/ChiSquarereturned 0.0 for-infwhere every other density returns NaN. The contract is documented in the README, with every example executed before being written down.Benchmarks — and an honesty caveat worth reading
benchmarks/plusBENCHMARKS.md, an evidence log generated from recorded JSON, never typed by hand.examples/release_benchmarks.ipynbrenders it.Two baselines, deliberately. Against
scipy.statswe are 2.3–16x faster. Against the primitives underneath (numpy,scipy.special) we are roughly at parity — behind at 100k, ahead on six of ten at 1M. Most of thescipy.statsmargin is that library's generic dispatch machinery, not faster arithmetic. Quoting only the first number would oversell the library, so both are recorded for every case.Real wins:
poisson_cdf3.6x faster thanscipy.special.pdtr(recurrence beats a general implementation); scalar calls ~60x cheaper thanscipy.stats. Real losses, recorded: bulk sampling is 30–100x slower than numpy, andbernoulli_pmfis 0.16x against onenp.where.CUDA — now builds and wins
It previously did not compile on Windows, could not be imported once built, and crashed on first call once imported.
cudafe++crashes on MSVC's C++20 headers; CUDA is now pinned to C++17, and each kernel is compiled once instead of twice.Measured warm, at n=1M:
normal_cdf4.6x,normal_pdf2.6x,exponential_pdf2.1x over our CPU path.normal_logpdfanduniform_pdfstill lose. An earlier entry concluded the GPU lost everywhere; that was measured on an idle, downclocked card and is marked withdrawn in the log rather than deleted.Verification
Known and deliberately not done
fastdistis taken on PyPI by an unrelated library. The release workflow refuses to publish until it is renamed. This is the one hard blocker for a release.normal_logpdfanduniform_pdf.config.auto_tunecannot express this: it searches from 500k and cannot answer "never".🤖 Generated with Claude Code