Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutContactPrivacyTerms
Skip to lesson

Course contents

  • Part 0 · What Debugging Is6
  • Part 1 · Reproduce8
    • Determinism and Seeds
    • Recording Everything to Replay
    • Shrinking a Failing Test
    • Reducing the Design
    • Making a Flaky Test Fail
    • When Simulation Cannot Reproduce It
    • Timeouts and Watchdogs in Benches
    • The Reproduction Checklist
  • Part 2 · Observe9
  • Part 3 · Localise10
  • Part 4 · Hypothesise and Test6
  • Part 5 · Bug Classes16
  • Part 6 · Debug by Stage9
  • Part 7 · Hangs and Deadlocks7
  • Part 8 · Intermittent Failures7
  • Part 9 · Fix, Prove, Prevent7
  • Part 10 · Case Studies12
  • Part 11 · Debug as a Team5
  • Part 12 · Interview Prep5
Loading lesson

‹Part 1 · Reproduce
  1. Topics
  2. The Art of Hardware Debug
  3. Part 1 · Reproduce
  4. Shrinking a Failing Test

Part 1 · Reproduce · Working (W)

Shrinking a Failing Test

Shrink a failing random test automatically: bisect its length, then run delta debugging over a recorded operation list. A real Python shrinker takes dbglab's seed 10 from 364 operations to one DMA copy, and shows why the definition of "same failure" decides the result.

