Python Part II · Module 35 · Industry VLSI Automation Projects
A Regression Manager
Chapter 35.3 built a runner that is honest about one simulation. This chapter calls it four hundred times, which sounds like a loop and is not: the moment runs happen concurrently, two things that were free become hard — making the report the same every time, and knowing which runs have genuinely finished. The second of those has a one-line answer that is easy to get backwards, and getting it backwards loses a run permanently while reporting success.
Advanced27 min readPythonPart IIAutomationRegressionConcurrency
Part II · Module 35 · Chapter 35.4 · A Regression Manager
1. What a Manager Owns
JOBS = [
("which runs exist", "suite config", "a file in the repo — 34.5"),
("where each run happens", "isolated dirs", "35.3 already does this"),
("how many at once", "--jobs", "bounded, and not from cpu_count"),
("what to skip", "--resume", "the hard one — §7"),
("what the answer is", "aggregation", "sorted, or it is not a report"),
("why it failed", "failure buckets", "35.1 already does this"),
("what the flow sees", "one exit code", "0 only if every job passed"),
]
print(f"{'responsibility':<24} {'mechanism':<18} note")
for what, how, note in JOBS:
print(f"{what:<24} {how:<18} {note}")
print()
print("responsibilities :", len(JOBS))
print("already solved by 35.1/35.3:", 2)
print("new because of concurrency :", 3)
print("the one with a wrong answer")
print(" that looks right :", "--resume")responsibility mechanism note
which runs exist suite config a file in the repo — 34.5
where each run happens isolated dirs 35.3 already does this
how many at once --jobs bounded, and not from cpu_count
what to skip --resume the hard one — §7
what the answer is aggregation sorted, or it is not a report
why it failed failure buckets 35.1 already does this
what the flow sees one exit code 0 only if every job passed
responsibilities : 7
already solved by 35.1/35.3: 2
new because of concurrency : 3
the one with a wrong answer
that looks right : --resumeTwo of the seven are already done. The three that are new all arrive with
concurrency, and --resume is the one whose wrong implementation looks
right — it runs faster, reports a complete suite, and is missing a run.
2. The Annotation
FLAGS = [
("--suite nightly", "names a list of (test, seed) pairs in config"),
("--jobs 32", "an explicit bound — never os.cpu_count()"),
("--resume", "skip runs that already produced a result"),
]
print(f"{'flag':<20} meaning")
for flag, meaning in FLAGS:
print(f"{flag:<20} {meaning}")
print()
WHY_NOT_CPU = [
("os.cpu_count()", "the report depends on which machine ran it"),
("cpu_count() - 1", "same, plus an off-by-one nobody can justify"),
("a fixed 32", "reproducible, and wrong on a laptop"),
("--jobs, required", "the operator decides and it is recorded"),
]
print(f"{'how --jobs could be set':<22} consequence")
for how, why in WHY_NOT_CPU:
print(f"{how:<22} {why}")
print()
print("the machine's cpu count is published here:", False)
print("because it is :", "machine-specific — 33.14")
print("and --jobs ends up in :", "cmd.sh, so the run is replayable")flag meaning
--suite nightly names a list of (test, seed) pairs in config
--jobs 32 an explicit bound — never os.cpu_count()
--resume skip runs that already produced a result
how --jobs could be set consequence
os.cpu_count() the report depends on which machine ran it
cpu_count() - 1 same, plus an off-by-one nobody can justify
a fixed 32 reproducible, and wrong on a laptop
--jobs, required the operator decides and it is recorded
the machine's cpu count is published here: False
because it is : machine-specific — 33.14
and --jobs ends up in : cmd.sh, so the run is replayable--jobs is an argument and not a discovery. Deriving it from
os.cpu_count() means the same suite on a build server and on a laptop produces
reports that differ in their timing behaviour and, if anything downstream is
order-sensitive, in their content — which is Chapter 33.14's undeclared input
with a particularly innocent face.
3. The First Version
from pathlib import Path
SUITE = [("axi_burst", 48210), ("axi_burst", 48211), ("axi_lite", 7734),
("axi_wrap", 9001)]
def fake_run(test, seed):
"""Stands in for 35.3's run_sim so this section is about the LOOP."""
status = "FAIL" if seed % 2 == 1 else "PASS"
return {"test": test, "seed": seed, "status": status}
# v1: a serial loop that prints
print("v1 — for each test, for each seed, run it and print")
passed = failed = 0
for test, seed in SUITE:
r = fake_run(test, seed)
print(f" {r['test']} {r['seed']} -> {r['status']}")
if r["status"] == "PASS":
passed += 1
else:
failed += 1
print()
print(" passed:", passed, "failed:", failed)
print()
print("what it gets right:", ["every run happens", "a verdict per run",
"a total"])
print("what it cannot do :", ["run two at once", "resume", "group failures",
"produce a file"])
print()
print("runs in a real nightly :", 400)
print("this loop's runtime for ")
print(" 400 serial runs :", "400 x one run — the whole night")v1 — for each test, for each seed, run it and print
axi_burst 48210 -> PASS
axi_burst 48211 -> FAIL
axi_lite 7734 -> PASS
axi_wrap 9001 -> FAIL
passed: 2 failed: 2
what it gets right: ['every run happens', 'a verdict per run', 'a total']
what it cannot do : ['run two at once', 'resume', 'group failures', 'produce a file']
runs in a real nightly : 400
this loop's runtime for
400 serial runs : 400 x one run — the whole nightThe loop is correct. It is also the entire reason regressions are run overnight: four hundred sequential simulations use the machine one core at a time.
4. The Limitation: Concurrency Breaks Two Things
SUBMITTED = [("axi_burst", 48210), ("axi_burst", 48211), ("axi_lite", 7734),
("axi_wrap", 9001), ("axi_err", 2024)]
# An explicit completion order. This page does NOT race to produce one,
# because a race would make its own output nondeterministic.
COMPLETION = [("axi_wrap", 9001), ("axi_burst", 48210), ("axi_err", 2024),
("axi_lite", 7734), ("axi_burst", 48211)]
def report(rows):
return "\n".join(f"{t},{s}" for t, s in rows)
print("submitted order :", [f"{t}:{s}" for t, s in SUBMITTED])
print("completion order:", [f"{t}:{s}" for t, s in COMPLETION])
print()
print("a report written as runs finish == a report in submitted order:",
report(COMPLETION) == report(SUBMITTED))
print("after sorting both :",
report(sorted(COMPLETION)) == report(sorted(SUBMITTED)))
print()
print("so two runs of the same suite produce:", "different files")
print("and 30.3's new-versus-fixed diff sees :", "every row as changed")
print()
BROKEN = [
("report in completion order", "a diff shows every row changed", "sort"),
("resume, marker written early", "a run is lost silently", "§7"),
("a shared scratch path", "two runs overwrite each other", "33.7"),
]
print(f"{'what concurrency breaks':<30} {'symptom':<32} fix")
for what, sym, fix in BROKEN:
print(f"{what:<30} {sym:<32} {fix}")submitted order : ['axi_burst:48210', 'axi_burst:48211', 'axi_lite:7734', 'axi_wrap:9001', 'axi_err:2024']
completion order: ['axi_wrap:9001', 'axi_burst:48210', 'axi_err:2024', 'axi_lite:7734', 'axi_burst:48211']
a report written as runs finish == a report in submitted order: False
after sorting both : True
so two runs of the same suite produce: different files
and 30.3's new-versus-fixed diff sees : every row as changed
what concurrency breaks symptom fix
report in completion order a diff shows every row changed sort
resume, marker written early a run is lost silently §7
a shared scratch path two runs overwrite each other 33.7Three things break, and the fix for the first is one call. Chapter 33.14 Section 7 listed completion order as the highest-frequency source of a non-reproducible artefact, and this is what it looks like: the rows are the same rows, the statuses are the same statuses, and the file is different.
Note that this section does not race to produce a completion order. A page that demonstrated nondeterminism by being nondeterministic could not state its own output, which is Chapter 33.14's rule turned on this chapter's own examples — so the order is written down explicitly and the measurement is about what sorting does to it.
5. The Architecture
Plan, filter, run bounded, collect, sort, decide once
The shape is deliberately an hourglass: the only concurrent stage is the pool, and everything before and after it is serial. That is what makes the rest of the tool testable — the planner, the resume filter, the collector and the decision are all pure functions of their inputs, and Chapter 34.3's middle layer is therefore most of the program.
6. Planning the Jobs
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Job:
test: str
seed: int
@property
def name(self):
return f"{self.test}_{self.seed}"
def run_dir(self, root):
return Path(root) / self.name
SUITES = {
"smoke": {"tests": ["axi_burst", "axi_lite"], "seeds": [48210]},
"nightly": {"tests": ["axi_burst", "axi_lite", "axi_wrap"],
"seeds": [48210, 48211, 7734]},
}
def plan(suite_name, suites=SUITES):
"""Pure: config in, an ordered list of Jobs out."""
cfg = suites[suite_name]
return [Job(t, s) for t in cfg["tests"] for s in cfg["seeds"]]
for name in sorted(SUITES):
jobs = plan(name)
print(f"{name:<10} {len(jobs):>3} jobs "
f"{len(SUITES[name]['tests'])} tests x "
f"{len(SUITES[name]['seeds'])} seeds")
print()
nightly = plan("nightly")
print("the first four jobs:")
for j in nightly[:4]:
print(f" {j.name:<22} dir={j.run_dir('runs')}")
print()
print("the plan is deterministic :", plan("nightly") == plan("nightly"))
print("job names are unique :",
len({j.name for j in nightly}) == len(nightly))
print("two seeds of one test collide :", False)
print("because the dir name includes :", "the seed — 35.3 §9")nightly 9 jobs 3 tests x 3 seeds
smoke 2 jobs 2 tests x 1 seeds
the first four jobs:
axi_burst_48210 dir=runs/axi_burst_48210
axi_burst_48211 dir=runs/axi_burst_48211
axi_burst_7734 dir=runs/axi_burst_7734
axi_lite_48210 dir=runs/axi_lite_48210
the plan is deterministic : True
job names are unique : True
two seeds of one test collide : False
because the dir name includes : the seed — 35.3 §9Nine jobs, unique names, deterministic plan. The Job is frozen and its
name is derived rather than stored, so a run directory and a result row can
never disagree about which job they belong to — the same property Chapter 34.5
Section 7 got from making filelist a property.
Note the nested comprehension iterates tests outer, seeds inner, which is a real choice: it groups a test's seeds together in submission order, so a partial run of a nightly covers fewer tests more deeply rather than every test shallowly. Reversing it is equally defensible and must be decided rather than inherited.
7. Resume: What Counts as Done
import json
from pathlib import Path
def run_job(name, root, marker_first, interrupt_on=None):
"""Simulates one run, with the two possible write orders."""
d = Path(root) / name
d.mkdir(parents=True, exist_ok=True)
if marker_first:
(d / "done").write_text("", encoding="utf-8")
if interrupt_on == name:
raise KeyboardInterrupt(f"the sweep was killed during {name}")
(d / "result.json").write_text(
json.dumps({"job": name, "status": "PASS"}), encoding="utf-8")
if not marker_first:
(d / "done").write_text("", encoding="utf-8")
def completed_by(root, filename):
return {d.name for d in Path(root).iterdir()
if (d / filename).exists()}
JOBS = [f"t{i}" for i in range(6)]
for marker_first in (True, False):
root = Path("marker_first" if marker_first else "result_first")
for name in JOBS:
try:
run_job(name, root, marker_first, interrupt_on="t3")
except KeyboardInterrupt:
break
by_marker = completed_by(root, "done")
by_result = completed_by(root, "result.json")
label = "marker BEFORE result" if marker_first else "marker AFTER result"
print(f"--- {label}")
print(f" jobs started : 4 (killed during t3)")
print(f" dirs with a 'done' file : {sorted(by_marker)}")
print(f" dirs with a result : {sorted(by_result)}")
print(f" a resume keyed on 'done' skips : {len(by_marker)}")
print(f" results that actually exist : {len(by_result)}")
print(f" skipped with NO result : {sorted(by_marker - by_result)}")--- marker BEFORE result
jobs started : 4 (killed during t3)
dirs with a 'done' file : ['t0', 't1', 't2', 't3']
dirs with a result : ['t0', 't1', 't2']
a resume keyed on 'done' skips : 4
results that actually exist : 3
skipped with NO result : ['t3']
--- marker AFTER result
jobs started : 4 (killed during t3)
dirs with a 'done' file : ['t0', 't1', 't2']
dirs with a result : ['t0', 't1', 't2']
a resume keyed on 'done' skips : 3
results that actually exist : 3
skipped with NO result : []With the marker written first, a resume skips four jobs and only three
results exist. t3 was interrupted after its marker was written and before
its result was, so every subsequent resume considers it finished — and the
suite's final report is permanently missing one run of nine.
The failure mode is the worst shape there is. The resumed sweep completes, every job is accounted for, the exit code is whatever the eight surviving runs say, and there is no error anywhere. Chapter 30.3 Section 5 required a missing result to be reported as missing; here it is not even known to be missing.
import json
from pathlib import Path
from dataclasses import dataclass
@dataclass(frozen=True)
class Job:
test: str
seed: int
@property
def name(self):
return f"{self.test}_{self.seed}"
def result_path(root, job):
return Path(root) / job.name / "result.json"
def is_done(root, job):
"""A job is done iff a COMPLETE result can be read back."""
p = result_path(root, job)
if not p.is_file():
return False
try:
data = json.loads(p.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return False # a half-written file is not done
return "status" in data
def to_run(root, jobs, resume):
return [j for j in jobs if not (resume and is_done(root, j))]
JOBS = [Job("axi_burst", s) for s in (48210, 48211, 7734)]
ROOT = Path("runs")
# two jobs completed, one left a TRUNCATED result file
for j in JOBS[:2]:
d = ROOT / j.name
d.mkdir(parents=True, exist_ok=True)
(d / "result.json").write_text(
json.dumps({"job": j.name, "status": "PASS"}), encoding="utf-8")
d = ROOT / JOBS[2].name
d.mkdir(parents=True, exist_ok=True)
(d / "result.json").write_text('{"job": "axi_burst_7734", "stat',
encoding="utf-8")
print("jobs in the suite :", len(JOBS))
print("result files on disk :",
sum(1 for j in JOBS if result_path(ROOT, j).is_file()))
print("jobs is_done() considers :", sum(1 for j in JOBS if is_done(ROOT, j)))
print()
print("without --resume, jobs to run:", len(to_run(ROOT, JOBS, resume=False)))
print("with --resume, jobs to run:",
[j.name for j in to_run(ROOT, JOBS, resume=True)])
print()
print("the truncated result is re-run:", True)
print("because is_done() requires :", "a parseable result with a status")
print("and a half-written file is :", "not a completed job")jobs in the suite : 3
result files on disk : 3
jobs is_done() considers : 2
without --resume, jobs to run: 3
with --resume, jobs to run: ['axi_burst_7734']
the truncated result is re-run: True
because is_done() requires : a parseable result with a status
and a half-written file is : not a completed jobThree result files on disk and only two completed jobs. is_done parses the
file instead of testing for its existence, so the run that was killed while
writing its JSON is correctly re-run — which is the same distinction Chapter
33.5 drew between a file being present and being usable.
A stricter version writes the result to a temporary name and renames it into
place, so a partial file never exists at the final path at all. That is Chapter
30.7's write-then-rename pattern, and it makes is_done able to go back to a
simple existence check — the rename is atomic, so the file is either absent or
complete.
8. Bounded Concurrency Without Losing the Order
from concurrent.futures import ThreadPoolExecutor, as_completed
JOBS = [f"job_{i}" for i in range(8)]
def work(name):
return {"job": name, "status": "PASS" if int(name[-1]) % 3 else "FAIL"}
# ex.map preserves submission order, by documented contract
with ThreadPoolExecutor(max_workers=4) as pool:
mapped = list(pool.map(work, JOBS))
print("ex.map returns results in submission order:",
[r["job"] for r in mapped] == JOBS)
# as_completed does not promise any order
with ThreadPoolExecutor(max_workers=4) as pool:
futures = {pool.submit(work, n): n for n in JOBS}
completed = [futures[f] for f in as_completed(futures)]
print("as_completed returns the same set :",
sorted(completed) == sorted(JOBS))
print("as_completed promises submission order :", False)
print("sorting recovers it :",
sorted(completed) == JOBS)
print()
print("jobs :", len(JOBS))
print("workers :", 4)
print("max in flight at once:", 4)
print()
print("so the rule is:")
print(" use ex.map when you only need the results")
print(" use as_completed when you want to PRINT progress as they land")
print(" and in either case, SORT before writing the report")ex.map returns results in submission order: True
as_completed returns the same set : True
as_completed promises submission order : False
sorting recovers it : True
jobs : 8
workers : 4
max in flight at once: 4
so the rule is:
use ex.map when you only need the results
use as_completed when you want to PRINT progress as they land
and in either case, SORT before writing the reportex.map preserves submission order and as_completed does not promise it.
Both return the same set, which is the only thing a correctness test should
assert about a concurrent stage — this page asserts sorted(completed) == sorted(JOBS) rather than the order, precisely because asserting the order
would make the page's own output depend on a race.
The practical consequence is that as_completed is still the right tool when
you want a progress line as each run lands, since a thirty-minute sweep that
prints nothing for thirty minutes is indistinguishable from a hung one. It just
must not be the thing that determines row order in the output file.
9. Aggregation and Failure Buckets
import re
from collections import defaultdict
from dataclasses import dataclass
LINE_NO, HEX, INT = (re.compile(r"\(\d+\)"), re.compile(r"0x[0-9a-fA-F]+"),
re.compile(r"\b\d+\b"))
@dataclass(frozen=True)
class Result:
test: str
seed: int
status: str
first_failure: str = ""
@property
def name(self):
return f"{self.test}_{self.seed}"
def bucket_key(message):
"""35.1's normaliser, reused unchanged."""
return INT.sub("N", HEX.sub("0xH", message))
RESULTS = [
Result("axi_burst", 48210, "PASS"),
Result("axi_burst", 48211, "FAIL", "drv.sv(88) data mismatch exp=0xa5"),
Result("axi_burst", 7734, "FAIL", "drv.sv(91) data mismatch exp=0xb7"),
Result("axi_lite", 48210, "PASS"),
Result("axi_lite", 48211, "TIMEOUT", ""),
Result("axi_lite", 7734, "FAIL", "mon.sv(12) response timeout after 500"),
Result("axi_wrap", 48210, "BUILD", ""),
Result("axi_wrap", 48211, "PASS"),
Result("axi_wrap", 7734, "FATAL", "env.sv(3) virtual interface not set"),
]
by_status = defaultdict(list)
for r in sorted(RESULTS, key=lambda r: (r.test, r.seed)):
by_status[r.status].append(r.name)
ORDER = ("BUILD", "FATAL", "TIMEOUT", "FAIL", "PASS")
print("by status, worst first:")
for status in ORDER:
if status in by_status:
print(f" {status:<8} {len(by_status[status]):>2} "
f"{by_status[status]}")
buckets = defaultdict(list)
for r in sorted(RESULTS, key=lambda r: (r.test, r.seed)):
if r.first_failure:
buckets[bucket_key(r.first_failure)].append(r.name)
print()
print("failure buckets, largest first:")
for key, runs in sorted(buckets.items(), key=lambda kv: (-len(kv[1]), kv[0])):
print(f" {len(runs):>2} {key}")
print(f" {runs}")
print()
total = len(RESULTS)
passed = len(by_status.get("PASS", []))
print("jobs :", total)
print("passed :", passed)
print("not passed :", total - passed)
print("distinct causes :", len(buckets))
print("runs with no message to bucket:",
sum(1 for r in RESULTS if r.status not in ("PASS",) and not r.first_failure))by status, worst first:
BUILD 1 ['axi_wrap_48210']
FATAL 1 ['axi_wrap_7734']
TIMEOUT 1 ['axi_lite_48211']
FAIL 3 ['axi_burst_7734', 'axi_burst_48211', 'axi_lite_7734']
PASS 3 ['axi_burst_48210', 'axi_lite_48210', 'axi_wrap_48211']
failure buckets, largest first:
2 drv.sv(N) data mismatch exp=0xH
['axi_burst_7734', 'axi_burst_48211']
1 env.sv(N) virtual interface not set
['axi_wrap_7734']
1 mon.sv(N) response timeout after N
['axi_lite_7734']
jobs : 9
passed : 3
not passed : 6
distinct causes : 3
runs with no message to bucket: 2Six runs did not pass and there are three distinct causes — and two of the six
have no message to bucket. That last number is the one worth noticing: a
TIMEOUT and a BUILD produce no first-failure line, so they cannot be
bucketed by cause and must be reported by status instead.
That is why the report has both views. The status table accounts for every job, and the bucket list explains the ones that have an explanation. A tool that only bucketed would silently drop two of nine jobs from its output, which is Chapter 30.3 Section 5's missing-result rule again.
The statuses are ordered worst-first by an explicit tuple rather than
alphabetically, for the reason Chapter 33.14 Section 5 gave: sorted() would
put BUILD, FAIL, FATAL, PASS, TIMEOUT, which is reproducible and tells a
reader nothing about severity.
10. The Manager, Assembled
import json
import re
import subprocess
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, asdict
from pathlib import Path
EXIT = {"PASS": 0, "FAIL": 1, "FATAL": 1, "TIMEOUT": 1, "BUILD": 3}
DONE_MARKER = "TEST DONE"
STATUS_ORDER = ("BUILD", "FATAL", "TIMEOUT", "FAIL", "PASS")
TB = """module tb;
reg clk = 0;
initial begin
$display("UVM_INFO @0: tb.sv(1) start seed=%0d", `SEED);
`ifdef INJECT_ERROR
$display("UVM_ERROR @10: drv.sv(88) data mismatch exp=0xa5 got=0x5a");
`endif
repeat (4) begin #5 clk = ~clk; end
`ifndef INJECT_TIMEOUT
$display("TEST DONE");
`endif
$finish;
end
endmodule
"""
@dataclass(frozen=True)
class Job:
test: str
seed: int
defines: tuple = ()
@property
def name(self):
return f"{self.test}_{self.seed}"
def classify(text, rc, built):
if not built:
return "BUILD"
lines = text.splitlines()
errs = sum(1 for l in lines
if l.startswith("UVM_ERROR") and "UVM_ERROR :" not in l)
fat = sum(1 for l in lines
if l.startswith("UVM_FATAL") and "UVM_FATAL :" not in l)
done = any(l.strip() == DONE_MARKER for l in lines)
if fat or rc != 0:
return "FATAL"
if not done:
return "TIMEOUT"
return "FAIL" if errs else "PASS"
def first_failure(text):
for line in text.splitlines():
if line.startswith(("UVM_ERROR", "UVM_FATAL")) \
and " :" not in line[:12]:
parts = line.split(": ", 1)
return parts[1] if len(parts) == 2 else line
return ""
def run_one(job, root):
"""35.3's runner, called once per job. Writes result.json LAST."""
d = Path(root) / job.name
d.mkdir(parents=True, exist_ok=True)
(d / "tb.v").write_text(TB, encoding="utf-8")
argv = (["iverilog", f"-DSEED={job.seed}"]
+ [f"-D{x}" for x in job.defines] + ["-o", "sim.vvp", "tb.v"])
(d / "cmd.sh").write_text("#!/bin/sh\n" + " ".join(argv)
+ "\nvvp sim.vvp\n", encoding="utf-8")
b = subprocess.run(argv, cwd=d, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
if b.returncode != 0:
status, text = "BUILD", b.stdout
else:
r = subprocess.run(["vvp", "sim.vvp"], cwd=d, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
text = r.stdout
(d / "sim.log").write_text(text, encoding="utf-8")
status = classify(text, r.returncode, built=True)
record = {"test": job.test, "seed": job.seed, "status": status,
"first_failure": first_failure(text) if status != "PASS" else ""}
tmp = d / "result.json.tmp"
tmp.write_text(json.dumps(record, sort_keys=True), encoding="utf-8")
tmp.replace(d / "result.json") # atomic — §7
return record
def is_done(root, job):
p = Path(root) / job.name / "result.json"
if not p.is_file():
return False
try:
return "status" in json.loads(p.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return False
def load_result(root, job):
return json.loads((Path(root) / job.name / "result.json")
.read_text(encoding="utf-8"))
def run_suite(jobs, root, jobs_at_once, resume=False):
pending = [j for j in jobs if not (resume and is_done(root, j))]
skipped = [j for j in jobs if resume and is_done(root, j)]
with ThreadPoolExecutor(max_workers=jobs_at_once) as pool:
list(pool.map(lambda j: run_one(j, root), pending))
# collect EVERYTHING from disk, in submission order — §8
results = [load_result(root, j) for j in jobs]
return results, len(pending), len(skipped)
SUITE = [
Job("axi_burst", 48210),
Job("axi_burst", 48211, ("INJECT_ERROR",)),
Job("axi_lite", 7734, ("INJECT_TIMEOUT",)),
Job("axi_wrap", 9001),
]
ROOT = Path("runs")
results, ran, skipped = run_suite(SUITE, ROOT, jobs_at_once=4, resume=False)
by_status = defaultdict(list)
for r in results:
by_status[r["status"]].append(f"{r['test']}_{r['seed']}")
print("--- first pass")
print(" jobs run :", ran, "| skipped:", skipped)
for s in STATUS_ORDER:
if s in by_status:
print(f" {s:<8} {len(by_status[s])} {by_status[s]}")
buckets = defaultdict(list)
for r in results:
if r["first_failure"]:
key = re.sub(r"\b\d+\b", "N",
re.sub(r"0x[0-9a-fA-F]+", "0xH",
re.sub(r"\(\d+\)", "(N)", r["first_failure"])))
buckets[key].append(f"{r['test']}_{r['seed']}")
print(" buckets :", len(buckets))
for k, v in sorted(buckets.items()):
print(f" {k} {v}")
worst = min((STATUS_ORDER.index(r["status"]) for r in results), default=4)
code = EXIT[STATUS_ORDER[worst]]
print(" exit code :", code)
# --- now resume: nothing should re-run
results2, ran2, skipped2 = run_suite(SUITE, ROOT, jobs_at_once=4, resume=True)
print()
print("--- resumed pass")
print(" jobs run :", ran2, "| skipped:", skipped2)
print(" same results :", results == results2)
print()
print("the report is identical across the two passes:", results == results2)
print("because results are collected :", "in submission order")
print("and the exit code is chosen :", "once, from the worst")--- first pass
jobs run : 4 | skipped: 0
TIMEOUT 1 ['axi_lite_7734']
FAIL 1 ['axi_burst_48211']
PASS 2 ['axi_burst_48210', 'axi_wrap_9001']
buckets : 1
drv.sv(N) data mismatch exp=0xH got=0xH ['axi_burst_48211']
exit code : 1
--- resumed pass
jobs run : 0 | skipped: 4
same results : True
the report is identical across the two passes: True
because results are collected : in submission order
and the exit code is chosen : once, from the worstFour real simulations, three statuses, and a resumed pass that ran nothing and produced an identical report. The second pass is the test that matters: if resume were keyed on a progress marker, or if the report were built in completion order, the two passes would differ and the difference would be invisible without comparing them.
Three details in run_suite carry the chapter's findings. The result is written
to result.json.tmp and renamed, so a killed run never leaves a partial
file at the final path. Results are loaded from disk in submission order
rather than taken from the pool, so the report's row order is a property of the
suite rather than of the machine. And the exit code is computed once from
the worst status, which is Chapter 33.11 Section 7's collect-then-decide applied
to a sweep.
11. How It Is Tested
import io
import json
import unittest
from dataclasses import dataclass
from pathlib import Path
STATUS_ORDER = ("BUILD", "FATAL", "TIMEOUT", "FAIL", "PASS")
EXIT = {"PASS": 0, "FAIL": 1, "FATAL": 1, "TIMEOUT": 1, "BUILD": 3}
@dataclass(frozen=True)
class Job:
test: str
seed: int
@property
def name(self):
return f"{self.test}_{self.seed}"
SUITES = {"nightly": {"tests": ["a", "b"], "seeds": [1, 2]}}
def plan(suite, suites=SUITES):
cfg = suites[suite]
return [Job(t, s) for t in cfg["tests"] for s in cfg["seeds"]]
def is_done(root, job):
p = Path(root) / job.name / "result.json"
if not p.is_file():
return False
try:
return "status" in json.loads(p.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return False
def to_run(root, jobs, resume):
return [j for j in jobs if not (resume and is_done(root, j))]
def overall_exit(statuses):
if not statuses:
return 3 # an empty suite is not a pass
worst = min(STATUS_ORDER.index(s) for s in statuses)
return EXIT[STATUS_ORDER[worst]]
def write_result(root, job, status, partial=False):
d = Path(root) / job.name
d.mkdir(parents=True, exist_ok=True)
text = ('{"status": "' + status + '"}') if not partial else '{"stat'
(d / "result.json").write_text(text, encoding="utf-8")
class Manager(unittest.TestCase):
def setUp(self):
import tempfile, shutil
self.root = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.root, True)
def test_the_plan_is_deterministic(self):
self.assertEqual(plan("nightly"), plan("nightly"))
def test_job_names_are_unique(self):
jobs = plan("nightly")
self.assertEqual(len({j.name for j in jobs}), len(jobs))
def test_without_resume_everything_runs(self):
jobs = plan("nightly")
write_result(self.root, jobs[0], "PASS")
self.assertEqual(len(to_run(self.root, jobs, resume=False)),
len(jobs))
def test_with_resume_completed_jobs_are_skipped(self):
jobs = plan("nightly")
write_result(self.root, jobs[0], "PASS")
self.assertEqual([j.name for j in to_run(self.root, jobs, True)],
[j.name for j in jobs[1:]])
def test_a_partial_result_is_rerun(self):
"""§7: a half-written JSON is not a completed job."""
jobs = plan("nightly")
write_result(self.root, jobs[0], "PASS", partial=True)
self.assertIn(jobs[0], to_run(self.root, jobs, resume=True))
def test_an_empty_dir_is_not_done(self):
jobs = plan("nightly")
(self.root / jobs[0].name).mkdir(parents=True)
self.assertFalse(is_done(self.root, jobs[0]))
def test_all_pass_exits_zero(self):
self.assertEqual(overall_exit(["PASS", "PASS"]), 0)
def test_one_failure_exits_one(self):
self.assertEqual(overall_exit(["PASS", "FAIL", "PASS"]), 1)
def test_a_build_failure_dominates(self):
self.assertEqual(overall_exit(["PASS", "FAIL", "BUILD"]), 3)
def test_an_empty_suite_is_not_a_pass(self):
self.assertEqual(overall_exit([]), 3)
def test_only_all_pass_gives_zero(self):
for statuses in (["FAIL"], ["TIMEOUT"], ["FATAL"], ["BUILD"],
["PASS", "FAIL"], []):
with self.subTest(statuses=statuses):
self.assertNotEqual(overall_exit(statuses), 0)
def test_report_rows_are_in_submission_order(self):
jobs = plan("nightly")
for j in jobs:
write_result(self.root, j, "PASS")
completion = [jobs[3], jobs[0], jobs[2], jobs[1]]
rows = [j.name for j in sorted(completion,
key=lambda j: (j.test, j.seed))]
self.assertEqual(rows, [j.name for j in jobs])
def run(case):
suite = unittest.TestLoader().loadTestsFromTestCase(case)
res = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
return {"run": res.testsRun, "failures": len(res.failures),
"ok": res.wasSuccessful()}
print("manager suite:", run(Manager))
print()
print("simulators launched in this suite:", 0)
print("behaviours pinned :", 12)
print()
print("the tests that exist because of a measured bug:")
print(" test_a_partial_result_is_rerun — §7")
print(" test_an_empty_suite_is_not_a_pass — a vacuous PASS")
print(" test_report_rows_are_in_submission_order — §8")manager suite: {'run': 12, 'failures': 0, 'ok': True}
simulators launched in this suite: 0
behaviours pinned : 12
the tests that exist because of a measured bug:
test_a_partial_result_is_rerun — §7
test_an_empty_suite_is_not_a_pass — a vacuous PASS
test_report_rows_are_in_submission_order — §8Twelve behaviours pinned with no simulator and no concurrency. Every stage except the pool is a pure function, so the suite tests the planner, the resume filter, the exit-code decision and the ordering rule without starting a process — and the four real simulations in Section 10 cover the impure stage.
test_an_empty_suite_is_not_a_pass deserves a note because it catches a bug
nobody writes on purpose. overall_exit([]) with a naive all(...) returns
True, so a suite whose config filtered down to zero jobs exits 0 and reports
success — the vacuous pass Chapter 29.5 warned about, arriving as a regression
that ran nothing and declared the design clean.
test_only_all_pass_gives_zero is the counterpart to Chapter 35.3's version of
the same test, one level up: six status combinations, none of which may produce
a zero exit code.
12. Predict, Run, Observe
import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
STATUS_ORDER = ("BUILD", "FATAL", "TIMEOUT", "FAIL", "PASS")
EXIT = {"PASS": 0, "FAIL": 1, "FATAL": 1, "TIMEOUT": 1, "BUILD": 3}
def overall_exit(statuses):
if not statuses:
return 3
return EXIT[STATUS_ORDER[min(STATUS_ORDER.index(s) for s in statuses)]]
# A
print("A:", overall_exit(["PASS", "PASS"]), overall_exit(["PASS", "FAIL"]))
# B
print("B:", overall_exit([]), all([]))
# C
with ThreadPoolExecutor(max_workers=3) as pool:
got = list(pool.map(lambda n: n * 2, range(6)))
print("C:", got == [0, 2, 4, 6, 8, 10])
# D
Path("d").mkdir(exist_ok=True)
(Path("d") / "result.json").write_text('{"stat', encoding="utf-8")
try:
json.loads((Path("d") / "result.json").read_text(encoding="utf-8"))
print("D:", "parsed")
except json.JSONDecodeError:
print("D:", (Path("d") / "result.json").is_file(), "but not parseable")The verified output:
A: 0 1
B: 3 True
C: True
D: True but not parseableA — all passing exits 0, one failure exits 1. The honest exit code in two values.
B — the pair that matters. overall_exit([]) returns 3 while
all([]) returns True, which is why an empty suite must be special-cased:
the natural Python idiom for "did everything pass" says yes when nothing ran.
C — pool.map returned results in submission order, by contract, so the
only ordering problem is with as_completed.
D — the file exists and does not parse. Section 7's is_done tests the
second property, not the first, which is the difference between a resume that
works and one that loses a run.
13. Exercises
Exercise 1 — seven responsibilities. List them for your own regression tool and mark which are already solved elsewhere.
Exercise 2 — the wrong answer that looks right. Explain why a broken
--resume is harder to notice than a broken aggregator.
Exercise 3 — --jobs from cpu_count. Say what that does to a report
produced on two machines.
Exercise 4 — record it. Put --jobs in cmd.sh and explain why.
Exercise 5 — the serial loop. Write it and say what it gets right.
Exercise 6 — four hundred runs. Work out what serial execution costs you.
Exercise 7 — completion order. Write two reports from the same results in different orders and diff them.
Exercise 8 — do not race to prove it. Explain why this chapter used an explicit order instead.
Exercise 9 — the hourglass. Identify the single concurrent stage in your own tool.
Exercise 10 — unique job names. Confirm two seeds of one test cannot collide.
Exercise 11 — tests outer or seeds outer. Decide which and justify it for a partial run.
Exercise 12 — the marker experiment. Reproduce Section 7 both ways and count the lost run.
Exercise 13 — lost forever. Explain why a later resume never recovers it.
Exercise 14 — one artefact. Remove the separate done file and say what you
gain.
Exercise 15 — parse, do not stat. Make is_done read the result and confirm
a truncated file is re-run.
Exercise 16 — write then rename. Add the atomic rename and say what check it lets you simplify.
Exercise 17 — map against as_completed. Measure which preserves order.
Exercise 18 — assert the set. Explain why a concurrency test asserts the set and not the order.
Exercise 19 — progress lines. Use as_completed for progress and say why a
silent sweep is a problem.
Exercise 20 — two views. Produce both the status table and the bucket list, and say which jobs appear only in one.
Exercise 21 — no message to bucket. Count your TIMEOUT and BUILD runs and say how they are reported.
Exercise 22 — worst first, explicitly. Replace an alphabetical status sort with a severity order.
Exercise 23 — one exit code. Compute it once from the worst status.
Exercise 24 — the empty suite. Check what your tool does when the config selects zero jobs.
Exercise 25 — all([]). Explain in one sentence why the idiom is dangerous
here.
Exercise 26 — twelve tests, no simulator. Build the pure suite for your planner, filter and decision.
Exercise 27 — resume twice. Run your suite, resume it, and assert the two reports are identical.
14. Summary
- Seven responsibilities, two already solved by Chapters 35.1 and 35.3, and three new ones that arrive only with concurrency.
--jobsis an argument, neveros.cpu_count()— a discovered bound makes the report depend on the machine that ran it.- The serial loop is correct and is why regressions run overnight.
- Concurrency breaks three things: report order, resume, and any shared
path. The first costs one
sorted(). - This chapter does not race to demonstrate nondeterminism, because a page that did could not state its own output — it uses an explicit completion order instead.
- Writing the resume marker before the result loses a run permanently.
Interrupted during
t3: four directories marked done, three results on disk, and every later resume skipst3while the suite reports complete. - The marker and the result should be the same file. A job is done iff its result exists and parses, so the predicate and the data cannot disagree.
is_doneparses rather than stats, so a run killed mid-write is correctly re-run; write to a temporary name and rename to make the partial state impossible.ThreadPoolExecutor.mappreserves submission order;as_completeddoes not promise it. Both return the same set, which is the only thing a concurrency test should assert.- Use
as_completedfor progress lines, since a sweep that prints nothing for thirty minutes looks hung — just not for row order. - Report two views. A
TIMEOUTand aBUILDhave no first-failure line to bucket, so the status table accounts for every job and the bucket list explains the ones that can be explained. - Order statuses by an explicit severity tuple, not alphabetically.
- Collect results from disk in submission order and choose the exit code once from the worst status.
- An empty suite must not exit 0.
all([])isTrue, so the natural idiom reports success for a regression that ran nothing. - Twelve behaviours pinned with no simulator, because every stage except the pool is pure.
The next chapter takes the results this manager produces and asks what they say about coverage over time, which is the first tool in the module that needs history rather than a single run.
Where this fits
Part of the Python curriculum.
