Problem
A training run is full of random choices: initial weights, the order of the data, dropout masks, augmentations, which examples land in the validation split. Change any of them and the final model changes, sometimes by more than the effect you are trying to measure. If two runs of the same script give accuracies of 91.2% and 90.6%, you cannot tell whether your new idea helped by 0.3 points, and you cannot debug a loss spike you cannot make happen again.
Fixing a seed is the start, not the end. Data-loader workers run in separate processes with their own generators. Some GPU kernels add numbers in whatever order threads finish, and floating-point addition is not associative, so the same inputs give slightly different outputs. And a result is only reproducible by someone else if they can get the same data, the same code and the same library versions. PyTorch's own notes start with the limit: completely reproducible results are not guaranteed across releases, commits or platforms, or between CPU and GPU, even with identical seeds (randomness.md 5-7).
Intuition
A pseudo-random generator is a long, fixed list of numbers, and the seed picks where in the list you start. Two runs with the same seed read the same numbers in the same order, so the same calls get the same values. Reproducibility breaks in three ways: a generator you forgot to seed starts somewhere unknown; an extra or missing draw shifts everything after it to a different place in the list; and a computation that does not use randomness at all still varies because the hardware schedules its additions differently. The fourth way is outside the program: the data file, the code or a library changed between the runs.
The first two are about random numbers and are fixed by seeding correctly. The third has nothing to do with randomness. The fourth is about what the run was given.
Mechanics
Seeding every generator
A PyTorch program usually draws from four kinds of generator. torch.manual_seed(seed) seeds the CPU generator and, through _manual_seed_impl, every CUDA, MPS, XPU and MTIA device and any custom backend, and returns the CPU Generator. Python's random and NumPy's global np.random are separate generators that PyTorch does not touch, so a script that wants all three repeatable seeds all three (randomness.md 25-72). torch.initial_seed() returns the seed in use, and get_rng_state / set_rng_state save and restore the generator's position, which is how a checkpoint resumes the same random stream.
A single global generator has a weakness: every draw moves everyone's position. Insert one extra random call (a new augmentation, a dropout layer, a debug sample) and every later draw comes from a different place in the stream, so the head's initial weights change even though you only touched the data pipeline. torch.random.fork_rng() runs a block and then restores the global state, so the block's draws do not shift what comes after. Passing an explicit torch.Generator to the code that needs randomness, one per purpose, goes further: each stream depends only on its own seed.
Data-loader workers
With num_workers > 0, each worker is a separate process. When an iterator starts, the loader draws one 64-bit _base_seed from loader.generator (or the global torch generator if none was given). Each worker then runs its seeding block: seed = base_seed + worker_id, then random.seed(seed), torch.manual_seed(seed), and, when NumPy is installed, np.random.seed(_generate_state(base_seed, worker_id)). _generate_state is a pure-Python copy of NumPy's SeedSequence hashing that turns (worker id, low and high 32 bits of base_seed) into four 32-bit words. Your worker_init_fn runs after this seeding and before data loading (dataloader.py 188-190), and get_worker_info() gives it the worker's id and seed.
So at this release every worker's global generators already differ, and they are repeatable if base_seed is. The order of the data is a separate draw: RandomSampler.__iter__ uses the loader's generator, and without one it draws a fresh seed from the global torch generator. PyTorch's recommended recipe passes a seeded torch.Generator as generator= and seeds random and NumPy from torch.initial_seed() in worker_init_fn (randomness.md 251-272). The trap that remains is a generator object you build yourself, for example self.rng = np.random.default_rng(0) in Dataset.__init__: each worker gets a copy of the same object in the same state, and the per-worker reseeding never touches it.
Nondeterministic kernels
Floating-point addition rounds after every step, so (a + b) + c and a + (b + c) can differ in the last bit. A GPU kernel that sums with atomic adds, such as scatter_add_ or the backward of an embedding or an index, adds contributions in whatever order threads arrive, which changes between runs. cuDNN adds another source: with torch.backends.cudnn.benchmark = True it times several convolution algorithms for each new input shape and keeps the fastest, and timing noise can pick a different one next run (randomness.md 73-92). Its default is False (cudnn/__init__.py 192-194).
torch.use_deterministic_algorithms(True) makes PyTorch pick a deterministic implementation where one exists and raise a RuntimeError for an operation that has none; its docstring lists both groups, for example index_add and scatter_add_ on CUDA switch, while AdaptiveAvgPool2d's CUDA backward and histc on CUDA raise. warn_only=True turns the errors into warnings so you can find every offender in one run. The same call turns on Inductor's deterministic mode, which skips on-device benchmarking for compiled code, and with it torch.utils.deterministic.fill_uninitialized_memory fills tensors from torch.empty with a known value (randomness.md 235-249). torch.backends.cudnn.deterministic = True (cudnn/__init__.py 221-229) is the older, cuDNN-only switch for convolutions. set_deterministic_debug_mode is the same setting under the names "default", "warn" and "error". Deterministic kernels are usually slower, and PyTorch says so in the docstring.
random_state in scikit-learn
Every scikit-learn estimator or splitter that uses randomness takes a random_state argument and passes it through check_random_state: None returns NumPy's global RandomState, an int builds a new RandomState(seed) each call, and a RandomState instance is returned as is; anything else raises ValueError. That difference matters. With an int, every call (every split, every fit) starts from the same place and gives the same result. With an instance, each call advances the shared generator, so calls differ from one another but the whole program is still repeatable. KFold shuffles with check_random_state(self.random_state), and at this release passing a random_state with shuffle=False raises an error because it would have no effect (_split.py 364-371).
Versioning data, code and environment
Seeds only reproduce a run on the same inputs. Record a content hash of the data (sha256 of each file, or of a sorted list of file hashes for a directory), the git commit of the code and whether the working tree was clean, and the exact environment: a lockfile with every package version, plus the Python, CUDA, cuDNN and driver versions and the GPU model, since kernels differ across them. Store these with the seed, the config and the metrics in one manifest next to each run's checkpoints. Tools such as DVC, lakeFS or a model registry do this at scale; the principle is that every input is named by its hash, not by a path that can change.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
torch.manual_seed |
int → Generator |
Seeds the CPU generator and every accelerator's. | random.py 49-86 |
get_rng_state / set_rng_state |
uint8 state tensor | Save and restore the CPU generator's position. | random.py 27-46 |
fork_rng |
devices, enabled | Context manager that restores the global state on exit. | random.py 156-239 |
_base_seed |
one int64 per iterator | Drawn from loader.generator; all worker seeds derive from it. |
dataloader.py 690-694 |
| Worker seeding | base_seed + worker_id | Seeds random, torch and NumPy in each worker. |
worker.py 274-282 |
_generate_state |
(base_seed, worker_id) → 4 × uint32 | SeedSequence-style hash for NumPy's worker seed. | worker.py 192-241 |
RandomSampler.__iter__ |
n, generator | Shuffle order; draws its own seed if no generator is given. | sampler.py 160-166 |
use_deterministic_algorithms |
mode, warn_only=False |
Deterministic kernels or an error; also Inductor's deterministic mode. | __init__.py 1765-1924 |
cudnn.benchmark, cudnn.deterministic |
bool, both default False | Autotuned convolution choice; deterministic cuDNN algorithms. | cudnn/__init__.py 221-229 |
check_random_state |
None, int or RandomState |
Global, fresh or shared NumPy generator for scikit-learn. | validation.py 1455-1485 |
You set torch.manual_seed(0) at the top of the script, but two runs still differ in their first batch. The loader has shuffle=True and four workers. What is the first thing to check?
Whether anything draws from the global torch generator between the seed and the first iteration, in a different amount from run to run. Without generator=, both the shuffle order and _base_seed come from the global generator, so a model whose construction varies (or a library that consumes random numbers on import) moves both. Passing a dedicated seeded torch.Generator to the DataLoader isolates the data order from everything else.
Worked example
The seeds four workers get
Take base_seed = 4181960235772163819 (any 64-bit value the loader's generator could draw) and 4 workers. The Python and torch seed is base_seed + worker_id. The NumPy seed is four 32-bit words from _generate_state; this page's port of it gives exactly the words NumPy's own SeedSequence([worker_id, low32, high32, 0]).generate_state(4) gives, which the tests check.
| Worker | random / torch seed | NumPy seed (4 × uint32) |
|---|---|---|
| 0 | 4181960235772163819 | 1310905175, 2081109477, 4124378009, 3038891885 |
| 1 | 4181960235772163820 | 2056174861, 3633103922, 3952264541, 381567842 |
| 2 | 4181960235772163821 | 411294537, 1608406008, 3287270764, 2351874606 |
| 3 | 4181960235772163822 | 1534353524, 2778767537, 1060257585, 3308704576 |
The torch seeds differ only in the last digit, which is fine because the generator scrambles its seed. The NumPy words share no visible pattern: the hash spreads neighboring worker ids across all 128 bits of state.
Each worker draws three crop offsets in 0-31. A generator built in Dataset.__init__ is copied into every process, so all four workers produce the same crops (1 distinct sequence); seeding it from get_worker_info().seed gives 4 distinct sequences that repeat from run to run.
One extra draw moves everything after it
Initialize two layers of 3 weights each from one generator seeded with 0, then add a one-draw "flip" augmentation between them. With a shared stream the embedding is unchanged but the head's weights slide along by one draw. With one generator per purpose, seeded from sha256 of "seed/name", the head does not move.
| Stream | Layer | Before | After inserting flip | Same? |
|---|---|---|---|---|
| one shared generator | embedding | 0.942, −1.397, −0.680 | 0.942, −1.397, −0.680 | yes |
| head | 0.371, −1.016, −0.072 | −1.016, −0.072, 0.179 | no | |
| one generator per purpose | embedding | 0.261, −0.171, −1.133 | 0.261, −0.171, −1.133 | yes |
| head | −0.366, 0.906, 2.006 | −0.366, 0.906, 2.006 | yes |
In the shared stream the new draw takes 0.371, which used to be the head's first weight, and the head's last weight is a value no run used before. Nothing is wrong with either run; they just cannot be compared weight for weight. The derived seeds are embedding = 5066150304409732245, flip = 10460745164837258374, head = 11897045697796561589.
The same 4096 numbers, eight sums
Draw 4096 standard normal values (seed 11), round each to float32, and add them in float32 in different orders: left to right, as a pairwise tree, in 32 blocks whose partial sums are then added (how a GPU reduction works), and in 5 shuffled orders standing in for atomic adds arriving in different orders. The exact sum, from math.fsum, is 42.02289481. One float32 unit in the last place (ULP) near it is 2−18 = 3.815e-06.
| Order | float32 sum | Error | Error in ULPs |
|---|---|---|---|
| left to right | 42.02291107 | 1.63e−05 | 4.3 |
| pairwise tree | 42.02288818 | −6.63e−06 | −1.7 |
| 32 blocks, then their partial sums | 42.02287292 | −2.19e−05 | −5.7 |
| arrival order 1 | 42.02291107 | 1.63e−05 | 4.3 |
| arrival order 2 | 42.02289581 | 1.00e−06 | 0.3 |
| arrival order 3 | 42.02291870 | 2.39e−05 | 6.3 |
| arrival order 4 | 42.02299500 | 1.00e−04 | 26.3 |
| arrival order 5 | 42.02284622 | −4.86e−05 | −12.7 |
Eight orders give 7 distinct results, spread over 39 ULPs (1.49e-04). None of them is a bug: each is a correctly rounded float32 computation. A loss that differs by this much in step 1 feeds a slightly different gradient into step 2, and over thousands of steps the runs drift apart visibly. That is why deterministic mode has to fix the order, not just the seed.
Every circle rounds its result to float32. On 4096 values the left-to-right sum is off by 4.3 ULPs and the pairwise tree by −1.7; the tree is usually closer because its partial sums stay similar in size.
An int versus a shared generator
Split 10 samples into 5 shuffled folds twice, the way KFold(shuffle=True) does through check_random_state. Python's random.Random stands in for NumPy's RandomState here, so the fold contents differ from scikit-learn's but the behavior is the same.
| Argument | First call's test folds | Second call's test folds | Same? |
|---|---|---|---|
random_state=0 |
7,8 | 1,5 | 3,4 | 0,2 | 6,9 | 7,8 | 1,5 | 3,4 | 0,2 | 6,9 | yes |
random_state=RandomState(0) |
7,8 | 1,5 | 3,4 | 0,2 | 6,9 | 4,9 | 6,8 | 0,1 | 2,7 | 3,5 | no |
The int rebuilds the generator each call, so both calls agree; the instance continues where the first call stopped. Both are reproducible run to run. Use an int when two places (say, two models being compared) must see identical splits, and an instance when you want different but repeatable randomness in each call. random_state=None uses the global generator and is repeatable only if something seeded it.
A run manifest
The toy run trains on a five-row CSV with a three-line script and a three-package lockfile. Changing one target from 3.2 to 3.3 changes 1 byte of the data and the whole hash, which is what makes a hash a reliable name.
| Input | How it is named | Value |
|---|---|---|
| data | sha256 of the file | 5d087406f8f48cec… |
| data, one value edited | sha256 of the file | 58a3b0b6a12ff427… |
| code | git blob id: sha1 of "blob <size>\0" + bytes | f1b034256689f3f6… |
| environment | sha256 of the lockfile | 8a39f48acef1ff28… |
| seed | the value itself | 0 |
The code hash is exactly what git hash-object prints for that script, so it can be looked up in the repository's history. In practice record the commit id (which covers every file) and a flag for uncommitted changes, rather than one blob.
Implementation
import hashlib, json, os, platform, random, subprocess
import numpy as np
import torch
from torch.utils.data import DataLoader, get_worker_info
def seed_everything(seed, deterministic=True):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed) # CPU and every GPU
torch.backends.cudnn.benchmark = False
torch.use_deterministic_algorithms(deterministic, warn_only=False)
class Crops(torch.utils.data.Dataset):
def __init__(self, images):
self.images = images
self.rng = None # built per worker, not here
def __getitem__(self, i):
if self.rng is None:
info = get_worker_info()
self.rng = np.random.default_rng(info.seed if info else torch.initial_seed())
x, y = self.rng.integers(0, 32, size=2)
return self.images[i][:, y:y + 224, x:x + 224]
def __len__(self):
return len(self.images)
def loader(dataset, seed):
g = torch.Generator()
g.manual_seed(seed) # data order and base_seed only
return DataLoader(dataset, batch_size=64, shuffle=True, num_workers=4, generator=g)
def manifest(seed, data_paths, config):
def sha256(path):
with open(path, "rb") as f:
return hashlib.file_digest(f, "sha256").hexdigest()
git = lambda *a: subprocess.run(["git", *a], capture_output=True, text=True).stdout.strip()
return {
"seed": seed, "config": config,
"data": {p: sha256(p) for p in sorted(data_paths)},
"commit": git("rev-parse", "HEAD"), "dirty": bool(git("status", "--porcelain")),
"torch": torch.__version__, "cuda": torch.version.cuda,
"cudnn": torch.backends.cudnn.version(), "python": platform.python_version(),
"gpu": torch.cuda.get_device_name() if torch.cuda.is_available() else None,
}
# Some CUDA matmuls also need this set before the first CUDA call:
# os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
The snippet has not been run against PyTorch here; it only uses the calls linked above. The CUBLAS_WORKSPACE_CONFIG line comes from NVIDIA's cuBLAS documentation on reproducibility, not from PyTorch's docstring at this release, which does not mention it. hashlib.file_digest needs Python 3.11 or later.
Tradeoffs
| Choice | Gains | Costs |
|---|---|---|
use_deterministic_algorithms(True) | Bitwise-identical runs on one platform | Slower kernels; some operations raise instead of running |
cudnn.benchmark = True | Fastest convolution for each shape | Algorithm choice can change between runs |
| One generator per purpose | Changing one component does not move another's randomness | More plumbing; every random call needs its generator |
| One global seed | One line of code | Any added or removed draw shifts everything after it |
| Reproduce bitwise vs statistically | Bitwise: easy debugging and exact comparison | Statistical (several seeds, report mean and spread) answers whether an idea helps, which bitwise reproduction of one seed cannot |
| Hash data and lock the environment | Someone else can rerun the result later | Storage for snapshots; lockfiles need updating |
Your baseline scores 91.2% with seed 0 and your change scores 91.5% with seed 0. Does making the runs bitwise reproducible tell you the change helped?
No. Bitwise reproducibility means seed 0 gives 91.5% every time, not that the 0.3-point gap is real. Seed-to-seed variation (initialization, data order, dropout) can be as large as the gap. Run both versions over several seeds and compare the distributions, as on cross-validation; use the fixed seed to debug, not to decide.
Two runs on identical hardware with every seed fixed and deterministic mode on diverge after a library upgrade. Is that a bug?
Not necessarily. PyTorch guarantees nothing across releases: a new version may choose a different kernel, change a reduction's order or draw random numbers differently. That is why the environment is part of the manifest. Pin versions for the runs you need to compare, and expect only statistical agreement across upgrades.
Check yourself
- Why does
self.rng = np.random.default_rng(0)inDataset.__init__give every worker the same augmentations, even though PyTorch reseeds NumPy in each worker? - What two things does the DataLoader's
generatorargument control? - Why can a GPU sum of the same tensor differ between runs with no randomness involved?
- When would you pass an int to
random_stateand when aRandomStateinstance?
Connections
Read first
- The training loop intro
Read next
No page builds on this one yet.
Saving and restoring generator states is the RNG part of checkpointing. The worker processes and samplers are explained on data loading and batching, why float32 rounding depends on order on mixed precision, and how kernels are scheduled on GPU execution. Fixed splits matter for data splits and cross-validation, where comparing models across several seeds is the honest test. Dropout, one of the random draws, is on regularization.
References
- PyTorch v2.15.0: Reproducibility notes,
manual_seed,fork_rng,_base_seed,_generate_state, worker seeding,RandomSampler.__iter__,use_deterministic_algorithms,set_deterministic_debug_mode, cuDNN flags. - scikit-learn 1.9.1:
check_random_state,KFoldshuffling, theshuffle=Falsecheck. - Goldberg. What Every Computer Scientist Should Know About Floating-Point Arithmetic. ACM Computing Surveys 23(1), 1991. doi:10.1145/103162.103163
- O'Neill. PCG: A Family of Simple Fast Space-Efficient Statistically Good Algorithms for Random Number Generation. 2014; its seed-mixing design underlies NumPy's
SeedSequence. pcg-random.org - Bouthillier, Laurent and Vincent. Unreproducible Research is Reproducible. ICML 2019. PMLR 97