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
    • The Debug Loop
    • Symptom, Fault and Cause
    • The Cost of a Bug by Stage
    • Guessing vs Searching
    • Meet dbglab
    • A Bug Report Others Can Act On
  • Part 1 · Reproduce8
  • 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 0 · What Debugging Is
  1. Topics
  2. The Art of Hardware Debug
  3. Part 0 · What Debugging Is
  4. Guessing vs Searching

Part 0 · What Debugging Is · Foundation (F)

Guessing vs Searching

Why changing things to see what happens wastes days: information per experiment, and a real comparison on one dbglab failure, where a halving search finds the bad operation in 11 runs and random pokes need 670.

On this page

    Why this matters

    An experiment whose result you could have predicted tells you nothing, yet most debugging time goes on exactly those: rerunning, trying another seed, changing a setting to see what happens. Choosing each experiment to split the suspects in half finds the answer in about log2 N runs instead of N.

    After this lesson you can: write down the suspects and what each possible result of an experiment would rule out, pick the experiment that splits them evenly, and run a halving search over a test's length, a design's history or its stimulus.

    Note

    Level: Foundation (F) · Debug-loop step: Localise and Test (choosing the next experiment)

    Before this: The Debug Loop

    Five runs and no closer

    dbglab's random test fails on seed 17: the scoreboard reads an unknown value from RAM word 816 at cycle 7949. Suppose you have not yet walked back from it (the symptom, fault and cause lesson does). An engineer spends the morning trying things. Every run below is real:

    Terminal
    $ tb/run.sh random 17
    dbglab: test=random seed=17
    FAIL test=random seed=17 cycle=7949: SCOREBOARD: RAM word 816 (address 00001cc0) read xxxxxxxx, expected 00000000 (last written by DMA run #4)
    $ tb/run.sh random 17
    dbglab: test=random seed=17
    FAIL test=random seed=17 cycle=7949: SCOREBOARD: RAM word 816 (address 00001cc0) read xxxxxxxx, expected 00000000 (last written by DMA run #4)
    $ tb/run.sh random 17 verilator
    dbglab: test=random seed=17
    PASS test=random seed=17 cycles=18442 time=184416 ns
    $ tb/run.sh random 18
    dbglab: test=random seed=18
    PASS test=random seed=18 cycles=19229 time=192286 ns
    $ tb/run.sh random 17 icarus +OPS=3000
    dbglab: test=random seed=17
    FAIL test=random seed=17 cycle=7949: SCOREBOARD: RAM word 816 (address 00001cc0) read xxxxxxxx, expected 00000000 (last written by DMA run #4)

    Five runs, and the engineer's notes say: fails in Icarus only, seed-dependent, maybe a simulator issue. What did those five runs actually rule out?

    What people usually do, and why it fails

    Changing something to see what happens feels like progress, because every run produces output. But a run is only worth the surprise in its result. Look at the five again:

    Experiment

    What you could predict before running it

    What it ruled out

    Rerun seed 17

    FAIL, identically: the bench is deterministic

    nothing

    Rerun seed 17 again

    the same

    nothing

    Seed 17 in Verilator

    unclear, so a real question

    something real (the result depends on how the simulator starts unreset flip-flops), but it was read as "an Icarus bug"

    Seed 18

    nothing about seed 17: different stimulus

    nothing about this failure

    Seed 17 with 3,000 operations

    FAIL at the same cycle: the failure is already inside the first 1,500

    nothing

    Four of the five had a result you could have written down in advance. The fifth carried real information and was misread because it changed two things at once (the simulator and, with it, how unknown values behave). None of them reduced the list of places the bug could be. This is guessing: experiments chosen because they are easy, not because of what they could tell you.

    The method: count what each experiment can tell you

    A run that ends in PASS or FAIL answers one yes-or-no question. If, before running it, you think FAIL has probability p, the information you can expect from it is

    H(p)=−plog2​p−(1−p)log2​(1−p) bits

    which is 1 bit when p = 0.5 and close to 0 when you are nearly sure: about 0.008 bits for a 1-in-1,500 chance. Each bit halves the suspects. So N equally likely suspects need at least

    log2​N runs, and log2​1500≈10.6

    and you get there only if every run is a fair coin toss. The rules that follow from this:

    1. Write the suspects down as a list or a range: cycles, operations, commits, modules, seeds. A search needs a space.

    2. Before each run, predict. Say what PASS would rule out and what FAIL would rule out. If one answer is nearly certain, pick another experiment.

    3. Split the suspects evenly. The run whose two outcomes each leave half the suspects is worth a full bit, the most a yes-or-no run can give.

    4. Change one thing at a time. A run that changes two things gives one answer to two questions.

    5. Make the question monotone. Halving works when "wrong by point n" stays true for every later point. Check the two ends before you start.

    6. Automate it. A halving search is mechanical. A script runs it while you think about the result.

    The picture: halving against random pokes

    Real runs on dbglab seed 17: suspects left after each run, both axes logarithmic. Halving (accent) reaches one suspect in 11 runs; random pokes over the same knob take 670, most of them spent hunting the last two.

    Random pokes start well: when almost everything is a suspect, any test cuts something. Then they stall. With two suspects left, only one length in 1,500 tells them apart, so each poke has a 1-in-1,500 chance of being useful. Halving never stalls, because it always tests the middle of what is left.

    Do it: find the operation that broke the state

    Choose the space and the question

    The random test runs 1,500 operations, and +OPS=<n> stops it after n. At the end of every test the bench compares the whole RAM against its model, so a run with +OPS=n asks: is the state already wrong after n operations? Once the state is wrong it stays wrong, so the question is monotone. The suspects are operations 1 to 1,500; the one we want is the first after which the answer is yes.

    Check the ends first. +OPS=0 runs no operations and passes (10 cycles); 1,500 fails, as reported. Then search:

    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
    # not-run: drives dbglab's tb/run.sh, so it must run from a dbglab checkout (output below is from a real run)
    """Find the shortest prefix of a failing random test that still fails.
    usage: ops_search.py halve|poke <seed> <ops>   (run from the dbglab folder)
    Each experiment is one real run: tb/run.sh random <seed> $SIM +OPS=<n> (SIM defaults to icarus).
    The suspects are the operations 1..<ops>: the one whose run first leaves the design wrong.
    halve: test the middle of the suspects that are left.
    poke:  test a random length each time, ignoring what earlier runs said."""
    import os, random, subprocess, sys
    
    mode, seed, ops = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
    
    def fails(n):
        r = subprocess.run(["tb/run.sh", "random", str(seed), os.environ.get("SIM", "icarus"), f"+OPS={n}"],
                           capture_output=True, text=True)
        return r.returncode == 1
    
    lo, hi = 0, ops            # known: OPS=lo passes (0 runs nothing), OPS=hi fails
    runs = 0
    rng = random.Random(1)
    while hi - lo > 1:
        n = (lo + hi) // 2 if mode == "halve" else rng.randint(1, ops)
        runs += 1
        bad = fails(n)
        if bad and n < hi: hi = n
        if not bad and n > lo: lo = n
        if mode == "halve" or os.environ.get("ALL") or runs % 100 == 0 or hi - lo == 1:
            print(f"run {runs:4d}: OPS={n:4d} {'FAIL' if bad else 'pass'}   suspects left: {hi - lo:4d} (ops {lo + 1}..{hi})")
    print(f"{runs} runs: operation {hi} is the first that leaves the design wrong")
    Terminal
    $ python3 ../search/ops_search.py halve 17 1500
    run    1: OPS= 750 FAIL   suspects left:  750 (ops 1..750)
    run    2: OPS= 375 FAIL   suspects left:  375 (ops 1..375)
    run    3: OPS= 187 FAIL   suspects left:  187 (ops 1..187)
    run    4: OPS=  93 FAIL   suspects left:   93 (ops 1..93)
    run    5: OPS=  46 FAIL   suspects left:   46 (ops 1..46)
    run    6: OPS=  23 pass   suspects left:   23 (ops 24..46)
    run    7: OPS=  34 pass   suspects left:   12 (ops 35..46)
    run    8: OPS=  40 pass   suspects left:    6 (ops 41..46)
    run    9: OPS=  43 pass   suspects left:    3 (ops 44..46)
    run   10: OPS=  44 pass   suspects left:    2 (ops 45..46)
    run   11: OPS=  45 FAIL   suspects left:    1 (ops 45..45)
    11 runs: operation 45 is the first that leaves the design wrong

    Read the column of suspects: 750, 375, 187, 93, 46, 23, 12, 6, 3, 2, 1. Every run halved it, whether it passed or failed. Eleven runs, close to the 10.6 the formula promised, and each Icarus run of a short prefix takes a fraction of a second.

    Now the same knob, poked at random:

    Terminal
    $ python3 ../search/ops_search.py poke 17 1500
    run  100: OPS=  90 FAIL   suspects left:   26 (ops 20..45)
    run  200: OPS= 792 FAIL   suspects left:   10 (ops 36..45)
    run  300: OPS= 546 FAIL   suspects left:    2 (ops 44..45)
    run  400: OPS=  82 FAIL   suspects left:    2 (ops 44..45)
    run  500: OPS=  66 FAIL   suspects left:    2 (ops 44..45)
    run  600: OPS= 952 FAIL   suspects left:    2 (ops 44..45)
    run  670: OPS=  44 pass   suspects left:    1 (ops 45..45)
    670 runs: operation 45 is the first that leaves the design wrong

    The same answer, 61 times the runs. Between run 212 and run 670 the pokes were down to operations 44 and 45, and only OPS=44 could split them. Most of those 458 runs had a result you could have predicted: FAIL.

    What the evidence says

    Look at what the eleventh run printed, because a search result is a new, much smaller report:

    Terminal
    $ tb/run.sh random 17 icarus +OPS=44
    dbglab: test=random seed=17
    PASS test=random seed=17 cycles=484 time=4836 ns
    $ tb/run.sh random 17 icarus +OPS=45
    dbglab: test=random seed=17
    FAIL test=random seed=17 cycle=535: SCOREBOARD (final sweep): RAM word 810 (address 00001ca8) holds xxxxxxxx, expected 00000000 (last written by DMA run #4)

    Operation 45 starts DMA run #4. After 44 operations (484 cycles) the RAM is right; after 45 it holds an X at word 810, the first word of that run. The failure that showed up at cycle 7949 is now pinned to one DMA run starting near cycle 484, and the next experiment is obvious: look at what run #4 was told to do. That is the walk back the symptom lesson takes, and it lands on the FILL register.

    The same halving works on every axis you can order and test automatically. On dbglab's 16 commits, bisection in history finds a guilty commit in four runs; Part 3 halves time, the design hierarchy and the stimulus.

    Traps

    Common pitfall

    A search finds when, not why. Operation 45 is where the bench can first see the damage. The fault, the unknown FILL register, existed from reset. Halving narrows the search; walking back from its answer finishes it.

    Common pitfall

    A question that is not monotone sends halving the wrong way. If later operations could overwrite the bad words before the final compare, a short prefix could fail and a longer one pass, and the search would chase a ghost. Check the neighbours of the answer: here OPS=44 passes and OPS=45 and OPS=46 both fail.

    Common pitfall

    A run that changes two things answers neither question. Switching simulators changed the tool and the way unknown values behave. Ask the second question on its own: Verilator with random start values (XRAND=1) fails seed 17, which says the bug is an unknown value, not a simulator.

    Why this matters

    Senior lens. The cost of a debug session is runs times the cost of a run. In simulation a run costs a second, so even a poor search finishes, and people never learn to search. On an emulator a run costs an hour and on silicon a lab day; there, 11 against 670 is the difference between a morning and a quarter. Practise halving where runs are cheap.

    When you review someone's debug log, look for experiments without a stated prediction. They are usually where the day went.

    Interview

    Asked: "A long random test fails after two hours of simulation. How do you narrow it down?"

    Weak answer: I would rerun it with a waveform dump and look around the failure time, and try a few other seeds to see if they fail too.

    Strong answer: First I confirm it reproduces with the same seed. Then I find a knob that orders the run, such as the number of transactions, with a check that sees the damage whenever the test stops, like an end-of-test memory compare. I halve on it: about log2 N runs to find the first transaction after which the state is wrong, and each of those is shorter than the full test. I check the answer's neighbours, then dump only around that transaction and walk back to the first wrong value. Trying other seeds tells me nothing about this failure.

    Follow-ups: (1) What if the end-of-test check cannot see the damage? (2) How would you run the search faster on a compute farm?

    Check yourself You have 200 suspect commits and a test that takes 30 minutes. Your colleague wants to start with the five commits that "look most likely". Compare the two plans in runs.

    Halving needs about log2 200, so 8 runs, 4 hours unattended, with a guaranteed answer. Checking five likely commits costs 5 runs, and if the guess is wrong it rules out only 5 of 200 and you still need about 8 more. A hunch is worth testing first only when you would bet heavily on it; otherwise it is a 1-in-40 poke.

    Check yourself Before a run, you are 95% sure it will fail. Is it worth running?

    It carries about 0.29 bits (H(0.95)), so less than a third of a halving step. Run it only if the 5% outcome would change your plan, or if it costs almost nothing. Otherwise design an experiment whose outcome you genuinely cannot predict.

    Remember

    • Before an experiment, predict its result. If you are almost sure of it, it buys almost nothing; the most a yes-or-no run can buy is one bit.

    • Halving N suspects takes about log2 N runs: 1,500 operations took 11 here. Random pokes over the same knob took 670.

    • Halving needs a monotone question, like "is the state already wrong after n operations?". Check both ends before you start.

    Rule of thumb: before you run anything, say what each possible result would rule out.

    How this was checked

    dbglab at HEAD 49c5f04 (rebuilt with dbglab/build-history.sh), Icarus Verilog 13.0 and Verilator 5.048, 2026-10-17. Guesses: tb/run.sh random 17 twice (FAIL at cycle 7949 both times), tb/run.sh random 17 verilator (PASS), tb/run.sh random 18 (PASS), tb/run.sh random 17 icarus +OPS=3000 (FAIL at cycle 7949).

    Search: python3 ops_search.py halve 17 1500 (the script shown, run from the dbglab folder) made 11 runs and named operation 45; python3 ops_search.py poke 17 1500 (random lengths from random.Random(1)) made 670 runs and named operation 45, and a second run gave the same 670. Check: +OPS=44 passes (484 cycles), +OPS=45 and +OPS=46 fail at cycle 535.

    Limits: one failure, one knob. The poke count depends on the random generator's seed; the expected count is what matters, and it grows with N while halving grows with log2 N. The engineer's guesses in the scene are illustrative; their runs are real.

    Keep going

    • Interview: run a mock interview on this topic

    Related topics

    • Part 0 · What Debugging Is

    • Bisection in history: the same halving on commits

    • Symptom, fault and cause: walking back from operation 45

    • Part 4: designing the experiment that splits the suspects

    Previous

    4 / 107

    Next: Meet dbglab

    Up next

    Up nextMeet dbglab

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • The Debug Loop
    • Symptom, Fault and Cause
    • A Bug Report Others Can Act On