The examples in SystemVerilog Concurrent Assertions checked simple boolean expressions on every single clock edge. But sequential checks take several clock cycles to complete, and the time delay between two parts of a sequence is specified with the ## operator.
12 min read | Intermediate Level
What You'll Learn
- What the
##operator means inside a sequence, and how it differs from a plain boolean check - Why a fresh attempt at the sequence starts on every clock edge, resulting in several overlapping instances tracked in parallel
- How to read a "started at Xns, failed/passed at Yns" log message for one specific pipelined instance
- Why a sequence's outcome for a given attempt is only known some cycles after it starts, not immediately
The ## Operator
If a is not high on a given clock cycle, that attempt at the sequence starts and fails on the very same cycle. But if a is high, the attempt starts and succeeds if b is high exactly 2 clocks later — it fails if b is low 2 clocks later instead:
module tb;
bit a, b;
bit clk;
// This is a sequence that says 'b' should be high 2 clocks after
// 'a' is found high. The sequence is checked on every positive
// edge of the clock which ultimately ends up having multiple
// assertions running in parallel since they all span for more
// than a single clock cycle.
sequence s_ab;
@(posedge clk) a ##2 b;
endsequence
// Print a display statement if the assertion passed
assert property(s_ab)
$display ("[%0t] Assertion passed !", $time);
always #10 clk = ~clk;
initial begin
for (int i = 0; i < 10; i++) begin
@(posedge clk);
a <= $random;
b <= $random;
$display("[%0t] a=%b b=%b", $time, a, b);
end
#20 $finish;
end
endmodule
Multiple Instances Run in Parallel
Since the assertion is checked on every clock edge, a brand-new attempt at s_ab begins each time — and since each attempt takes 2 full cycles to resolve, several attempts end up "in flight" at once, each independently tracked from its own starting point:
| Time (ns) | a | b | Sequence Start | Result |
|---|---|---|---|---|
| 10 | 0 | 0 | No | FAIL (Start@10, Fail@10) |
| 30 | 0 | 1 | No | FAIL (Start@30, Fail@30) |
| 50 | 1 | 1 | Yes | |
| 70 | 1 | 1 | Yes | |
| 90 | 1 | 0 | Yes | FAIL (Start@50, Fail@90) |
| 110 | 1 | 1 | Yes | PASS (Start@70, Pass@110) |
| 130 | 0 | 1 | No | PASS (Start@90, Pass@130) |
| 150 | 1 | 0 | Yes | FAIL (Start@110, Fail@150) |
| 170 | 1 | 0 | Yes | |
| 190 | 1 | 0 | Yes | FAIL (Start@150, Fail@190) |
Walking through two specific instances end to end makes the pattern concrete:
- At 50ns,
a=1, so an attempt starts there. It needsb=1two edges later, at 90ns — butb=0at 90ns, so this specific attempt fails, reported on the 90ns row asFAIL (Start@50, Fail@90). - At 70ns, a separate attempt starts (
a=1again). It needsb=1at 110ns — andb=1there, so this attempt passes, reported on the 110ns row asPASS (Start@70, Pass@110).
Notice these two attempts overlap in time — the one started at 50ns is still unresolved when the one at 70ns begins, and both are being tracked simultaneously. Each row in the table can carry two separate pieces of information: whether a new attempt starts on that exact edge, and whether some earlier attempt happens to resolve on that same edge.
Output
Compiler version P-2019.06-1; Runtime version P-2019.06-1; Jan 16 06:49 2020
[10] a=0 b=0
"testbench.sv", 14: tb.unnamed$$_0: started at 10ns failed at 10ns
Offending 'a'
[30] a=0 b=1
"testbench.sv", 14: tb.unnamed$$_0: started at 30ns failed at 30ns
Offending 'a'
[50] a=1 b=1
[70] a=1 b=1
[90] a=1 b=0
"testbench.sv", 14: tb.unnamed$$_0: started at 50ns failed at 90ns
Offending 'b'
[110] a=1 b=1
[110] Assertion passed !
[130] a=0 b=1
"testbench.sv", 14: tb.unnamed$$_0: started at 130ns failed at 130ns
Offending 'a'
[130] Assertion passed !
[150] a=1 b=0
"testbench.sv", 14: tb.unnamed$$_0: started at 110ns failed at 150ns
Offending 'b'
[170] a=1 b=0
[190] a=1 b=0
"testbench.sv", 14: tb.unnamed$$_0: started at 150ns failed at 190ns
Offending 'b'
$finish called from file "testbench.sv", line 27.
$finish at simulation time 210
V C S S i m u l a t i o n R e p o r t
Time: 210 ns
Each log line names exactly which attempt it's reporting on, by its own start time — "started at 50ns failed at 90ns" is the same attempt traced above, and "started at 130ns failed at 130ns" is a brand-new attempt failing immediately because a=0 right at its own start.
Quick Check
Q: At 130ns, two separate things are reported: a new attempt's start status ("No"), and an older attempt's outcome ("PASS"). Which earlier attempt is that PASS actually reporting on?
Show Answer
The attempt that started at 90ns. It needed b=1 two edges later, at 130ns — and b=1 there, so it passes. This is completely independent of the fact that a different, brand-new attempt also happens to fail immediately at that same 130ns edge, since a=0 there.
Quick Check
Q: With a fresh attempt starting on every edge and each one taking exactly 2 edges to resolve, how many unresolved attempts can be "in flight" at any given moment (not counting the one resolving on that exact edge)?
Show Answer
Up to 2 — the attempt that started 1 edge ago (needing 1 more edge to resolve) and the attempt that just started this edge (needing 2 more). Since resolution always takes exactly 2 edges, no more than 2 unresolved attempts ever need to be tracked simultaneously for a ##2 delay.
Where Is This Used?
Modern pipelined bus protocols routinely have several transactions in flight at once — a second request can be issued before the first one's response has arrived. Checking a protocol like this with assertions requires exactly this overlapping-instance behavior: each request needs its own independently-tracked check for its corresponding response, without interfering with checks already in progress for earlier requests.
Common Beginner Mistakes
Mistake #1: Assuming only one sequence attempt runs at a time
As this article shows, a concurrent assertion starts a fresh attempt on every clock edge by default. Why? Several attempts can be genuinely in flight simultaneously, each tracked from its own independent starting point — a summary table or log needs to be read with that in mind, not as if only one check were ever active.
Mistake #2: Assuming a ##2 sequence's outcome is known immediately
An attempt that starts because a is high doesn't resolve until 2 full cycles later, when b is finally checked. Why? There's a real gap between when a sequence begins and when its outcome is known — assuming an assertion has already "passed" the moment its first condition is met is premature.
Mistake #3: Misreading a summary table's row-to-outcome mapping
A row showing values for a given edge doesn't necessarily report the outcome of an attempt that started on that same row. Why? As the Quick Check above shows, the outcome shown on a given row can belong to an attempt that started several rows earlier — always check the "Start@" annotation to know which attempt an outcome actually belongs to.
Mistake #4: Assuming one attempt's immediate failure affects other attempts
A new attempt failing right at its own start (because a=0 there) has no effect on any other attempt still in flight from an earlier edge. Why? Every attempt is tracked completely independently — one failing doesn't cancel, pause, or otherwise interfere with any of the others.
Practice Exercise
Challenge: Trace a ##1 Sequence
Given the sequence a ##1 b (a 1-cycle delay instead of 2), and a/b sampled at six consecutive edges as (1,0), (0,1), (1,1), (0,0), (1,1), (1,0), identify every attempt that starts, and report each one's outcome.
Hint
With a 1-cycle delay, each attempt resolves on the very next edge after it starts — there's never more than one unresolved attempt in flight at a time.
Solution
- Edge 1 (a=1): starts. Resolves at Edge 2 (b=1) → PASS
- Edge 2 (a=0): its own new attempt fails immediately
- Edge 3 (a=1): starts. Resolves at Edge 4 (b=0) → FAIL
- Edge 4 (a=0): its own new attempt fails immediately
- Edge 5 (a=1): starts. Resolves at Edge 6 (b=0) → FAIL
- Edge 6 (a=1): starts, but its resolution would need a 7th edge, beyond this trace — left unresolved
With only a 1-cycle delay, each attempt is fully resolved before the next one even has a chance to overlap with it — a simpler case than the article's own ##2 example, but built on exactly the same "fresh attempt every edge" principle.
Summary
In this tutorial, you learned:
- The
##operator specifies a delay between two parts of a sequence, measured in clock cycles - A concurrent assertion starts a fresh attempt on every single clock edge, not just when its first condition happens to be true
- Because each attempt takes multiple cycles to resolve, several attempts can be genuinely in flight and independently tracked at the same time
- A log message's "started at Xns" annotation identifies exactly which attempt an outcome belongs to — which may not be the attempt that started on the same row where the outcome is reported
- One attempt failing has no effect on any other attempt still in flight from a different starting edge