Hardware Engineering · HDL and Synthesis
Delta Cycles and the Stratified Event Queue in HDL Simulation
Why an event-driven simulator orders zero-delay updates by region rather than by source text, how combinational feedback stalls simulated time, and which races the queue does not prevent.
Two always blocks in the same design, one assigning a register and the other reading it, have no
inherent order. In silicon they are gates connected by wires, and the wire settles to one value after
a propagation delay. In a simulator there is no wire and no propagation delay: there is a scheduler
that decides which process runs first. Verilog resolves this by refusing to define an order between
processes at all, and instead defining an order between classes of events. Every value update is
placed in one of a fixed set of regions, the regions are drained in sequence, and only when they are
all empty does simulated time move forward.
That structure is the reason two simulators can disagree about a badly written design, the reason a
combinational loop hangs instead of oscillating, and the reason a $display inside a clocked block
can print a value that is about to change. None of it is visible in the source text; all of it is in
the queue.
A time step is four regions, drained in order
IEEE 1364 defines a stratified event queue with four regions. The active region holds events that
are ready now: evaluation of continuous assignments, evaluation of gate primitives, execution of
always blocks whose trigger condition has occurred, and the effect of blocking assignments, which
update their target immediately as part of the running process. The inactive region holds events
scheduled with a zero delay, written #0. The non-blocking assign update region holds the
left-hand-side updates produced by <= statements, whose right-hand sides were already evaluated in
the active region. The monitor region holds $monitor and $strobe, which by definition must run
after everything else at that time has settled.
SystemVerilog adds regions to the same skeleton: an observed region for property evaluation and a set of re-active regions that programs and assertions can use to schedule further work. It also renames the monitor region to the postponed region, which is where read-only sampling of settled values happens. The additions matter to verification code rather than to the basic question of when a register sees a new value, but they preserve the same discipline: a region is drained completely before the next one starts.
The skeleton looks like this, with each region a queue of pending events and each event a closure that may enqueue more events anywhere:
/* One simulation time step, stripped to the regions that matter for RTL. */
for (;;) {
while (!empty(ACTIVE)) run(pop(ACTIVE)); /* may enqueue more ACTIVE */
while (!empty(INACTIVE)) run(pop(INACTIVE)); /* #0 delays land here */
while (!empty(NBA)) apply(pop(NBA)); /* deferred <= updates */
if (empty(ACTIVE) && empty(INACTIVE) && empty(NBA))
break; /* nothing left: settle */
}
while (!empty(MONITOR)) run(pop(MONITOR)); /* $strobe, $monitor */
advance_time_to_next_scheduled_event();
The inner for loop is the whole point. Draining the active region can produce new active events,
because evaluating an always block changes a net, and changing a net wakes whatever is sensitive to
it. The loop repeats until no region has work left. Each repetition that completes without advancing
time is a delta cycle, a term borrowed from VHDL’s simulation cycle, which has the same shape.
Why the active region needs more than one pass
Consider three blocking assignments in a chain, each in its own always block sensitive to the
previous one’s output. When the first input changes, the simulator schedules the first block. Running
it updates the intermediate net, which schedules the second block into the active region. That event
sits behind whatever else was already queued. It is still evaluated before the active region empties,
so the second block runs at the same simulated time and does not add a delta cycle; the third follows
the same way. A chain of $k$ combinational stages therefore settles within a single delta cycle
however long the chain, and what the chain costs is scheduler work rather than simulated time.
Scheduler work is not free, though. A design with long combinational paths and blocking assignments in every stage pays for each stage twice: once in the gate delay that the timing analysis will eventually model, and once in scheduler iterations that consume host time without consuming simulated time. The usual fix is not to shorten the chain but to write the chain as one continuous assignment, which the simulator can evaluate as a single event with internal dependencies resolved in one pass.
The inactive region exists because #0 had to mean something other than “immediately”. A process
that assigns with #0 yields to everything already in the active region, then resumes. This is
occasionally useful for ordering testbench stimulus after the design has settled, and it is a
liability anywhere else: it splits one delta cycle into two and makes the order of two #0 blocks
depend on the order in which they were scheduled.
The loop that never advances time
A continuous assignment whose right-hand side reads its own left-hand side, assign a = ~a;, is the
minimal failing case. Whenever a changes, the assignment is evaluated and schedules a new value for
a; that update schedules another evaluation; the active region never empties. Simulated time cannot
advance because time only advances when every region is empty.
Real hardware does not do this. An inverter with a wire from output to input oscillates, but it oscillates with a period set by the wire and gate delays, and a simulator that modelled those delays would advance time. A zero-delay model has removed the period, so the equation $a = \neg a$ has no solution and the iteration has no fixed point. Simulators protect themselves with an iteration limit per time step and abort with an error naming the region or the offending process; without the limit, the process would spin forever on one timestamp.
The same trap catches always @(*) blocks that read a net they also drive, and pairs of blocks that
form a cycle through blocking assignments. It also catches something subtler: an always block whose
sensitivity list is incomplete. always @(a or b) y = a & b & c; never re-evaluates when c changes,
so y holds a stale value until some other input moves. Verilog-2001 introduced @* and @(*) to
build the sensitivity list from the nets actually read inside the block, which removes the omission
but not the class of bug: a net read only inside a called function, or a memory word, is not
guaranteed to appear in the inferred list, and the block can still go stale.
What the regions guarantee, and what they do not
The non-blocking update region gives one hard guarantee: the right-hand side of every <= in a time
step is evaluated before any of their left-hand sides are updated. No non-blocking update in a time
step can therefore observe the effect of another one in the same step. That single property is what
makes a shift register shift rather than collapse, and it is why the swap idiom a <= b; b <= a;
exchanges two registers while a = b; b = a; overwrites both with b.
What the queue does not guarantee is any order between two processes that both use blocking
assignments on shared state. Two clocked blocks, one doing q = d and the other doing r = q, may
run in either order, and the language permits both results. Whether r captures the old or the new
q depends on the tool, the optimization level, and the order in which the blocks were elaborated.
Non-blocking assignment removes the ambiguity for this shape because both blocks sample q before
either update lands, which is the subject of
blocking and non-blocking assignment.
The monitor region explains an old confusion about printing. $display executes where it appears, in
the active region, so a $display placed after a <= in a clocked block prints the old value of
the target. $strobe and $monitor run in the monitor region after the time step has settled, so
they print the value the design will actually hold until the next event. A testbench that appears to
show a one-cycle-late register is often showing the print statement’s region rather than the
register’s behaviour.
Two scheduling differences that change behaviour
SystemVerilog’s always_comb is not a synonym for always @(*). The standard requires an
always_comb process to execute once at time zero, so the combinational output is initialized before
any input changes; always @(*) waits for a change and leaves its target at its initial value until
then. The same construct also forbids any other process from writing the variables it assigns, which
turns a class of race into an elaboration error. always_ff goes further and requires exactly one
event control and no blocking timing controls, encoding the scheduling discipline that clocked blocks
rely on.
The second difference is initialisation. A reg with no initial value starts as x, and x
propagates through the queue like any other value: an if (x) takes the false branch in simulation,
which is a choice the language makes and hardware does not. A design whose reset does not reach every
register will simulate to a definite state and power up to an indefinite one. That gap is not a
scheduling bug, but it is found by the same reasoning about what the simulator knows at time zero,
and it is the reason the reset structure described in
inferred latches and registers
is part of the scheduling story rather than a separate style question.
References
- IEEE Std 1364-2005: IEEE Standard for Verilog Hardware Description Language — IEEE
- IEEE Std 1800-2017: IEEE Standard for SystemVerilog — IEEE
- Verilog HDL — Samir Palnitkar, Prentice Hall
- Nonblocking Assignments in Verilog Synthesis, Coding Styles That Kill! — Clifford E. Cummings, SNUG 2000