Skip to content

Commit fd167b4

Browse files
committed
Add an RFC for async testbench functions.
1 parent 09893fe commit fd167b4

File tree

1 file changed

+167
-0
lines changed

1 file changed

+167
-0
lines changed
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
- Start Date: (fill me in with today's date, YYYY-MM-DD)
2+
- RFC PR: [amaranth-lang/rfcs#0000](https://github.com/amaranth-lang/rfcs/pull/0000)
3+
- Amaranth Issue: [amaranth-lang/amaranth#0000](https://github.com/amaranth-lang/amaranth/issues/0000)
4+
5+
Note: This draft makes references to additions from RFC #27 and assumes it is merged first.
6+
7+
# Async testbench functions
8+
9+
## Summary
10+
[summary]: #summary
11+
12+
Introduce an improved simulator testbench interface using `async`/`await` style coroutines.
13+
14+
## Motivation
15+
[motivation]: #motivation
16+
17+
For the purpose of writing a testbench, an `async` function will read more naturally than a generator function, especially when calling subfunctions/methods.
18+
19+
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.
20+
21+
Passing a simulator context to the testbench function provides a convenient place to gather all simulator operations.
22+
23+
Having `.get()` and `.set()` methods provides a convenient way for value castables to implement these in a type-specific manner.
24+
25+
## Guide-level explanation
26+
[guide-level-explanation]: #guide-level-explanation
27+
28+
As an example, let's consider a simple stream interface with `valid`, `ready` and `data` members.
29+
On the interface class, we can then implement `.send()` and `.recv()` methods like this:
30+
31+
```python
32+
class StreamInterface(PureInterface):
33+
async def recv(self, sim):
34+
await self.ready.set(1)
35+
await sim.tick().until(self.valid)
36+
37+
value = await self.data.get()
38+
39+
await sim.tick()
40+
await self.ready.set(0)
41+
42+
return value
43+
44+
async def send(self, sim, value):
45+
await self.data.set(value)
46+
47+
await self.valid.set(1)
48+
await sim.tick().until(self.ready)
49+
50+
await sim.tick()
51+
await self.valid.set(0)
52+
```
53+
54+
`sim.tick()` replaces the existing `Tick()`. It returns a trigger object that either can be awaited directly, or made conditional through `.until()`.
55+
56+
Using this stream interface, let's consider a colorspace converter accepting a stream of RGB values and outputting a stream of YUV values:
57+
58+
```python
59+
class RGBToYUVConverter(Component):
60+
input: In(StreamSignature(RGB888))
61+
output: Out(StreamSignature(YUV888))
62+
```
63+
64+
A testbench could then look like this:
65+
66+
```python
67+
async def test_rgb(sim, r, g, b):
68+
rgb = {'r': r, 'g': g, 'b': b}
69+
await dut.input.send(sim, rgb)
70+
yuv = await dut.output.recv(sim)
71+
72+
print(rgb, yuv)
73+
74+
async def testbench(sim):
75+
await test_rgb(sim, 0, 0, 0)
76+
await test_rgb(sim, 255, 0, 0)
77+
await test_rgb(sim, 0, 255, 0)
78+
await test_rgb(sim, 0, 0, 255)
79+
await test_rgb(sim, 255, 255, 255)
80+
```
81+
82+
Since `.send()` and `.recv()` invokes `.get()` and `.set()` that a value castable (here `data.View`) can implement in a suitable manner, it is general enough to work for streams with arbitrary shapes.
83+
84+
`Tick()` and `Delay()` are replaced by `sim.tick()` and `sim.delay()` respectively.
85+
In addition, `sim.changed()` is introduced that allows creating triggers from arbitrary signals.
86+
These all return a trigger object that can be made conditional through `.until()`.
87+
88+
`Active()` and `Passive()` are replaced by an `passive=False` keyword argument to `.add_process()`, `.add_sync_process()` and `.add_testbench()`.
89+
To mark a passive testbench temporarily active, `sim.active()` is introduced, which is used as a context manager:
90+
91+
```python
92+
async def packet_reader(sim, stream):
93+
while True
94+
# Wait until stream has valid data.
95+
await sim.tick().until(stream.valid)
96+
97+
# Go active to ensure simulation doesn't end in the middle of a packet.
98+
with sim.active():
99+
packet = await stream.read_packet()
100+
print('Received packet:', packet.hex(' '))
101+
```
102+
103+
TBD: Does this need to be an async context manager?
104+
105+
## Reference-level explanation
106+
[reference-level-explanation]: #reference-level-explanation
107+
108+
The following `Simulator` methods have their signatures updated:
109+
110+
* `add_process(process, *, passive=False)`
111+
* `add_sync_process(process, *, domain="sync", passive=False)`
112+
* `add_testbench(process, *, domain="sync", passive=False)` (TBD: RFC #27: Does `domain` default to `None` or `"sync"` here?)
113+
114+
The new optional named argument `passive` registers the testbench as passive when true.
115+
116+
All three methods are updated to accept an async function passed as `process`. When the function passed to `process` accepts an argument named `sim`, it will be passed a simulator context.
117+
118+
The simulator context have the following methods:
119+
- `delay(interval=None)`
120+
- Return a trigger object for advancing simulation by `interval` seconds.
121+
- `tick(domain=None)`
122+
- Return a trigger object for advancing simulation by one tick of `domain`. `domain` defaults to the argument given to `add_sync_process()` or `add_testbench()` if not specified.
123+
- `changed(signal, value=None)`
124+
- Return a trigger object for advancing simulation until `signal` is changed to `value`. `None` is a wildcard and will trigger on any change.
125+
- `active()`
126+
- Return a context manager that temporarily marks the testbench as active for the duration.
127+
- `time()`
128+
- Return the current simulation time.
129+
130+
A trigger object has the following methods:
131+
- `until(condition)`
132+
- Repeat the trigger until `condition` is true. If `condition` is initially true, `await` will return immediately without advancing simulation.
133+
134+
`Value`, `data.View` and `enum.EnumView` have `.get()` and `.set()` methods added.
135+
136+
`Tick()`, `Delay()`, `Active()` and `Passive()` as well as the ability to pass generator coroutines as `process` are deprecated and removed in a future version.
137+
138+
## Drawbacks
139+
[drawbacks]: #drawbacks
140+
141+
Reserves two new names on `Value` and value castables. Increase in API surface area and complexity. Churn.
142+
143+
## Rationale and alternatives
144+
[rationale-and-alternatives]: #rationale-and-alternatives
145+
146+
- Do nothing. Keep the existing interface, add `Changed()` alongside `Delay()` and `Tick()`, use `yield from` when calling functions.
147+
148+
- Don't introduce `.get()` and `.set()`. Instead require a value castable and the return value of its `.eq()` to be awaitable so `await value` and `await value.eq(foo)` is possible.
149+
150+
151+
## Prior art
152+
[prior-art]: #prior-art
153+
154+
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.
155+
156+
## Unresolved questions
157+
[unresolved-questions]: #unresolved-questions
158+
159+
- Is there any other functionality that's natural to have on the simulator context?
160+
- Is there any other functionality that's natural to have on the trigger object?
161+
- Maybe a way to skip a given number of triggers? We still lack a way to say «advance by n cycles».
162+
- Bikeshed all the names.
163+
164+
## Future possibilities
165+
[future-possibilities]: #future-possibilities
166+
167+
Add simulation helpers in the manner of `.send()` and `.recv()` to standard interfaces where it makes sense.

0 commit comments

Comments
 (0)