|
| 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