NumPy RNG in multiprocessing: forked workers draw identical numbers
Tested on CPython 3.12.16 / NumPy 2.5.4, Pool(4), one draw per worker (sleep in the task so every worker gets one). With fork, np.random.random() after np.random.seed(0) in the parent gave 1 distinct value out of 4, and a module-level G = np.random.default_rng(42) also gave 1 of 4. Stdlib random gave 4 of 4, because it reseeds in the child after fork. With spawn, the legacy global gave 4 of 4 (each child starts unseeded from OS entropy, so it is not reproducible either), but the module-level default_rng(42) gave 1 of 4, since every child re-imports the module and re-seeds it identically. Fix: in the parent, build per-task generators with np.random.SeedSequence(seed).spawn(n) (or Generator.spawn(n)) and pass one to each task, or pass the SeedSequence child. Don't use seed+i: it is reproducible but has no independence guarantee. Without a sleep in the test, one fast worker can take every task and hide the duplication.
Reproduced all six results exactly on CPython 3.12.16 / NumPy 2.5.4 (Pool(4), chunksize=1, sleep 0.2 s per task): fork 1/1/4 and spawn 4/1/4 for legacy, module-level default_rng(42) and stdlib. A version caveat, because Python 3.14 changed the Linux default: on 3.14.10, mp.get_start_method() is 'forkserver', and a plain Pool(4) gave 1 distinct of 4 for both the legacy global and the module-level default_rng. That was true with and without np.random.seed(0) in the parent. The shared value was not the parent's seed(0) value and differed between my two runs, so it is neither independent nor reproducible. Explicit forkserver on 3.12.16 gave 4 of 4 for the legacy global, so 'forkserver' does not mean the same thing across these versions. The recommended fix holds on both: per-task generators from SeedSequence(0).spawn(4) or default_rng(0).spawn(4) gave 4 of 4 on 3.12.16 and 3.14.10.
Follow-up on why forkserver differs between 3.12 and 3.14, measured by recording os.getpid() at module import. On 3.14.10, a module imported by __main__ was imported once in the forkserver process (import pid ≠ worker pid), so workers forked from the server inherit its module-level state, including NumPy's legacy global RNG and any module-level default_rng. On 3.12.16 the same module was imported inside each worker (import pid = worker pid). The 3.12 source explains it: forkserver filters the preparation data for a 'main_path' key, but spawn.get_preparation_data() only provides 'init_main_from_path', so __main__ is never preloaded. 3.14 reads 'init_main_from_path'. Practical upshot: under the 3.14 default, anything seeded at import time is shared by every worker.