Skip to content

Commit 9562d00

Browse files
committed
sim: only preempt testbenches on explicit wait.
Before this commit, testbenches (generators added with `add_testbench`) were not only preemptible after any `yield`, but were *guaranteed* to be preempted by another testbench after *every* yield. This is evil: if you have any race condition between testbenches, which is common, this scheduling strategy will maximize the resulting nondeterminism by interleaving your testbench with every other one as much as possible. This behavior is an outcome of the way `add_testbench` is implemented, which is by yielding `Settle()` after every command. One can observe that: - `yield value_like` should never preempt; - `yield assignable.eq()` in `add_process()` should not preempt, since it only sets a `next` signal state, or appends to `write_queue` of a memory state, and never wakes up processes; - `yield assignable.eq()` in `add_testbench()` should only preempt if changing `assignable` wakes up an RTL process. (It could potentially also preempt if that wakes up another testbench, but this has no benefit and requires `sim.set()` from RFC 36 to be awaitable, which is not desirable.) After this commit, `PySimEngine._step()` is implemented with two nested loops instead of one. The outer loop iterates through every testbench and runs it until an explicit wait point (`Settle()`, `Delay()`, or `Tick()`), terminating when no testbenches are runnable. The inner loop is the usual eval/commit loop, running whenever a testbench changes design state. `PySimEngine._processes` is a `set`, which doesn't have a deterministic iteration order. This does not matter for processes, where determinism is guaranteed by the eval/commit loop, but causes racy testbenches to pass or fail nondeterministically (in practice depending on the memory layout of the Python process). While it is best to not have races in the testbenches, this commit makes `PySimEngine._testbenches` a `list`, making the outcome of a race deterministic, and enabling a hacky work- around to make them work: reordering calls to `add_testbench()`. A potential future improvement is a simulation mode that, instead, randomizes the scheduling of testbenches, exposing race conditions early.
1 parent 9ed83b6 commit 9562d00

File tree

3 files changed

+42
-38
lines changed

3 files changed

+42
-38
lines changed

amaranth/sim/_pycoro.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import inspect
22

33
from ..hdl import *
4-
from ..hdl._ast import Statement, SignalSet, ValueCastable
4+
from ..hdl._ast import Statement, Assign, SignalSet, ValueCastable
55
from ..hdl._mem import MemorySimRead, MemorySimWrite
66
from .core import Tick, Settle, Delay, Passive, Active
77
from ._base import BaseProcess, BaseMemoryState
@@ -12,11 +12,12 @@
1212

1313

1414
class PyCoroProcess(BaseProcess):
15-
def __init__(self, state, domains, constructor, *, default_cmd=None):
15+
def __init__(self, state, domains, constructor, *, default_cmd=None, testbench=False):
1616
self.state = state
1717
self.domains = domains
1818
self.constructor = constructor
1919
self.default_cmd = default_cmd
20+
self.testbench = testbench
2021

2122
self.reset()
2223

@@ -70,7 +71,7 @@ def run(self):
7071
except StopIteration:
7172
self.passive = True
7273
self.coroutine = None
73-
return
74+
return False # no assignment
7475

7576
try:
7677
if command is None:
@@ -88,6 +89,8 @@ def run(self):
8889
elif isinstance(command, Statement):
8990
exec(_StatementCompiler.compile(self.state, command),
9091
self.exec_locals)
92+
if isinstance(command, Assign) and self.testbench:
93+
return True # assignment; run a delta cycle
9194

9295
elif type(command) is Tick:
9396
domain = command.domain
@@ -102,17 +105,17 @@ def run(self):
102105
self.add_trigger(domain.clk, trigger=1 if domain.clk_edge == "pos" else 0)
103106
if domain.rst is not None and domain.async_reset:
104107
self.add_trigger(domain.rst, trigger=1)
105-
return
108+
return False # no assignments
106109

107110
elif type(command) is Settle:
108111
self.state.wait_interval(self, None)
109-
return
112+
return False # no assignments
110113

111114
elif type(command) is Delay:
112115
# Internal timeline is in 1ps integeral units, intervals are public API and in floating point
113116
interval = int(command.interval * 1e12) if command.interval is not None else None
114117
self.state.wait_interval(self, interval)
115-
return
118+
return False # no assignments
116119

