"It fails sometimes" is the most expensive sentence in verification. The failure is real until proven otherwise, but nobody can debug it until it is made repeatable. This lesson turns an intermittent failure into a deterministic one, then finds the cause.
Kind | What changes between runs | Reproduces with the same seed and build? |
|---|---|---|
Seed-dependent | The random stimulus. Only some seeds reach the corner. | Yes, every time. |
Stability-dependent | The seed is the same, but a code change shifted the random streams. | Yes for one build, no across builds. |
Race-dependent | The order of processes in one time step. Depends on tool, version, compile order, optimisation. | Usually yes on one tool and build; can flip on any change. |
The first question is always: does the same seed on the same build fail every time? If yes, it is not intermittent at all. It is a rare seed, and it can be debugged like any other failure.
A made-up scenario. The nightly regression of a 4-entry FIFO shows 4 failing seeds out of 40. Yesterday it was 0. The only change was a refactor of the write path. Someone suggests rerunning the failures "to see if they are flaky".
Rerunning a seed-dependent failure gives the same result, so the rerun proves nothing new. The better first step is to count which seeds reach the risky state, and compare that with which seeds fail.
module fifo4 (
input logic clk,
input logic rst_n,
input logic push,
input logic [7:0] wdata,
input logic pop,
output logic [7:0] rdata,
output logic full,
output logic empty
);
logic [7:0] mem [4];
logic [1:0] wptr, rptr;
logic [2:0] cnt;
assign full = (cnt == 3'd4);
assign empty = (cnt == 3'd0);
assign rdata = mem[rptr];
wire do_push = push && !full; // one definition, used everywhere
wire do_pop = pop && !empty;
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) begin
wptr <= '0; rptr <= '0; cnt <= '0;
end else begin
if (do_push) begin mem[wptr] <= wdata; wptr <= wptr + 2'd1; end
if (do_pop) rptr <= rptr + 2'd1;
cnt <= cnt + 3'(do_push) - 3'(do_pop);
end
endmodule
The bench runs 40 seeds in one simulation. Each seed is set with srandom(seed) on the running thread's std::process handle, so the stream is the same every run on the same build (see Random Stability and Seeding).
With the correct FIFO it passes, and reports that 9 of the 40 seeds reach the state "full, with push and pop in the same cycle". Now apply the refactor that caused the report: the write path uses its own copy of the push condition.
// snippet: the refactor, write path only
if (push && (!full || pop)) begin mem[wptr] <= wdata; wptr <= wptr + 2'd1; end
// the count still uses do_push = push && !fullWith this change the bench prints FAIL: 4 of 40 seeds failed: 1 16 21 22 (Verilator 5.048). The pattern is the clue:
Every failing seed is one of the 9 that reach the corner. No seed fails without it.
Not every corner seed fails. The pointer and the count disagree after the corner, but the data goes wrong only if another push lands before the end of the test.
So the bug is reachable on about one seed in four, and visible on about one in ten. That is what "intermittent" usually means: rare, not random.
Root cause: two places compute "is this push accepted". At full with a pop, the write path accepts the push and moves the pointer, but the count does not. The next push overwrites the oldest entry. The fix is the one in the listing: define do_push once and use it everywhere.
Pin seed, build, tool version and plusargs. Rerun once. If it passes now, you have a stability or race problem, not a rare seed.
Find the first wrong event, not the end-of-test message. Here: the first read that returns the wrong data.
Shrink: cut the test length to just past the first wrong event, and turn off agents that do not matter.
Correlate: which seeds reach the suspected state? If every failing seed reaches it and no passing seed shows the symptom, you have strong evidence.
Add the state as a coverage point, so the regression reports how often the corner is reached.
If a failure disappears when a $display is added, or appears only on one simulator, suspect a race. Two processes woken by the same event run in an order the LRM leaves undefined (IEEE 1800-2017, 4.7). The classic case is a testbench driving with a blocking assignment at the clock edge:
// snippet: a testbench race and its fix
always @(posedge clk) valid = next_valid; // races with the DUT's always_ff reading valid
always @(posedge clk) valid <= next_valid; // fix 1: nonblocking, updated after all reads
initial forever @(negedge clk) valid = next_valid; // fix 2: drive half a cycle away from the edge
// fix 3: drive through a clocking block with an output skewWhether the DUT sees the old or the new valid depends on which always runs first. Many simulators keep a stable order for one build, so the failure looks repeatable until an unrelated edit changes the order. See Anatomy of a TB/RTL Race for the scheduler detail.
Random stability causes a third kind. Each thread and object has its own random generator, seeded from its parent when it is created (IEEE 1800-2017, 18.14). Adding one randomize() call or one new object before others can shift every stream after it. The same seed then takes a different path. The fix is not to rerun the old seed, but to accept that the old seed is gone and keep the regression wide enough to find the corner again.
In a sense, yes: the pointer and count are already wrong after the corner, but the data goes bad only when a later push overwrites the oldest entry. An assertion on the pointer difference catches the bad state on the cycle after the corner, but it must be written modulo the pointer width. A literal wptr - rptr == cnt is evaluated at 3 bits, so it false-fires after any pointer wrap and on every full cycle (where wptr == rptr and cnt == 4); on the correct FIFO it fired on all 40 seeds. Written as 2'(wptr - rptr) == cnt[1:0], it stayed silent on the correct FIFO and, with the refactor, fired on 8 of the 9 corner seeds. The ninth, seed 40, reaches the corner on the last cycle of its test, so no clock edge samples the bad state before the next reset.
Most likely random stability. A new random field changes how many values the generator draws, which shifts every later value. Check by rerunning Monday's build: if it still fails, the bug is real and the seed was lost, so widen the regression or add coverage for the state.