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