ILE: make --seed actually reproducible on GPU - #103
Open
oshaughnessy-junior wants to merge 3 commits into
Open
Conversation
--seed was implemented as a bare numpy.random.seed(opts.seed). The samplers
draw through the array backend they were configured with (self.xpy /
xpy_default), which is cupy on GPU -- mcsamplerGPU.draw_simplified's
inverse-CDF uniforms, mcsamplerAV's sample_from_bins, and the fair-draw
xpy.random.choice in GPU/AV/Portfolio/Ensemble. cupy keeps its own global
generator, which numpy.random.seed does not touch, so GPU runs were
irreproducible even when a seed was given: two byte-identical invocations of
the ILE demo with --seed 101 returned lnL 73.807 (n_eff 5.9) and 73.520
(n_eff 10.8). Beyond being unbisectable, that silently invalidates any paired
or replicate-seed comparison design on GPU, since the "same seed" arms are not
actually paired.
Add RIFT/integrators/seeding.py:seed_everything, which seeds every backend a
RIFT sampler can reach (python, numpy, cupy, torch) and reports what it
actually managed to seed -- the original failure mode was invisible. Call it
from the four drivers that implement --seed.
Seeding the RNGs was necessary but not sufficient. The adapted sampling
histogram (vectorized_general_tools.histogram) uses a weighted cupy.bincount,
which accumulates through float atomicAdd; the summation order follows GPU
thread scheduling, so the adapted CDF -- and hence every subsequent draw --
still moved at the ULP level between identical runs. Add a deterministic
sort-and-prefix-sum branch, enabled by seed_everything so that unseeded
production runs pay nothing for it. Measured cost 1.2-1.5x on a call that
happens once per parameter per adaptation, far off the likelihood hot path.
Verified on ldas-pcdev13 (RTX 2080 Ti, cupy 10.6/CUDA 11.2) with the
ILE-GPU-Paper demo:
GPU, --sampler-method adaptive_cartesian_gpu: seeds 101 and 202 each
reproduce BIT-IDENTICAL output files across repeat runs; the two seeds
differ from each other.
GPU, --sampler-method AV: same, bit-identical at seed 101.
GPU, fully adaptive (no --no-adapt-after-first, n_max 4e5): all 40 iteration
diagnostics bit-identical across the pair, so the fix survives the
adaptation feedback loop, not just a frozen proposal.
CPU: still bit-identical at a fixed seed and still seed-sensitive; lnL moved
by 9e-13 nats, the rounding difference of the new summation order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oshaughnessy-junior
deployed
to
private-review-dispatch-rift
August 16, 2026 10:04 — with
GitHub Actions
Active
Four findings from an adversarial pass over the previous commit:
1. The default (unseeded) path was no longer byte-for-byte the old code: the
ascontiguousarray that materializes the unweighted broadcast_to view sat in
histogram(), so an unseeded run allocated n_samples of weights it never used
to need. Moved inside the deterministic branch. With the flag off,
_bincount_weighted is now literally the original xpy.bincount call.
2. Prefix-sum differencing has an accuracy cost the first commit did not state.
A bin total is the difference of two partial sums both of order the grand
total, so a bin's relative error is amplified by (total / bin). Measured
against an exact rational reference at n_bins=100:
weights bin-total spread deterministic atomic
exponential ~1 2e-14 7e-15
exp(lnL)-peaked ~2e5 5e-11 3e-14
Acceptable -- this histogram is a proposal density, not an estimator; the
importance weights correct for it, it is consumed as a 100-bin interpolated
CDF, and --adapt-floor-level mixes in a uniform component. The atomic
branch's accuracy is unusable anyway, being irreproducible. Now documented
in the docstring and pinned by a test at 1e-9 so it cannot drift.
3. ile_postproc_add_time is dead code, not a driver this fix repairs. It reads
opts.seed with no matching add_option, so it dies with AttributeError at that
line. Adding --seed just moves the crash to the next undefined option
(manual_logarithm_offset), so that would be a misleading half-fix; reverted
it and said so in a comment instead. The seed_everything swap stays, so
reviving the script does not reintroduce the bug.
4. Added the missing GPU coverage for the unweighted (broadcast_to) branch,
raised the atomics-nondeterminism probe to 32 repeats, and dropped an unused
exception binding.
Checked and found fine: no driver switches CUDA device after the seed call, so
seeding the current device is sufficient; out-of-range and empty inputs behave
the same in both branches.
Re-verified on ldas-pcdev13: GPU seeds 101/202 still bit-identical across
repeats and different from each other, the 40-iteration fully adaptive pair
still bit-identical, and every output file bit-identical to the previous
commit's -- these changes move no numbers. 11 passed GPU, 6 passed / 5 skipped
CPU-only, 51 passed across neighbouring integrator suites (unchanged from base).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oshaughnessy-junior
had a problem deploying
to
private-review-dispatch-rift
August 16, 2026 11:54 — with
GitHub Actions
Error
…his change Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oshaughnessy-junior
had a problem deploying
to
private-review-dispatch-rift
August 16, 2026 11:55 — with
GitHub Actions
Error
oshaughnessy-junior
marked this pull request as ready for review
August 16, 2026 11:55
oshaughnessy-junior
deployed
to
private-review-dispatch-rift
August 16, 2026 11:55 — with
GitHub Actions
Active
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.
Problem
--seeddoes not make GPU ILE runs reproducible.bin/integrate_likelihood_extrinsic_batchmodeimplemented it as a barenumpy.random.seed(opts.seed). The samplers, however, draw through the arraybackend they were configured with —
self.xpyon an instance,xpy_defaultatmodule scope — and that backend is
cupywhenever the job runs on a GPU:self.xpy.random.uniforminmcsamplerGPU.draw_simplified(the inverse-CDFproposal — this is the draw that decides the answer)
xpy_default.random.uniforminmcsamplerAdaptiveVolume.sample_from_binsself.xpy.random.choicein the fair-draw / extrinsic-resample paths ofmcsamplerGPU, mcsamplerAV, mcsamplerPortfolio, mcsamplerEnsemble
cupy keeps its own global generator, per device, which
numpy.random.seednevertouches. Reported measurement: two byte-identical GPU invocations with
--seed 101gave lnL = 75.857 (n_eff 1.02) and lnL = 71.687 (n_eff 2.04), a4.17 nat spread. Reproduced here on the ILE-GPU-Paper demo at 0.287 nats.
Beyond being unbisectable, this silently invalidates any paired / replicate-seed
comparison design run on GPU, because the "same seed" arms are not in fact
paired. It was found while trying to run a stencil comparison campaign, which
had to be restricted to CPU as a result.
None of the samplers hold their own
Generatorobjects — they all usemodule-global backend state — so global seeding is the correct fix, not
per-instance plumbing.
Seeding the RNGs was necessary but not sufficient
After seeding cupy, same-seed GPU runs still disagreed at ~1e-14.
cupy.bincount(..., weights=...)accumulates through floatatomicAdd, whoseordering follows GPU thread scheduling. Measured on an RTX 2080 Ti, 7/7 repeated
calls on byte-identical input disagreed at ~2e-15 relative (the unweighted
bincount is exact — integer atomics). RIFT hits this in
RIFT/likelihood/vectorized_general_tools.histogram, which builds the adaptedsampling CDF — so the ULP noise is injected into every subsequent draw.
What isolated it: pinning
PYTHONHASHSEEDdid not help, andn_effdivergedwhile
sqrt(2 lnLmax)stayed bit-identical — same sample stream, so the defectwas in an accumulator, not the RNG.
Changes
RIFT/integrators/seeding.py—seed_everything(seed)seeds everybackend a RIFT sampler can reach (python, numpy, cupy, torch) and returns/prints
what it actually managed to seed. The failure mode this fixes was invisible,
so the report is deliberate. Absent backends are reported, not raised: a
CPU-only install has no cupy, and only mcsamplerNFlow needs torch.
RIFT/likelihood/vectorized_general_tools.py— deterministic weighted-bincountbranch (argsort + prefix-sum differencing), behind a module flag defaulting to
False.seed_everythingflips it, so unseeded production runs pay nothing.Measured cost 1.2–1.5x, on a call that fires once per parameter per adaptation —
far off the likelihood hot path. The flag is pushed from
seedingrather thanpulled, so
RIFT.likelihoodkeeps no dependency onRIFT.integrators.--seed:integrate_likelihood_extrinsic_batchmode,..._batchmode_lisa,integrate_likelihood_extrinsic,ile_postproc_add_time.test/integrators/test_seeding_reproducibility.py— GPU tests skipcleanly on a CPU-only machine.
Verification
ILE-GPU-Paper demo (
.travis/ILE-GPU-Paper/demos), onldas-pcdev13,RTX 2080 Ti / sm_75, cupy 10.6 / CUDA 11.2,
CUDA_VISIBLE_DEVICES=3.--sampler-method adaptive_cartesian_gpu: seeds 101 and 202 each reproducebit-identical output files across repeat runs; the two seeds differ from
each other, so this seeds rather than freezes.
--sampler-method AV: same — bit-identical at seed 101, differs at 202.--no-adapt-after-first,--n-max 4e5, 40iterations): every iteration diagnostic line bit-identical across the pair. The
fix survives the adaptation feedback loop, not just a frozen proposal.
9e-13 nats — the rounding difference of the new summation order.
New suite: 9 passed on GPU; 5 passed / 4 skipped CPU-only.
Neighbouring integrator suites run base-vs-branch on both CPU and GPU are
identical (51 passed CPU; 49 passed / 2 failed GPU, both failures pre-existing on
rift_O4d).test_mcsampler_gpu.py,test_mcsampler_foridiots.pyandtest_mcsamplerEnsemble*.pyfail at collection on the base branch too —pre-existing and unrelated.
Self-review findings (second commit)
An adversarial pass over the first commit turned up four things, all addressed:
The default path was no longer byte-for-byte the old code. The
ascontiguousarraythat materializes the unweightedbroadcast_toview sat inhistogram(), so an unseeded run allocatedn_samplesof weights it neverpreviously needed. Moved inside the deterministic branch — with the flag off,
_bincount_weightedis now literally the originalxpy.bincountcall.Prefix-sum differencing has an accuracy cost the first commit did not state.
A bin total is the difference of two partial sums both of order the grand total,
so a bin's relative error is amplified by
(total / bin). Measured against anexact rational reference at
n_bins=100:Acceptable: this histogram is a proposal density, not an estimator — the
importance weights correct for whatever it is, it is consumed as a 100-bin
interpolated CDF, and
--adapt-floor-levelmixes a uniform component in on top.The atomic branch's extra accuracy is unusable anyway, being irreproducible.
Now in the docstring and pinned by a test at 1e-9 so it cannot drift.
ile_postproc_add_timeis dead code, not a driver this fixes. It readsopts.seedwith no matchingadd_option, so it dies withAttributeErroratthat line. Adding
--seedjust moves the crash to the next undefined option(
manual_logarithm_offset), which would be a misleading half-fix — reverted, andsaid so in a comment. The
seed_everythingswap stays so reviving it does notreintroduce the bug. Resurrecting that script is out of scope here.
Added the missing GPU coverage for the unweighted
broadcast_tobranch, raisedthe atomics probe to 32 repeats, dropped an unused exception binding.
Checked and found fine: no driver switches CUDA device after the seed call, so
seeding the current device is sufficient; out-of-range and empty inputs behave
identically in both branches.
Every output file after these changes is bit-identical to the first commit's — the
review moved no numbers. Final counts: 11 passed GPU, 6 passed / 5 skipped
CPU-only, 51 passed across neighbouring integrator suites (unchanged from base).
Known residual, deliberately not fixed here
statutils.bootstrap_lnZ_quantilesdefaultsrng_seed=None→numpy.random.default_rng(None), which pulls OS entropy, so thelnZ_ci90diagnostic is irreproducible even on CPU. It is reporting-only (it does not feed
lnL/lnZ) and is gated behind a large-sigma condition. Left alone by decision, to
avoid perturbing the existing numpy stream in
statutils.🤖 Generated with Claude Code