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

  1. IEEE Std 1364-2005: IEEE Standard for Verilog Hardware Description Language — IEEE
  2. IEEE Std 1800-2017: IEEE Standard for SystemVerilog — IEEE
  3. Verilog HDL — Samir Palnitkar, Prentice Hall
  4. Nonblocking Assignments in Verilog Synthesis, Coding Styles That Kill! — Clifford E. Cummings, SNUG 2000