Skip to content

Commit 6c44415

Browse files
committed
RFC #36: Amend with a concrete concurrency model.
1 parent ea8b1d2 commit 6c44415

File tree

1 file changed

+78
-44
lines changed

1 file changed

+78
-44
lines changed

text/0036-async-testbench-functions.md

Lines changed: 78 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -25,34 +25,24 @@ As an example, let's consider a simple stream interface with `valid`, `ready` an
2525
We can then implement `stream_send()` and `stream_recv()` functions like this:
2626

2727
```python
28-
@testbench_helper
2928
async def stream_recv(sim, stream):
30-
await sim.set(stream.ready, 1)
31-
await sim.tick().until(stream.valid)
32-
33-
value = await sim.get(stream.data)
34-
29+
sim.set(stream.ready, 1)
30+
value = await sim.tick().until(stream.valid).sample(stream.data)
3531
await sim.tick()
36-
await sim.set(stream.ready, 0)
37-
32+
sim.set(stream.ready, 0)
3833
return value
3934

40-
@testbench_helper
4135
async def stream_send(sim, stream, value):
42-
await sim.set(stream.data, value)
43-
44-
await sim.set(stream.valid, 1)
36+
sim.set(stream.data, value)
37+
sim.set(stream.valid, 1)
4538
await sim.tick().until(stream.ready)
46-
4739
await sim.tick()
48-
await sim.set(stream.valid, 0)
40+
sim.set(stream.valid, 0)
4941
```
5042

51-
`await sim.get()` and `await sim.set()` replaces the existing operations `yield signal` and `yield signal.eq()` respectively.
52-
53-
`sim.tick()` replaces the existing `Tick()`. It returns a trigger object that either can be awaited directly, or made conditional through `.until()`.
43+
`sim.get()` and `sim.set()` replaces the existing operations `yield signal` and `yield signal.eq()` respectively.
5444

55-
The `testbench_helper` decorator indicates that this function is only designed to be called from testbench processes and will raise an exception if called elsewhere.
45+
`sim.tick()` replaces the existing `Tick()`. It returns a trigger object that either can be awaited directly, or made conditional through `.until()`. Values of signals can be captured using `.sample()`, which is used to sample the interface members at the active edge of the clock. This approach makes these functions robust in presence of combinational feedback or concurrent use in multiple testbench processes.
5646

5747
> **Note**
5848
> This simplified example does not include any way of specifying the clock domain of the interface and as such is only directly applicable to single domain simulations.
@@ -89,7 +79,7 @@ Since `stream_send()` and `stream_recv()` invokes `sim.get()` and `sim.set()` th
8979
`Tick()` and `Delay()` are replaced by `sim.tick()` and `sim.delay()` respectively.
9080
In addition, `sim.changed()` and `sim.edge()` is introduced that allows creating triggers from arbitrary signals.
9181

92-
`sim.tick()` return a domain trigger object that can be made conditional through `.until()` or repeated through `.repeat()`.
82+
`sim.tick()` return a domain trigger object that can be made conditional through `.until()` or repeated through `.repeat()`. Arbitrary expressions may be sampled at the active edge of the domain clock using `.sample()`.
9383

9484
`sim.delay()`, `sim.changed()` and `sim.edge()` return a combinable trigger object that can be used to add additional triggers.
9585

@@ -118,7 +108,7 @@ Combinational adder as a process:
118108
a = Signal(); b = Signal(); o = Signal()
119109
async def adder(sim):
120110
async for a_val, b_val in sim.changed(a, b):
121-
await sim.set(o, a_val + b_val)
111+
sim.set(o, a_val + b_val)
122112
sim.add_process(adder)
123113
```
124114

@@ -128,9 +118,9 @@ clk = Signal(); o = Signal(2); pin = Signal()
128118
async def ddr_buffer(sim):
129119
while True: # could be extended to pre-capture next `o` on posedge
130120
await sim.negedge(clk)
131-
await sim.set(pin, o[0])
121+
sim.set(pin, o[0])
132122
await sim.posedge(clk)
133-
await sim.set(pin, o[1])
123+
sim.set(pin, o[1])
134124
sim.add_process(ddr_buffer)
135125
```
136126

