Sample randint directly instead of through a float32 uniform - #3955
Open
axiom-of-choice wants to merge 1 commit into
Open
Sample randint directly instead of through a float32 uniform#3955axiom-of-choice wants to merge 1 commit into
axiom-of-choice wants to merge 1 commit into
Conversation
randint routed its samples through a float32 uniform and cast the result. Whenever the bounds are not exactly representable in float32 that loses the integer interval: at 2**24 adjacent float32 values are already two integers apart, so randint(2**24, 2**24 + 2) returned the excluded upper bound and never returned 2**24 + 1. At 2**40 the spacing is 2**17, and a 1024-wide int64 interval collapsed to a single value. Sample the integers directly instead. The interval width is computed in int64 and reinterpreted as uint64, so widths above 2**63 survive, and 64 uniform bits per sample are reduced onto it with a remainder. The residual modulo bias is bounded by range / 2**64 -- below 1e-9 for any range under 2**32, and exactly zero when the width is a power of two -- against the current behaviour of dropping values outright. Empty intervals keep returning low, as before.
5 tasks
|
Replied in detail on #3936 (#3936 (comment)) rather than here, which left this PR with no response at all — correcting that. Short version, so this thread stands on its own:
auto cmp_dtype = issubdtype(dtype, unsignedinteger) ? uint64 : int64;
auto empty = less_equal(
astype(high, cmp_dtype, stream), astype(low, cmp_dtype, stream), stream);Happy either way on which PR lands it — yours with variant C folded in and I close #3936, or mine and you close this. Your reinterpret-as- |
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.
Proposed changes
Fixes #3926.
Problem
randintsampled through a float32 uniform and cast the result:float32 has a 24-bit mantissa, so once the bounds reach 2^24 the integer interval is gone before the cast. Three distinct symptoms, all on
main:At 2^24 adjacent float32 values are two integers apart; at 2^40 the spacing is 2^17, so the whole interval sits between two representable floats.
Change
Sample the integers directly. The interval width is computed in int64 and reinterpreted as uint64, then 64 uniform bits (two
bits()draws) are reduced onto it with a remainder.The residual modulo bias is bounded by
range / 2^64, below 1e-9 for any range under 2^32, and exactly zero when the width is a power of two. That is against the current behaviour of making values unreachable outright.Empty intervals (
high <= low) keep returninglow, as before.Relationship to #3936
#3936 proposes the same integer-domain approach, and I want to be clear that the idea is not mine alone; I reached it independently and said so there, offering the fix below as a patch to that branch rather than a competing PR. Opening this now so there is a reviewable version that closes the remaining case; happy for it to be closed in favour of #3936 if that branch picks the fix up.
The gap is that #3936 clamps the signed difference:
For an interval wider than 2^63 the true width overflows int64 and wraps negative, so
maximum(range, 1)reads it as negative and clamps to1. Every draw then lands onlow.I built that branch (
6de5378, CPU-only, macOS/arm64) and measured 20,000 draws per interval against this one:[2^24, 2^24+2)[2^40, 2^40+1024)[0, 2^63-1)[-2^62, 2^62)[-2^63, 2^63-1)So
randint(-2^62, 2^62)returns a constant, the same failure this work sets out to fix, relocated to a wider magnitude. An int64 interval can be up to 2^64 - 1 wide, and uint64 is the only type that holds every widthrandintcan legitimately be asked for, which is why the clamp has to happen after the reinterpretation:One smaller point: #3936's comment argues numpy
remaindersemantics keep the 64-bit path exact when the combined draw goes negative. The sign of the result is right, butremainder(raw, r)for negativerawisraw % r + r, which skews the distribution toward the low end of the interval by roughly2^63 mod r. Reducing in uint64 removes the need for that argument.Verification
Reported cases, and the overflow edges:
Uniformity over 600,000 draws. Every value reachable, worst-case deviation from uniform ~1%:
Existing behaviour preserved:
randint(10, -10, ...)still returnslow, same-key draws still reproduce, broadcasting of array bounds unchanged, andbool/uint8dtypes unchanged.Suites, CPU-only build on macOS/arm64:
python/tests/test_random.py: 16 passedpython/tests/: 727 passed, 66 skipped, 9718 subtests passed./build-tests/tests/tests: 244 cases, 3245 assertions, all passedI could not exercise the Metal path (no full Xcode locally, so no
metalcompiler). The change is inmlx/random.cppabove any backend dispatch and the issue reproduces identically onmx.cpuandmx.gpu, but the GPU path is the part worth a second look.Tests added
python/tests/test_random.py::test_randint_exact_integer_range: the 2^24 and 2^40 cases plus a width above 2^63.python/tests/test_random.py::test_randint_uniform_coverage: every value in a small interval is reachable at roughly equal frequency.tests/random_tests.cpp: the same three intervals in the existingtest random randintcase.Both Python tests fail on
mainand pass with the change.Checklist
pre-commit(on the changed files: clang-format, black, isort all pass) prior to committing changes[low, high)with equal probability") is unchanged; this makes the implementation match it