Skip to content

Commit 3e738b0

Browse files
authored
RFC #80: Simulation task groups
2 parents 89716e3 + a9f3f00 commit 3e738b0

File tree

1 file changed

+133
-0
lines changed

1 file changed

+133
-0
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
- Start Date: 2025-07-28
2+
- RFC PR: [amaranth-lang/rfcs#80](https://github.com/amaranth-lang/rfcs/pull/80)
3+
- Amaranth Issue: [amaranth-lang/amaranth#1620](https://github.com/amaranth-lang/amaranth/issues/1620)
4+
5+
# Simulation task groups
6+
7+
## Summary
8+
[summary]: #summary
9+
10+
Add task groups to the simulator to allow parallel execution in a testbench.
11+
12+
## Motivation
13+
[motivation]: #motivation
14+
15+
When testing a component, it's common to need to interact with multiple interfaces in parallel.
16+
For instance, when testing a stream component, the output stream must typically be read in parallel with writing the input stream to avoid backpressure from the output stream to propagate back to the input stream and deadlock the simulation.
17+
Currently this must be done by adding independent testbenches, which can be awkward to synchronize.
18+
19+
## Guide-level explanation
20+
[guide-level-explanation]: #guide-level-explanation
21+
22+
Typical testbenches for a stream component can currently look like this:
23+
```python
24+
test_vectors = [...]
25+
26+
async def input_testbench(ctx: SimulatorContext):
27+
for input in test_vectors:
28+
await send_packet(ctx, dut.i, input)
29+
30+
async def output_testbench(ctx: SimulatorContext):
31+
for input in test_vectors:
32+
output = await recv_packet(ctx, dut.o)
33+
assert output == expected_output(input)
34+
```
35+
36+
With task groups, this can instead be written like:
37+
```python
38+
test_vectors = [...]
39+
40+
async def testbench(ctx: SimulatorContext):
41+
for input in test_vectors:
42+
async with ctx.group() as group:
43+
group.start(send_packet(ctx, dut.i, input))
44+
output = await recv_packet(ctx, dut.o)
45+
assert output == expected_output(input)
46+
```
47+
48+
In a similar manner to background testbenches, it is also possible to add background tasks, for tasks that are not intended to run to completion.
49+
This allows code like this:
50+
```python
51+
@asynccontextmanager
52+
async def timeout(ctx, ticks, domain = 'sync'):
53+
async def task():
54+
await ctx.tick(domain).repeat(ticks) # Never returns if the task group ends before `ticks` have elapsed.
55+
raise TimeoutError()
56+
57+
async with ctx.group() as group:
58+
group.start(task(ctx, ticks, domain), background = True)
59+
yield
60+
61+
async def testbench(ctx):
62+
async with timeout(ctx, 100):
63+
... # Some operation expected to take less than 100 ticks
64+
65+
async with timeout(ctx, 200):
66+
... # Some other operation expected to take less than 200 ticks
67+
```
68+
69+
## Reference-level explanation
70+
[reference-level-explanation]: #reference-level-explanation
71+
72+
`SimulatorContext` have the following methods added:
73+
- `group() -> TaskGroup`
74+
- Create a new task group.
75+
- `async gather(coros*) -> tuple`
76+
- Shorthand for creating a task group, starting all `coros`, letting the group run to completion and collecting the return values.
77+
- Example implementation:
78+
```python
79+
async def gather(self, *coros):
80+
async with self.group() as group:
81+
tasks = [group.start(coro) for coro in coros]
82+
return tuple(task.result() for task in tasks)
83+
```
84+
85+
`TaskGroup` is added with the following methods:
86+
- `start(coro, *, background = False) -> Task`
87+
- Create and start a new task.
88+
- A background task can be temporarily made non-background with `ctx.critical()` like a regular testbench.
89+
- Raise an exception if called before `__aenter__()` or after `__aexit__()`.
90+
- `async __aenter__() -> Self`
91+
- Return `self`.
92+
- Raise an exception if called multiple times.
93+
- `async __aexit__(...)`
94+
- Wait for all non-background tasks to run to completion.
95+
- Once all non-background tasks are done, any remaining background tasks are dropped without returning or unwinding from their pending awaits.
96+
- Raise an exception if called multiple times or if called without calling `__aenter__()` first.
97+
98+
`Task` is added with the following methods:
99+
- `done() -> bool`
100+
- Return whether the task has completed.
101+
- `result() -> Any`
102+
- Get the return value of a completed task.
103+
- Raise an exception if the task has not completed.
104+
105+
Exception propagation and task cancellation (beyond dropping background tasks when a group is done) are out of scope for this RFC.
106+
If a task raises any unhandled exceptions, this immediately terminates the simulation and propagates out of the simulator as if raised from an independent testbench.
107+
108+
## Drawbacks
109+
[drawbacks]: #drawbacks
110+
111+
- Increased simulator complexity.
112+
113+
## Rationale and alternatives
114+
[rationale-and-alternatives]: #rationale-and-alternatives
115+
116+
Exception propagation and task cancellation was omitted from the scope of this RFC because it would significantly increase complexity, for limited benefit.
117+
It is expected that the desired outcome of an unhandled exception in a task in most cases would be to terminate simulation and therefore don't need the ability for the parent testbench to catch it.
118+
119+
## Prior art
120+
[prior-art]: #prior-art
121+
122+
The proposed API is modelled after `asyncio.TaskGroup` and `asyncio.gather()`.
123+
124+
## Unresolved questions
125+
[unresolved-questions]: #unresolved-questions
126+
127+
- The usual bikeshedding of names.
128+
129+
## Future possibilities
130+
[future-possibilities]: #future-possibilities
131+
132+
- A future RFC could add exception propagation and task cancellation.
133+
- Context managers like the timeout example above could be added for common cases.

0 commit comments

Comments
 (0)