@@ -140,7 +130,7 @@ clk = Signal(); rst = Signal(); d = Signal(); q = Signal()
140130
def dff(rst_edge):
141131
async def process(sim):
142132
async for clk_hit, rst_hit in sim.posedge(clk).edge(rst, rst_edge):
143-
await sim.set(q, 0 if rst_hit else await sim.get(d))
133+
sim.set(q, 0 if rst_hit else d)
144134
return process
145135
sim.add_process(dff(rst_edge=0))
146136
```
@@ -157,32 +147,63 @@ Both methods are updated to accept an async function passed as `process`.
157147
The async function must accept an argument `sim`, which will be passed a simulator context.
158148
(Argument name is just convention, will be passed positionally.)
159149

150+
The usage model of the two kinds of processes are:
151+
- Processes are added with `add_process()` for the sole purpose of simulating a part of the netlist with behavioral Python code.
152+
- Typically such a process will consist of a top-level `async for values in sim.tick().sample(...):` or `async for values in sim.changed(...)`, but this is not a requirement.
153+
- Such processes may only wait on signals, via `sim.tick()`, `sim.changed()`, and `sim.edge()`. They cannot advance simulation.
154+
- In these processes, `sim.get()` is not available; values of signals may only be obtained by awaiting on triggers.
155+
`sim.set(x, y)` may be used to propagate the value of `y` without reading it.
156+
- The function passed to `add_process()` must be idempotent: applying it multiple times to the same simulation state and with same local variable values must produce the same effect each time.
157+
Provided that, the outcome of running such a process is deterministic regardless of the order of their execution.
158+
- Processes are added with `add_testbench()` for any other purpose, including but not limited to: providing a stimulus, performing I/O, displaying state, asserting outcomes, and so on.
159+
- Such a process may be a simple linear function, use a top-level loop, or have arbitrarily complex structure.
160+
- Such processes may wait on signals as well as advance simulation time.
161+
- In these processes, `sim.get(x)` is available and returns the most current value of `x` (after all pending combinatorial propagation finishes).
162+
- The function passed to `add_testbench()` may have arbitrary side effects.
163+
These processes are scheduled in an unspecified order that may not be deterministic, and no mechanisms are provided to recover determinism of outcomes.
164+
- When waiting on signals, e.g. via `sim.tick()`, the requested expressions are sampled before the processes added with `add_process()` and RTL processes perform combinatorial propagation. However, execution continues only after all pending combinatorial propagation finishes.
165+
166+
The following concurrency guarantees are provided:
167+
- Async processes registered with `add_testbench` may be preempted by:
168+
- Any other process when calling `await ...`.
169+
- A process registered with `add_process` (or an RTL process) when calling `sim.set()` or `sim.memory_write()`. In this case, control is returned to the same testbench after combinational settling.
170+
- Async processes registered with `add_process` may be preempted by:
171+
- Any other process when calling `await ...`.
172+
- Legacy processes follow the same rules as async processes, with the exception of:
173+
- A legacy process may not be preempted when calling `yield x:ValueLike` or `yield x:Assign`.
174+
- Once running, a process continues to execute until it terminates or is preempted.
175+
160176
The new optional named argument `background` registers the testbench as a background process when true.
161177
Processes created through `add_process` are always registered as background processes (except when registering legacy non-async generator functions).
162178

163179
The simulator context has the following methods:
164180
- `get(expr: Value) -> int`
165181
- `get(expr: ValueCastable) -> any`
166-
- Returns the value of `expr` when awaited.
182+
- Returns the value of `expr`.
167183
When `expr` is a value-castable, and its `shape()` is a `ShapeCastable`, the value will be converted through the shape's `.from_bits()`.
168184
Otherwise, a plain integer is returned.
185+
This function is not available in processes created through `add_process`.
169186
- `set(expr: Value, value: ConstLike)`
170187
- `set(expr: ValueCastable, value: any)`
171-
- Set `expr` to `value` when awaited.
188+
- Set `expr` to `value`.
172189
When `expr` is a value-castable, and its `shape()` is a `ShapeCastable`, the value will be converted through the shape's `.const()`.
173190
Otherwise, it must be a const-castable `ValueLike`.
191+
When used in a process created through `add_testbench`, it may execute RTL processes and processes created through `add_process`.
174192
- `memory_read(instance: MemoryIdentity, address: int)`
175-
- Read the value from `address` in `instance` when awaited.
193+
- Read the value from `address` in `instance`.
194+
This function is not available in processes created through `add_process`.
176195
- `memory_write(instance: MemoryIdentity, address: int, value: int, mask:int = None)`
177-
- Write `value` to `address` in `instance` when awaited. If `mask` is given, only the corresponding bits are written.
196+
- Write `value` to `address` in `instance`. If `mask` is given, only the corresponding bits are written.
178197
Like `MemoryInstance`, these two functions are an internal interface that will be usually only used via `lib.Memory`.
198+
When used in a process created through `add_testbench`, it may execute RTL processes and processes created through `add_process`.
179199
It comes without a stability guarantee.
180200
- `tick(domain="sync", *, context=None)`
181201
- Create a domain trigger object for advancing simulation until the next active edge of the `domain` clock.
182202
When an elaboratable is passed to `context`, `domain` will be resolved from its perspective.
183203
- If `domain` is asynchronously reset while this is being awaited, `amaranth.sim.AsyncReset` is raised.
184204
- `delay(interval: float)`
185205
- Create a combinable trigger object for advancing simulation by `interval` seconds.
206+
This function is not available in processes created through `add_process`.
186207
- `changed(*signals)`
187208
- Create a combinable trigger object for advancing simulation until any signal in `signals` changes.
188209
- `edge(signal, value: int)`
@@ -198,29 +219,37 @@ The simulator context has the following methods:
198219

199220
A domain trigger object is immutable and has the following methods:
200221
- `__await__()`
201-
- Advance simulation. No value is returned.
222+
- Advance simulation and return the value(s) of the sampled expression(s). Values are returned in the same order as the expressions were added.
223+
- `__aiter__()`
224+
- Return an async generator that is equivalent to repeatedly awaiting the trigger object in an infinite loop.
225+
- The async generator yields value(s) of the sampled expression(s).
226+
- `sample(*expressions)`
227+
- Create a new trigger object by copying the current object and appending the expressions to be sampled.
202228
- `until(condition)`
203229
- Repeat the trigger until `condition` is true.
204230
`condition` is an arbitrary Amaranth expression.
205-
If `condition` is initially true, `await` will return immediately without advancing simulation.
206231
The return value is an unspecified awaitable with `await` as the only defined operation.
207-
It is only awaitable once and awaiting it returns no value.
208-
- Example implementation:
232+
It is only awaitable once and returns the value(s) of the sampled expression(s) at the last time the trigger was repeated.
233+
- Example implementation (without error checking):
209234
```python
210235
async def until(self, condition):
211-
while not await self._sim.get(condition):
212-
await self
236+
while True:
237+
*values, done = await self.sample(condition)
238+
if done:
239+
return values
213240
```
214241
- `repeat(times: int)`
215242
- Repeat the trigger `times` times.
216-
Valid values are `times >= 0`.
243+
Valid values are `times > 0`.
217244
The return value is an unspecified awaitable with `await` as the only defined operation.
218-
It is only awaitable once and awaiting it returns no value.
219-
- Example implementation:
245+
It is only awaitable once and returns the value(s) of the sampled expression(s) at the last time the trigger was repeated.
246+
- Example implementation (without error checking):
220247
```python
221248
async def repeat(self, times):
222-
for _ in range(times):
223-
await self
249+
values = None
250+
for _ in range(times):
251+
values = await self
252+
return values
224253
```
225254

226255
A combinable trigger object is immutable and has the following methods:
@@ -232,6 +261,7 @@ A combinable trigger object is immutable and has the following methods:
232261
In case of multiple triggers occuring at the same time step, it is unspecified which of these will show up in the return value beyond “at least one”.
233262
- `__aiter__()`
234263
- Return an async generator that is equivalent to repeatedly awaiting the trigger object in an infinite loop.
264+
- The async generator yields value(s) of the trigger(s).
235265
- `delay(interval: float)`
236266
- `changed(*signals)`
237267
- `edge(signal, value)`
@@ -240,10 +270,6 @@ A combinable trigger object is immutable and has the following methods:
240270
- Create a new trigger object by copying the current object and appending another trigger.
241271
- Awaiting the returned trigger object pauses the process until the first of the combined triggers hit, i.e. the triggers are combined using OR semantics.
242272

243-
To ensure testbench helper functions are only called from a testbench process, the `amaranth.sim.testbench_helper` decorator is added.
244-
The function wrapper expects the first positional argument (or second, after `self` or `cls` if decorating a method/classmethod) to be a simulator context, and will raise `TypeError` if not.
245-
If the function is called outside a testbench process, an exception will be raised.
246-
247273
`Tick()`, `Delay()`, `Active()` and `Passive()` as well as the ability to pass generator coroutines as `process` are deprecated and removed in a future version.
248274

249275
## Drawbacks
@@ -255,7 +281,12 @@ If the function is called outside a testbench process, an exception will be rais
255281
## Rationale and alternatives
256282
[rationale-and-alternatives]: #rationale-and-alternatives
257283

258-
- Do nothing. Keep the existing interface, add `Changed()` alongside `Delay()` and `Tick()`, use `yield from` when calling functions.
284+
`sim.get()` is not available in processes created with `add_process()` to simplify the user interface and eliminate the possibility of misusing a helper function by calling it from the wrong type of process.
285+
- Most helper functions will be implemented using `await sim.tick().sample(...)`, mirroring the structure of the gateware they are driving. These functions may be safely called from either processes added with `add_testbench()` or with `add_process()` since the semantics of `await sim.tick()` is the same between them.
286+
- Some helper functions will be using `sim.get(val)`, and will only be callable from processes added with `add_testbench()`, raising an error otherwise. In the legacy interface, the semantics of `yield val` changes depending on the type of the process, potentially leading to extremely confusing behavior. This is not possible in the async interface.
287+
288+
Alternatives:
289+
- Do nothing. Keep the existing interface, add `Changed()` alongside `Delay()` and `Tick()`, expand `Tick()` to add sampling, use `yield from` when calling functions.
259290

260291
## Prior art
261292
[prior-art]: #prior-art
@@ -265,7 +296,10 @@ Other python libraries like [cocotb](https://docs.cocotb.org/en/stable/coroutine
265296
## Unresolved questions
266297
[unresolved-questions]: #unresolved-questions
267298

268-
None.
299+
- Is there really a need to ban `sim.delay()` from processes added with `add_process()`?
300+
- The value of `add_process()` is in ensuring that multiple processes waiting on the same trigger will modify simulation state deterministically no matter which order they run. Multiple processes waiting on a specific point in time using `sim.delay()` does not appear a common case.
301+
- `sim.delay()` in processes added with `add_process()` may unduly complicate implementation, since timeline advancement then can raise readiness of two kinds of processes instead of one. It is also likely to cause issues with CXXRTL integration.
302+
- `sim.delay()` in processes added with `add_process()` is useful to implement delay and phase shift blocks. However, these can be implemented in processes added with `add_testbench()` with no loss of functionality, as such blocks do not need delta cycle accurate synchronization with others on the same trigger.
269303

270304
## Future possibilities
271305
[future-possibilities]: #future-possibilities

0 commit comments

Comments
 (0)