From b6a64ba9fe04b816f7a084d63d0db87219ebd19c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 04:04:21 +0000 Subject: [PATCH] 13 Run several simulations at once (tools/run_many.py) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Builds on PR 8 (#24)**; it uses the `-rngstart` option from PR 3. It doesn't touch any of Vida's code, and doesn't depend on PRs 10–12. [This PR's changes on their own](https://github.com/adamf/vida/compare/pr8-web-viewer...pr13-run-many). ## Run several simulations at once (`tools/run_many.py`) Within one simulation everything happens in a set order, drawing on the same stream of random numbers, so one simulation can't be split across cores without changing its results. But separate simulations don't share anything. This tool runs each one as its own copy of Vida, side by side, so a computer with 4 or 8 cores gets through 4 or 8 times as many runs. ```sh # the same simulation with -rngstart 1 to 8, four at a time python tools/run_many.py -rngstarts 1-8 -jobs 4 -- -n forest -w 100 -s 400 -t 50 # a list of different simulations, one line of options each python tools/run_many.py -file runs.txt ``` - **Names:** with `-rngstarts`, each run is named after the `-n` name and its `-rngstart` (`forest-rng1`, `forest-rng2`, ...). Its output goes where Vida puts it (`Output-forest-rng1/`), and what it prints goes to `Output-forest-rng1.log` next to it. - **How many at once:** `-jobs` defaults to the number of cores. - **Summary:** at the end it says how long everything took, and which runs failed, if any. ### Checked - **Speed:** on a 4-core machine, four 100 m, 400-seed, 50-cycle runs took 41.0 s one after another and 11.7 s side by side. - **Same results:** each of those runs' `viewer.jsonl` (every plant, every cycle) is identical to a run with the same `-rngstart` on its own, apart from the run's name. - **Unit tests** cover reading the `-rngstarts` list and the file, and naming the runs. ### Also The HOWTO gets an example 9, next to example 8's `-x`, which still runs its repeats one after another. The `-x` repeats share one stream of random numbers: the second repeat starts where the first one left off. So they can't simply run side by side with the same results. `run_many` gives each run its own `-rngstart` instead. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013425y5x98TqAv3BvHodpdx --- Readme_Files/Vida HOWTO.txt | 6 ++ tests/unit/test_run_many.py | 41 ++++++++++ tools/run_many.py | 146 ++++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 tests/unit/test_run_many.py create mode 100644 tools/run_many.py diff --git a/Readme_Files/Vida HOWTO.txt b/Readme_Files/Vida HOWTO.txt index d48f736..617e5bf 100644 --- a/Readme_Files/Vida HOWTO.txt +++ b/Readme_Files/Vida HOWTO.txt @@ -219,6 +219,12 @@ BASIC USAGE Runs the same setup three times, giving Output-repeatTest, Output-repeatTest1 and Output-repeatTest2. Because seeds, dispersal and death are random, each run turns out differently, which lets you look at the spread of outcomes. + Example 9: running several simulations at once + + python tools/run_many.py -rngstarts 1-8 -jobs 4 -- -n example9 -w 100 -s 10 -t 200 -m 550 + + The -x repeats of example 8 run one after another. To use every core of the computer instead, tools/run_many.py runs several simulations at once, each as its own copy of Vida, so each gives exactly the same results as it would on its own. This runs example 1 (without the images) eight times, with -rngstart 1 to 8, four at a time, in folders named "Output-example9-rng1" and so on, with what each prints saved in "Output-example9-rng1.log". It can also run a list of different simulations from a file; see python tools/run_many.py -h. + GRAPHICS, VIDEOS AND 3D FILES ----------------------------- diff --git a/tests/unit/test_run_many.py b/tests/unit/test_run_many.py new file mode 100644 index 0000000..e67f9dc --- /dev/null +++ b/tests/unit/test_run_many.py @@ -0,0 +1,41 @@ +"""Tests for tools/run_many.py: working out which runs to start.""" + +import importlib.util +from pathlib import Path + +import pytest + +TOOL = Path(__file__).resolve().parents[2] / "tools" / "run_many.py" +spec = importlib.util.spec_from_file_location("run_many", TOOL) +run_many = importlib.util.module_from_spec(spec) +spec.loader.exec_module(run_many) + + +def test_reading_rng_starts(): + assert run_many.readRngStarts("1-4") == [1, 2, 3, 4] + assert run_many.readRngStarts("1,3,5") == [1, 3, 5] + assert run_many.readRngStarts("1-3,10") == [1, 2, 3, 10] + + +def test_one_run_per_rng_start_each_with_its_own_name(): + runs = run_many.runsForRngStarts([1, 2], ["-n", "forest", "-w", "100", "-t", "50"]) + assert runs == [ + ["-n", "forest-rng1", "-rngstart", "1", "-w", "100", "-t", "50"], + ["-n", "forest-rng2", "-rngstart", "2", "-w", "100", "-t", "50"], + ] + + +def test_runs_without_a_name_use_vidas_default_name(): + assert run_many.runsForRngStarts([7], ["-w", "50"]) == [["-n", "default-rng7", "-rngstart", "7", "-w", "50"]] + + +def test_rngstart_in_the_options_is_refused(): + with pytest.raises(SystemExit): + run_many.runsForRngStarts([1], ["-n", "forest", "-rngstart", "3"]) + + +def test_runs_from_a_file(tmp_path): + runs = tmp_path / "runs.txt" + runs.write_text("# dry and wet\n-n dry -w 100 -rngstart 1\n\n-n wet -w 100 -rngstart 1\n") + assert run_many.runsFromFile(str(runs)) == [["-n", "dry", "-w", "100", "-rngstart", "1"], ["-n", "wet", "-w", "100", "-rngstart", "1"]] + assert run_many.nameOf(["-n", "dry", "-w", "100"]) == "dry" diff --git a/tools/run_many.py b/tools/run_many.py new file mode 100644 index 0000000..86bcebf --- /dev/null +++ b/tools/run_many.py @@ -0,0 +1,146 @@ +"""Run several Vida simulations at once, one on each processor core. + +Within one simulation everything happens in a set order, drawing on the +same stream of random numbers, so one simulation can't be split across +cores without changing its results. But separate simulations don't share +anything: this runs each one as its own copy of Vida, side by side. Each +gives exactly the same results as it would if run on its own; only the +order they finish in changes. + +Run it from the folder that has Vida.py in it. Examples: + + # the same simulation with -rngstart 1 to 8, four at a time + python tools/run_many.py -rngstarts 1-8 -jobs 4 -- -n forest -w 100 -s 400 -t 50 + + # every line of runs.txt is the options for one run, e.g. + # -n dry -w 100 -s 400 -t 50 -rngstart 1 + # -n wet -w 100 -s 400 -t 50 -rngstart 1 + python tools/run_many.py -file runs.txt + +With -rngstarts, each run is named after the -n name and its -rngstart +(forest-rng1, forest-rng2, ...). Each run's output goes where Vida puts it +(Output-forest-rng1/ and so on), and what it prints goes to a log file next +to it (Output-forest-rng1.log). -jobs is how many run at once; it starts +at the number of cores. +""" + +import argparse +import os +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor + + +def readRngStarts(text): + # "1-8" or "1,3,5" or "1-4,10" -> [1, 2, ...] + rngStarts = [] + for part in text.split(","): + part = part.strip() + if "-" in part: + first, last = part.split("-") + for rngStart in range(int(first), int(last) + 1): + rngStarts.append(rngStart) + elif part: + rngStarts.append(int(part)) + return rngStarts + + +def nameOf(options): + # the value after -n in a run's options, or None + for i in range(len(options) - 1): + if options[i] == "-n": + return options[i + 1] + return None + + +def runsForRngStarts(rngStarts, options): + # One run per starting value: the same options, with its own -rngstart and name. + if "-rngstart" in options: + sys.exit("run_many: leave -rngstart out of the options; -rngstarts sets it for each run") + baseName = nameOf(options) or "default" + rest = [] + skipNext = False + for option in options: + if skipNext: + skipNext = False + elif option == "-n": + skipNext = True + else: + rest.append(option) + runs = [] + for rngStart in rngStarts: + name = "%s-rng%d" % (baseName, rngStart) + runs.append(["-n", name, "-rngstart", str(rngStart)] + rest) + return runs + + +def runsFromFile(fileName): + # One run for each line that isn't empty or a comment (#). + runs = [] + with open(fileName) as theFile: + for line in theFile: + line = line.strip() + if line and not line.startswith("#"): + runs.append(line.split()) + return runs + + +def runOne(options): + # Run Vida once, with what it prints going to a log file. + name = nameOf(options) or "run" + logName = "Output-%s.log" % name + start = time.time() + with open(logName, "w") as log: + result = subprocess.run([sys.executable, "Vida.py"] + options, + stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT) + seconds = time.time() - start + if result.returncode == 0: + print("finished %s in %.1f s" % (name, seconds)) + else: + print("FAILED %s (exit code %d): see %s" % (name, result.returncode, logName)) + return seconds, result.returncode + + +def main(): + parser = argparse.ArgumentParser(description="Run several Vida simulations at once.") + parser.add_argument("-rngstarts", help="a run for each of these -rngstart values, e.g. 1-8 or 1,3,5") + parser.add_argument("-file", help="a file with the options for one run on each line") + parser.add_argument("-jobs", type=int, default=os.cpu_count() or 1, help="how many to run at once (default: the number of cores)") + parser.add_argument("options", nargs=argparse.REMAINDER, help="after --, Vida's options for every run (with -rngstarts)") + arguments = parser.parse_args() + options = arguments.options + if options and options[0] == "--": + options = options[1:] + + if not os.path.exists("Vida.py"): + sys.exit("run_many: run this from the folder that has Vida.py in it") + if arguments.rngstarts and arguments.file: + sys.exit("run_many: use -rngstarts or -file, not both") + if arguments.rngstarts: + runs = runsForRngStarts(readRngStarts(arguments.rngstarts), options) + elif arguments.file: + runs = runsFromFile(arguments.file) + else: + sys.exit("run_many: say which runs, with -rngstarts or -file (see python tools/run_many.py -h)") + + jobs = max(1, arguments.jobs) + print("%d runs, %d at a time" % (len(runs), jobs)) + start = time.time() + with ThreadPoolExecutor(max_workers=jobs) as pool: + # each thread just starts one copy of Vida and waits for it + results = list(pool.map(runOne, runs)) + wallClock = time.time() - start + total = 0.0 + failed = 0 + for seconds, returnCode in results: + total += seconds + if returnCode != 0: + failed += 1 + print("all done in %.1f s (the runs took %.1f s between them)" % (wallClock, total)) + if failed: + sys.exit("%d of the runs failed" % failed) + + +if __name__ == "__main__": + main()