Skip to content

Commit f86c4cb

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 f86c4cb

File tree

5 files changed

+62
-20
lines changed

5 files changed

+62
-20
lines changed

amaranth/sim/_base.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,13 @@ def wait_interval(self, process, interval):
6565

6666

6767
class BaseEngine:
68+
def add_clock_process(self, clock, *, phase, period):
69+
raise NotImplementedError # :nocov:
70+
6871
def add_coroutine_process(self, process, *, default_cmd):
6972
raise NotImplementedError # :nocov:
7073

71-
def add_clock_process(self, clock, *, phase, period):
74+
def add_testbench_process(self, process):
7275
raise NotImplementedError # :nocov:
7376

7477
def reset(self):

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: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,6 @@ def add_testbench(self, process):
118118
process = self._check_process(process)
119119
def wrapper():
120120
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)
124121
result = None
125122
exception = None
126123
while True:
@@ -137,11 +134,10 @@ def wrapper():
137134
try:
138135
result = yield command
139136
exception = None
140-
yield object.__new__(Settle)
141137
except Exception as e:
142138
result = None
143139
exception = e
144-
self._engine.add_coroutine_process(wrapper, default_cmd=None)
140+
self._engine.add_testbench_process(wrapper)
145141

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

amaranth/sim/pysim.py

Lines changed: 30 additions & 8 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 _step_rtl(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_tb(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._step_rtl(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._step_rtl(changed)
463+
442464
for vcd_writer in self._vcd_writers:
443465
for change in changed:
444466
if isinstance(change, _PySignalState):
@@ -452,9 +474,9 @@ def _step(self):
452474
assert False # :nocov:
453475

454476
def advance(self):
455-
self._step()
477+
self._step_tb()
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, *self._testbenches))
458480

459481
@property
460482
def now(self):

tests/test_sim.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,22 @@ def process():
11741174
Coverage hit at .*test_sim\.py:\d+: Counter: 009
11751175
""").lstrip())
11761176

1177+
def test_testbench_preemption(self):
1178+
sig = Signal(8)
1179+
def testbench_1():
1180+
yield sig[0:4].eq(0b1010)
1181+
yield sig[4:8].eq(0b0101)
1182+
def testbench_2():
1183+
yield Passive()
1184+
while True:
1185+
val = yield sig
1186+
assert val in (0, 0b01011010), f"{val=:#010b}"
1187+
yield Delay(0)
1188+
sim = Simulator(Module())
1189+
sim.add_testbench(testbench_1)
1190+
sim.add_testbench(testbench_2)
1191+
sim.run()
1192+
11771193

11781194
class SimulatorRegressionTestCase(FHDLTestCase):
11791195
def test_bug_325(self):

0 commit comments

Comments
 (0)