UVM
Parallel Sequences
Running sequences concurrently with fork/join — the three join variants (join, join_any, join_none), parallel on one vs many sequencers, and managing completion with disable fork.
Advanced Sequences · Module 10 · Page 10.5
The Engineering Problem
Module 9.5 introduced running sub-sequences in parallel with fork/join, and Module 10.4 covered how the sequencer arbitrates the resulting concurrent items. This chapter focuses on running and controlling concurrent sequences in depth — because "run these in parallel" is not one thing. Sometimes you want to wait for all concurrent sequences to finish; sometimes for the first one (a race — response vs timeout); sometimes you want to not wait at all (a background sequence). And once branches are running concurrently, you have to manage them: kill the losers of a race, isolate shared state, decide what runs on one sequencer versus many. Get the join semantics wrong and a race's loser keeps running, or a background sequence is killed before it finishes.
The core tool is fork with three join variants, each a different synchronization contract: join waits for all branches (concurrent-and-converge), join_any waits for the first (a race — then you disable fork to kill the rest), and join_none waits for none (fire-and-forget — which you must later wait for, or it's killed at phase end). Beyond the variant, you choose where the parallel sequences run — one sequencer (their items interleave, arbitrated) or many (truly independent) — and how to manage completion (disable fork, events). This chapter is parallel sequences: the join variants and when to use each, parallel on one vs many sequencers, and why join_any without disable fork leaks the unfinished branches.
How do you run sequences concurrently — the three
forkjoin variants (join,join_any,join_none) and their synchronization contracts, parallel on one vs many sequencers, and managing completion (e.g.,disable forkafter a race)?
Motivation — why parallelism needs explicit join semantics
Parallel sequences need explicit control because "concurrent" hides several distinct intents, and each join variant names one:
- Sometimes all must finish, so
joinexists. When several concurrent sequences each contribute to a scenario (multi-interface traffic that must all complete before the test proceeds), you wait for all of them —join. It's the safe default: nothing is left running. - Sometimes the first wins, so
join_anyexists. A race — a response sequence vs a timeout sequence, "whichever happens first" — needs to proceed when the first branch finishes and then stop the others.join_anyprovides the "first finished" trigger;disable forkdoes the stopping. - Sometimes you don't wait, so
join_noneexists. A background sequence (continuous traffic while a foreground scenario runs) should start and let the code continue —join_none. But "don't wait" means you own its lifetime (Module 9.5). - Concurrency interacts with the sequencer, so where matters. Parallel sequences on one sequencer share it — their items interleave (arbitration, Module 10.4), and atomic groups need
lock/grab. On different sequencers they're independent. The sameforkmeans different things depending on where the branches run. - Running branches must be managed, so completion control exists. After a race, the losers must be killed (
disable fork); a background branch must be waited for or it leaks/dies. Parallelism isn't "start and forget" — it's "start and manage."
The motivation, in one line: "concurrent" spans wait-for-all, wait-for-first, and wait-for-none — distinct intents with distinct lifetimes — so parallel sequences require choosing the join variant that matches the intent and managing the running branches (kill the losers, wait for the background), not just forking and walking away.
Mental Model
Hold parallel sequences as launching concurrent runners with three finish-line rules:
Forking sequences is starting several runners at once, and the join variant is your rule for when you leave the finish line — wait for everyone, wait for the first, or don't wait at all. You fire the starting gun (
fork) and several runners (sequences) take off concurrently. Now: when do you proceed? Withjoin, you wait for every runner to cross the line — the race isn't over until all are done (concurrent-and-converge). Withjoin_any, you proceed the moment the first runner finishes — a race for first place — but the other runners are still running, so if you don't want them to keep going, you have to call them off the track (disable fork); forget, and they keep running laps you didn't want. Withjoin_none, you walk away immediately and let them all run — fire-and-forget — but now you're responsible for them: if the event ends (the phase closes) while they're still running, they're pulled off the track mid-stride (killed) unless you arranged to wait. And where they run matters: on one track (sequencer) they jostle for the same lane (interleaved by arbitration); on separate tracks (sequencers) they run unobstructed.
So parallel sequences are fire the gun, then choose your finish-line rule: wait for all (join), wait for first and call off the rest (join_any + disable fork), or walk away and manage them yourself (join_none). The bug is forgetting that the runners you didn't wait for are still running — call them off, or wait for them.
Visual Explanation — the three join variants
The defining picture is the three synchronization contracts: same fork, three rules for when the calling code continues and what happens to unfinished branches.
The figure shows the three contracts that the same fork can carry. join (top) is wait for all: the calling code continues only after every branch has finished, so nothing is left running — the safe, converge-here variant for concurrent work that must all complete. join_any (middle) is wait for the first: the calling code continues the moment any one branch finishes — a race — but, crucially, the other branches keep running; they are not automatically stopped, so to end them you must disable fork. join_none (bottom) is wait for none: the calling code continues immediately, and all branches keep running concurrently — fire-and-forget — which means you are responsible for their lifetime (waiting for them later, or accepting they're killed at phase end, Module 9.5). The warning nodes are the heart of the chapter: with both join_any and join_none, there are branches still running after the calling code moves on, and what happens to them is your responsibility — disable fork to kill them, or a later wait to let them finish. So the variant choice is not just "when do I continue" but "what about the branches I didn't wait for" — and forgetting the second half is the recurring parallel-sequence bug. The contract is the whole pattern: pick the variant whose synchronization and branch-fate match your intent.
RTL / Simulation Perspective — the variants and managing branches
The three variants are three fork statements, and managing the unfinished branches (disable fork, a later wait) is the other half. The code shows each pattern.
// ── JOIN: wait for ALL concurrent sequences (concurrent-and-converge) ──
fork
axi_seq.start(axi_sqr); // these run concurrently...
apb_seq.start(apb_sqr);
join // ...and we continue only after BOTH finish
// ── JOIN_ANY: a RACE — first to finish wins; KILL the rest with disable fork ──
fork
resp_seq.start(sqr); // response sequence
timeout_seq.start(sqr); // timeout/watchdog sequence
join_any // continue when the FIRST finishes
disable fork; // ← KILL the remaining branch (the loser) — or it keeps running!
// ── JOIN_NONE: fire-and-forget BACKGROUND; you own its lifetime ──
fork
bg_seq.start(sqr); // background traffic — runs concurrently
join_none // continue immediately (does NOT wait)
// ... run foreground work ...
// later: wait for bg_seq (e.g. wait_for state) or it's killed at phase end (Module 9.5)
// ── parallel on ONE sequencer → items interleave (arbitrated; lock/grab for atomicity) ──
// ── parallel on DIFFERENT sequencers → independent ──The code centers on the variant and what follows it. join runs the branches concurrently and continues only after all finish — the AXI and APB sequences both complete before the code proceeds; nothing is left running. join_any is the race: resp_seq and timeout_seq run concurrently, join_any continues when the first finishes — but the loser keeps running, so disable fork kills the remaining branch (without it, a beaten timeout sequence would keep firing — the DebugLab). join_none is fire-and-forget: bg_seq starts and the code continues immediately to foreground work, with bg_seq running concurrently — and you must later wait for it or accept it's killed at phase end (Module 9.5). The two trailing comments name the where: parallel sequences on one sequencer have their items interleave (arbitration, Module 10.4 — use lock/grab for atomicity, Module 10.3), while on different sequencers they run independently. The shape to carry: join = wait all, join_any + disable fork = race-and-kill-losers, join_none + later-wait = background — and the line after the join (the disable fork, the later wait) is as important as the join itself, because it decides the fate of the branches you didn't synchronize on.
Verification Perspective — parallel on one vs many sequencers
A decision that shapes parallel sequences as much as the join variant is where the branches run: on one sequencer their items interleave; on many they're independent. Choosing correctly is choosing the concurrency you actually want.
The where determines the kind of parallelism. With parallel sequences on one sequencer (top), the concurrent sequences share the single driver, so their items can't truly run at the same time — they interleave, with the sequencer arbitrating which item goes at each grant (Module 10.4). This is interleaved concurrency on one interface: useful for mixing traffic (background + interrupt on the same bus), but an atomic group within one branch needs lock/grab (Modules 9.2, 10.3) or another sequence's item slips in. With parallel sequences on different sequencers (bottom) — one per interface — the concurrent sequences are truly independent: each drives its own driver, so there's no interleaving and full parallelism (AXI traffic and APB traffic genuinely simultaneous). This is independent concurrency across interfaces, the multi-interface parallelism a virtual sequence coordinates (Module 10.1). The key verification insight is that the same fork produces different concurrency depending on where the branches run: forking two sequences onto one sequencer gives you an interleaved stream on one interface (subject to arbitration and needing lock/grab for atomicity), while forking them onto two sequencers gives you independent activity on two interfaces. So when you fork, ask not only "what join variant" but "one sequencer or many" — because that decides whether you get interleaving (and the arbitration/lock concerns that come with it) or independence.
Runtime / Execution Flow — the race pattern (join_any + disable fork)
The most subtle parallel pattern is the race: fork/join_any to proceed at the first completion, then disable fork to kill the losers. The flow shows why both halves are needed.
The race pattern is two operations, and skipping the second is the bug. Step 1, you fork the competing branches — classically a response sequence (waits for the DUT to respond) and a timeout sequence (waits a fixed time, then signals timeout) — running concurrently. Step 2, join_any continues when the first finishes: if the response arrives first, the response branch finishes and join_any proceeds (success); if the timeout fires first, the timeout branch finishes and join_any proceeds (timeout). Either way, join_any gives you the race result — who won. Step 3 is the catch: the other branch is still running. join_any only waited for the first; it did not stop the rest. So after a response wins, the timeout branch keeps counting and will eventually fire a spurious timeout; after a timeout wins, the response branch keeps waiting. Step 4, disable fork kills the remaining branches, so the loser stops — the beaten timeout never fires, the abandoned response sequence ends. Both halves are required: join_any answers who won, and disable fork stops the rest. The pattern is fork-join_any-disable_fork, and the DebugLab is exactly what happens when you do the first two and forget the third — the loser leaks, producing behavior (a late timeout, lingering stimulus) you didn't intend. This is the canonical use of join_any, and it's incomplete without disable fork.
Waveform Perspective — join vs join_any
The difference between the join variants is when the calling code continues and what's left running — visible on a timeline as the two branches and the point of continuation.
join (continue after both) vs join_any + disable fork (continue at first, kill the rest)
12 cyclesThe waveform contrasts the two waiting contracts on the same two branches. Branch A finishes early (around cycle 2–3); branch B finishes later (around cycle 5). With join, the calling code continues only after both finish — join_cont rises at B's completion (the slower branch), because join waits for the last one. With join_any + disable fork, the code continues at A's completion (the first) — any_cont rises early, at cycle 3 — and disable fork kills B then, so B_active drops early (B is terminated, not left running to cycle 5). The visual difference is the continuation point and the fate of B: join waits for the slowest (both run to completion), while join_any proceeds at the fastest and stops the rest. The crucial detail the waveform makes concrete is B_active dropping early under join_any + disable fork — that is the kill; without the disable fork, B_active would continue to cycle 5 (B running on after the code moved past join_any), which is the leak. So the timeline shows both the synchronization (when you continue) and the branch management (whether the loser is stopped) — the two things the variant-plus-disable-fork choice controls, and exactly the picture to carry: join converges on the slowest; join_any + disable fork races to the fastest and ends the rest.
DebugLab — the timeout that fired after the response arrived
A race where the losing timeout branch kept running because disable fork was missing
A test used a race between a response sequence and a timeout sequence: fork ... join_any to proceed as soon as either finished. The response usually arrived first (correctly), and the test proceeded — but then, later, a spurious timeout fired anyway, flagging a timeout even though the response had already been received and the test had moved on. The race logic picked the right winner (the response), yet the timeout still happened afterward, causing false failures.
join_any proceeded when the response finished, but the timeout branch was never killed (no disable fork), so it kept running and fired its timeout later:
fork
resp_seq.start(sqr); // waits for the DUT response
timeout_seq.start(sqr); // waits N cycles, then signals timeout
join_any // continue when the FIRST finishes (response, usually)
// ✗ MISSING: disable fork; ← the timeout branch is STILL RUNNING
sequence of events:
response arrives first → resp_seq finishes → join_any proceeds (correct winner)
BUT timeout_seq was NOT killed → it keeps counting → after N cycles it FIRES
→ spurious timeout, even though the response already won the race
fix — kill the remaining branch(es) after join_any:
fork
resp_seq.start(sqr);
timeout_seq.start(sqr);
join_any
disable fork; // ✓ kill the loser (the timeout) so it can't fireThis is the join_any-without-disable-fork leak, the canonical parallel-sequence bug. join_any waits for the first branch to finish and then proceeds — but it does not stop the other branches; they keep running. The race here was correct in picking the winner: the response arrived first, resp_seq finished, and join_any proceeded. But the timeout branch was never killed, so it continued counting in the background and, after its N cycles elapsed, fired its timeout — a spurious one, because the response had already won and the test had moved on. The result is a false failure: a timeout reported when there wasn't one. The fix is the second half of the race pattern: disable fork after join_any, which kills the remaining branches so the loser (the timeout) can't fire. The general lesson, and the chapter's thesis, is that join_any is incomplete on its own: it answers who won but leaves the losers running, so you must disable fork (or otherwise terminate them) to stop them. Any time you join_any for a race, the very next action should be to kill the branches you didn't wait for — otherwise a beaten competitor keeps running and produces behavior (a late timeout, lingering stimulus, a leaked background process) you didn't intend.
The tell is unwanted behavior from a branch after a join_any race proceeded. Diagnose leaked-branch bugs:
- Look for
join_anywithout a followingdisable fork. A race that proceeds at the first completion but doesn't kill the rest leaves the losers running — the signature. - Correlate the spurious behavior with a losing branch. A late timeout, lingering stimulus, or a still-counting watchdog after the race result points at a branch that was never stopped.
- Confirm the winner was correct. If the race picked the right winner but the loser still acted, the bug is the missing kill, not the race logic.
- Check
join_nonebranches too. A fire-and-forget branch that's never waited for or killed is the related leak (or it's killed at phase end, Module 9.5).
Manage the branches you don't wait for:
- Follow
join_anywithdisable fork. For a race, kill the remaining branches immediately afterjoin_any, so the losers can't keep running and act. - Pair
join_nonewith a later wait (or accept phase-end kill). A background branch must be waited for to complete, or it's killed at phase end (Module 9.5) — decide which, deliberately. - Prefer
joinwhen all must finish. It leaves nothing running, so there's nothing to leak; reach forjoin_any/join_noneonly when you genuinely want first-wins or fire-and-forget. - Scope
disable forkcarefully.disable forkkills all child processes of the current fork — ensure it targets the intended branches and isn't accidentally killing more (or use a labeled block / process handles for precision).
The one-sentence lesson: join_any proceeds when the first branch finishes but leaves the other branches running, so a race without a following disable fork lets the loser (a beaten timeout, lingering stimulus) keep running and act spuriously — always disable fork after a join_any race to kill the branches you didn't wait for.
Common Mistakes
join_anywithoutdisable fork. The losing branches keep running and act (a beaten timeout fires); kill them withdisable forkafter the race.join_nonewithout managing the branch. A fire-and-forget sequence not waited for leaks (or is killed at phase end, Module 9.5); wait for it or accept the kill deliberately.- Assuming parallel on one sequencer is truly simultaneous. Items on one sequencer interleave (arbitrated, Module 10.4); atomic groups need
lock/grab. Use separate sequencers for true independence. disable forkkilling more than intended. It terminates all child processes of the fork; scope it (labeled blocks, process handles) so it doesn't kill unrelated branches.- Using
joinwhen a race is meant (or vice versa).joinwaits for the slowest; a race needsjoin_any. Match the variant to the synchronization intent. - Racing shared state without isolation. Parallel sequences mutating shared data race; isolate state or synchronize access.
Senior Design Review Notes
Interview Insights
fork...join waits for all branches to finish before the calling code continues, so it's the converge-here variant for concurrent work that must all complete — for example, running AXI and APB traffic in parallel where both must finish before the test proceeds; nothing is left running afterward. fork...join_any continues as soon as the first branch finishes, which makes it the variant for a race — for instance, a response sequence versus a timeout sequence, proceeding when whichever happens first completes; but the other branches keep running, so you almost always follow join_any with disable fork to kill the losers. fork...join_none continues immediately without waiting for any branch, which makes it the variant for fire-and-forget background work — a background traffic sequence that runs concurrently while a foreground scenario proceeds; but because nothing waited for it, you own its lifetime, so you must later wait for it to complete or accept that it's killed at phase end. The way to choose is by your synchronization intent and what should happen to the branches you don't wait for: join when all must finish and nothing should be left running; join_any when you want the first result and will then stop the rest; join_none when you want to start something and continue, managing it yourself. The common thread, and the most important point, is that with join_any and join_none there are branches still running after the code moves on, and their fate is your responsibility — disable fork to kill them, or a later wait to let them finish — which is the part people forget.
Because join_any only waits for the first branch to finish; it does not stop the other branches, so they keep running unless you kill them, and disable fork is what kills them. In a typical race — a response sequence against a timeout sequence — join_any proceeds when whichever finishes first completes, giving you the race result, the winner. But the losing branch is still running: if the response won, the timeout branch keeps counting, and after its configured time elapses it fires a timeout anyway, a spurious one, because the response already won and the test moved on. That produces a false failure. disable fork, placed immediately after join_any, terminates the remaining child processes of the fork, so the beaten timeout never fires and any lingering stimulus stops. So the race pattern is two halves: join_any answers who won, and disable fork stops the rest, and the pattern is incomplete without the second half. The bug of omitting disable fork is one of the most common parallel-sequence mistakes, and the symptom is unwanted behavior from a branch after the race already proceeded — a late timeout, a still-counting watchdog, lingering traffic. The rule of thumb is that any time you join_any for a race, the very next action should be to kill the branches you didn't wait for. One caution is that disable fork kills all child processes of the current fork, so you should scope it carefully — using a labeled fork block or explicit process handles when you need to kill only specific branches — so it doesn't accidentally terminate unrelated concurrent work.
Exercises
- Pick the variant. For (a) AXI and APB traffic that must both finish, (b) a response-or-timeout race, (c) continuous background traffic during a foreground test — name the join variant and any follow-up (disable fork, later wait).
- Write the race. Write a
fork/join_anytimeout race for a response sequence, including the line that prevents a spurious timeout. - One vs many. Explain what differs when two sequences are forked onto one sequencer versus two sequencers, and when you'd choose each.
- Diagnose the leak. A race proceeds with the right winner but a timeout fires later anyway. Name the missing line and where it goes.
Summary
- Parallel sequences run concurrently with
fork, and the join variant is the synchronization contract:joinwaits for all branches (concurrent-and-converge),join_anywaits for the first (a race),join_nonewaits for none (fire-and-forget). - The branches you don't wait for keep running:
join_anyleaves the losers running (kill them withdisable fork), andjoin_noneleaves all branches running (wait for them later or they're killed at phase end, Module 9.5). - The race pattern is
join_any+disable fork:join_anyanswers who won,disable forkstops the rest — both halves required, or a beaten branch (a timeout) keeps running and acts spuriously. - Where the branches run decides the concurrency: parallel on one sequencer interleaves items (arbitrated, Module 10.4;
lock/grabfor atomicity); on many sequencers they're independent. - The durable rule of thumb: choose the join variant for your intent —
joinfor all-finish,join_any(+disable fork) for a race,join_none(+ a later wait) for background — and always manage the branches you didn't wait for, becausejoin_anyandjoin_noneleave branches running, and an unmanaged branch leaks (acts spuriously) or is truncated at phase end.
Next — Reusable Traffic Generation: the advanced sequence techniques converge on a goal — reusable stimulus. The next chapter pulls them together into traffic generators that are portable across tests, projects, and configurations, the capstone of the sequences modules.