CIRCT 24.0.0git
Loading...
Searching...
No Matches
channel_arbiter.py
Go to the documentation of this file.
1# ===- channel_arbiter.py - pipelined list-aware channel mux -------------===//
2#
3# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
4# See https://llvm.org/LICENSE.txt for license information.
5# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
6#
7# ===----------------------------------------------------------------------===//
8#
9# A high-performance, pipelined, list-aware N:1 ESI channel multiplexer. See
10# `docs/components/ChannelArbiter.md` for the design details.
11#
12# ===----------------------------------------------------------------------===//
13
14from typing import List, Optional, Tuple
15
16from pycde import AppID, Clock, Input, Module, Output, Reset, generator
17from pycde.constructs import Counter, Mux, Reg, Wire
18from pycde.esi import Telemetry
19from pycde.module import modparams
20from pycde.seq import FIFO as SeqFIFO
21from pycde.signals import (BitsSignal, ChannelSignal, ClockSignal, Or, Signal)
22from pycde.support import clog2
23from pycde.types import (Array, Bits, Channel, ChannelSignaling, StructType,
24 UInt, Window)
25
26# Default for `wide_fanin`: above this many inputs, register per-input `ready`
27# and use one-hot loop selections. Measured at 356 bits: +10 MHz at 35 inputs,
28# -12 MHz at 5-8. A proxy -- the `ready` fan-out scales with `num_inputs * width`.
29_WIDE_FANIN_THRESHOLD = 16
30
31
32def _select_reg_levels(num_inputs: int,
33 mux_pipeline_levels: Optional[int]) -> List[int]:
34 """Tree levels after which `_select_mux` inserts a pipeline register.
35
36 A register is placed after every `mux_pipeline_levels` levels, except after
37 the final (root) level -- its result is registered downstream. This is the
38 single source of truth for the mux-tree pipelining: `_select_mux` builds the
39 registers at these levels and `_select_latency` just counts them."""
40 if num_inputs <= 1 or not mux_pipeline_levels:
41 return []
42 gw = clog2(num_inputs)
43 return [
44 level for level in range(gw)
45 if (level + 1) % mux_pipeline_levels == 0 and level < gw - 1
46 ]
47
48
49def _select_latency(num_inputs: int, mux_pipeline_levels: Optional[int]) -> int:
50 """Pipeline-register latency (cycles) that `_select_mux` inserts."""
51 return len(_select_reg_levels(num_inputs, mux_pipeline_levels))
52
53
54def _select_mux(sel: BitsSignal, values: List[BitsSignal], clk: ClockSignal,
55 rst: Signal, mux_pipeline_levels: Optional[int]) -> BitsSignal:
56 """Return `values[sel]`.
57
58 With `mux_pipeline_levels` falsy this is a flat combinational mux (a single
59 `hw.array_get`, which CIRCT lowers to an unpipelined mux tree). Otherwise it
60 is built as an explicit balanced binary mux tree -- 2:1 nodes consuming one
61 `sel` bit per level -- with a pipeline register inserted after every
62 `mux_pipeline_levels` levels. This lets a large/wide selection mux (the
63 Fmax bottleneck of a big fan-in mux) be retimed across registers. The
64 remaining `sel` bits are pipelined alongside the partial results so each
65 level selects with the correctly-delayed index. The added latency is
66 `_select_latency(len(values), mux_pipeline_levels)` cycles."""
67 n = len(values)
68 if n == 1:
69 return values[0]
70 if not mux_pipeline_levels:
71 return Mux(sel, *values)
72 gw = clog2(n)
73 reg_levels = set(_select_reg_levels(n, mux_pipeline_levels))
74 # Pad to a full 2**gw-leaf tree; padded leaves carry a never-selected copy
75 # (the index is always < n).
76 cur = list(values) + [values[0]] * ((1 << gw) - n)
77 rem = sel
78 for level in range(gw):
79 bit = rem[0]
80 cur = [Mux(bit, cur[2 * i], cur[2 * i + 1]) for i in range(len(cur) // 2)]
81 if rem.type.width > 1:
82 rem = rem[1:]
83 if level in reg_levels:
84 cur = [c.reg(clk) for c in cur]
85 rem = rem.reg(clk)
86 return cur[0]
87
88
89def _onehot_to_index(onehot: BitsSignal) -> BitsSignal:
90 """Encode a one-hot bit-vector to its binary index. Bit `b` of the result is
91 the OR of the one-hot bits whose index has bit `b` set."""
92 num_inputs = onehot.type.width
93 bits = []
94 for b in range(clog2(num_inputs)):
95 terms = [onehot[i] for i in range(num_inputs) if (i >> b) & 1]
96 bits.append(Or(*terms) if terms else Bits(1)(0))
97 return BitsSignal.concat(list(reversed(bits)))
98
99
100# Grant-control strategies. `GrantSchedulerMod` and `RoundRobinControlMod` are
101# interchangeable: they deliberately carry the *same* port signature, documented
102# per-port on `GrantScheduler` below, so the arbiter picks one and wires it up
103# identically. (PyCDE scans only a class's own dict for ports, so the signature
104# cannot be inherited from a common base -- it is spelled out in each and must be
105# kept in sync.) `launch` is unused by the round-robin strategy; it is present so
106# the signature stays uniform.
107#
108# A control module owns the grant FSM state (`grant`/`grant_oh`/`busy`, plus
109# whatever else the strategy needs) and exposes it for the datapath to read. Its
110# inputs are purely observations of the datapath: which inputs are offering
111# (`valids`), and whether a flit / a final flit was accepted (`launch`,
112# `msg_end`). `_build_grant_state` below builds the state common to both.
113
114
116 ports, clk: ClockSignal, rst: Signal, num_inputs: int,
117 next_grant: BitsSignal,
118 next_busy: BitsSignal) -> Tuple[BitsSignal, BitsSignal, BitsSignal]:
119 """Register `next_grant`/`next_busy` into the grant FSM state every control
120 module has, drive the `grant`/`grant_oh`/`busy` ports with it, and return
121 `(grant, grant_oh, busy)` for the strategy to compute its next state from
122 (typically via `Wire`s, since next state depends on current).
123
124 `grant_oh` is decoded *ahead* of its registers -- one flop per input -- so
125 each high-fanout per-input grant is driven straight from a flop rather than a
126 shared combinational decode of `grant`. Decoding at the instantiation site
127 would necessarily land after the register, hence it lives here. Both are fed
128 from the same next-state, so `grant_oh[i]` is high exactly when
129 `grant == i`."""
130 gw = clog2(num_inputs)
131 grant = next_grant.reg(clk, rst, name="grant")
132 next_grant_oh_bits = [(next_grant == Bits(gw)(i)) for i in range(num_inputs)]
133 grant_oh = BitsSignal.concat([
134 next_grant_oh_bits[i].reg(clk,
135 rst,
136 rst_value=(1 if i == 0 else 0),
137 name=f"grant_oh_{i}")
138 for i in reversed(range(num_inputs))
139 ])
140 busy = next_busy.reg(clk, rst, name="busy")
141 ports.grant = grant
142 ports.grant_oh = grant_oh
143 ports.busy = busy
144 # Next-state values for the datapath's registered `ready`. Always exported:
145 # they already feed the flops above, so this costs no logic.
146 ports.next_grant_oh = BitsSignal.concat(list(reversed(next_grant_oh_bits)))
147 ports.next_busy = next_busy
148 return grant, grant_oh, busy
149
150
151@modparams
152def GrantSchedulerMod(num_inputs: int, queue_depth: int):
153 """Decoupled, pipelinable grant scheduler (`pipelined_scheduler=True`).
154
155 A **grant queue** holds upcoming winners for the datapath to pop, and a
156 **sweep scheduler** refills it off the critical path. That breaks the flat
157 arbiter's single-cycle `grant -> grant` loop, its dominant timing limiter at
158 high fan-in. A queued entry is a hint about who to serve next, not a promise
159 that a particular message is waiting: an entry whose input has since gone
160 idle is skipped in one cycle (`stale` below) rather than stalling the output.
161
162 Consequently service order is best-effort, and `queue_depth` bounds only how
163 far ahead of the datapath decisions are committed -- it is not a fairness
164 knob. See section 7.1 of `docs/components/ChannelArbiter.md` for why
165 committing early is safe and for the full ordering/latency caveats."""
166 assert num_inputs >= 2, "GrantSchedulerMod requires at least two inputs"
167 gw = clog2(num_inputs)
168
169 class GrantScheduler(Module):
170 clk = Clock()
171 rst = Reset()
172
173 # Per-input `valid`; bit `i` is high when input `i` is offering a flit.
174 valids = Input(Bits(num_inputs))
175 # High on cycles a flit is accepted from the granted input.
176 launch = Input(Bits(1))
177 # High on the `launch` of a message's final flit.
178 msg_end = Input(Bits(1))
179
180 # Index of the currently granted input.
181 grant = Output(Bits(gw))
182 # `grant` pre-decoded to one-hot, one register per bit.
183 grant_oh = Output(Bits(num_inputs))
184 # High while `grant` is in force, i.e. an input is currently being served.
185 busy = Output(Bits(1))
186 # High on cycles the grant is (re)loaded from the queue; telemetry only.
187 switch = Output(Bits(1))
188 # Next-state `grant_oh`/`busy`; used only when `wide_fanin`.
189 next_grant_oh = Output(Bits(num_inputs))
190 next_busy = Output(Bits(1))
191
192 @generator
193 def build(ports) -> None:
194 clk = ports.clk
195 rst = ports.rst
196 next_grant = Wire(Bits(gw), "next_grant")
197 next_busy = Wire(Bits(1), "next_busy")
198 grant, grant_oh, busy = _build_grant_state(ports, clk, rst, num_inputs,
199 next_grant, next_busy)
200
201 # Grant queue. `rd_latency=0` makes it show-ahead, so `q_head` is a
202 # registered value available the same cycle -- popping adds no bubble.
203 gq = SeqFIFO(Bits(gw), queue_depth, clk, rst)
204 q_pop = Wire(Bits(1), "gq_pop")
205 q_head = gq.pop(q_pop)
206 q_nonempty = ~gq.empty
207
208 # ---- Sweep scheduler (off the datapath's critical path). ----
209 pending = Reg(Bits(num_inputs),
210 clk,
211 rst,
212 rst_value=0,
213 name="sched_pending")
214 pend_nonzero = pending != Bits(num_inputs)(0)
215 # Isolate the lowest set bit: x & (-x), with -x == ~x + 1.
216 neg_pending = ((~pending).as_uint(num_inputs) +
217 UInt(num_inputs)(1)).as_bits(num_inputs)
218 low = pending & neg_pending
219 push = pend_nonzero & ~gq.full
220 gq.push(_onehot_to_index(low), push)
221
222 # Clear the bit just scheduled (or hold if the queue is full), and reload
223 # the snapshot as soon as the sweep is exhausted. The reload has to happen
224 # on the *same* cycle the last bit is pushed: deferring it to the cycle
225 # after `pending` reads zero costs one idle cycle per sweep, capping
226 # throughput at `n/(n+1)` for `n` concurrently-active inputs. That only
227 # bites for single-flit messages; with multi-flit lists the datapath is
228 # still streaming the current message while the sweep refills, so the
229 # bubble is hidden.
230 cleared = pending & ~low
231 sweep_done = push & (cleared == Bits(num_inputs)(0))
232 pending.assign(
233 Mux(~pend_nonzero | sweep_done, Mux(push, pending, cleared),
234 ports.valids))
235
236 # ---- Datapath grant FSM. ----
237 # `started` distinguishes "this grant has not delivered a flit yet" (safe
238 # to abandon) from "mid-message" (abandoning would split the message).
239 started = Reg(Bits(1), clk, rst, rst_value=0, name="grant_started")
240 sel_valid_now = (ports.valids & grant_oh).or_reduce()
241 # Abandon a grant that has not yet delivered a flit and whose input is not
242 # offering one, but only when there is someone else to serve.
243 stale = busy & ~started & ~sel_valid_now & q_nonempty
244 advance = ports.msg_end | stale
245 take_next = ~busy | advance
246 q_pop.assign(take_next & q_nonempty)
247
248 next_grant.assign(Mux(take_next & q_nonempty, grant, q_head))
249 next_busy.assign(Mux(take_next, busy, q_nonempty))
250 # Taking a new grant clears `started`; otherwise the first launched flit
251 # sets it.
252 started_next = Mux(ports.launch, started, Bits(1)(1))
253 started.assign(Mux(take_next, started_next, Bits(1)(0)))
254
255 # The grant is replaced by a queued decision exactly when it is popped.
256 ports.switch = q_pop
257
258 return GrantScheduler
259
260
261@modparams
262def RoundRobinArbiterMod(num_inputs: int):
263 """Combinational round-robin winner selection, factored into its own module
264 for waveform visibility.
265
266 Given a per-input `valids` bitmask (bit `i` is input `i`) and a `start` index,
267 `winner` is the lowest-index input that is valid and at index `>= start`
268 (cyclically), falling back to the lowest-index valid input overall; `any_valid`
269 is high when any input is valid. Purely combinational -- the owning state
270 (`rr_ptr`, `grant`/`busy`) lives in `RoundRobinControlMod`."""
271 assert num_inputs >= 2, "RoundRobinArbiterMod requires at least two inputs"
272 gw = clog2(num_inputs)
273
274 class RoundRobinArbiter(Module):
275 valids = Input(Bits(num_inputs))
276 start = Input(Bits(gw))
277 winner = Output(Bits(gw))
278 any_valid = Output(Bits(1))
279
280 @generator
281 def build(ports) -> None:
282
283 def priority_lsb(
284 bits_list: List[BitsSignal]) -> Tuple[BitsSignal, BitsSignal]:
285 """Index of the lowest-index set bit, plus an any-set flag, computed as
286 a balanced binary tree (O(log N) depth) rather than an O(N) chain. Each
287 node combines two subtrees, giving priority to the lower index, and
288 prefixes the selected sub-index with the branch bit."""
289 # Leaves carry (any, sub-index); pad up to 2**gw with never-set leaves
290 # so the tree is perfect and each level consumes one index bit.
291 level = [(b, None) for b in bits_list]
292 level += [(Bits(1)(0), None) for _ in range((1 << gw) - len(bits_list))]
293 width = 0
294 while len(level) > 1:
295 nxt = []
296 for j in range(0, len(level), 2):
297 la, li = level[j]
298 ra, ri = level[j + 1]
299 # The lower-index (left) subtree wins if it has any set bit.
300 take_right = ~la
301 if width == 0:
302 idx = take_right
303 else:
304 idx = BitsSignal.concat([take_right, Mux(take_right, li, ri)])
305 nxt.append((la | ra, idx))
306 level = nxt
307 width += 1
308 idx = level[0][1]
309 return (idx if idx is not None else Bits(gw)(0)), level[0][0]
310
311 valid_bits = [ports.valids[i] for i in range(num_inputs)]
312 start_u = ports.start.as_uint(gw)
313 # Winner among inputs at-or-after `start`, else the lowest-index winner.
314 hi = [valid_bits[i] & (UInt(gw)(i) >= start_u) for i in range(num_inputs)]
315 hi_idx, hi_any = priority_lsb(hi)
316 lo_idx, lo_any = priority_lsb(valid_bits)
317 ports.winner = Mux(hi_any, lo_idx, hi_idx)
318 ports.any_valid = hi_any | lo_any
319
320 return RoundRobinArbiter
321
322
323@modparams
324def RoundRobinControlMod(num_inputs: int):
325 """Flat round-robin grant control (the default strategy).
326
327 Answers "who is granted next?" combinationally in the cycle the current
328 message ends, using two `RoundRobinArbiter` instances -- one for picking up
329 from idle, one for the message-end turnaround -- plus the `rr_ptr` fairness
330 pointer, which is private to this strategy. See section 7 of
331 `docs/components/ChannelArbiter.md`.
332
333 `launch` is unused; it exists only to match `GrantSchedulerMod`'s
334 signature."""
335 assert num_inputs >= 2, "RoundRobinControlMod requires at least two inputs"
336 gw = clog2(num_inputs)
337
338 class RoundRobinControl(Module):
339 clk = Clock()
340 rst = Reset()
341
342 valids = Input(Bits(num_inputs))
343 launch = Input(Bits(1))
344 msg_end = Input(Bits(1))
345
346 grant = Output(Bits(gw))
347 grant_oh = Output(Bits(num_inputs))
348 busy = Output(Bits(1))
349 switch = Output(Bits(1))
350 # Next-state `grant_oh`/`busy`; used only when `wide_fanin`.
351 next_grant_oh = Output(Bits(num_inputs))
352 next_busy = Output(Bits(1))
353
354 @generator
355 def build(ports) -> None:
356 clk = ports.clk
357 rst = ports.rst
358 next_grant = Wire(Bits(gw), "next_grant")
359 next_busy = Wire(Bits(1), "next_busy")
360 grant, grant_oh, busy = _build_grant_state(ports, clk, rst, num_inputs,
361 next_grant, next_busy)
362 rr_ptr = Reg(Bits(gw), clk, rst, name="rr_ptr")
363 rr_arbiter = RoundRobinArbiterMod(num_inputs)
364
365 def round_robin(valids_vec: BitsSignal, start: BitsSignal,
366 name: str) -> Tuple[BitsSignal, BitsSignal]:
367 """Instantiate a RoundRobinArbiter over `valids_vec` starting from
368 `start`."""
369 inst = rr_arbiter(valids=valids_vec, start=start, instance_name=name)
370 return inst.winner, inst.any_valid
371
372 grant_u = grant.as_uint(gw)
373 is_last_idx = grant == Bits(gw)(num_inputs - 1)
374 grant_p1 = Mux(is_last_idx, (grant_u + UInt(gw)(1)).as_bits(gw),
375 Bits(gw)(0))
376
377 winner_idle, any_idle = round_robin(ports.valids, rr_ptr, "rr_idle")
378 # At a message end the just-consumed input still asserts `valid` this
379 # cycle (the flit is consumed on the clock edge), so mask it out of the
380 # re-arbitration. Otherwise the round-robin wrap-around would
381 # speculatively re-grant that stale valid and the FSM would get stuck
382 # `busy` on an input that goes empty next cycle. A genuinely backlogged
383 # input is re-selected on the following idle cycle instead.
384 valids_next = ports.valids & ~grant_oh
385 winner_next, any_next = round_robin(valids_next, grant_p1, "rr_next")
386
387 pick = ~busy & any_idle
388 reend = busy & ports.msg_end
389 grant_if_not_reend = Mux(pick, grant, winner_idle)
390 busy_if_not_reend = Mux(pick, busy, Bits(1)(1))
391
392 next_grant.assign(Mux(reend, grant_if_not_reend, winner_next))
393 next_busy.assign(Mux(reend, busy_if_not_reend, any_next))
394 rr_ptr.assign(Mux(reend, rr_ptr, grant_p1))
395 ports.switch = pick | (reend & any_next)
396
397 return RoundRobinControl
398
399
400@modparams
401def ChannelArbiterMod(channel_type: Channel,
402 num_inputs: int,
403 output_fifo_depth: int,
404 buffer_inputs: bool,
405 telemetry: bool,
406 mux_pipeline_levels: Optional[int],
407 pipelined_scheduler: bool,
408 grant_queue_depth: int,
409 wide_fanin: Optional[bool] = None):
410 """Build a pipelined, list-aware N:1 channel multiplexer module. See the
411 `ChannelArbiter` convenience function for the user-facing entry point and
412 `docs/components/ChannelArbiter.md` for the design."""
413
414 assert num_inputs >= 2, "ChannelArbiterMod requires at least two inputs"
415 if wide_fanin is None:
416 wide_fanin = num_inputs > _WIDE_FANIN_THRESHOLD
417 inner = channel_type.inner_type
418
419 # Determine the bit width of the datapath and whether the payload is a list
420 # window (which carries a per-flit 'last' field).
421 is_window = isinstance(inner, Window)
422 if is_window:
423 lowered = inner.lowered_type
424 field_names = [n for n, _ in lowered.fields] if isinstance(
425 lowered, StructType) else None
426 if field_names is None or "last" not in field_names:
427 raise TypeError(
428 "ChannelArbiter can only auto-detect list framing for window types "
429 "whose lowered frame is a struct with a 'last' field; got lowered "
430 f"type {lowered}. (Serial/union-framed windows are not supported.)")
431 width = lowered.bitwidth
432 else:
433 width = inner.bitwidth
434 if width is None:
435 raise TypeError(
436 f"ChannelArbiter requires a fixed-width payload; got {inner}")
437
438 # The FIFO beat is just the raw payload bits (for list/window payloads the
439 # per-flit 'last' flag is already part of them). A zero-width (token) payload
440 # carries no data, so it has no beat/FIFO at all -- the output stage uses an
441 # outstanding-beat counter instead (SeqFIFO also requires a non-zero width).
442 beat_type = Bits(width)
443
444 # Input-index width. (The credit-counter width depends on the resolved
445 # output-FIFO depth and is computed in the generator.)
446 gw = clog2(num_inputs)
447
448 # Latency (cycles) added when the selection mux is pipelined into a tree.
449 tree_latency = (0 if width == 0 else _select_latency(num_inputs,
450 mux_pipeline_levels))
451 # One register latches the mux result before the FIFO, so the total
452 # launch-to-FIFO pipeline latency is the mux-tree latency plus one.
453 pipe_latency = tree_latency + 1
454 if output_fifo_depth is not None and \
455 output_fifo_depth <= pipe_latency:
456 raise ValueError(
457 f"output_fifo_depth ({output_fifo_depth}) must be > the pipeline "
458 f"latency ({pipe_latency})")
459
460 class ChannelArbiterImpl(Module):
461 # Extra output-FIFO depth over the pipeline length, covering the credit
462 # round-trip; private and class-scoped.
463 _SLACK = 2
464
465 clk = Clock()
466 rst = Reset()
467
468 inputs = Input(Array(channel_type, num_inputs))
469 output = Output(channel_type)
470
471 @generator
472 def build(ports) -> None:
473 # Resolve the output-FIFO depth (defaulting from the private,
474 # class-scoped `_SLACK`, and covering the pipeline latency) and the
475 # credit-counter width.
476 depth = (pipe_latency + ChannelArbiterImpl._SLACK
477 if output_fifo_depth is None else output_fifo_depth)
478 cw = max(1, depth.bit_length())
479 clk = ports.clk
480 rst = ports.rst
481
482 def flit_last(typed_sig: Signal) -> BitsSignal:
483 """High when 'typed_sig' is the last flit of its message."""
484 if is_window:
485 return typed_sig.unwrap()["last"]
486 return Bits(1)(1)
487
488 def to_bits(typed_sig: Signal) -> BitsSignal:
489 """Bitcast the payload to raw bits for the datapath."""
490 if is_window:
491 typed_sig = typed_sig.unwrap()
492 return typed_sig.bitcast(Bits(width))
493
494 def from_bits(bits: BitsSignal) -> Signal:
495 """Reconstruct the payload from raw bits for the output channel."""
496 if is_window:
497 return inner.wrap(bits.bitcast(inner.lowered_type))
498 return bits.bitcast(inner)
499
500 # ---- Arbiter state. `grant`/`grant_oh`/`busy` are owned and registered
501 # by the grant-control module instantiated below; these wires forward-
502 # declare them because the input stage reads them first. ----
503 grant = Wire(Bits(gw), "grant")
504 grant_oh = Wire(Bits(num_inputs), "grant_oh")
505 busy = Wire(Bits(1), "busy")
506 if wide_fanin:
507 next_grant_oh = Wire(Bits(num_inputs), "next_grant_oh")
508 next_busy = Wire(Bits(1), "next_busy_dp")
509 next_credit_gt0 = Wire(Bits(1), "next_credit_gt0")
510 credit = Reg(UInt(cw), clk, rst, rst_value=depth, name="credit")
511
512 credit_gt0 = credit > UInt(cw)(0)
513 if wide_fanin:
514 credit_gt1 = credit > UInt(cw)(1)
515
516 # ---- Inputs: optional skid buffer, then unwrap with a local ready. ----
517 valids: List[BitsSignal] = []
518 last_bits: List[BitsSignal] = []
519 data_bits: List[BitsSignal] = []
520 for i in range(num_inputs):
521 chan = ports.inputs[i]
522 if buffer_inputs:
523 chan = chan.buffer(clk, rst, stages=1)
524 # ready[i]: consume only the granted input, and only when a credit is
525 # available. Independent of valid, so no combinational ready loop.
526 # With `wide_fanin` it is registered per input from next-state values
527 # (bit-identical), so the shared terms drive `num_inputs` flops rather
528 # than `num_inputs * width` skid-buffer enables.
529 if wide_fanin:
530 ready_i = (next_busy & next_grant_oh[i] & next_credit_gt0).reg(
531 clk, rst, rst_value=0, name=f"ready_{i}")
532 else:
533 ready_i = busy & grant_oh[i] & credit_gt0
534 data_i, valid_i = chan.unwrap(ready_i)
535 valids.append(valid_i)
536 last_bits.append(flit_last(data_i))
537 data_bits.append(to_bits(data_i))
538
539 # ---- Select the granted input. ----
540 # `launch`/`msg_end` sit inside the `grant -> grant` loop. With
541 # `wide_fanin`, select by one-hot reduction against `grant_oh` (equal to
542 # `valids[grant]`, since `grant_oh[i]` is exactly `grant == i`): the ANDs
543 # fold into the OR tree and `msg_end` no longer stacks on `launch`.
544 if wide_fanin:
545 go = busy & credit_gt0
546 sel_valid = Or(*[valids[i] & grant_oh[i] for i in range(num_inputs)])
547 sel_valid_last = Or(
548 *
549 [valids[i] & last_bits[i] & grant_oh[i] for i in range(num_inputs)])
550 else:
551 sel_valid = Mux(grant, *valids)
552 sel_last = Mux(grant, *last_bits)
553 if width == 0:
554 sel_bits = Bits(0)(0)
555 else:
556 sel_bits = _select_mux(grant, data_bits, clk, rst, mux_pipeline_levels)
557
558 # A beat is launched into the pipeline when the granted input is valid and
559 # a credit is available.
560 if wide_fanin:
561 launch = go & sel_valid
562 msg_end = go & sel_valid_last
563 else:
564 launch = busy & sel_valid & credit_gt0
565 msg_end = launch & sel_last
566
567 # ---- Output stage (feed-forward, no backpressure). ----
568 # `pop` returns to the arbiter only through the registered credit counter,
569 # so the datapath never stalls. The zero-width case is the datapath case
570 # minus the data: no pipeline and no FIFO -- the credit counter itself is
571 # the token buffer, and a token is available whenever one is in flight.
572 if width == 0:
573 out_valid = credit < UInt(cw)(depth) # in-flight (depth - credit) > 0
574 payload_bits = Bits(0)(0)
575 fifo_pop = None
576 else:
577 # Delay the launch/valid to match the mux-tree pipeline, add one output
578 # register, then buffer the beat in the FIFO.
579 pipe_valid = launch
580 for _ in range(tree_latency):
581 pipe_valid = pipe_valid.reg(clk, rst)
582 pipe_valid = pipe_valid.reg(clk, rst, name="pipe_valid")
583 # No reset on the beat: it is payload -- not control.
584 pipe_beat = sel_bits.reg(clk, name="pipe_beat")
585 fifo = SeqFIFO(beat_type, depth, clk, rst)
586 fifo.push(pipe_beat, pipe_valid)
587 fifo_pop = Wire(Bits(1), "arb_pop")
588 out_valid = ~fifo.empty
589 payload_bits = fifo.pop(fifo_pop)
590
591 out_chan, out_ready = channel_type.wrap(from_bits(payload_bits),
592 out_valid)
593 ports.output = out_chan
594 pop = out_valid & out_ready
595 if fifo_pop is not None:
596 fifo_pop.assign(pop)
597
598 # ---- Credit accounting: credit = depth - in-flight. ----
599 next_credit = ((credit + pop.as_uint(cw)).as_uint(cw) -
600 launch.as_uint(cw)).as_uint(cw)
601 credit.assign(next_credit)
602 if wide_fanin:
603 # `next_credit > 0` by cases, so it does not wait on the credit adder:
604 # credit moves by at most one per cycle.
605 next_credit_gt0.assign(
606 Mux(pop ^ launch, credit_gt0, Mux(pop, credit_gt1,
607 Bits(1)(1))))
608
609 # ---- Arbitration. ----
610 # Either grant-control strategy presents the same ports, so the only
611 # difference here is which module gets instantiated.
612 ctrl_mod = (GrantSchedulerMod(num_inputs, grant_queue_depth)
613 if pipelined_scheduler else RoundRobinControlMod(num_inputs))
614 ctrl = ctrl_mod(clk=clk,
615 rst=rst,
616 valids=BitsSignal.concat(list(reversed(valids))),
617 launch=launch,
618 msg_end=msg_end,
619 instance_name="arb_ctrl")
620 grant.assign(ctrl.grant)
621 grant_oh.assign(ctrl.grant_oh)
622 busy.assign(ctrl.busy)
623 if wide_fanin:
624 next_grant_oh.assign(ctrl.next_grant_oh)
625 next_busy.assign(ctrl.next_busy)
626 arb_switch = ctrl.switch
627
628 # ---- Telemetry. ----
629 if telemetry:
630 Telemetry.report_signal(clk, rst, AppID("selectedChannel"), grant)
631 Telemetry.report_signal(clk, rst, AppID("busy"), busy)
632
633 for i in range(num_inputs):
634 served = Counter(64)(clk=clk,
635 rst=rst,
636 clear=Bits(1)(0),
637 increment=launch & grant_oh[i])
638 Telemetry.report_signal(clk, rst, AppID(f"grantCount_{i}"),
639 served.out)
640
641 total_flits = Counter(64)(clk=clk,
642 rst=rst,
643 clear=Bits(1)(0),
644 increment=launch)
645 Telemetry.report_signal(clk, rst, AppID("totalFlits"), total_flits.out)
646 total_msgs = Counter(64)(clk=clk,
647 rst=rst,
648 clear=Bits(1)(0),
649 increment=msg_end)
650 Telemetry.report_signal(clk, rst, AppID("totalMessages"),
651 total_msgs.out)
652 arb_switches = Counter(64)(clk=clk,
653 rst=rst,
654 clear=Bits(1)(0),
655 increment=arb_switch)
656 Telemetry.report_signal(clk, rst, AppID("arbSwitches"),
657 arb_switches.out)
658
659 # Max per-message flit count.
660 cur_len = Counter(32)(clk=clk, rst=rst, clear=msg_end, increment=launch)
661 msg_len = (cur_len.out + UInt(32)(1)).as_uint(32)
662 max_len = Reg(UInt(32), clk, rst, rst_value=0, name="max_list_len")
663 is_new_max = msg_end & (msg_len > max_len)
664 max_len.assign(Mux(is_new_max, max_len, msg_len))
665 Telemetry.report_signal(clk, rst, AppID("maxListLen"), max_len)
666
667 # Max output in-flight occupancy (depth - credit).
668 occ = (UInt(cw)(depth) - credit).as_uint(cw)
669 inflight_hw = Reg(UInt(cw), clk, rst, rst_value=0, name="inflight_hw")
670 is_new_hw = occ > inflight_hw
671 inflight_hw.assign(Mux(is_new_hw, inflight_hw, occ))
672 Telemetry.report_signal(clk, rst, AppID("inflightHighWater"),
673 inflight_hw)
674
675 return ChannelArbiterImpl
676
677
678def ChannelArbiter(input_channels: List[ChannelSignal],
679 clk: ClockSignal,
680 rst: Signal,
681 *,
682 appid: Optional[AppID] = None,
683 output_fifo_depth: Optional[int] = None,
684 buffer_inputs: bool = True,
685 mux_pipeline_levels: Optional[int] = None,
686 pipelined_scheduler: bool = False,
687 grant_queue_depth: int = 4,
688 wide_fanin: Optional[bool] = None,
689 telemetry: bool = True) -> ChannelSignal:
690 """Build a pipelined, list-aware N:1 channel multiplexer.
691
692 Unlike the combinational `pycde.esi.ChannelMux`, this is a flat registered
693 round-robin arbiter with a feed-forward output stage (output register + FIFO
694 + credit counter), so it closes timing at high fan-in. It also keeps
695 multi-flit list messages contiguous: once an input is granted, it holds the
696 output until a flit whose 'last' field is set has been transferred. List
697 framing is auto-detected from the channel type (window payloads with a 'last'
698 field); all other payloads are treated as single-flit messages.
699
700 Arguments:
701 input_channels: the channels to multiplex. All must share the same
702 (ValidReady) type.
703 clk, rst: clock and reset.
704 appid: optional `AppID` for the arbiter instance (e.g. to address it or to
705 disambiguate its telemetry in the appid hierarchy).
706 output_fifo_depth: depth of the output FIFO; must be greater than the
707 pipeline latency (one output register plus any selection-mux pipeline
708 latency). Defaults to that plus a small internal slack.
709 buffer_inputs: insert a per-input skid buffer to localize backpressure.
710 mux_pipeline_levels: if set, build the N:1 data-selection mux as an explicit
711 binary tree and insert a pipeline register after every this-many tree
712 levels (1 = register every level). This retimes the wide selection mux
713 for very large fan-in; the added latency is absorbed by the output FIFO /
714 credit counter. `None` (default) uses a flat combinational mux.
715 pipelined_scheduler: decouple grant selection from the datapath using a
716 grant queue fed by a sweep scheduler, instead of re-arbitrating
717 combinationally at each message end. This takes the round-robin tree out
718 of the single-cycle `grant -> grant` loop, which is the Fmax limiter at
719 high fan-in. Changes the service order (see `GrantSchedulerMod`).
720 grant_queue_depth: depth of that grant queue -- how many grant decisions
721 may be committed ahead of the datapath. Must be >= 2: a single entry
722 cannot keep the datapath fed back to back, so every message would cost a
723 refill bubble. This is not a fairness knob; a newly-valid input's wait
724 also scales with the number of concurrently active inputs (see
725 `GrantSchedulerMod`).
726 wide_fanin: timing structures for large fan-in (registered per-input
727 `ready`, one-hot loop selections); behaviour is unchanged. `None`
728 (default) enables them above `_WIDE_FANIN_THRESHOLD` inputs.
729 telemetry: emit telemetry (selected channel, list-length stats, etc.).
730
731 See `docs/components/ChannelArbiter.md`."""
732
733 assert len(input_channels) > 0
734 num_inputs = len(input_channels)
735 if num_inputs == 1:
736 return input_channels[0]
737
738 channel_type = input_channels[0].type
739 for c in input_channels:
740 if c.type != channel_type:
741 raise TypeError("All ChannelArbiter inputs must have the same type; got "
742 f"{channel_type} and {c.type}")
743 if channel_type.signaling != ChannelSignaling.ValidReady:
744 raise TypeError("ChannelArbiter requires ValidReady channels; got "
745 f"{channel_type}")
746
747 if mux_pipeline_levels is not None and mux_pipeline_levels < 1:
748 raise ValueError(
749 f"mux_pipeline_levels must be >= 1, got {mux_pipeline_levels}")
750
751 # Validated here rather than left to the FIFO: a bad depth otherwise surfaces
752 # as a `seq.fifo` verifier error from deep inside the lowering, with no
753 # mention of the knob that caused it. Depth 1 is rejected too -- `push` is
754 # blocked whenever the queue is non-empty, so a single entry can never keep
755 # the datapath fed back to back and every message would cost a refill
756 # bubble, silently undoing the Fmax win the option exists for.
757 if pipelined_scheduler and grant_queue_depth < 2:
758 raise ValueError(f"grant_queue_depth must be >= 2, got {grant_queue_depth}")
759
760 if wide_fanin is None: # resolve here too, for a canonical module name
761 wide_fanin = num_inputs > _WIDE_FANIN_THRESHOLD
762 mod = ChannelArbiterMod(channel_type, num_inputs, output_fifo_depth,
763 buffer_inputs, telemetry, mux_pipeline_levels,
764 pipelined_scheduler, grant_queue_depth, wide_fanin)
765 inputs_array = Array(channel_type, num_inputs)(input_channels)
766 inst = mod(clk=clk, rst=rst, inputs=inputs_array, appid=appid)
767 return inst.output
BitsSignal _onehot_to_index(BitsSignal onehot)
int _select_latency(int num_inputs, Optional[int] mux_pipeline_levels)
List[int] _select_reg_levels(int num_inputs, Optional[int] mux_pipeline_levels)
Tuple[BitsSignal, BitsSignal, BitsSignal] _build_grant_state(ports, ClockSignal clk, Signal rst, int num_inputs, BitsSignal next_grant, BitsSignal next_busy)
BitsSignal _select_mux(BitsSignal sel, List[BitsSignal] values, ClockSignal clk, Signal rst, Optional[int] mux_pipeline_levels)
GrantSchedulerMod(int num_inputs, int queue_depth)