ml-models / Inference and deployment / Reproducibility intro

Reproducibility

Run the same training script twice and you would like the same model twice. That needs every source of randomness pinned, kernels that add numbers in the same order, and a record of exactly which data, code and libraries went in. This page traces where randomness enters a PyTorch run, computes the seeds DataLoader gives its workers, shows two seeding bugs, sums the same 4096 numbers in eight orders and gets 7 different answers, and builds a run manifest. Every number is computed and every library link is pinned.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Seeding every generator
  5. Data-loader workers
  6. Nondeterministic kernels
  7. random_state in scikit-learn
  8. Versioning data, code and environment
  9. Worked example
  10. The seeds four workers get
  11. One extra draw moves everything after it
  12. The same 4096 numbers, eight sums
  13. An int versus a shared generator
  14. A run manifest
  15. Implementation
  16. Tradeoffs
  17. Check yourself
  18. Connections
  19. Read first
  20. Read next
  21. References

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.

Where variation enters a training run Four groups of inputs feed one training run. Seeded random number generators: Python random, NumPy, the torch CPU generator and one generator per GPU, all set by torch.manual_seed plus random.seed and NumPy seeding. Data-loader workers: one process each, seeded from base_seed plus the worker id, with base_seed drawn from the loader's generator. Kernel choice and order: cuDNN benchmarking and atomic additions, controlled by torch.backends.cudnn.benchmark and torch.use_deterministic_algorithms. Versions: data, code and environment, recorded by hashes and lockfiles. The run's output is the same only when all four are fixed. random generators Python, NumPy, torch CPU, each GPU manual_seed, random.seed data-loader workers seed = base_seed + worker_id DataLoader(generator=g) kernels algorithm choice, summation order use_deterministic_algorithms versions data, code, environment hashes, git commit, lockfile training run same output only when all four are fixed (and on the same platform)

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.

ComponentShape/ParamsMeaningSource
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.

Workerrandom / torch seedNumPy seed (4 × uint32)
041819602357721638191310905175, 2081109477, 4124378009, 3038891885
141819602357721638202056174861, 3633103922, 3952264541, 381567842
24181960235772163821411294537, 1608406008, 3287270764, 2351874606
341819602357721638221534353524, 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.

How a DataLoader seeds its workers, and the copied-generator trap The loader's generator draws one base_seed. It fans out to four worker processes, each seeded with base_seed plus its id, so their global random, torch and NumPy generators differ. A generator object created in the dataset's constructor is copied into every worker unchanged. In the example, each worker draws three random crop offsets from 0 to 31. With the copied generator all four workers draw 24, 26, 2. Reseeded from base_seed plus worker id they draw 3, 31, 1; 30, 14, 6; 7, 31, 11; 22, 25, 26. loader.generator draws base_seed worker 0 seed = base_seed + 0 copied rng: 24, 26, 2 reseeded: 3, 31, 1 worker 1 seed = base_seed + 1 copied rng: 24, 26, 2 reseeded: 30, 14, 6 worker 2 seed = base_seed + 2 copied rng: 24, 26, 2 reseeded: 7, 31, 11 worker 3 seed = base_seed + 3 copied rng: 24, 26, 2 reseeded: 22, 25, 26

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.

StreamLayerBeforeAfter inserting flipSame?
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.

Orderfloat32 sumErrorError in ULPs
left to right42.022911071.63e−054.3
pairwise tree42.02288818−6.63e−06−1.7
32 blocks, then their partial sums42.02287292−2.19e−05−5.7
arrival order 142.022911071.63e−054.3
arrival order 242.022895811.00e−060.3
arrival order 342.022918702.39e−056.3
arrival order 442.022995001.00e−0426.3
arrival order 542.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.

Two summation orders for the same four numbers Four numbers a, b, c, d are added two ways. On the left, sequentially: a plus b, then plus c, then plus d, rounding after each addition. On the right, as a tree: a plus b and c plus d in parallel, then the two partial sums. Both are correct roundings, but each addition rounds a different intermediate value, so the results can differ in the last bits. Atomic adds on a GPU pick an order at run time, so the result can change between runs. a a b b c c d d + + + ((a + b) + c) + d + + + (a + b) + (c + d)

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.

ArgumentFirst call's test foldsSecond call's test foldsSame?
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.

InputHow it is namedValue
datasha256 of the file5d087406f8f48cec…
data, one value editedsha256 of the file58a3b0b6a12ff427…
codegit blob id: sha1 of "blob <size>\0" + bytesf1b034256689f3f6…
environmentsha256 of the lockfile8a39f48acef1ff28…
seedthe value itself0

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

ChoiceGainsCosts
use_deterministic_algorithms(True)Bitwise-identical runs on one platformSlower kernels; some operations raise instead of running
cudnn.benchmark = TrueFastest convolution for each shapeAlgorithm choice can change between runs
One generator per purposeChanging one component does not move another's randomnessMore plumbing; every random call needs its generator
One global seedOne line of codeAny added or removed draw shifts everything after it
Reproduce bitwise vs statisticallyBitwise: easy debugging and exact comparisonStatistical (several seeds, report mean and spread) answers whether an idea helps, which bitwise reproduction of one seed cannot
Hash data and lock the environmentSomeone else can rerun the result laterStorage 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) in Dataset.__init__ give every worker the same augmentations, even though PyTorch reseeds NumPy in each worker?
  • What two things does the DataLoader's generator argument 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_state and when a RandomState instance?

Connections

Read first

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