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. Determinism and Seeds

Part 1 · Reproduce · Foundation (F)

Determinism and Seeds

What makes a simulation run repeat exactly: the seed, the plusargs, the commit, the simulator and its options, and the process order you must design out. Real runs on dbglab and three small benches.

On this page

    Why this matters

    A failure you cannot repeat cannot be debugged, and a fix you cannot test against the same run proves nothing. Knowing exactly which inputs decide a run turns "it failed last night" into one command that fails every time.

    After this lesson you can: list the inputs a simulation run depends on, check that a failure repeats cycle for cycle, and spot the bench code (shared generators, unseeded $urandom) that breaks determinism.

    Note

    Level: Foundation (F) · Debug-loop step: Reproduce

    Before this: The Debug Loop

    It failed last night, and it passes this morning

    The nightly regression reports that dbglab's random test failed. You open a terminal and run the test:

    Terminal
    $ tb/run.sh random
    dbglab: test=random seed=1
    PASS test=random seed=1 cycles=19972 time=199716 ns

    It passes. Is the bug gone, was it a fluke, or did you just run a different test?

    What people usually do, and why it fails

    The common reaction is to rerun a few times, see green, and mark the failure as flaky. But that rerun was not the failing run. The nightly used seed 10; the command above used the default, seed 1. A different seed is different stimulus: a different test that happens to share a name.

    The same mistake comes in other forms. Rerunning on today's commit instead of last night's. Rerunning in the other simulator. Rerunning with a debug switch that changes the length of the test. Each one changes an input of the run, and each one can turn a real failure into a pass that means nothing.

    The method: name every input, then fix it

    A simulation is a program. Given the same inputs, a well-built bench produces the same run, cycle for cycle. Your job at the Reproduce step is to know what those inputs are:

    1. The seed. It decides every random choice. Always pass it on the command line and print it in the first log line, as dbglab does (dbglab: test=random seed=10).

    2. The test and its plusargs. +TEST, and anything else read with $value$plusargs, such as dbglab's +OPS (the length of the random test).

    3. The commit, plus any uncommitted edits. Different RTL or bench code is a different program.

    4. The simulator and its version. Simulators differ in scheduling, in X handling and in their built-in random generators.

    5. Options and environment. Defines, compile switches, and environment variables your scripts read, such as dbglab's XRAND.

    There is a sixth input you do not want: the order in which the simulator runs processes that wake at the same time. The language leaves that order open. A good bench is written so the order cannot change the result. Then check the claim: run the same command twice and compare the logs.

    The picture

    A run depends on five inputs you can record and one you must design out: the order of processes that run at the same time.

    Do it: dbglab repeats itself

    Same inputs, same run

    Terminal
    $ tb/run.sh random 10 > /dev/null; cp build/icarus-random-10.log run1.log
    $ tb/run.sh random 10 > /dev/null; cp build/icarus-random-10.log run2.log
    $ cmp run1.log run2.log && echo identical
    identical
    $ cat run1.log
    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)
    FATAL: tb/dbglab_tb.sv:57: test failed
           Time: 48946000  Scope: dbglab_tb.fail
    $ tb/run.sh random 10 verilator > /dev/null
    $ diff build/icarus-random-10.log build/verilator-random-10.log
    3,4c3,5
    < FATAL: tb/dbglab_tb.sv:57: test failed
    <        Time: 48946000  Scope: dbglab_tb.fail
    ---
    > [48946000] %Fatal: dbglab_tb.sv:57: Assertion failed in dbglab_tb.fail: test failed
    > %Error: tb/dbglab_tb.sv:57: Verilog $stop
    > Aborting...

    Read it from the top. Two Icarus runs give byte-identical logs. Verilator gives the same FAIL line, and the only lines that differ are each simulator's own way of reporting $fatal; both stop at the same simulation time, 48946000. Comparing whole logs, not just the verdict, is what makes this a proof.

    Change one input, get another run

    Terminal
    $ tb/run.sh random 11
    dbglab: test=random seed=11
    PASS test=random seed=11 cycles=18509 time=185086 ns
    $ tb/run.sh random 13
    dbglab: test=random seed=13
    FAIL test=random seed=13 cycle=3701: SCOREBOARD: RAM word 250 (address 000013e8) read f42b063a, expected c6278de6 (last written by DMA run #22)
    $ tb/run.sh random 10 icarus +OPS=200
    dbglab: test=random seed=10
    PASS test=random seed=10 cycles=2528 time=25276 ns
    $ git checkout -q bc2bb34 && tb/run.sh random 10      # commit 12, the day before the bug
    dbglab: test=random seed=10
    PASS test=random seed=10
    • Seed 11 draws different stimulus and never hits the bug. Seed 13 hits it at a different cycle, on a different word.

    • `+OPS=200` keeps the seed but stops after 200 operations, before the failing DMA run.

    • Commit 12 is the same seed and command on older code. It passes because the bug arrives in commit 13. (The PASS line has no cycle count yet; that arrives in commit 16.)

    The simulator is an input too. dbglab's seed 17 fails on an unknown value:

    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 verilator
    dbglab: test=random seed=17
    PASS test=random seed=17 cycles=18442 time=184416 ns
    $ XRAND=1 tb/run.sh random 17 verilator
    dbglab: test=random seed=17
    FAIL test=random seed=17 cycle=7949: SCOREBOARD: RAM word 816 (address 00001cc0) read 5d88e2dd, expected 00000000 (last written by DMA run #4)

    Icarus has an X state; Verilator, by default, starts every flip-flop at 0, which here happens to be the right value. With XRAND=1 dbglab's script asks Verilator for random initial values, seeded by the same +SEED, and the failure comes back. Same seed, same commit, three results: the simulator and its options belong in every reproduction. (Why the value is unknown is a story for Part 5.)

    Where determinism comes from in dbglab's bench

    dbglab does not use the simulator's random functions. It has its own generator, a xorshift, and only the main test process calls it:

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // snippet: tb/dbglab_tb.sv, the bench's random generator and how the seed starts it
      function automatic int unsigned rnd();
        rng = rng ^ (rng << 13);
        rng = rng ^ (rng >> 17);
        rng = rng ^ (rng << 5);
        return rng;
      endfunction
    ...
        rng = seed * 32'h9E37_79B9 + 32'h7F4A_7C16;   // spread small seeds apart
        if (rng == 0) rng = 1;                           // xorshift must not start at 0

    Three shifts and three XORs are plain integer arithmetic, so every simulator computes the same numbers. Compare the built-in $urandom, called with no seed:

    urandom_demo.sv
    1
    2
    3
    4
    // Three calls to $urandom with no seed given: what does each simulator return?
    module urandom_demo;
      initial $display("urandom: %08h %08h %08h", $urandom, $urandom, $urandom);
    endmodule
    Terminal
    $ iverilog -g2012 -o u.vvp urandom_demo.sv && vvp -n u.vvp
    urandom: 92153524 40895e81 0484d609
    $ verilator --binary -Wno-fatal --top-module urandom_demo -Mdir vl urandom_demo.sv > /dev/null && vl/Vurandom_demo
    urandom: fa43d709 c004b34e 719ca9ad
    ...
    $ vl/Vurandom_demo +verilator+seed+7
    urandom: 9666691b a2c1a89c c57479fe
    ...

    Each simulator is repeatable on its own, but the numbers differ between them, and each has its own way to set the seed. That is fine for a bench that will only ever run on one tool. dbglab's own generator is why seed 10 fails at cycle 4895 in both.

    Random stability: a new draw can move other numbers

    Next hazard: you add one random call to one part of the bench. Does every other part still get the same numbers? SystemVerilog intends each process to have its own generator ("random stability"), but simulators differ:

    stability.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // Random stability: does one extra draw in process A change what process B draws?
    module stability #(parameter bit EXTRA = 1'b0);
      int unsigned a1, b1, b2;
      initial begin                                  // process A
        a1 = $urandom;
        if (EXTRA) void'($urandom);                  // a new draw, added later in A only
      end
      initial begin                                  // process B
        #1 b1 = $urandom;
        b2 = $urandom;
        $display("EXTRA=%0d  B draws: %08h %08h", EXTRA, b1, b2);
      end
    endmodule
    Terminal
    $ iverilog -g2012 -Pstability.EXTRA=0 -o s0.vvp stability.sv && vvp -n s0.vvp
    EXTRA=0  B draws: 40895e81 0484d609
    $ iverilog -g2012 -Pstability.EXTRA=1 -o s1.vvp stability.sv && vvp -n s1.vvp
    EXTRA=1  B draws: 0484d609 31f05663
    $ verilator --binary -Wno-fatal -GEXTRA=0 --top-module stability -Mdir vl0 stability.sv > /dev/null && vl0/Vstability
    EXTRA=0  B draws: fa43d709 c004b34e
    ...
    $ verilator --binary -Wno-fatal -GEXTRA=1 --top-module stability -Mdir vl1 stability.sv > /dev/null && vl1/Vstability
    EXTRA=1  B draws: fa43d709 c004b34e
    ...

    In Icarus all processes share one stream, so A's extra draw shifts B's numbers by one. Verilator keeps B's numbers unchanged. On a simulator like Icarus, adding a debug $urandom anywhere can change the stimulus everywhere, and the failure you were chasing can vanish the moment you instrument it.

    Process order: two processes, one generator

    The last hazard is the one in the figure. dbglab's generator is safe because only one process calls it. Here two processes call the same generator on the same clock edge:

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // bad-example: two processes share one generator, so who gets which number depends on scheduling
    module shared_rng_tb;
      logic clk = 1'b0;
      int unsigned rng = 32'h1234_5678;           // ONE generator state for the whole bench
      function automatic int unsigned rnd();      // xorshift32, as in dbglab's bench
        rng = rng ^ (rng << 13);
        rng = rng ^ (rng >> 17);
        rng = rng ^ (rng << 5);
        return rng;
      endfunction
      int unsigned len_a, len_b;
      always @(posedge clk) len_a = 1 + rnd() % 64;   // stimulus for port A
      always @(posedge clk) len_b = 1 + rnd() % 64;   // stimulus for port B
      initial
        repeat (3) begin
          #5 clk = 1'b1;
          #1 $display("port A len=%0d  port B len=%0d", len_a, len_b);
          #4 clk = 1'b0;
        end
    endmodule

    Run it in both simulators, then swap the two always lines (shared_ba.sv) and run again:

    Run

    Edge 1 (A, B)

    Edge 2 (A, B)

    Edge 3 (A, B)

    Icarus, A first in the file

    36, 38

    5, 25

    14, 9

    Icarus, B first in the file

    38, 36

    25, 5

    9, 14

    Verilator, A first in the file

    38, 36

    5, 25

    9, 14

    Verilator, B first in the file

    36, 38

    25, 5

    14, 9

    The same six numbers come out every time, but which port gets which depends on the order the simulator runs the two processes. Icarus follows the source order here; Verilator's order differs, and not even the same way on every edge. A harmless-looking edit, such as moving a block or adding a file, would change the stimulus of both ports. The fix is to give each stream its own generator:

    design.sv
    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
    // Each process owns its generator, seeded from the test seed and its own name.
    // The numbers a process draws no longer depend on which process the simulator runs first.
    module own_rng_tb;
      logic clk = 1'b0;
      int unsigned seed = 10;
      function automatic int unsigned xs(input int unsigned s);   // one xorshift32 step
        s = s ^ (s << 13);
        s = s ^ (s >> 17);
        s = s ^ (s << 5);
        return s;
      endfunction
      int unsigned rng_a, rng_b, len_a, len_b;
      initial begin
        rng_a = seed * 32'h9E37_79B9 + 32'h0000_00A1;             // stream for port A
        rng_b = seed * 32'h9E37_79B9 + 32'h0000_00B2;             // stream for port B
      end
      always @(posedge clk) begin rng_a = xs(rng_a); len_a = 1 + rng_a % 64; end
      always @(posedge clk) begin rng_b = xs(rng_b); len_b = 1 + rng_b % 64; end
    
      // Check: recompute both streams on their own, in a fixed order, and compare every cycle.
      int unsigned ref_a, ref_b;
      initial begin
        ref_a = seed * 32'h9E37_79B9 + 32'h0000_00A1;
        ref_b = seed * 32'h9E37_79B9 + 32'h0000_00B2;
        repeat (3) begin
          #5 clk = 1'b1;
          #1 ref_a = xs(ref_a); ref_b = xs(ref_b);
          $display("port A len=%0d  port B len=%0d", len_a, len_b);
          if (len_a != 1 + ref_a % 64 || len_b != 1 + ref_b % 64) $fatal(1, "FAIL: stream depends on process order");
          #4 clk = 1'b0;
        end
        $display("PASS: each port's lengths come from its own stream");
        $finish;
      end
    endmodule
    Terminal
    $ iverilog -g2012 -o o.vvp own_rng_tb.sv && vvp -n o.vvp
    port A len=52  port B len=8
    port A len=10  port B len=60
    port A len=39  port B len=26
    PASS: each port's lengths come from its own stream
    ...

    With the two always lines swapped, and in Verilator in both orders, the output is the same three lines and PASS.

    What the evidence says

    dbglab's seed 10 is a real reproduction: the same command gives the same log in two simulators, and changing the seed, a plusarg, the commit or the simulator options changes the result in a way you can explain. So the morning's PASS proved nothing; the right command is tb/run.sh random 10 at last night's commit, and it fails. The small benches show the three ways a bench can lose this: the simulator's own generator, a draw added in one place shifting numbers elsewhere, and a generator shared between processes.

    Traps

    Common pitfall

    Comparing only the last line. Two runs can end with the same FAIL line and still differ earlier, for example in how many DMA runs they made. cmp or diff the logs, and for waveforms ignore the header: two DUMP=1 runs of seed 10 gave VCD files with different hashes, and the only difference was the $date line.

    Common pitfall

    A debug edit that draws a number. On a simulator without per-process stability, a $urandom added to print something, or a new randomised field, moves the stimulus everywhere. If the failure disappears after you add instrumentation, check that first.

    Why this matters

    Senior lens. Determinism is a property you design into the bench, not one you hope for. Write the rules down: one generator per stream, seeded from the test seed and a fixed name; no time-of-day seeds; no reads of uninitialised memory to make decisions; the seed and command printed on the first line. Then add a check to the regression that reruns one seed per test twice and compares the logs, so a change that breaks determinism fails on the day it lands.

    When a bench must run on two simulators, use your own generator, as dbglab does. Built-in generators are only repeatable within one tool, and sometimes within one version of it.

    Interview

    Asked: "A test failed in last night's regression. You rerun it and it passes. What do you do?"

    Weak answer: It is probably flaky, so I would rerun it a few more times, and if it keeps passing I would close it as not reproducible.

    Strong answer: First I check that I reran the same run: the same seed, plusargs, commit and local state, simulator version and options. The regression log should give all of them. If the exact run passes, I run it twice and compare the logs to check the bench is deterministic. A real flaky failure means something outside the recorded inputs changes the run, often a shared random generator or an ordering race, and that is the bug to chase first. Only after that do I say "cannot reproduce".

    Follow-ups: (1) How would you make a bench deterministic across two simulators? (2) Which inputs would you add to the first log line?

    Check yourself A colleague adds $display("pkt len %0d", $urandom_range(1, 64)); to a monitor to see a value, and the failing seed starts to pass. Has the bug gone away?

    No. The new call draws a random number, and on a simulator where processes share one stream, that moves every later draw, so the seed now runs different stimulus. Remove the draw (print the value the bench already has) and rerun the original seed. If the bench is meant to be stable, this is also a bug in the bench: each component should have its own generator.

    Check yourself Seed 17 fails at cycle 7949 in Icarus and passes in Verilator. Is that a reproduction problem or a clue?

    A clue, once you have ruled out the bench. If both simulators get the same stimulus (dbglab's own generator makes sure of this), then a different result usually means the design's behaviour depends on something the two tools model differently: X values, uninitialised state, or a race. That is what seed 17 turns out to be: Verilator passes only because its flip-flops start at 0, and with random initial values it fails at the same cycle as Icarus.

    Remember

    • A run is decided by its seed, test and plusargs, commit and local edits, simulator and version, and options. Change any one and you may get a different run.

    • Prove determinism: run the same command twice and compare the logs, not only the last line.

    • Give each stream of stimulus its own generator, owned by one process. A shared generator makes the numbers depend on process order.

    Rule of thumb: a seed is a reproduction only together with the commit, the command line and the simulator that ran it.

    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. tb/run.sh random 10 twice: both logs byte-identical (cmp); with verilator the same FAIL line, and the full logs differ only in the simulator's $fatal footer, both at time 48946000. tb/run.sh random (seed 1) and tb/run.sh random 11 pass; tb/run.sh random 13 fails at cycle 3701.

    Other inputs: tb/run.sh random 10 icarus +OPS=200 passes (cycles=2528); at commit 12 (git checkout bc2bb34) tb/run.sh random 10 passes; tb/run.sh random 17 fails in Icarus, passes in Verilator, and fails with a random value under XRAND=1. Two DUMP=1 runs give VCDs whose hashes differ; diff shows only the $date line, and the files without their first three lines hash the same.

    Small benches, compiled with iverilog -g2012 and verilator --binary -Wno-fatal --top-module <top>: urandom_demo (Verilator also with +verilator+seed+7); stability with -Pstability.EXTRA=0/1 and -GEXTRA=0/1; shared_rng_tb with the two always lines in both orders; own_rng_tb in both orders prints PASS in both simulators.

    Limits: one installed version of each simulator, so a tool-version change is described, not shown. The process-order results are what these two simulators did with these files; the language allows either order, so another tool or version may differ again.

    Keep going

    • Practice: Coverage-driven stimulus and targeted seeding

    • Practice: Blocking vs non-blocking in sequential logic

    • Code lab: Seed plusarg

    • Code lab: Stochastic random stability

    • Code lab: 8-bit LFSR

    • Interview: run a mock interview on this topic

    Related topics

    • Part 1 · Reproduce

    • Next: recording everything to replay a failure

    • The debug loop

    • SystemVerilog: seeds and reproducibility

    • SystemVerilog: randomisation debug

    • RTL: the blocking vs non-blocking race

    Previous

    7 / 107

    Next: Recording Everything to Replay

    Up next

    Up nextRecording Everything to Replay

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Shrinking a Failing Test
    • Reducing the Design
    • Making a Flaky Test Fail