Skip to content

Commit f3cc6f4

Browse files
committed
ENH: derive Monte Carlo per-index seeds in O(1) and seed models with 128-bit ints
Each simulation index is seeded from its own child of the run's root seed. Building that child by extending the captured root spawn_key is bit-identical to root.spawn(number_of_simulations)[index] but O(1) in time and memory, so a worker reconstructs any index from a small root state instead of the full spawned list being materialized and pickled to every process. Hand each model a 128-bit int rather than a SeedSequence: a plain int is the seed type accepted alike by numpy.random.default_rng, RandomState and the stdlib random.Random (which rejects a SeedSequence with a TypeError since Python 3.11), so a custom sampler whose reset_seed documents an int keeps working; all four uint32 words are combined by value (not via tobytes) so the seed is byte-order independent and keeps the full 128-bit pool instead of collapsing to 32 bits. The random_seed docstring now lists the accepted types and notes the seeding is informed by SPEC 7 while keeping immutable seed-snapshot semantics. The unit tests assert the SeedSequence copy preserves full .state (an entropy-only copy would fail), the O(1) child equals spawn bit-for-bit -- including a root whose child counter has advanced and indices past 2**32 -- and each model receives a distinct 128-bit int. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
1 parent 69bb3ce commit f3cc6f4

2 files changed

Lines changed: 238 additions & 67 deletions

File tree

rocketpy/simulation/monte_carlo.py

