Part 4 · Assertions (SVA) · Intermediate
Assertion Performance & Noise Control
Attempt explosion from weak antecedents, the cost of unbounded ranges, reset-phase $assertoff, severity discipline, and readable regressions.
Where assertion CPU time actually goes
A concurrent assertion starts a new evaluation attempt every clock edge , and each attempt may fork threads at every range and or. The simulator pays for live threads, so the cost of an assertion is roughly: attempts that get past the antecedent, times threads per attempt, times average thread lifetime. All three factors are under your control — and the worst offenders are always weak antecedents and unbounded windows.
ATTEMPT/THREAD EXPLOSION — same check, two costs
WEAK: 1'b1 |-> ##[1:100] (done && tag_ok)
cycle : 0 1 2 3 4 ...
attempts : A0 A1 A2 A3 A4 ... ← antecedent always true:
threads : A0 spawns up to 100 threads EVERY edge starts work
A1 spawns up to 100 threads ~100 live threads per
... attempt, all cycles, forever
PRECISE: $rose(start) |-> ##[1:100] (done && tag_ok)
start : 0 1 0 0 0 ...
attempts : -- A1 -- -- -- ← work only when a real
threads : A1: up to 100 threads transaction begins
Same protocol rule. The weak form does ~N_cycles times more work —
and drowns the thread viewer when you debug it.Strengthen antecedents: $rose(start), not 1'b1 or a level that holds for the whole burst.
Bound every range: ##[1:64] with a real protocol bound, not ##[1:$] — unbounded threads from lost responses live until end of sim.
first_match() collapses multi-match sequences when any single match suffices — fewer surviving threads.
Prefer one assertion with a precise trigger over a cycle-invariant form recomputed every edge.
Unbounded ranges — the slow leak
##[1:$] and s_eventually threads never die while unresolved. If responses are occasionally dropped by a buggy DUT (exactly when you need assertions most), every lost response leaks a permanent live thread; a long soak test accumulates thousands, simulation slows progressively, and the failure — if liveness ever resolves — reports at end of test, far from the cause. In simulation, replace liveness with bounded liveness : a generous but finite window is both faster and a better bug report.
// LEAKY: thread per lost response lives until end of sim,
// failure (via strong operator) only reported at sim end
ap_done_leaky: assert property (@(posedge clk) disable iff (!rst_n)
$rose(start) |-> s_eventually done);
// BOUNDED: pick worst-case-plus-margin from the spec; fails AT the
// timeout, near the cause, and the thread always terminates
localparam int MAX_LAT = 200;
ap_done: assert property (@(posedge clk) disable iff (!rst_n)
$rose(start) |-> ##[1:MAX_LAT] done)
else $error("done timeout: > %0d cycles after start", MAX_LAT);Phase control and severity discipline
$assertoff during reset and configuration
initial begin
$assertkill(0, tb.dut); // X-soup phase: total silence
wait (tb.rst_n);
repeat (2) @(posedge tb.clk);
$asserton(0, tb.dut); // main phase: everything on
end
task automatic reconfigure_link();
$assertoff(0, tb.dut.u_link); // rules legitimately violated
program_registers(); // during reconfig
@(posedge tb.clk);
$asserton(0, tb.dut.u_link);
endtaskSeverity is routing information
$fatal — corruption is spreading and nothing after this is meaningful (clock dead, scoreboard poisoned). Stops the sim; use sparingly.
$error — real protocol violation; test should FAIL but can usually keep running to expose more. The default for assertions.
$warning — suspicious but legal, or a known waiver under investigation. Must not fail the test, must not be the dumping ground for demoted errors.
$info / pass-action prints — debug aid only; keep out of regression logs (or PassOff them via $assertcontrol).
The regression script greps these levels to decide pass/fail — wrong severity literally misroutes triage.
Rate-limiting repeated failures
// One broken signal can fire every cycle for a million cycles.
// Cap the noise, keep the count, never hide the first occurrences.
int unsigned fail_cnt;
ap_stable: assert property (@(posedge clk) disable iff (!rst_n)
valid && !ready |=> $stable(data))
else begin
fail_cnt++;
if (fail_cnt <= 10)
$error("data moved under backpressure (occurrence %0d)", fail_cnt);
else if (fail_cnt == 11)
$error("data-stability: further failures suppressed (see final count)");
end
final if (fail_cnt > 10)
$display("ap_stable total failures: %0d", fail_cnt);Keeping regressions readable
The purpose of all this discipline is a regression log a human can triage in minutes: the first failure of each distinct check, with sampled values and a timestamp near the cause, and nothing else. A thousand-line wall of repeated identical failures hides the second, different bug sitting at line 970.
NOISY LOG (untriagable) DISCIPLINED LOG (triagable)
────────────────────────── ─────────────────────────────
1150ns ERROR ap_stable ... 1150ns ERROR ap_stable data moved
1160ns ERROR ap_stable ... under backpressure (1)
1170ns ERROR ap_stable ... ...occurrences 2..10...
... 997 more identical ... 1260ns ERROR ap_stable further
9870ns ERROR ap_fifo_ov ◄─buried failures suppressed
... 9870ns ERROR ap_fifo_ov push
while full cnt=16 ◄─visible
2 bugs, 1 findable 2 bugs, 2 findableInterview angle
Performance questions test whether you connect SVA semantics to cost: "Why is 1'b1 |-> ##[1:100] done expensive?" — an attempt every edge gets past the trivial antecedent and forks up to 100 threads, so work scales with cycles times window instead of with transactions. "Why avoid s_eventually in simulation?" — unresolved threads never die, lost responses leak threads for the rest of the run, and the failure reports at end-of-sim instead of at a timeout near the cause. Severity discipline ($error fails the test and continues; $fatal stops; $warning must never hide demoted errors) rounds out the senior answer.
Key takeaways
Assertion cost = attempts past the antecedent, times threads per attempt, times thread lifetime — tune all three.
Precise edge-triggered antecedents ($rose) cut attempt count from every-cycle to per-transaction.
Replace unbounded liveness with bounded windows in simulation — faster, and the failure lands near the cause.
$assertkill before reset, $asserton after settle, scoped $assertoff for reconfig/error-injection phases.
Rate-limit repeating failures but keep counts — one broken wire must not bury the second bug.
Common pitfalls
Antecedent 1'b1 or an always-true level — attempt explosion and vacuity statistics become meaningless.
##[1:$] on a response that a buggy DUT can drop — permanent thread leak, progressive slowdown.
$fatal on recoverable protocol errors — one failure hides every other bug the test would have exposed.
Demoting noisy $error to $warning instead of fixing the noise — regressions go green while the bug remains.
Forgetting $asserton after a scoped $assertoff — that checker is silently dead for the rest of the run.