Skip to content

Commit 0604ada

Browse files
authored
RFC #36: Async testbench functions
2 parents b4e3235 + 09771a0 commit 0604ada

File tree

1 file changed

+275
-0
lines changed

1 file changed

+275
-0
lines changed
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
- Start Date: 2024-03-18
2+
- RFC PR: [amaranth-lang/rfcs#36](https://github.com/amaranth-lang/rfcs/pull/36)
3+
- Amaranth Issue: [amaranth-lang/amaranth#1213](https://github.com/amaranth-lang/amaranth/issues/1213)
4+
5+
# Async testbench functions
6+
7+
## Summary
8+
[summary]: #summary
9+
10+
Introduce an improved simulator testbench interface using `async`/`await` style coroutines.
11+
12+
## Motivation
13+
[motivation]: #motivation
14+
15+
For the purpose of writing a testbench, an `async` function will read more naturally than a generator function, especially when calling subfunctions/methods.
16+
17+
A more expressive way to specify trigger/wait conditions allows the condition checking to be offloaded to the simulator engine, only returning control to the testbench process when it has work to do.
18+
19+
Passing a simulator context to the testbench function provides a convenient place to gather all simulator operations.
20+
21+
## Guide-level explanation
22+
[guide-level-explanation]: #guide-level-explanation
23+
24+
As an example, let's consider a simple stream interface with `valid`, `ready` and `data` members.
25+
We can then implement `stream_send()` and `stream_recv()` functions like this:
26+
27+
```python
28+
@testbench_helper
29+
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+
35+
await sim.tick()
36+
await sim.set(stream.ready, 0)
37+
38+
return value
39+
40+
@testbench_helper
41+
async def stream_send(sim, stream, value):
42+
await sim.set(stream.data, value)
43+
44+
await sim.set(stream.valid, 1)
45+
await sim.tick().until(stream.ready)
46+
47+
await sim.tick()
48+
await sim.set(stream.valid, 0)
49+
```
50+
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()`.
54+
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.
56+
57+
> **Note**
58+
> 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.
59+
> A way to attach clock domain information to interfaces is desireable, but out of scope for this RFC.
60+
61+
Using this stream interface, let's consider a colorspace converter accepting a stream of RGB values and outputting a stream of YUV values:
62+
63+
```python
64+
class RGBToYUVConverter(Component):
65+
input: In(StreamSignature(RGB888))
66+
output: Out(StreamSignature(YUV888))
67+
```
68+
69+
A testbench could then look like this:
70+
71+
```python
72+
async def test_rgb(sim, r, g, b):
73+
rgb = {'r': r, 'g': g, 'b': b}
74+
await stream_send(sim, dut.input, rgb)
75+
yuv = await stream_recv(sim, dut.output)
76+
77+
print(rgb, yuv)
78+
79+
async def testbench(sim):
80+
await test_rgb(sim, 0, 0, 0)
81+
await test_rgb(sim, 255, 0, 0)
82+
await test_rgb(sim, 0, 255, 0)
83+
await test_rgb(sim, 0, 0, 255)
84+
await test_rgb(sim, 255, 255, 255)
85+
```
86+
87+
Since `stream_send()` and `stream_recv()` invokes `sim.get()` and `sim.set()` that in turn will invoke the appropriate value conversions for a value castable (here `data.View`), it is general enough to work for streams with arbitrary shapes.
88+
89+
`Tick()` and `Delay()` are replaced by `sim.tick()` and `sim.delay()` respectively.
90+
In addition, `sim.changed()` and `sim.edge()` is introduced that allows creating triggers from arbitrary signals.
91+
92+
`sim.tick()` return a domain trigger object that can be made conditional through `.until()` or repeated through `.repeat()`.
93+
94+
`sim.delay()`, `sim.changed()` and `sim.edge()` return a combinable trigger object that can be used to add additional triggers.
95+
96+
`Active()` and `Passive()` are replaced by an `background=False` keyword argument to `.add_testbench()`.
97+
Processes created through `.add_process()` are always created as background processes.
98+
To allow a background process to ensure an operation is finished before end of simulation, `sim.critical()` is introduced, which is used as a context manager:
99+
100+
```python
101+
async def packet_reader(sim, stream):
102+
while True:
103+
# Wait until stream has valid data.
104+
await sim.tick().until(stream.valid)
105+
106+
# Ensure simulation doesn't end in the middle of a packet.
107+
async with sim.critical():
108+
packet = await stream.read_packet()
109+
print('Received packet:', packet.hex(' '))
110+
```
111+
112+
When a combinable trigger object is awaited, it'll return the value(s) of the trigger(s), and it can also be used as an async generator to repeatedly await the same trigger.
113+
Multiple triggers can be combined.
114+
Consider the following examples:
115+
116+
Combinational adder as a process:
117+
```python
118+
a = Signal(); b = Signal(); o = Signal()
119+
async def adder(sim):
120+
async for a_val, b_val in sim.changed(a, b):
121+
await sim.set(o, a_val + b_val)
122+
sim.add_process(adder)
123+
```
124+
125+
DDR IO buffer as a process:
126+
```python
127+
clk = Signal(); o = Signal(2); pin = Signal()
128+
async def ddr_buffer(sim):
129+
while True: # could be extended to pre-capture next `o` on posedge
130+
await sim.negedge(clk)
131+
await sim.set(pin, o[0])
132+
await sim.posedge(clk)
133+
await sim.set(pin, o[1])
134+
sim.add_process(ddr_buffer)
135+
```
136+
137+
Flop with configurable edge reset and posedge clock as a process:
138+
```python
139+
clk = Signal(); rst = Signal(); d = Signal(); q = Signal()
140+
def dff(rst_edge):
141+
async def process(sim):
142+
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))
144+
return process
145+
sim.add_process(dff(rst_edge=0))
146+
```
147+
148+
## Reference-level explanation
149+
[reference-level-explanation]: #reference-level-explanation
150+
151+
The following `Simulator` methods have their signatures updated:
152+
153+
* `add_process(process)`
154+
* `add_testbench(process, *, background=False)`
155+
156+
Both methods are updated to accept an async function passed as `process`.
157+
The async function must accept an argument `sim`, which will be passed a simulator context.
158+
(Argument name is just convention, will be passed positionally.)
159+
160+
The new optional named argument `background` registers the testbench as a background process when true.
161+
Processes created through `add_process` are always registered as background processes (except when registering legacy non-async generator functions).
162+
163+
The simulator context has the following methods:
164+
- `get(expr: Value) -> int`
165+
- `get(expr: ValueCastable) -> any`
166+
- Returns the value of `expr` when awaited.
167+
When `expr` is a value-castable, and its `shape()` is a `ShapeCastable`, the value will be converted through the shape's `.from_bits()`.
168+
Otherwise, a plain integer is returned.
169+
- `set(expr: Value, value: ConstLike)`
170+
- `set(expr: ValueCastable, value: any)`
171+
- Set `expr` to `value` when awaited.
172+
When `expr` is a value-castable, and its `shape()` is a `ShapeCastable`, the value will be converted through the shape's `.const()`.
173+
Otherwise, it must be a const-castable `ValueLike`.
174+
- `memory_read(instance: MemoryIdentity, address: int)`
175+
- Read the value from `address` in `instance` when awaited.
176+
- `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.
178+
Like `MemoryInstance`, these two functions are an internal interface that will be usually only used via `lib.Memory`.
179+
It comes without a stability guarantee.
180+
- `tick(domain="sync", *, context=None)`
181+
- Create a domain trigger object for advancing simulation until the next active edge of the `domain` clock.
182+
When an elaboratable is passed to `context`, `domain` will be resolved from its perspective.
183+
- If `domain` is asynchronously reset while this is being awaited, `amaranth.sim.AsyncReset` is raised.
184+
- `delay(interval: float)`
185+
- Create a combinable trigger object for advancing simulation by `interval` seconds.
186+
- `changed(*signals)`
187+
- Create a combinable trigger object for advancing simulation until any signal in `signals` changes.
188+
- `edge(signal, value: int)`
189+
- Create a combinable trigger object for advancing simulation until `signal` is changed to `value`.
190+
`signal` must be a 1-bit signal or a 1-bit slice of a signal.
191+
Valid values for `value` are `1` for rising edge and `0` for falling edge.
192+
- `posedge(signal)`
193+
- `negedge(signal)`
194+
- Aliases for `edge(signal, 1)` and `edge(signal, 0)` respectively.
195+
- `critical()`
196+
- Context manager.
197+
If the current process is a background process, `async with sim.critical():` makes it a non-background process for the duration of the statement.
198+
199+
A domain trigger object is immutable and has the following methods:
200+
- `__await__()`
201+
- Advance simulation. No value is returned.
202+
- `until(condition)`
203+
- Repeat the trigger until `condition` is true.
204+
`condition` is an arbitrary Amaranth expression.
205+
If `condition` is initially true, `await` will return immediately without advancing simulation.
206+
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:
209+
```python
210+
async def until(self, condition):
211+
while not await self._sim.get(condition):
212+
await self
213+
```
214+
- `repeat(times: int)`
215+
- Repeat the trigger `times` times.
216+
Valid values are `times >= 0`.
217+
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:
220+
```python
221+
async def repeat(self, times):
222+
for _ in range(times):
223+
await self
224+
```
225+
226+
A combinable trigger object is immutable and has the following methods:
227+
- `__await__()`
228+
- Advance simulation and return the value(s) of the trigger(s).
229+
- `delay` and `edge` triggers return `True` when they are hit, otherwise `False`.
230+
- `changed` triggers return the current value of the signals they are monitoring.
231+
- At least one of the triggers hit will be reflected in the return value.
232+
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”.
233+
- `__aiter__()`
234+
- Return an async generator that is equivalent to repeatedly awaiting the trigger object in an infinite loop.
235+
- `delay(interval: float)`
236+
- `changed(*signals)`
237+
- `edge(signal, value)`
238+
- `posedge(signal)`
239+
- `negedge(signal)`
240+
- Create a new trigger object by copying the current object and appending another trigger.
241+
- 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.
242+
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+
247+
`Tick()`, `Delay()`, `Active()` and `Passive()` as well as the ability to pass generator coroutines as `process` are deprecated and removed in a future version.
248+
249+
## Drawbacks
250+
[drawbacks]: #drawbacks
251+
252+
- Increase in API surface area and complexity.
253+
- Churn.
254+
255+
## Rationale and alternatives
256+
[rationale-and-alternatives]: #rationale-and-alternatives
257+
258+
- Do nothing. Keep the existing interface, add `Changed()` alongside `Delay()` and `Tick()`, use `yield from` when calling functions.
259+
260+
## Prior art
261+
[prior-art]: #prior-art
262+
263+
Other python libraries like [cocotb](https://docs.cocotb.org/en/stable/coroutines.html) that originally used generator based coroutines have also moved to `async`/`await` style coroutines.
264+
265+
## Unresolved questions
266+
[unresolved-questions]: #unresolved-questions
267+
268+
None.
269+
270+
## Future possibilities
271+
[future-possibilities]: #future-possibilities
272+
273+
- Add simulation helper methods to standard interfaces where it makes sense.
274+
- This includes `lib.memory.Memory`.
275+
- There is a desire for a `sim.time()` method that returns the current simulation time, but it needs a suitable return type to represent seconds with femtosecond resolution and that is out of the scope for this RFC.

0 commit comments

Comments
 (0)