Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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         : --resume

Two 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 night

The 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.7

Three 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

Six blocks. Top row, left to right: the suite configuration listing test and seed pairs; a planner that expands it into jobs each with its own run directory; and a resume filter that drops jobs whose result file already exists. An arrow then runs downward from the filter to the bottom row. Bottom row, right to left: a bounded worker pool running at most the requested number of simulations at once; a collector that gathers every result and sorts them into submission order; and the report stage, which buckets the failures and chooses one exit code.Suite configtests x seeds — 34.5Plan the jobsone run dir eachResume filterhas a RESULT? — §7Bounded pool--jobs, not cpu_countCollect and sortnever completion orderBucket and decideone exit code — 33.8jobs to runresults12
The filter stage is `--resume`, and it reads the result files rather than a progress marker — Section 7 measures why. Everything after the pool is deliberately serial and sorted, so the report does not depend on which run finished first.

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 §9

Nine 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
--- 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 job

Three 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 report

ex.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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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: 2

Six 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
--- 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 worst

Four 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 — §8

Twelve 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: 0 1
B: 3 True
C: True
D: True but not parseable

A — 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.
  • --jobs is an argument, never os.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 skips t3 while 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_done parses 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.map preserves submission order; as_completed does not promise it. Both return the same set, which is the only thing a concurrency test should assert.
  • Use as_completed for progress lines, since a sweep that prints nothing for thirty minutes looks hung — just not for row order.
  • Report two views. A TIMEOUT and a BUILD have 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([]) is True, 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.