Fix amrex::Random() returning 1.0 in SP & add amrex::RandomPositive - #5643
Open
ax3l wants to merge 2 commits into
Open
Fix amrex::Random() returning 1.0 in SP & add amrex::RandomPositive#5643ax3l wants to merge 2 commits into
Fix amrex::Random() returning 1.0 in SP & add amrex::RandomPositive#5643ax3l wants to merge 2 commits into
Conversation
amrex::Random() is documented to return a uniform real in [0,1), but on CUDA/HIP it is implemented as `1 - curand_uniform()`. hiprand/curand draw from (0,1], so the flip is correct in exact arithmetic but not in floating-point: when the draw is smaller than half an ULP of one, `1 - draw` rounds up to exactly 1.0. In single precision that happens for a draw <= 2^-25, i.e. with probability ~3e-8 per call (double precision: 2^-54, ~5e-17), so the documented open upper bound is violated. This breaks callers that rely on the strict upper bound, such as `int(N*Random())` used as an array index or `problo + Random()*dx` used as a position within a cell, where the failure is silent rather than loud. Clamp the result to the largest representable Real below one. The constant is computed as `1 - eps/2`, which is exactly std::nextafter(Real(1), Real(0)) in both precisions (verified for float and double, with and without -ffast-math) and is constexpr, so it works in device code where std::nextafter is not available. The lower bound needs no clamp: 0.0 is included in [0,1) by design. The host and SYCL paths already produce [0,1) natively and are untouched. Also document that 0.0 is a possible return value and that `1 - amrex::Random()` is not a valid way to obtain a non-zero value, since in single precision it evaluates to exactly 0.0 whenever Random() returns the largest value below one. Refs AMReX-Codes#5638. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ax3l
force-pushed
the
topic-random-unit-interval
branch
from
August 24, 2026 21:39
3a82df6 to
0bb4f0c
Compare
ax3l
commented
Aug 24, 2026
ax3l
commented
Aug 24, 2026
Fix amrex::Random() returning 1.0 in SP & add amrex::RandomPositive
ax3l
force-pushed
the
topic-random-unit-interval
branch
from
August 24, 2026 22:00
0bb4f0c to
1567032
Compare
amrex::Random() returns a uniform real in [0,1), so it can return exactly 0.0. That makes it unsafe to pass unguarded to samplers that are singular at zero, which are common in user code: -log(u) for an exponential distribution, sqrt(-2*log(u)) in the Box-Muller transform, pow(u,-a) for a power law. Downstream codes work around this by writing `1 - amrex::Random()`, which is not a valid guard: in single precision that expression evaluates to exactly 0.0 whenever Random() returns the largest representable value below one. The result is a silent log(0) = -inf, which is what motivated AMReX-Codes#5638. Add amrex::RandomPositive(), drawing from (0,1], so that the correct interval is available directly instead of being reconstructed by hand at each call site. There is no cost to it: * On CUDA/HIP, (0,1] is the native interval of hiprand/curand, so RandomPositive is the raw generator call. It performs no arithmetic at all and is therefore cheaper than Random(), which has to flip and clamp. * On the host and with SYCL the generator is natively [0,1), and there the flip `1 - u` is the safe direction: u <= 1-eps/2 implies 1-u >= eps/2 > 0. Both guarantees hold under every floating-point mode AMReX may be built with, including -ffast-math / --use_fast_math and flush-to-zero, since the smallest value that can be returned is eps/2, a normal number that cannot be flushed as a denormal, and there is no reassociable arithmetic to begin with. Use it inside RandomGamma, which is AMReX's own exposure to this: the Marsaglia-Tsang algorithm needs u in (0,1) for both std::log(u) in the rejection step and std::pow(u, 1/alpha) in the alpha < 1 boost. Document both generators and their intervals in the user's guide, in a new "Random Numbers" section of the Basics chapter, and cross-reference it from the FAQ entry on random numbers. While documenting intervals, correct the FillRandom doxygen, which reports its (genuinely backend-dependent) interval with typos, and note that inconsistency in the manual as well. Refs AMReX-Codes#5638. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ax3l
force-pushed
the
topic-random-unit-interval
branch
from
August 24, 2026 22:01
1567032 to
1c683ff
Compare
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.
Fixes #5638.
Two commits: the first restores the documented contract of
amrex::Random(), the second adds the generator that user code actually keeps reaching for.1.
amrex::Random()can return exactly1.0amrex::Random()is documented as[0,1), but on CUDA/HIP it is1 - curand_uniform(). hiprand/curand draw from(0,1], so the flip is right in exact arithmetic and wrong in floating point: when the draw is below half an ULP of one,1 - drawrounds up to exactly1.0. In single precision that is a draw<= 2^-25, i.e. ~3e-8 per call (double precision:2^-54, ~5e-17).The loud consequence is the one from #5638 (
1 - amrex::Random()is not a zero-guard). The quieter and arguably worse one is that callers relying on the strict upper bound break silently:int(N*Random())becomes an out-of-bounds index, andproblo + Random()*dxputs a particle one cell over. Noinf, no assert.Fixed by clamping to the largest representable
Realbelow one. The constant is1 - eps/2, which is exactlystd::nextafter(Real(1), Real(0))in both precisions — I verified this forfloatanddouble, with and without-ffast-math. Since it isconstexpr, it works in device code, wherestd::nextafteris not available, so neither of the two workarounds discussed in #5638 is needed: no1 - epsilon(which is 2 ULPs below one and needlessly discards a representable value), and no host-computed value stashed inRandomEngine.The lower bound needs no clamp —
0.0is in[0,1)by design. Host and SYCL already produce[0,1)natively and are untouched.2.
amrex::RandomPositive(), a uniform real in(0,1]The deeper issue behind #5638 is that user code needs
(0,1]at least as often as[0,1)— every-log(u),sqrt(-2*log(u)),pow(u,-a)— and AMReX offered no way to ask for it. So downstream codes invent1 - amrex::Random(), which is exactly the transform that does not survive single precision. Documenting the hazard is necessary but not sufficient; there has to be something to point people to.RandomPositive()costs nothing, because the flip is safe in one direction and unsafe in the other:Random()→[0,1)RandomPositive()→(0,1](0,1]uniform_real_distribution)[0,1)1 - u, safeuniform)[0,1)1 - u, safeOn GPU it is cheaper than
Random(). On host/SYCL the flip is the safe direction:u <= 1 - eps/2implies1 - u >= eps/2 > 0.Also switched
RandomGammaover to it — that is AMReX's own exposure. Marsaglia–Tsang needsuin(0,1)for both thestd::log(u)rejection step and thestd::pow(u, 1/alpha)boost whenalpha < 1.Safety under all math modes
Both guarantees hold regardless of how AMReX is built — desktop or HPC, IEEE or relaxed:
almost_oneis exactly representable, so constant folding is stable under fast-math;RandomPositive()can return iseps/2(2^-24in single precision), a normal number — flush-to-zero and denormals-are-zero cannot touch it;This is asserted in the code comments and in the manual, and verified empirically below under
--use_fast_math -ftz=true.Does the clamp perturb the uniformity of the draw?
No, and the reason is stronger than "the probability is small": every value inside the documented range passes through bit-for-bit unchanged. The clamp fires only on an output of exactly
1.0, which was never in[0,1)to begin with, so the distribution restricted to the contract is identical to before — the change only redirects mass that was out-of-contract.Quantitatively, it moves
2^-25(~3e-8) of probability by one ULP, from1.0onto the adjacent value1-2^-24. That bounds the shift in any moment by2^-25 x 2^-24 = 2^-49(~2e-15), i.e. ~7 orders of magnitude below the2^-24resolution of a single-precision sample itself. In double precision the moved mass is2^-54.It is worth putting that next to the discretization the transform already has. Because
1 - curand_uniform()rounds onto the coarse float grid near one,amrex::Random()returns exactly0.0with probability ~2^-25— the run above measured 103 zeros in 3.9e9 draws — which is vastly more than an ideal uniform-over-floats would give. The endpoints of this distribution are already lumpy at exactly the2^-25scale; the clamp relocates one such atom by a single ULP. It is not a new artifact, and it is smaller than what is already there.Two things it does not touch:
RandomPositive. It contains no clamp at all. On CUDA/HIP it is the raw generator output, so it applies strictly less transformation thanRandom()does; on host/SYCL theu >= 0.5half of the flip is exact by Sterbenz.Empirically, at 4e8 draws in single precision on GPU, binned into 1024 bins:
Both are consistent with uniform; neither shows any bias attributable to the change.
Docs
New Random Numbers section in the Basics chapter of the user's guide (
sec:basics:random): how to draw inside a GPU kernel withParallelForRNG, seeding, thread safety, a table of both uniform generators against what each is for, worked exponential and power-law examples, a table of the other distributions (RandomNormal,RandomPoisson,RandomGamma,Random_int,Random_long), and awarning::block on the1 - amrex::Random()anti-pattern. Cross-referenced from the FAQ entry on random numbers, and Doxygen updated on both functions.While documenting intervals I also fixed the
FillRandomdoxygen, which reported its interval with typos ("SYCl", "CUADA") — see the note below.Why not just tell people to use
amrex::RandomNormal?For a plain normal deviate, we should — the manual's distribution table lists it directly below these examples, and the doxygen carries a
\seecross-reference.RandomNormalis faster (on GPU it is the vendor's own normal generator, and it does not burn two uniforms per deviate), and since it never evaluatesstd::logit is untouched by this whole class of bug. Worth noting that this is not a coincidence:curand_uniformexcludes zero precisely because curand's own normal generator needslog(u). The1 - cflip discarded exactly the property the vendor put there on purpose.Hand-written Box-Muller still earns its place when the radial variable itself is needed, which
RandomNormalcannot give you:r^2 = -2 log(1 - u(1-e^-f))withf = cut^2/2, plus a variance renormalization. Rejection sampling onRandomNormalwould be an unbounded loop with divergent warps on GPU, would consume a variable number of draws (breaking reproducibility), and would give a square cut in(x,px)rather than the intended circular one — a different distribution, not just a slower route to the same one.So Box-Muller is a legitimate
RandomPositiveconsumer, but a poor headline example, since it invites hand-rolling a normal that AMReX already provides. The doxygen and manual examples now lead with the exponential and power-law cases, which have no AMReX alternative, and Box-Muller appears only in the note explaining when it is actually the right tool.Verification
All on an RTX A2000 (
sm_86), CUDA 13.2.The bug, reproduced on hardware. Direct comparison of the old and new transform over real
curand_uniformdraws:109 violations in 3.9e9 draws against ~118 predicted by
2^-25. Byte-identical results under-O3and under--use_fast_math -ftz=true. The pathological sweep also shows the downstream effect directly — forcurand = 2^-25,1 - Random()goes from0(old) to5.96e-08(new), so the WarpX-style guard starts working again:Against the real API, single precision + CUDA, via
ParallelForRNG:Builds and tests
Sphinx builds clean; the only warning is the pre-existing missing
amrex.pdfdownload.Notes / open questions
RandomPositivefits theRandom*prefix,RandomNonZeroreads more literally. Happy to change.Random()keep returning0.0? It is legal for[0,1)and this PR keeps it. A(0,1)variant excluding both endpoints would cover everything with one function (it would also serve logistic/Cauchy inversion, which(0,1]does not), at the cost of a clamp and an epsilon on every backend. I left it out; easy to add if wanted.FillRandomis still backend-dependent ([0,1)on CPU/SYCL,(0,1]on CUDA/HIP), since it forwards to the vendor host-side generators. Making it consistent would need a full extra pass over the array, which is a real cost on a bulk-fill path and a call for maintainers — so this PR only documents it accurately in both the doxygen and the manual. Worth a follow-up decision.amrex::max(u1, numeric_limits::min())clamps in favor ofRandomPositive, and WarpX has four sites that want it (SampleGaussianFluxDistribution.H:49,InjectorMomentum.H:320,322,DefaultInitialization.H:71).To Do