Lines changed: 88 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -191,15 +191,22 @@ def simulate(
191191
number of workers will be equal to the number of CPUs available.
192192
A minimum of 2 workers is required for parallel mode.
193193
Default is None.
194-
random_seed : int or numpy.random.SeedSequence, optional
194+
random_seed : int, numpy integer, sequence of ints, or SeedSequence, optional
195195
Root seed for the run. When provided, the sampled inputs are
196-
reproducible and identical across serial and parallel execution
197-
and across any number of workers, because each simulation index
198-
draws from its own child stream spawned from this root. It accepts
199-
an int or a ``SeedSequence``; a supplied ``SeedSequence`` is copied
200-
rather than consumed, so repeated calls with the same seed produce
201-
the same inputs. Default is None, which draws fresh entropy (not
202-
reproducible), preserving the previous behavior.
196+
reproducible and identical across serial and parallel execution and
197+
across any number of workers: each simulation index derives its own
198+
decorrelated child stream from this root, so index ``i`` receives the
199+
same inputs no matter which worker runs it. A supplied ``SeedSequence``
200+
is copied from its full state rather than consumed, so repeated calls
201+
with the same seed reproduce the same inputs. Each model is reseeded
202+
with a 128-bit integer -- the seed type a custom sampler's
203+
``reset_seed`` accepts. A stateful ``numpy.random.Generator`` or
204+
``BitGenerator`` is rejected (it is an RNG to draw from, not a fixed
205+
seed); pass ``rng.bit_generator.seed_seq`` to seed from one. Default is
206+
None, which draws fresh entropy on each run -- the previous,
207+
non-reproducible default. This seeding is informed by Scientific Python
208+
SPEC 7 but keeps immutable seed-snapshot semantics rather than sharing a
209+
``Generator``.
203210
kwargs : dict
204211
Custom arguments for simulation export of the ``inputs`` file. Options
205212
are:
@@ -233,6 +240,9 @@ def simulate(
233240
self._export_config = kwargs
234241
self.number_of_simulations = number_of_simulations
235242
self._initial_sim_idx = self.num_of_loaded_sims if append else 0
243+
# Small, picklable root seed state captured once per run; every
244+
# simulation index derives its child seed from it (see __child_seed).
245+
self.__root_state = None
236246

237247
print("Starting Monte Carlo analysis")
238248

@@ -302,19 +312,52 @@ def __root_seed_sequence(random_seed):
302312
)
303313
return np.random.SeedSequence(random_seed)
304314

315+
def __capture_root_state(self, random_seed):
316+
"""Capture the small, picklable root seed state for this run.
317+
318+
Stored once so serial mode and every parallel worker derive the same
319+
per-index child seeds from it (see ``__child_seed``), instead of
320+
materializing and pickling the full ``spawn(number_of_simulations)``
321+
list to each process.
322+
"""
323+
root = self.__root_seed_sequence(random_seed)
324+
self.__root_state = (
325+
root.entropy,
326+
root.spawn_key,
327+
root.pool_size,
328+
root.n_children_spawned,
329+
)
330+
331+
def __child_seed(self, sim_idx):
332+
"""Return the seed sequence for a single simulation index.
333+
334+
This equals ``root.spawn(number_of_simulations)[sim_idx]`` but is O(1)
335+
in time and memory: ``SeedSequence.spawn`` derives child ``i`` by
336+
appending ``n_children_spawned + i`` to the parent ``spawn_key``, so
337+
rebuilding that one child directly reproduces it bit-for-bit while
338+
letting a worker reconstruct any index from the small root state alone.
339+
"""
340+
entropy, spawn_key, pool_size, base = self.__root_state
341+
return np.random.SeedSequence(
342+
entropy=entropy,
343+
spawn_key=(*spawn_key, base + sim_idx),
344+
pool_size=pool_size,
345+
)
346+
305347
def __seed_simulation(self, child_seed):
306348
"""Reseed the stochastic models for a single simulation index.
307349
308350
The per-index child seed is split three ways so the environment,
309351
rocket and flight draw from independent streams instead of sharing
310352
one. Seeding per simulation index (not per worker) is what makes the
311353
sampled inputs invariant to the execution mode and to the number of
312-
workers.
354+
workers. Each sub-stream is handed over as a 128-bit ``int`` (see
355+
``_seed_sequence_to_int``) so custom samplers keep working.
313356
"""
314357
env_seed, rocket_seed, flight_seed = child_seed.spawn(3)
315-
self.environment._set_stochastic(env_seed)
316-
self.rocket._set_stochastic(rocket_seed)
317-
self.flight._set_stochastic(flight_seed)
358+
self.environment._set_stochastic(_seed_sequence_to_int(env_seed))
359+
self.rocket._set_stochastic(_seed_sequence_to_int(rocket_seed))
360+
self.flight._set_stochastic(_seed_sequence_to_int(flight_seed))
318361

319362
def __run_in_serial(self, random_seed=None): # pylint: disable=too-many-statements
320363
"""
@@ -334,15 +377,13 @@ def __run_in_serial(self, random_seed=None): # pylint: disable=too-many-stateme
334377
n_simulations=self.number_of_simulations,
335378
start_time=time(),
336379
)
337-
child_seeds = self.__root_seed_sequence(random_seed).spawn(
338-
self.number_of_simulations
339-
)
380+
self.__capture_root_state(random_seed)
340381
try:
341382
while sim_monitor.keep_simulating():
342383
sim_idx = sim_monitor.increment() - 1
343384
inputs_json, outputs_json = "", ""
344385

345-
self.__seed_simulation(child_seeds[sim_idx])
386+
self.__seed_simulation(self.__child_seed(sim_idx))
346387
flight = self.__run_single_simulation()
347388
inputs_json = self.__evaluate_flight_inputs(sim_idx)
348389
outputs_json = self.__evaluate_flight_outputs(flight, sim_idx)
@@ -399,19 +440,18 @@ def __run_in_parallel(self, n_workers=None, random_seed=None):
399440
)
400441

401442
processes = []
402-
# One independent child seed per simulation index (not per
403-
# worker), shared with every worker. The shared counter assigns
404-
# indices, and index i always seeds from child_seeds[i], so the
405-
# sampled inputs do not depend on the number of workers.
406-
child_seeds = self.__root_seed_sequence(random_seed).spawn(
407-
self.number_of_simulations
408-
)
443+
# Each worker derives one independent child seed per simulation
444+
# index (not per worker) from the shared root state: the counter
445+
# assigns indices and index i always seeds from __child_seed(i), so
446+
# the sampled inputs do not depend on the number of workers. The
447+
# root state is small and travels with the pickled instance, so no
448+
# per-index seed list is materialized or sent to each process.
449+
self.__capture_root_state(random_seed)
409450

410451
for _ in range(n_workers):
411452
sim_producer = multiprocess.Process(
412453
target=self.__sim_producer,
413454
args=(
414-
child_seeds,
415455
sim_monitor,
416456
mutex,
417457
simulation_error_event,
@@ -453,16 +493,11 @@ def __validate_number_of_workers(self, n_workers):
453493
raise ValueError("Number of workers must be at least 2 for parallel mode.")
454494
return n_workers
455495

456-
def __sim_producer(self, child_seeds, sim_monitor, mutex, error_event): # pylint: disable=too-many-statements
496+
def __sim_producer(self, sim_monitor, mutex, error_event): # pylint: disable=too-many-statements
457497
"""Simulation producer to be used in parallel by multiprocessing.
458498
459499
Parameters
460500
----------
461-
child_seeds : list[numpy.random.SeedSequence]
462-
One seed sequence per simulation index. Before each simulation
463-
the worker seeds the stochastic models from
464-
``child_seeds[sim_idx]``, where ``sim_idx`` comes from the shared
465-
counter, so the inputs are invariant to the number of workers.
466501
sim_monitor : _SimMonitor
467502
The simulation monitor object to keep track of the simulations.
468503
mutex : multiprocess.Lock
@@ -478,7 +513,7 @@ def __sim_producer(self, child_seeds, sim_monitor, mutex, error_event): # pylin
478513

479514
inputs_json, outputs_json = "", ""
480515

481-
self.__seed_simulation(child_seeds[sim_idx])
516+
self.__seed_simulation(self.__child_seed(sim_idx))
482517
flight = self.__run_single_simulation()
483518
inputs_json = self.__evaluate_flight_inputs(sim_idx)
484519
outputs_json = self.__evaluate_flight_outputs(flight, sim_idx)
@@ -1675,14 +1710,34 @@ def export_errors_to_json(self, filename):
16751710
self._write_log_to_json(self.errors_log, filename)
16761711

16771712

1713+
def _seed_sequence_to_int(seed_sequence):
1714+
"""Collapse a ``SeedSequence`` into a 128-bit Python ``int``.
1715+
1716+
A plain ``int`` is the one seed type accepted alike by
1717+
``numpy.random.default_rng``, ``numpy.random.RandomState`` and the stdlib
1718+
``random.Random`` (which rejects a ``SeedSequence`` with a ``TypeError``
1719+
since Python 3.11), so a custom sampler whose ``reset_seed`` documents an
1720+
``int`` keeps working. All four ``uint32`` words are combined to keep the
1721+
full 128-bit pool, so the environment/rocket/flight sub-streams stay
1722+
decorrelated instead of collapsing to a single 32-bit word.
1723+
1724+
The words are combined by value (little-endian word order), not via
1725+
``tobytes()``, so the seed is the same on big- and little-endian machines
1726+
-- a byte-order-dependent seed would break the cross-platform
1727+
reproducibility this whole scheme exists to provide.
1728+
"""
1729+
words = seed_sequence.generate_state(4, dtype=np.uint32)
1730+
return sum(int(word) << (32 * position) for position, word in enumerate(words))
1731+
1732+
16781733
def _claim_next_index(sim_monitor, mutex):
16791734
"""Atomically claim the next 0-based simulation index, or ``None`` if done.
16801735
16811736
``keep_simulating()`` and ``increment()`` are two separate manager calls, so
16821737
the shared ``mutex`` has to be held across both. Without it, two workers can
16831738
each pass the ``count < number_of_simulations`` check at the tail before
1684-
either increments, and both then claim an index, overrunning the requested
1685-
number of simulations and indexing past the per-index seed list.
1739+
either increments, and both then claim an index, running more simulations
1740+
than were requested (and duplicating a simulation index).
16861741
"""
16871742
mutex.acquire()
16881743
try:

0 commit comments

Comments
 (0)