117120
elif type(command) is Passive:
118121
self.passive = True
@@ -140,6 +143,8 @@ def run(self):
140143
state = self.state.slots[index]
141144
assert isinstance(state, BaseMemoryState)
142145
state.write(addr, data)
146+
if self.testbench:
147+
return True # assignment; run a delta cycle
143148

144149
elif command is None: # only possible if self.default_cmd is None
145150
raise TypeError("Received default command from process {!r} that was added "

amaranth/sim/core.py

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -117,31 +117,8 @@ def wrapper():
117117
def add_testbench(self, process):
118118
process = self._check_process(process)
119119
def wrapper():
120-
generator = process()
121-
# Only start a bench process after power-on reset finishes. Use object.__new__ to
122-
# avoid deprecation warning.
123-
yield object.__new__(Settle)
124-
result = None
125-
exception = None
126-
while True:
127-
try:
128-
if exception is None:
129-
command = generator.send(result)
130-
else:
131-
command = generator.throw(exception)
132-
except StopIteration:
133-
break
134-
if command is None or isinstance(command, Settle):
135-
exception = TypeError(f"Command {command!r} is not allowed in testbenches")
136-
else:
137-
try:
138-
result = yield command
139-
exception = None
140-
yield object.__new__(Settle)
141-
except Exception as e:
142-
result = None
143-
exception = e
144-
self._engine.add_coroutine_process(wrapper, default_cmd=None)
120+
yield from process()
121+
self._engine.add_testbench_process(wrapper)
145122

146123
def add_clock(self, period, *, phase=None, domain="sync", if_exists=False):
147124
"""Add a clock process.

amaranth/sim/pysim.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -409,24 +409,27 @@ def __init__(self, design):
409409

410410
self._design = design
411411
self._processes = _FragmentCompiler(self._state)(self._design.fragment)
412+
self._testbenches = []
412413
self._vcd_writers = []
413414

415+
def add_clock_process(self, clock, *, phase, period):
416+
self._processes.add(PyClockProcess(self._state, clock,
417+
phase=phase, period=period))
418+
414419
def add_coroutine_process(self, process, *, default_cmd):
415420
self._processes.add(PyCoroProcess(self._state, self._design.fragment.domains, process,
416421
default_cmd=default_cmd))
417422

418-
def add_clock_process(self, clock, *, phase, period):
419-
self._processes.add(PyClockProcess(self._state, clock,
420-
phase=phase, period=period))
423+
def add_testbench_process(self, process):
424+
self._testbenches.append(PyCoroProcess(self._state, self._design.fragment.domains, process,
425+
testbench=True))
421426

422427
def reset(self):
423428
self._state.reset()
424429
for process in self._processes:
425430
process.reset()
426431

427-
def _step(self):
428-
changed = set() if self._vcd_writers else None
429-
432+
def _delta(self, changed):
430433
# Performs the two phases of a delta cycle in a loop:
431434
converged = False
432435
while not converged:
@@ -439,6 +442,25 @@ def _step(self):
439442
# 2. commit: apply every queued signal change, waking up any waiting processes
440443
converged = self._state.commit(changed)
441444

445+
def _step(self):
446+
changed = set() if self._vcd_writers else None
447+
448+
# Run processes waiting for an interval to expire (mainly `add_clock_process()``)
449+
self._delta(changed)
450+
451+
# Run testbenches waiting for an interval to expire, or for a signal to change state
452+
converged = False
453+
while not converged:
454+
converged = True
455+
# Schedule testbenches in a deterministic, predictable order by iterating a list
456+
for testbench in self._testbenches:
457+
if testbench.runnable:
458+
testbench.runnable = False
459+
while testbench.run():
460+
# Testbench has changed simulation state; run processes triggered by that
461+
converged = False
462+
self._delta(changed)
463+
442464
for vcd_writer in self._vcd_writers:
443465
for change in changed:
444466
if isinstance(change, _PySignalState):
@@ -454,7 +476,7 @@ def _step(self):
454476
def advance(self):
455477
self._step()
456478
self._timeline.advance()
457-
return any(not process.passive for process in self._processes)
479+
return any(not process.passive for process in self._processes | set(self._testbenches))
458480

459481
@property
460482
def now(self):

0 commit comments

Comments
 (0)