On this page

    Why this matters

    A random test that fails after hundreds of operations hides the few that matter, and reading it by hand takes hours. An automatic shrinker finds a small test that fails the same way, which often points straight at the cause and becomes the regression test.

    After this lesson you can: define a failure signature, shrink a failing random test by length and then by content with delta debugging, and turn the result into a short directed test.

    Note

    Level: Working (W) · Debug-loop step: Reproduce

    Before this: Determinism and Seeds, Recording Everything to Replay

    Twenty-nine DMA runs, and one of them matters

    dbglab's random test fails with seed 10, every time, in both simulators:

    Terminal
    $ tb/run.sh random 10
    dbglab: test=random seed=10
    FAIL test=random seed=10 cycle=4895: SCOREBOARD: RAM word 808 (address 00001ca0) read d5202ff9, expected b6c3311c (last written by DMA run #29)

    Before that line the bench made 363 operations: RAM reads and writes, register writes, words into the peripheral, idle gaps and 29 DMA runs. Somewhere in there is the cause. Which operations does the failure actually need?

    What people usually do, and why it fails

    The usual move is to open a waveform at cycle 0 and read forward, or to start deleting parts of the test by hand to see what still fails. Reading forward means looking at thousands of cycles that do not matter. Deleting by hand is slow and error-prone: you remove ten operations, the test passes, and you cannot tell whether you removed the cause or merely changed the timing so another check fires first. Most people give up after a few tries and debug the long test.

    Shrinking is a search, and searches can be automated. You need three things: a test you can run with less stimulus, a machine-checkable definition of "the same failure", and a loop that tries smaller tests.

    The method

    1. Fix the repro. The run must be deterministic (determinism and seeds), or the shrinker chases noise.

    2. Write the signature. A regular expression that matches this failure and no other: the check that fires and what it says, without the parts that change as the test shrinks (cycle numbers, run numbers, addresses).

    3. Shrink the length first. Most random benches take a length. Bisect it to the shortest run that still fails with the signature. This is cheap and needs no bench change.

    4. Then shrink the content. Record the operations as a list the bench can replay, and let delta debugging remove chunks of it, keeping any smaller list that still fails with the signature.

    5. Keep every try legal. A shortened list must still obey the bench's rules, or the shrinker will find a broken test instead of your bug.

    6. Read the result and keep it. The small list usually says what the bug is. It becomes the directed regression test.

    Delta debugging ("ddmin") is a simple algorithm. Split the list into two halves and try each on its own. If neither fails, try removing one half; if that fails neither, split into more, smaller chunks and repeat. Every time a smaller list still fails, keep it and start again. It stops when no single operation can be removed.

    The picture

    Two real delta-debugging runs on seed 10's 364 recorded operations. The bars are the list size after each accepted step; the numbers on the left count simulations so far.

    Both runs start the same way: halving works while the failure needs only one small region of the list. They differ at 23 operations, where the definition of "same failure" takes over. The two sections below explain why.

    Do it, step 1: shrink the length

    dbglab's random test reads +OPS=<n>, the number of operations (default 1500). This script bisects it:

    example.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    # not-run: runs dbglab's simulator through subprocess; run it from the dbglab folder (command in the Evidence box)
    """shrink_ops.py: the fewest +OPS for which a seed still fails with the same signature.
    usage: python3 shrink_ops.py <seed> <max_ops> <signature regex>"""
    import re, subprocess, sys
    
    seed, hi, sig = sys.argv[1], int(sys.argv[2]), re.compile(sys.argv[3])
    
    def fails(ops):
        out = subprocess.run(["vvp", "-n", "build/dbglab.vvp", "+TEST=random", f"+SEED={seed}", f"+OPS={ops}"],
                             capture_output=True, text=True).stdout
        line = next((l for l in out.splitlines() if l.startswith("FAIL")), "")
        hit = bool(sig.search(line))
        print(f"OPS={ops:5d}  {'same failure' if hit else ('other failure' if line else 'pass')}  {line[:90]}")
        return hit
    
    assert fails(hi), "the starting length must fail"
    lo = 0                       # invariant: lo passes (or is 0), hi fails with the signature
    while hi - lo > 1:
        mid = (lo + hi) // 2
        if fails(mid): hi = mid
        else: lo = mid
    print(f"smallest failing OPS: {hi}")
    Terminal
    $ python3 ../shrink/shrink_ops.py 10 1500 'SCOREBOARD.*(read|holds) [0-9a-f]{8}, expected [0-9a-f]{8} \(last written by DMA run'
    OPS= 1500  same failure  FAIL test=random seed=10 cycle=4895: SCOREBOARD: RAM word 808 (address 00001ca0) read d520
    OPS=  750  same failure  FAIL test=random seed=10 cycle=4895: SCOREBOARD: RAM word 808 (address 00001ca0) read d520
    OPS=  375  same failure  FAIL test=random seed=10 cycle=4895: SCOREBOARD: RAM word 808 (address 00001ca0) read d520
    OPS=  187  pass
    OPS=  281  pass
    OPS=  328  same failure  FAIL test=random seed=10 cycle=4868: SCOREBOARD (final sweep): RAM word 763 (address 00001
    OPS=  304  pass
    OPS=  316  pass
    OPS=  322  same failure  FAIL test=random seed=10 cycle=4868: SCOREBOARD (final sweep): RAM word 763 (address 00001
    OPS=  319  pass
    OPS=  320  same failure  FAIL test=random seed=10 cycle=4868: SCOREBOARD (final sweep): RAM word 763 (address 00001
    smallest failing OPS: 320

    Eleven runs take 1,500 operations to 320. Read the lines from 328 down: the test stops before word 808 is read, but the bench's final sweep compares every RAM word with its model and finds word 763 wrong, also last written by DMA run #29. The signature here accepts both checks (read|holds), because both report the same damage. Run with a signature that accepts only read, and the same search stops at 333, because 328 to 332 now count as "other failure". That is the first lesson of shrinking: the signature is a decision, and it changes the answer.

    Length shrinking has a limit: it only cuts the tail. All 29 DMA runs, and hundreds of operations before the bad one, are still there.

    Do it, step 2: make the stimulus a list

    To remove operations from the middle, the bench must replay a list of operations instead of drawing them from the seed. dbglab's bench cannot read an external stimulus. So, in a scratch copy of its bench, the random test gained a +REC=<file> switch that writes each operation as it runs it, and a new replay test reads such a file. The RTL is unchanged. Each line is one operation:

    Terminal
    $ tb/run.sh random 10 icarus +REC=seed10.ops
    dbglab: test=random seed=10
    FAIL test=random seed=10 cycle=4895: SCOREBOARD: RAM word 808 (address 00001ca0) read d5202ff9, expected b6c3311c (last written by DMA run #29)
    $ head -6 seed10.ops
    ie 1
    wr 508 7daa3ca3
    wr 804 927e7694
    ctrl 1
    wr 601 0c27e377
    rd 755
    $ vvp -n build/dbglab.vvp +TEST=replay +SEED=10 +STIM=seed10.ops | grep FAIL
    FAIL test=replay seed=10 cycle=4895: SCOREBOARD: RAM word 808 (address 00001ca0) read d5202ff9, expected b6c3311c (last written by DMA run #29)

    The replay matches the original to the cycle, which is the check you must make before trusting it. (The list is longer than the 333 steps the length search found because one step of the random test can poll the DMA and then do one more thing; each is a line.) The replay test also keeps the bench's rules, so any shortened list is still a legal test:

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // snippet: the scratch bench's replay test, the rules that keep a shortened list legal
        end else if (op == "poll") begin
          if (r_active) r_poll_dma();                 // nothing to poll without a run
        end else if (op == "wr") begin
          n = $fscanf(fd, "%d %h", a, v);
          if (!r_owned(a)) ram_wr(a, v);              // never touch words the running DMA owns
        end else if (op == "rd") begin
          n = $fscanf(fd, "%d", a);
          if (!r_owned(a)) ram_check(a);
    ...
        end else if (op == "dma") begin
          n = $fscanf(fd, "%d %d %d %d %h", a, b, c, d, v);
          if (!r_active) begin                        // one DMA run at a time
    ...
            r_go(a, mode);
          end
    ...
      while (r_active) r_poll_dma();                  // finish and check the last run

    Do it, step 3: delta debugging

    The algorithm itself knows nothing about hardware. It takes a list and a function that says whether a candidate still fails. It runs on its own here with a self-test, and you can run it as it is:

    example.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    """ddmin.py: delta debugging. Shrink a list while a test keeps failing the same way.
    fails(candidate) -> True when the candidate still shows the failure (same signature)."""
    
    def split(items, n):
        """n nearly equal, contiguous chunks."""
        return [items[len(items) * i // n: len(items) * (i + 1) // n] for i in range(n)]
    
    def ddmin(items, fails, log=print):
        n = 2
        while len(items) >= 2:
            chunks = split(items, n)
            for i, chunk in enumerate(chunks):                 # 1. does one chunk fail on its own?
                if fails(chunk):
                    log(f"keep chunk {i + 1}/{n}: {len(items)} -> {len(chunk)}")
                    items, n = chunk, 2
                    break
            else:
                for i in range(n):                             # 2. can we drop one chunk?
                    rest = [x for j, c in enumerate(chunks) if j != i for x in c]
                    if fails(rest):
                        log(f"drop chunk {i + 1}/{n}: {len(items)} -> {len(rest)}")
                        items, n = rest, max(n - 1, 2)
                        break
                else:
                    if n >= len(items):                        # 3. single items, none removable
                        break
                    n = min(2 * n, len(items))                 #    nothing helped: finer chunks
                    log(f"finer: {n} chunks of about {len(items) // n}")
        return items
    
    if __name__ == "__main__":
        # Self-test: 300 operations; the "bug" needs operation 41 and, later, operation 207.
        calls = 0
        def toy_fails(ops):
            global calls
            calls += 1
            return 41 in ops and 207 in ops
        small = ddmin(list(range(300)), toy_fails, log=lambda m: None)
        print(f"minimal: {small} after {calls} tests")
        assert small == [41, 207], small
        print("PASS: ddmin keeps exactly the two operations the failure needs")

    The driver connects it to the simulator: write the candidate list to a file, run the replay, check the FAIL line against the signature, and never run the same list twice.

    example.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    # not-run: runs dbglab's simulator through subprocess; run it where the scratch bench's build/dbglab.vvp is
    """shrink_stim.py: shrink a recorded dbglab operation list with ddmin.
    usage: python3 shrink_stim.py <seed> <ops file> <signature regex>   (run where build/dbglab.vvp is)"""
    import re, subprocess, sys, time
    from ddmin import ddmin
    
    seed, src, sig = sys.argv[1], sys.argv[2], re.compile(sys.argv[3])
    ops = open(src).read().splitlines()
    runs, seen = 0, {}
    
    def fails(candidate):
        global runs
        key = tuple(candidate)
        if key not in seen:                                   # never run the same list twice
            runs += 1
            with open("cand.ops", "w") as f:
                f.write("\n".join(candidate) + "\n")
            out = subprocess.run(["vvp", "-n", "build/dbglab.vvp", "+TEST=replay", f"+SEED={seed}",
                                  "+STIM=cand.ops"], capture_output=True, text=True).stdout
            line = next((l for l in out.splitlines() if l.startswith("FAIL")), "")
            seen[key] = bool(sig.search(line))                # same failure, not just any failure
        return seen[key]
    
    t0 = time.time()
    assert fails(ops), "the full list must fail with the signature"
    small = ddmin(ops, fails, log=lambda m: print(f"run {runs:4d}: {m}"))
    open(src.replace(".ops", ".min.ops"), "w").write("\n".join(small) + "\n")
    print(f"{len(ops)} operations -> {len(small)} in {runs} runs, {time.time() - t0:.1f} s:")
    print("\n".join(small))
    Terminal
    $ python3 ../shrink_stim.py 10 seed10.ops 'SCOREBOARD.*(read|holds) [0-9a-f]{8}, expected [0-9a-f]{8} \(last written by DMA run'
    run    3: keep chunk 2/2: 364 -> 182
    run    5: keep chunk 2/2: 182 -> 91
    run    7: keep chunk 2/2: 91 -> 46
    run    9: keep chunk 2/2: 46 -> 23
    run   10: keep chunk 1/2: 23 -> 11
    run   12: keep chunk 2/2: 11 -> 6
    run   14: keep chunk 2/2: 6 -> 3
    run   15: keep chunk 1/2: 3 -> 1
    364 operations -> 1 in 15 runs, 0.4 s:
    dma 70 308 378 711 00000000
    $ vvp -n build/dbglab.vvp +TEST=replay +SEED=10 +STIM=seed10.min.ops | grep FAIL
    FAIL test=replay seed=10 cycle=447: SCOREBOARD (final sweep): RAM word 763 (address 00001bec) holds 42ea2b0d, expected 35288af0 (last written by DMA run #1)

    Read the result. Fifteen simulations, under half a second. One operation is left: a DMA run whose first field, 70, selects a plain RAM-to-RAM copy, of 308 words from word 378 to word 711. On its own it fails at cycle 447 instead of 4895, and the final sweep finds word 763 wrong: 711 + 52. The copy stopped after 52 words, and 308 − 52 = 256. You have not opened a waveform, and the hypothesis for the next step of the loop is already on the table: lengths above 255 lose 256 words.

    What the evidence says, and the strict signature

    The same shrinker with a signature that accepts only a CPU read (not the final sweep) gives a different answer:

    Terminal
    $ python3 ../shrink_stim.py 10 seed10.ops 'SCOREBOARD: RAM word \d+ \(address [0-9a-f]+\) read [0-9a-f]{8}, expected [0-9a-f]{8} \(last written by DMA run'
    run    3: keep chunk 2/2: 364 -> 182
    ...
    run   71: drop chunk 13/14: 14 -> 13
    364 operations -> 13 in 83 runs, 1.5 s:
    dma 70 308 378 711 00000000
    wr 244 cb606bc4
    poll
    wr 80 cc5d2c65
    wr 340 b93b775f
    per a0c2f255
    per 5317e084
    wr 73 14018373
    poll
    scratch c78b0290
    poll
    poll
    rd 808

    Thirteen operations, and only two carry the bug: the copy and the read of word 808. The other eleven are there because the read must wait until the copy is finished and closed; the replay skips a read of a word the running DMA owns. Remove any one of them and the read comes too early, is skipped, and the failure turns into the final sweep, which this signature rejects. Delta debugging promises a list from which no single operation can be removed. It does not promise the shortest test. Writing the directed test is your job: one 308-word copy, wait for done, read the copy back.

    The tool also sorts failures. Seed 13 shrank to one copy of 290 words (dma 82 290 500 41), the same bug. Seed 17, with a signature for an unknown value, shrank to dma 24 9 209 810: a nine-word zero fill that nobody programmed a fill value for, which is a different bug, for Part 5.

    Traps

    Common pitfall

    A signature that is too loose. A signature of just FAIL lets the shrinker slide from your bug to any other failure that a shorter test happens to hit, such as a watchdog or a protocol check. You end with a tiny test for the wrong bug. Match the check and its message; leave out only the numbers that change as the test shrinks.

    Common pitfall

    Shortened tests that break the bench's rules. Remove the poll that closes a DMA run, and a naive replay would read words the DMA is still writing, and fail. That failure is real, but it is the test's fault. Every rule the random test obeys when it generates stimulus must also be checked when it replays.

    Why this matters

    Senior lens. Build replay into the bench from the start. A random test that can write its operations as a list and replay a list costs a few dozen lines, as here, and turns every random failure into a shrinkable one. Benches built on a transaction-level layer can record at the sequence-item level for the same effect.

    The shrunk test is also your best regression test. One 308-word copy runs in a few hundred cycles and fails on every commit with the bug, where the original needed seed 10 and the random test's 1-in-400 chance of a long run.

    Interview

    Asked: "A constrained-random test fails after two hours with thousands of transactions. How do you get to something you can debug?"

    Weak answer: I would open the waveform near the failure time and work backwards from the error, maybe adding more debug messages.

    Strong answer: I make the failure small before I debug it. First I check the run repeats with its seed. Then I write a signature for this failure, the checker and its message without the numbers, and bisect the test length to cut the tail. If the bench can replay a recorded transaction list, I run delta debugging on that list: drop chunks, keep any smaller list that still fails with the same signature. That usually gets to a handful of transactions in a few dozen runs. I read that, write a directed test from it, and debug the directed test.

    Follow-ups: (1) What goes wrong if the signature is just "any failure"? (2) The bench cannot replay a list. What can you still shrink?

    Check yourself Your shrinker ends with 40 operations, and you can see that only three of them touch the block under suspicion. Why did it keep the other 37, and what do you do?

    Because removing any one of them changed the result: they probably supply timing (waiting for something to finish), set up state the three need, or keep the test legal. Delta debugging guarantees that no single operation can go, not that the result is the shortest test. Read the 40, form a guess about what the other 37 provide (often just time), and write a directed test with the three operations and an explicit wait. If it fails with the same signature, you have your minimal repro.

    Check yourself With signature FAIL, your shrinker turns a scoreboard failure into a two-operation test that fails with a WATCHDOG message. What happened?

    The signature was too loose. Somewhere in the search a candidate list hit a different failure, here a hang, and the shrinker accepted it because it was still a failure. It then shrank that bug instead of yours. Use a signature that matches the original checker and message, rerun, and keep the watchdog test: it may be a real second bug, or a test that breaks the bench's rules.

    Remember

    • Write the signature first: what counts as the same failure. Too loose and you shrink into another bug; too strict and the result keeps noise.

    • Cut length first (bisect the operation count), then content (delta debugging on a recorded list). Each try must still be a legal test.

    • Delta debugging gives a list where no single operation can go, not the smallest possible list. Read it, then write the directed test.

    Rule of thumb: shrink before you debug: a failure that takes one operation has very few places to hide.

    How this was checked

    dbglab at HEAD 49c5f04, Icarus Verilog 13.0, Python 3.14.3, 2026-10-17. Length: python3 shrink_ops.py 10 1500 '<signature>' run in dbglab after tb/run.sh smoke 1 built build/dbglab.vvp; the loose signature gave 320 operations in 11 runs, the strict one 333.

    Content: dbglab's bench cannot read a stimulus file, so a scratch copy of HEAD's tb/ got +REC=<file> in the random test and a replay test (103 changed lines). tb/run.sh random 10 icarus +REC=seed10.ops and the replay of its 364 lines give the same FAIL line at cycle 4895; seeds 2, 13, 16 and 17 also replayed identically.

    python3 shrink_stim.py 10 seed10.ops '<loose>': 364 to 1 operation in 15 runs (0.4 s); its replay fails at cycle 447. With the strict signature: 13 operations in 83 runs (1.5 s), failing at cycle 450. Seed 13 shrank to dma 82 290 500 41, seed 17 (signature read|holds x+) to dma 24 9 209 810. ddmin.py prints PASS on its self-test.

    Limits: one design and one bug class; the replay bench is a scratch extension, not part of dbglab. Delta debugging assumes the test is deterministic; on a flaky test it shrinks towards noise.

    Keep going

    • Practice: Design a simple DMA controller

    • Practice: Counter with wrap and saturate modes

    • Code lab: DMA control FSM

    • Interview: run a mock interview on this topic

    Related topics

    • Part 1 · Reproduce

    • Next: reducing the design

    • Recording everything to replay

    • Part 3: localise, including bisection in stimulus

    • The debug loop: the same bug, found with a probe

    Previous

    9 / 107

    Next: Reducing the Design

    Up next

    Up nextReducing the Design

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Determinism and Seeds
    • Making a Flaky Test Fail
    • When Simulation Cannot Reproduce It