Scheduling¶
Read this page when you need to know exactly when a coroutine resumes — which is most of what separates a testbench that works from one that reads the previous cycle’s values. It defines every trigger, what each one guarantees about the state you observe, and how concurrent processes are ordered.
cpptb maps coroutine suspension points to simulator events. Ordinary C++ runs
inside the current DPI callback until the coroutine reaches co_await.
See clocking for clock registration, waveform ownership, and
the clock-edge callback policy.
Trigger reference¶
Every co_await in a cpptb testbench falls into one of two groups, and the
difference is the one worth internalizing: simulator waits can advance
simulation time; coordination waits cannot advance it by themselves.
Simulator waits¶
The simulator decides when these resume, so time may pass.
Wait |
Resumes |
Notes |
|---|---|---|
on a low-to-high transition |
any one-bit signal, including DUT-produced clocks and hierarchy objects |
|
on a high-to-low transition |
||
on either transition |
||
after |
|
|
after an absolute simulation-time delay |
works in designs with no clocks |
|
after the current evaluation settles |
may still read and drive; phase wait |
|
at the stable end of the current timestep |
writes are an error here; phase wait |
|
at the start of the next scheduled timestep |
before that timestep’s HDL evaluation; phase wait |
The last three are simulator phase waits. They are how a testbench hooks into
a specific point of any timestep, and they are what make cpptb’s timing match
cocotb’s. Every project has them: timing_backend defaults to
"verilator-direct", and the only other choice is "vpi". See
Timing backend support for how the two differ –
they are held to identical results, so the choice is about speed and
portability, not semantics.
Coordination waits¶
These resume when another coroutine acts, not when the simulator does. They add no delay of their own; time passes only if the work they are waiting on suspends on a simulator wait.
Wait |
Resumes |
|---|---|
|
when another process calls |
|
when space, or an item, becomes available |
|
when ownership or a permit is handed over |
|
when that spawned process completes or is cancelled |
when every child task has finished |
|
when the first of several triggers fires; returns its index |
|
when |
|
when the predicate is true, re-evaluated on each rising edge of |
wait_until is the one hybrid: it polls on a clock, so it does advance time
while the predicate stays false.
Tasks and concurrency covers the coordination waits in depth. The rest of this page covers the simulator waits.
Time and edges¶
test.start_clock(dut.clk, 10_ns) starts a periodic DUT input clock. Call it
before the test’s first co_await; it does not itself advance time.
clock_cycles is not tied to a primary or generated clock — it counts rising
edges of whichever clock you name.
Delay accepts integer fs, ps, ns, us, and ms literals. A duration
must be positive and representable at the wrapper’s configured
timeprecision; zero and sub-precision durations abort with a diagnostic.
For multiple domains, initialize and register each input separately. The
optional phase argument offsets the first edge by phase + period / 2:
dut.core_clk.set_now(0);
dut.peripheral_clk.set_now(0);
test.start_clock(dut.core_clk, 4_ns);
test.start_clock(dut.peripheral_clk, 10_ns, 1_ns);
Do not call start_clock() for an output clock. A divided, recovered, or gated
DUT output is observed with the same RisingEdge, FallingEdge, and Edge
primitives as any other one-bit signal.
An edge wait resumes in the simulator callback associated with that edge. To
observe logic after the design has evaluated, follow the edge with
co_await ReadOnly{} — the settle idiom every clocked example uses. An
explicit Delay{1_ps} remains the right tool only in clockless benches that
step physical time directly. Signal writes and backdoor operations never
settle anything automatically.
Simulator phases¶
Use phase waits when a test needs a specific point within the current or next simulator timestep, rather than a particular signal transition.
ReadWrite{} is the drive anchor: the coroutine may read and drive there. A
coroutine resuming at ReadWrite{} finds its own queued writes freshly
flushed but not yet re-evaluated, so reads at that point still see the
pre-flush design state; by a later ReadOnly{} in the same timestep the
flushed writes are evaluated and settled. ReadOnly{} is the sample anchor:
it is for observation and checking, and set(), deposit(), force(), and
release() all report an error in that phase. NextTimeStep{} steps out of
the current timestep entirely, resuming before the next one’s HDL
evaluation — which is how a test leaves ReadOnly in order to drive again.
dut.request.set(request); // queued; flushes at this timestep's ReadWrite
co_await ReadOnly{}; // flushed and evaluated: safe to check
test.expect_eq("combinational request", dut.request_seen.get(), request);
co_await NextTimeStep{}; // leave ReadOnly before driving again
dut.request.set(next_request);
These waits describe simulator ordering, not arbitrary delays. Use Delay for
elapsed simulation time and edge waits for a particular signal transition.
The Verilator backend dispatches these phases directly when cpptb owns the
host loop; other supported simulator integrations use the equivalent standard
VPI callbacks. Both paths run the same conformance contracts.
Timing backend support¶
Generated DPI remains the signal and data transport in every mode. The timing
backend only determines how the simulator resumes ReadWrite, ReadOnly, and
NextTimeStep waiters.
cpptb supports exactly two timing backends, selected by name in cpptb.toml:
[build]
timing_backend = "verilator-direct" # fastest; Verilator's scheduler, driven directly
# or
timing_backend = "vpi" # standard VPI callbacks; the portable route
Timing backend |
Status |
Timing contract |
Portability |
|---|---|---|---|
|
Supported; conformance-checked by every |
Complete |
Verilator-specific |
|
Supported; conformance-checked by every |
Complete |
Standard simulator API; cross-simulator validation is roadmap milestone 6 |
Generated SV-DPI calendar |
Experimental; not selectable through |
Complete for generated and observed events |
Cross-simulator validation pending |
The generated calendar owns framework clocks and timers and observes selected
external signals, but standard DPI cannot query the simulator’s complete event
queue. Its NextTimeStep therefore cannot yet promise to wake for an arbitrary
unobserved internal DUT event. Direct Verilator dispatch and standard VPI are
the supported contract-complete choices. Both own the host loop through
src/verilator_timing_main.cpp, and timing_backend emits that link; the two
build identically apart from one define. See Performance for
the exact backend comparison.
The two backends are held to more than passing the same tests. Every
make test runs three equivalence layers between them: the 292-check
scheduler conformance contract on each; the deferred-write contract on
each; and make backend-equivalence-test, which builds the same
examples once per backend with --wave and requires the runs to come
out identical — result records field for field (wall time excepted,
simulation time included) and wave dumps byte for byte. Choosing a
backend changes how fast the simulation runs and nothing else; see
Waveforms for what the dump comparison
covers.
Note
There is no build without a timing backend. timing_backend defaults to
"verilator-direct", so every build links a backend that dispatches the full
phase contract without any configuration. Hand-assembled alternatives are
rejected rather than left to answer wrongly: verilator_args = ["--vpi"]
used to build a bridge that placed writes on the right edge while failing two
of the five contract checks silently – ReadOnly did not observe a write
settled in ReadWrite – and the build tool now refuses it, naming the key.
The timing defines (CPPTB_SV_DPI_TIMING and friends) are likewise owned by
the key: setting them in design.defines or build.cxx_flags is an error,
and the remaining SV-DPI pump and calendar builds are reachable only through
the conformance runner, as experiments.
The write model¶
cpptb’s documented write model is cocotb’s. set() queues the write, and
the queue flushes at the ReadWrite point of the current timestep. A write made
straight after an awaited edge therefore lands for the next edge — exactly
what dut.sig.value = x does in cocotb.
This is the default. A project gets it without configuring anything:
timing_backend defaults to "verilator-direct" and deferred_writes
defaults to true. Every code sample in this documentation assumes them.
Name them explicitly to pick the other backend, as every example project in this repository does:
[build]
timing_backend = "verilator-direct" # or "vpi"
deferred_writes = true
The two keys travel together: a queued write is applied at a simulator phase,
so deferred_writes needs a timing backend, and there is no supported way to
build without one.
What the model guarantees¶
Pinned by tests/integration/deferred_writes on both backends in every
make test:
a
get()betweenset()and the flush returns the simulator’s value, not the queued one, matching cocotb’s caching;by
ReadOnlyof the same timestep the write is applied and settled;set_now()is the immediate deposit – cocotb’ssetimmediatevalue()– used for initialization and the rare intentional same-edge write;writing from
ReadOnlystill fails at the offending line, because write legality is checked at the call, not at the flush.
Queueing alone arms the phase: writes flush whether or not anything awaits
ReadWrite{}.
Immediate writes are legacy¶
A build with deferred_writes = false applies set() immediately. That behavior
predates the timing backends and is intended for deprecation, so that cpptb
matches cocotb’s semantics without a per-project switch. It is not a supported
authoring style: testbenches written against it need a drive-point convention
of their own to keep writes off the edge being awaited, and nothing in the API
states or checks that convention. Set both keys and the question does not
arise.
Composition¶
Use an ordinary co_await task() when the next operation is sequential. The
composition primitives cover the cases where work must overlap:
Join{...}owns a fixed group of child tasks and resumes after all finish.First{...}races two or more triggers and returns the winner’s zero-based index.with_timeout(...)adds a checked deadline to one trigger or task.spawn()returns aProcesshandle for work whose lifetime is controlled dynamically.Eventbroadcasts state changes;Queue<T>transfers FIFO data between tasks.
No composition primitive advances time by itself. Time advances only when a
child suspends on an edge, cycle count, or Delay.
Driver, monitor, and scoreboard¶
Join is the natural shape when every component must complete before the test
can finish. The runnable FIFO scoreboard example
joins reset, two drivers, a monitor, and a scoreboard:
Task<void> fifo_test(Dut dut, TestContext& test) {
dut.clk.set_now(0);
test.start_clock(dut.clk, 10_ns);
Event reset_done;
Queue<uint32_t> expected_words;
Queue<uint32_t> observed_words;
uint32_t input_stalls = 0;
co_await Join{reset_dut(dut, reset_done),
input_driver(dut, reset_done, expected_words, input_stalls),
output_ready_driver(dut, reset_done),
output_monitor(dut, reset_done, observed_words),
scoreboard(test, expected_words, observed_words)};
}
The parent owns the synchronization objects, and Join guarantees that every
child is finished before those objects leave scope. Event is sticky, so a
driver waiting after reset has already completed still resumes immediately:
Task<void> reset_dut(Dut dut, Event& reset_done) {
dut.rst_n.set(0);
dut.in_valid.set(0);
dut.in_data.set(0);
dut.out_ready.set(0);
co_await clock_cycles(dut.clk, 2);
dut.rst_n.set(1);
reset_done.set();
}
Task<void> input_driver(Dut dut, Event& reset_done,
Queue<uint32_t>& expected_words,
uint32_t& input_stalls) {
co_await reset_done;
// RisingEdge resumes before the design evaluates that edge, so a get()
// here reads the value the DUT is about to sample, and a set() applies
// after this edge's own updates -- in time for the next one.
co_await RisingEdge{dut.clk};
for (uint32_t index = 0; index < kWordCount; ++index) {
const uint32_t word = next_word(state);
dut.in_data.set(word);
dut.in_valid.set(1);
while (true) {
co_await RisingEdge{dut.clk};
if (dut.in_ready.get() != 0) break;
++input_stalls;
}
expected_words.put_nowait(word);
}
dut.in_valid.set(0);
}
Queues decouple production from checking. get() suspends only while the
FIFO is empty, allowing the driver and monitor to run at different rates:
Task<void> scoreboard(TestContext& test,
Queue<uint32_t>& expected_words,
Queue<uint32_t>& observed_words) {
for (uint32_t index = 0; index < kWordCount; ++index) {
const uint32_t expected = co_await expected_words.get();
const uint32_t actual = co_await observed_words.get();
test.expect_eq("FIFO payload", actual, expected);
}
}
Coming from cocotb¶
The trigger vocabulary is deliberately the same: RisingEdge, FallingEdge,
ReadOnly, ReadWrite, NextTimeStep, and drivers, monitors and scoreboards
compose the same way. One difference changes how a driver must be written, and
translating a testbench line for line will produce a driver that acts a cycle
early.
Writes translate directly¶
In cocotb, assigning to a signal queues the write and applies it at the next
ReadWrite point. Writing straight after await RisingEdge(clk) therefore
cannot affect the edge just awaited; it behaves like a non-blocking assignment.
The write model makes set() do exactly the same thing,
so a cocotb driver translates line for line:
# cocotb: the write is queued, so this edge is already over for it.
async def driver(dut):
while True:
await RisingEdge(dut.clk)
dut.wdata.value = next_word() # lands for the *next* edge
dut.wvalid.value = 1
// cpptb: identical shape, identical semantics.
Task<void> driver(Dut dut) {
while (true) {
co_await RisingEdge{dut.clk};
dut.wdata.set(next_word()); // queued; lands for the *next* edge
dut.wvalid.set(1);
}
}
Only a legacy deferred_writes = false build behaves differently — there
set() applies at once and this shape drives into the edge being awaited.
That mode needs a drive-point convention of its own and is
not a supported authoring style.
Monitors¶
Sampling needs no change of shape, and the reason differs from cocotb’s.
cocotb reads after await ReadOnly() because a read taken directly on an edge
trigger is not guaranteed to see settled values. co_await RisingEdge{} here
resumes before the design evaluates the edge, so it deterministically yields
the values a signal held during the cycle just ending, which is what
always_ff @(posedge clk) samples.
# cocotb
async def monitor(dut, queue):
while True:
await RisingEdge(dut.clk)
await ReadOnly()
if dut.valid.value:
queue.append(dut.data.value)
// cpptb
Task<void> monitor(Dut dut, Queue<uint32_t>& queue) {
while (true) {
co_await RisingEdge{dut.clk};
if (dut.valid.get()) co_await queue.put(dut.data.get());
}
}
A driver and monitor together¶
Because writes queue, one loop can sample and drive at the same anchor without racing itself: the reads see the cycle just ending, and the writes land for the next one.
Task<void> agent(Dut dut, RegisterModel& model) {
while (true) {
co_await RisingEdge{dut.clk};
if (dut.access.get()) model.check(dut.addr.get(), dut.rdata.get());
const auto next = stimulus.next();
dut.addr.set(next.addr); // queued; lands for the next edge
dut.wdata.set(next.wdata);
dut.access.set(next.valid);
}
}
What to change when translating¶
With the standard configuration, writes carry over unchanged. The one thing to
watch is where a read is anchored, because co_await RisingEdge{} resumes
before the design evaluates the edge while cocotb’s resumes after it.
cocotb |
here |
|---|---|
|
|
|
|
|
|
|
|
A bare co_await RisingEdge{} followed by get() is exactly right for a
monitor: it yields the values the design is about to sample. It is the wrong
anchor for a driver that reads protocol pins, which is
trap 1.
The three translation traps — where a bare edge read is right and where it is not — are worked through with symptoms in Coming from cocotb.
Race an event against a deadline¶
First is useful when the caller needs to know which trigger won. This pattern
from the multiple-clocks example gives the clock edge
index 0 and the deadline index 1:
const auto winner =
co_await First{RisingEdge{dut.read_clk}, Delay{100_ns}};
test.expect_eq("read clock beat deadline",
static_cast<uint32_t>(winner), 0u);
The losing trigger registration is removed. For the common two-way deadline
case, with_timeout() gives the outcome a name instead of exposing an index:
const auto outcome =
co_await with_timeout(RisingEdge{dut.response_valid}, 100_ns);
test.expect_eq("response arrived",
outcome == TimeoutOutcome::Triggered, true);
Put a deadline around a transaction¶
with_timeout() also accepts a typed task. Completion returns its value;
timeout recursively cancels the transaction and any nested waits. The
watchdog example uses both outcomes:
auto response = co_await with_timeout(
transaction(dut, request_word, 3, false), 200_ns);
test.expect_eq("transaction completed", response.has_value(), true);
if (response) {
test.expect_eq("response payload", response.value(), expected_word);
}
auto stalled = co_await with_timeout(
transaction(dut, stalled_word, 2, true), 60_ns);
test.expect_eq("stalled transaction timed out", stalled.timed_out(), true);
Use First when multiple heterogeneous triggers are meaningful to the caller.
Use with_timeout when one operation either completes or exceeds a deadline.
Control a long-lived process¶
Use spawn() when a monitor or service should outlive the immediate sequence
and needs an explicit handle. A common verification shape is a background
monitor feeding a queue while the foreground sequence drives and checks
transactions:
Task<void> response_monitor(Dut dut, Queue<uint32_t>& observed) {
while (true) {
co_await RisingEdge{dut.response_valid};
co_await ReadOnly{};
co_await observed.put(dut.response_data.get());
}
}
Task<void> request_test(Dut dut, TestContext& test) {
Queue<uint32_t> observed{8};
auto monitor = test.spawn(response_monitor(dut, observed));
for (uint32_t request = 0; request < 32; ++request) {
co_await drive_request(dut, request);
const uint32_t actual = co_await observed.get();
test.expect_eq("response payload", actual,
expected_response(request));
}
monitor.cancel();
co_await monitor;
test.expect_eq("monitor cancelled", monitor.cancelled(), true);
}
The queue, monitor, and sequence overlap in simulation time. Cancelling and
awaiting the monitor before observed leaves scope makes the ownership
boundary explicit. An uncaught monitor exception is attributed to this test
and to the monitor’s spawn location.
Use Join when the child set and lifetime are lexical. Use spawn when code
must query, await, or cancel a process later. spawn_detached() is reserved for
roots that truly need no handle; pass TestContext by value if such a root may
outlive the registered test coroutine.
Coordinate independent clocks¶
Composition is clock-agnostic. Each child can wait on its own input or
DUT-produced clock while one Join defines the overall test lifetime:
dut.write_clk.set_now(0);
dut.read_clk.set_now(0);
test.start_clock(dut.write_clk, 4_ns);
test.start_clock(dut.read_clk, 6_ns, 1_ns);
co_await Join{reset_dut(dut, test),
producer(dut),
consumer(dut, test),
output_clock_probe(dut, test)};
The producer awaits write_clk, the consumer awaits read_clk, and the probe
awaits a clock generated by the DUT. The scheduler does not require a single
global testbench clock. The clockless timers example
shows the same Join composition with two independent Delay cadences and no
clock at all.
Waiters sharing an edge or deadline resume in registration order. Equal-time events from different simulator processes retain simulator process ordering; testbenches should not infer hardware priority from that ordering.
Wait graphs and deadlock diagnostics¶
TestContext::wait_graph() returns an allocation-owning snapshot of every
active coroutine in the current test. Testbench::wait_graph() provides the
same scheduler-level view for custom harnesses. Snapshotting is read-only and
does not resume, cancel, or otherwise alter a process.
Give synchronization resources short names when their role is not obvious from the declaration:
Event reset_done{"reset_done"};
Queue<uint32_t> observed_words{16, "observed_words"};
Semaphore credits{0, "response_credits"};
Lock bus_lock{"bus_lock"};
auto monitor = test.spawn(response_monitor(dut, observed_words));
auto graph = test.wait_graph();
for (const auto& node : graph.nodes) {
test.logger("diagnostics").debug([&] {
return "process " + std::to_string(node.process_id) +
" waits on " + std::string(wait_reason_name(node.reason));
});
}
Use co_await reset_done.wait() when the exact wait call site matters. Direct
co_await reset_done still reports the named event and its declaration, but it
cannot capture the operator call site. Blocked queue operations currently
report the queue declaration; the get() and put(value) signatures remain
endpoint-compatible with verification components.
The snapshot contains stable coroutine IDs, logical process IDs and parent
process IDs, spawn provenance, outstanding trigger or synchronization reason,
resource name, wait start time, optional deadline, and child dependencies.
format_wait_graph(graph) produces deterministic line-oriented text suitable
for a terminal or CI artifact:
cpptb wait graph at 2000000 fs: deadlocked (3 active coroutines)
[1] root process #1 spawned at testbench.cpp:42
waiting on task since 0 fs
depends on [2]
[2] root process #1 child-of [1] spawned at testbench.cpp:42
waiting on task since 0 fs
depends on [3]
[3] root process #1 child-of [2] spawned at testbench.cpp:42
waiting on Event (Event response_ready) since 0 fs at testbench.cpp:45
WaitGraphSnapshot::status explains what can make progress:
Status |
Meaning |
|---|---|
|
No active coroutine remains |
|
At least one leaf coroutine is running or ready |
|
A live delay or timeout can wake the scheduler |
|
A signal edge or simulator phase can resume work |
|
Active leaf coroutines remain, but all are blocked on internal synchronization |
The deadlock classification is deliberately conservative. A process waiting on
RisingEdge, ReadOnly, or a future Delay is not called deadlocked merely
because it has not resumed yet. An Event, empty Queue, exhausted
Semaphore, or held Lock is classified as deadlocked only when no runnable,
timed, or simulator-driven leaf can release it.
Registered-test simulation timeouts automatically capture the graph before
the timed task is cancelled. A deadlocked timeout says so in status_reason,
prints the graph, and stores the structured snapshot in the result JSON. The
global generated-wrapper watchdog and end-of-simulation starvation hook use
the same formatter. This ordering is important: taking the snapshot after
cancellation would erase the wait that caused the timeout.
CPPTB_REGISTER_TEST_WITH_OPTIONS(
response_test,
TestOptions{.simulation_timeout = 2_us});
The full integration regression is the wait_graph_deadlock negative case in
tests/conformance/runtime/testbench.cpp. It parks on a named event, lets the
real simulator watchdog expire, and requires the emitted report to identify
Event response_ready.
The clockless timer_only_deadlock example in
examples/timer_only/testbench.cpp exercises the separate no-future-event
path through the generated wrapper’s SystemVerilog final hook. Its matching
pure-SystemVerilog negative test waits on the same logical event.
Projects with a handwritten transport that use
CPPTB_DEFINE_NAMED_DPI_RUNTIME keep the legacy six-argument API. After
regenerating wrappers with starvation reporting, migrate that transport to
CPPTB_DEFINE_NAMED_DPI_RUNTIME_WITH_STARVATION and pass the generated
*_report_starvation function name as its final argument.
Scheduler ordering and cleanup¶
Waiters registered on the same edge resume in registration order. Timers with
the same deadline also resume FIFO by registration order. Registrations made
stale by First, cancellation, or completion are removed without resuming the
coroutine; this cleanup includes edge queues and falling-edge interest counts,
not only the timer heap.
C++-owned clocks are registered as static edge sources before the first wait.
The first start_clock() call selects the primary cycle counter; later calls
add independent domains and may specify a phase. Registered input-clock rising
edges are delivered on every cycle; falling edges are delivered while the
scheduler has a falling- or either-edge waiter. Waits on clock IDs do not
publish dynamic edge-interest masks. DUT-produced and manually driven edges use
interest-gated observers. See clocking for the concise user
contract.
Generated DPI wrappers use one persistent, clock-agnostic timer owner. The
module-level timer_deadline is the source of truth, timer_owner_target
describes only the owner’s current positive sleep, and timer_kick wakes an
idle owner. A generation-checked one-shot process is retained only when a
non-owner callback inserts a deadline strictly earlier than the owner’s stale
sleep target. Clock drivers and observers remain independent, so
clock_cycles() is still an edge primitive while Delay works with no clocks.
The generated timer contract is:
I1: after
STEP_TIMER_CHANGED,timer_deadlineequals the scheduler’s earliest live deadline orNO_TIMER;I2: the persistent owner and strict-earlier fallback deliver each live deadline exactly once; generation checks prevent stale fallback delivery;
I3: timer dispatch uses no zero delay, delayed nonblocking assignment, or
disable fork;I4: every live deadline has an owner or fallback wake no later than that deadline;
I5: the fallback is unreachable in a clockless wrapper because no non-owner step can insert an earlier deadline while the owner sleeps;
I6: there remains one next-deadline DPI query per timer-change request, and steady-state timer arms allocate no SystemVerilog process. Only the exceptional strict-earlier fallback allocates a one-shot process.
Zero-duration delays and delays that cannot be represented at the configured
simulation precision abort with a diagnostic. Awaiting a default-constructed,
expired, or otherwise invalid Process also aborts instead of silently
continuing.
Scheduler-facing objects are simulation-thread confined. Do not access a
Testbench, TestContext, signal handle, synchronization primitive, or
Process from a worker OS thread. Authored coroutine processes remain
concurrent in simulation time while executing through the one scheduler that
owns deterministic ordering.