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()