CIRCT 24.0.0git
Loading...
Searching...
No Matches
test_channel_arbiter.py
Go to the documentation of this file.
1# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
2# See https://llvm.org/LICENSE.txt for license information.
3# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
4"""Cosim integration tests for `esiaccel.components.ChannelArbiter`.
5
6Runs under Verilator via the `@cosim_test` harness. Two DUTs (built by
7`hw/channel_arbiter.py`) are exercised:
8
9 * `arbiter_test` / `arbiter_test_odd`: host-driven single-flit multiplexers
10 with a power-of-two ('balanced') and non-power-of-two ('unbalanced') input
11 count. The host writes distinct tagged values to each input and checks that
12 every value comes out of the single output exactly once (no loss /
13 duplication) and that every input is served.
14
15 * `list_test`: two in-hardware list-window producers contend for the
16 arbiter; a hardware checker verifies message contiguity and streams a
17 per-message report back to the host.
18
19 * `token_test`: several in-hardware zero-width (`i0`) token producers, each
20 emitting a fixed number of tokens, contend for the arbiter. A hardware
21 counter reports a running ordinal per delivered token so the host can
22 confirm every token is delivered exactly once and every producer is served.
23"""
24
25from __future__ import annotations
26
27from collections import Counter
28from pathlib import Path
29
30import esiaccel
31from esiaccel.accelerator import AcceleratorConnection
32from esiaccel.cosim.pytest import cosim_test
33
34HW_DIR = Path(__file__).resolve().parent / "hw"
35
36NUM_INPUTS = 4 # power-of-two ("balanced") input count.
37ODD_NUM_INPUTS = 3 # non-power-of-two ("unbalanced") input count.
38PIPE_NUM_INPUTS = 6 # input count for the pipelined-mux-tree variant.
39WIDE_NUM_INPUTS = 13 # wide fan-in; must match hw/channel_arbiter.py.
40WIDE_FANIN_NUM_INPUTS = 17 # above the wide-fan-in threshold; must match hw.
41TOKEN_NUM_INPUTS = 5 # input count for the zero-width (i0) token variant.
42TOKENS_PER_INPUT = 8 # tokens each producer emits in the token test.
43THROUGHPUT_NUM_INPUTS = 4 # must match hw/channel_arbiter.py.
44THROUGHPUT_WINDOW = 1000 # measurement window in cycles; must match hw.
45
46
47def _check_mux(conn: AcceleratorConnection,
48 dut_name: str,
49 num_inputs: int,
50 max_in_flight: int = 2) -> None:
51 """Drive an N-input host mux and check every value is delivered exactly
52 once and every input is served."""
53 acc = conn.build_accelerator()
54 dut = acc.children[esiaccel.AppID(dut_name)]
55 ins = [dut.ports[esiaccel.AppID(f"in_{i}")] for i in range(num_inputs)]
56 out = dut.ports[esiaccel.AppID("out")]
57 for p in ins:
58 p.connect()
59 out.connect()
60
61 # The value encodes its source input in the upper bits (i << 16) and a round
62 # counter in the low bits, so the received multiset uniquely identifies every message.
63 rounds = 6
64 writes = [
65 ((i << 16) | r, ins[i]) for r in range(rounds) for i in range(num_inputs)
66 ]
67
68 # Keep the number of in-flight (written-but-not-yet-read) messages strictly
69 # below the output FIFO depth. This keeps a couple of inputs backlogged at
70 # once (so the round-robin arbiter has to choose between them) while
71 # avoiding the write-a-burst-before-reading deadlock.
72 sent: list[int] = []
73 recv: list[int] = []
74 wi = 0
75 while len(recv) < len(writes):
76 while wi < len(writes) and (len(sent) - len(recv)) < max_in_flight:
77 value, port = writes[wi]
78 port.write(value)
79 sent.append(value)
80 wi += 1
81 recv.append(out.read().result())
82
83 assert sorted(recv) == sorted(sent), \
84 "arbiter dropped, duplicated or corrupted a value"
85
86 # Every input (decoded by v >> 16) is served exactly `rounds` times.
87 by_src = Counter(v >> 16 for v in recv)
88 for i in range(num_inputs):
89 assert by_src[i] == rounds, \
90 f"input {i} served {by_src[i]} times, expected {rounds}"
91
92
93@cosim_test(HW_DIR / "channel_arbiter.py")
95
96 def test_mux_correctness(self, conn: AcceleratorConnection) -> None:
97 """Balanced (power-of-two) input count: every value appears once."""
98 _check_mux(conn, "arbiter_test", NUM_INPUTS)
99
101 conn: AcceleratorConnection) -> None:
102 """Unbalanced (non-power-of-two) input count: the array-indexed mux over
103 N < 2**clog2(N) elements and the round-robin wrap still deliver every
104 value exactly once."""
105 _check_mux(conn, "arbiter_test_odd", ODD_NUM_INPUTS)
106
107 def test_mux_correctness_pipelined(self, conn: AcceleratorConnection) -> None:
108 """Pipelined selection mux tree: the multi-cycle mux latency (absorbed by a
109 deeper output FIFO + credit counter) must still deliver every value exactly
110 once."""
111 _check_mux(conn, "arbiter_test_pipe", PIPE_NUM_INPUTS)
112
113 def test_mux_correctness_wide(self, conn: AcceleratorConnection) -> None:
114 """Wide fan-in: the BSP instantiates arbiters with ~31 inputs, a regime the
115 small counts above never reach. Every value must still be delivered exactly
116 once and every input served."""
117 _check_mux(conn, "arbiter_test_wide", WIDE_NUM_INPUTS)
118
119 def test_mux_correctness_scheduled(self, conn: AcceleratorConnection) -> None:
120 """Decoupled grant-queue scheduler at wide fan-in: grants are chosen ahead
121 of time and buffered, so this exercises the queue, the sweep reload and the
122 stale-entry skip. Delivery must still be exactly-once and every input
123 served."""
124 _check_mux(conn, "arbiter_test_sched", WIDE_NUM_INPUTS)
125
127 self, conn: AcceleratorConnection) -> None:
128 """The scheduler with a non-power-of-two input count: the one-hot->index
129 encode and the sweep must not produce an out-of-range grant."""
130 _check_mux(conn, "arbiter_test_sched_odd", ODD_NUM_INPUTS)
131
133 conn: AcceleratorConnection) -> None:
134 """`wide_fanin` structures, with a throttled output so credits run out."""
135 _check_mux(conn, "arbiter_test_widefanin", WIDE_FANIN_NUM_INPUTS, 8)
136
138 self, conn: AcceleratorConnection) -> None:
139 """As above, under the grant-queue scheduler."""
140 _check_mux(conn, "arbiter_test_widefanin_sched", WIDE_FANIN_NUM_INPUTS, 8)
141
143 self, conn: AcceleratorConnection) -> None:
144 """`wide_fanin=True` below the threshold."""
145 _check_mux(conn, "arbiter_test_forced_on", NUM_INPUTS)
146
148 self, conn: AcceleratorConnection) -> None:
149 """`wide_fanin=False` above the threshold."""
150 _check_mux(conn, "arbiter_test_forced_off", WIDE_FANIN_NUM_INPUTS)
151
152 def test_list_contiguity(self, conn: AcceleratorConnection) -> None:
153 """Contending multi-flit list messages are never interleaved."""
154 self._check_contiguity(conn, "list_test", {1, 2})
155
156 def test_list_contiguity_scheduled(self, conn: AcceleratorConnection) -> None:
157 """Message atomicity under the decoupled grant-queue scheduler, with more
158 contending producers than the grant queue is deep."""
159 self._check_contiguity(conn, "list_test_sched", set(range(1, 7)))
160
162 conn: AcceleratorConnection) -> None:
163 """Contiguity with `wide_fanin` forced on."""
164 self._check_contiguity(conn, "list_test_widefanin", {1, 2})
165
167 self, conn: AcceleratorConnection) -> None:
168 """As above, under the grant-queue scheduler."""
169 self._check_contiguity(conn, "list_test_widefanin_sched", set(range(1, 7)))
170
171 @staticmethod
172 def _check_contiguity(conn: AcceleratorConnection, dut_name: str,
173 expected_src: set[int]) -> None:
174 acc = conn.build_accelerator()
175 dut = acc.children[esiaccel.AppID(dut_name)]
176 report = dut.ports[esiaccel.AppID("report")]
177 report.connect()
178
179 seen_src: set[int] = set()
180 # Enough reports to see every producer served several times over.
181 num_reports = 20 * len(expected_src)
182 for _ in range(num_reports):
183 value = report.read().result()
184 err = (value >> 24) & 0x1
185 src = (value >> 16) & 0xff
186 assert err == 0, \
187 f"hardware detected interleaved list flits (report {value:#010x})"
188 seen_src.add(src)
189
190 # Every contending producer must get through -- i.e. no starvation.
191 assert seen_src == expected_src, \
192 f"expected sources {sorted(expected_src)}, saw {sorted(seen_src)}"
193
194 def test_token_conservation(self, conn: AcceleratorConnection) -> None:
195 """Zero-width (`i0`) token payloads: the credit-counter-as-buffer path
196 delivers every token exactly once (no loss/duplication/reorder) and serves
197 every producer -- the total is only reachable if no producer is starved."""
198 acc = conn.build_accelerator()
199 dut = acc.children[esiaccel.AppID("token_test")]
200 report = dut.ports[esiaccel.AppID("token_report")]
201 report.connect()
202
203 total = TOKEN_NUM_INPUTS * TOKENS_PER_INPUT
204 for expected in range(1, total + 1):
205 value = report.read().result()
206 assert value == expected, \
207 f"token {expected} arrived as {value} (loss / duplication / reorder)"
208
209 def test_throughput_flat(self, conn: AcceleratorConnection) -> None:
210 """The flat round-robin arbiter sustains ~one beat per cycle."""
211 self._check_throughput(conn, "throughput_test")
212
213 def test_throughput_scheduled(self, conn: AcceleratorConnection) -> None:
214 """The decoupled grant-queue scheduler must sustain ~one beat per cycle
215 too. Regression test: reloading the sweep snapshot a cycle after it drains
216 (rather than on the cycle the last entry is queued) costs one idle cycle
217 per sweep, which caps throughput at `n/(n+1)` -- 0.8 here. Correctness
218 tests do not notice that, only this one does.
219
220 The producers emit **single-flit** messages deliberately: with multi-flit
221 lists the datapath keeps streaming while the sweep refills, which hides the
222 bubble entirely (measured: no loss at list length >= 2)."""
223 self._check_throughput(conn, "throughput_test_sched")
224
225 @staticmethod
226 def _check_throughput(conn: AcceleratorConnection, dut_name: str) -> None:
227 acc = conn.build_accelerator()
228 dut = acc.children[esiaccel.AppID(dut_name)]
229 report = dut.ports[esiaccel.AppID("throughput_report")]
230 report.connect()
231
232 beats = report.read().result()
233 throughput = beats / THROUGHPUT_WINDOW
234 # Allow for pipeline fill at the start of the window; well above the 0.8
235 # a per-sweep bubble would produce with this input count.
236 assert throughput >= 0.95, (
237 f"{dut_name}: {beats} beats in {THROUGHPUT_WINDOW} cycles "
238 f"({throughput:.3f}/cycle); expected >= 0.95. A throughput of about "
239 f"{THROUGHPUT_NUM_INPUTS / (THROUGHPUT_NUM_INPUTS + 1):.3f} means the "
240 "scheduler is losing a cycle per sweep.")
static mlir::Operation * resolve(Context &context, mlir::SymbolRefAttr sym)
None _check_contiguity(AcceleratorConnection conn, str dut_name, set[int] expected_src)
None test_list_contiguity_wide_fanin(self, AcceleratorConnection conn)
None test_mux_correctness_pipelined(self, AcceleratorConnection conn)
None test_mux_correctness_wide_fanin_scheduled(self, AcceleratorConnection conn)
None _check_throughput(AcceleratorConnection conn, str dut_name)
None test_mux_correctness_scheduled_unbalanced(self, AcceleratorConnection conn)
None test_mux_correctness_scheduled(self, AcceleratorConnection conn)
None test_mux_correctness_unbalanced(self, AcceleratorConnection conn)
None test_list_contiguity_wide_fanin_scheduled(self, AcceleratorConnection conn)
None test_mux_correctness_wide_fanin_forced_off(self, AcceleratorConnection conn)
None test_mux_correctness_wide_fanin_forced_on(self, AcceleratorConnection conn)
None test_mux_correctness_wide_fanin(self, AcceleratorConnection conn)
None test_list_contiguity_scheduled(self, AcceleratorConnection conn)
None _check_mux(AcceleratorConnection conn, str dut_name, int num_inputs, int max_in_flight=2)