rhdl_ubft/card/fabric.rs
1//! The on-chip fabric of the one-card cluster: what RoCE would carry
2//! between replicas, as an RHDL experiment (D2).
3//!
4//! # What it is, and what it is not
5//!
6//! Each replica sends RDMA writes as a meta beat (opcode, QPN, addresses,
7//! length) and data beats, exactly as it would to the RoCE stack. The fabric
8//! turns each write into AXI writes into the destination replica's HBM
9//! region. It reproduces the parts of RoCE the source audit established
10//! (Atlas, `docs/rhdl_ubft-roce.md`) and nothing more:
11//!
12//! * **The host-programmed connection table.** Replica `i`'s local QPN
13//! `(N−1)·i + r` (`r < N − 1`) reaches peer `j = r` if `r < i`, else
14//! `r + 1`. Any other QPN has no entry on replica `i`: it is not delivered
15//! and counts as `unmapped`. A QPN that has an entry is delivered to that
16//! entry's peer even when the sender meant another. Upstream's kernel
17//! indexes QPNs as `id·N + j`; with `upstream_qpns` set (the replay of
18//! upstream's sends) the fabric counts every write whose actual peer
19//! differs from that meaning as `misrouted`.
20//! * **No loopback.** No QPN reaches the sender itself.
21//! * **Packet boundaries.** A write takes data beats up to the first
22//! `tlast`. Beats past `len` bytes before `tlast` are not written and
23//! count as `oversize`; a `tlast` before `len` bytes ends the write short
24//! (`short_packet`); `len = 0` writes nothing (`zero_length`); a write past
25//! the destination region, or whose HBM address would wrap 64 bits,
26//! counts as `out_of_bounds` and is not written. A runtime `n` outside
27//! `2..=16` or an `id ≥ n` makes every descriptor `bad_config` (nothing
28//! is delivered).
29//! The final beat's strobe covers only the bytes left of `len`.
30//! * **Physical lanes.** AXI write lanes belong to the 64-byte-aligned
31//! address, so the payload stream is re-aligned onto lines: a write at
32//! `raddr` starts `raddr mod 64` bytes into its first line, each line takes
33//! the previous beat's tail and the current beat's head ([`realign`]), and
34//! one extra line carries the last beat's tail when the shift pushes it
35//! past. Shifts are in 4-byte steps (rhdl_ubft addresses words), so a
36//! `raddr` that is not a multiple of 4 is refused (`misaligned`), and a
37//! region base must be 64-byte aligned (else `bad_config`).
38//!
39//! # Receive authorization
40//!
41//! A mapped write is then checked as a whole, before any AXI write, by
42//! Atlas's [`authorize_write`]: the trusted ingress (this link's `id`), the
43//! destination, the requested interval and `W` trusted [`WriteWindow`]s.
44//! A rejected descriptor is drained without writing and counted once, by
45//! the guard's reason: membership (`bad_config`), empty (`zero_length`),
46//! outside the region or wrapping (`out_of_bounds`), or no window
47//! (`unauthorized`). The windows are configuration and change only while
48//! the fabric is drained.
49//!
50//! What the audit left unknown is a labelled assumption of this model, not
51//! a claim about RoCE: the source is the fabric port the write came in on
52//! (never a payload field); one write at a time per source, and the next
53//! starts only when every byte of the previous one is acknowledged by HBM
54//! (B responses), which keeps each source's writes in order and makes a
55//! later flag write land after its payload. That B response is this
56//! model's commit point, not a statement about when real RDMA data is
57//! visible. A fixed `latency` (cycles before a write starts) stands in for
58//! the network.
59
60use rhdl::prelude::*;
61use rhdl_fpga::core::dff::DFF;
62
63use super::authorization::{WriteCheck, WriteWindow, authorize_write};
64use crate::ports::{AxiWrite, AxiWriteResp, Axis256, Axis512};
65
66/// Error and progress counters of one source link.
67#[derive(Clone, Copy, Debug, PartialEq, Digital, Default)]
68pub struct LinkCounters {
69 /// Writes delivered (all their bytes acknowledged).
70 pub delivered: b32,
71 /// Delivered to a different peer than the sender meant (`qpn − id·N`).
72 pub misrouted: b32,
73 /// QPNs with no connection entry (not delivered).
74 pub unmapped: b32,
75 /// `tlast` before `len` bytes.
76 pub short_packet: b32,
77 /// Data beats after `len` bytes and before `tlast` (not written).
78 pub oversize: b32,
79 /// Writes with `len = 0`.
80 pub zero_length: b32,
81 /// Writes reaching past the destination region (not written).
82 pub out_of_bounds: b32,
83 /// Opcodes other than write (not delivered).
84 pub not_write: b32,
85 /// Descriptors dropped because `n` or `id` is out of range.
86 pub bad_config: b32,
87 /// Mapped writes no trusted window allows (not delivered).
88 pub unauthorized: b32,
89 /// Writes whose `raddr` is not a multiple of 4 (not delivered; the
90 /// re-aligner shifts in 4-byte steps, the granularity rhdl_ubft uses).
91 pub misaligned: b32,
92}
93
94/// One source link's inputs.
95#[derive(Clone, Copy, Debug, PartialEq, Digital)]
96pub struct LinkIn<const W: usize> {
97 /// Replicas.
98 pub n: b5,
99 /// This source.
100 pub id: b5,
101 /// Every replica's region base (byte address in HBM).
102 pub bases: [b64; 16],
103 /// Region size in bytes (each replica's).
104 pub region: b64,
105 /// Cycles before a write starts (the network stand-in).
106 pub latency: b16,
107 /// The sender indexes QPNs as upstream does (`id·N + j`): count
108 /// writes that reach a different peer than that meant.
109 pub upstream_qpns: bool,
110 /// Trusted write windows (see the module docs).
111 pub windows: [WriteWindow; W],
112 /// The source's `m_axis_tx_meta`.
113 pub meta: Axis256,
114 /// The source's `m_axis_tx_data`.
115 pub data: Axis512,
116 /// HBM write responses.
117 pub mem: AxiWriteResp,
118}
119
120/// One source link's outputs.
121#[derive(Clone, Copy, Debug, PartialEq, Digital, Default)]
122pub struct LinkOut {
123 /// `tx_meta` `tready`.
124 pub meta_ready: bool,
125 /// `tx_data` `tready`.
126 pub data_ready: bool,
127 /// HBM writes.
128 pub mem: AxiWrite,
129 /// Counters.
130 pub counters: LinkCounters,
131}
132
133/// Link phases.
134const IDLE: b3 = b3(0);
135/// Waiting out the latency.
136const DELAY: b3 = b3(1);
137/// Moving data beats.
138const DATA: b3 = b3(2);
139/// Dropping data beats up to `tlast` (unmapped, not a write, out of bounds).
140const DROP: b3 = b3(3);
141/// Waiting for the last B responses.
142const DRAIN: b3 = b3(4);
143/// Emitting the last line, which holds only the tail of the last beat.
144const FLUSH: b3 = b3(5);
145
146/// A link's registers.
147#[derive(Clone, Copy, Debug, PartialEq, Digital, Default)]
148#[doc(hidden)]
149pub struct LinkState {
150 phase: b3,
151 /// Destination address of the next beat.
152 addr: b64,
153 /// Bytes still to write.
154 left: b32,
155 /// Cycles of latency left.
156 wait: b16,
157 /// The write's `len` was 0.
158 zero: bool,
159 /// AW of the current beat accepted.
160 aw_done: bool,
161 /// W of the current beat accepted.
162 w_done: bool,
163 /// Beat registered for writing.
164 beat_valid: bool,
165 /// Its data.
166 beat_data: [b64; 8],
167 /// Its strobe.
168 beat_strb: b64,
169 /// Its address.
170 beat_addr: b64,
171 /// The beat with `tlast` has been taken.
172 last_seen: bool,
173 /// The write's offset within a 64-byte line, in 4-byte words.
174 shift: b4,
175 /// The previous payload beat (its tail starts the next line).
176 prev_data: [b64; 8],
177 /// Its byte enables (masked to the declared length).
178 prev_keep: b64,
179 /// Bytes of the line in hand that are still inside the write: line `j`
180 /// covers payload bytes `64j − offset ..`, so this is `offset + len −
181 /// 64j` (saturating).
182 rem: b32,
183 /// Write responses outstanding.
184 pending: b8,
185 counters: LinkCounters,
186}
187
188/// One source link (see the module docs).
189#[derive(Clone, Debug, Synchronous, SynchronousDQ)]
190pub struct FabricLink<const W: usize> {
191 state: DFF<LinkState>,
192}
193
194impl<const W: usize> Default for FabricLink<W> {
195 fn default() -> Self {
196 Self { state: DFF::new(LinkState::default()) }
197 }
198}
199
200impl<const W: usize> SynchronousIO for FabricLink<W> {
201 type I = LinkIn<W>;
202 type O = LinkOut;
203 type Kernel = fabric_link_kernel<W>;
204}
205
206/// The strobe of a beat with `left` bytes still to write.
207#[kernel]
208pub fn strobe(left: b32) -> b64 {
209 if left >= b32(64) { !b64(0) } else { (b64(1) << left.resize::<64>()) - b64(1) }
210}
211
212/// One 64-byte line of a re-aligned write: the payload stream shifted up by
213/// `sw` 4-byte words, from the previous beat's tail and the current beat's
214/// head (`sw = 0`: the current beat as it is).
215#[allow(clippy::needless_range_loop)]
216#[kernel]
217pub fn realign(prev: [b64; 8], cur: [b64; 8], sw: b4) -> [b64; 8] {
218 // Thirty-two 32-bit words: previous beat, then current beat.
219 let mut win = [b32(0); 32];
220 for l in 0..8 {
221 win[2 * l] = prev[l].resize();
222 win[2 * l + 1] = (prev[l] >> 32).resize();
223 win[16 + 2 * l] = cur[l].resize();
224 win[16 + 2 * l + 1] = (cur[l] >> 32).resize();
225 }
226 let mut out = [b32(0); 16];
227 for w in 0..16 {
228 for k in 0..16 {
229 if sw == b4(k as u128) {
230 out[w] = win[16 - k + w];
231 }
232 }
233 }
234 let mut lanes = [b64(0); 8];
235 for l in 0..8 {
236 lanes[l] = out[2 * l].resize::<64>() | (out[2 * l + 1].resize::<64>() << 32);
237 }
238 lanes
239}
240
241/// The byte enables of a re-aligned line, shifted like [`realign`].
242#[kernel]
243pub fn realign_keep(prev: b64, cur: b64, sw: b4) -> b64 {
244 let window: b128 = (cur.resize::<128>() << 64) | prev.resize::<128>();
245 let by: b128 = b128(64) - (sw.resize::<128>() << 2);
246 (window >> by).resize()
247}
248
249/// The link's logic.
250#[allow(clippy::too_many_lines)]
251#[kernel]
252pub fn fabric_link_kernel<const W: usize>(
253 _cr: ClockReset,
254 i: LinkIn<W>,
255 q: FabricLinkQ<W>,
256) -> (LinkOut, FabricLinkD<W>) {
257 let r = q.state;
258 let mut s = r;
259 let mut c = r.counters;
260 let mut meta_ready = false;
261 let mut data_ready = false;
262
263 // ------------------------------------------------ write channel
264 // One registered beat at a time: AW and W independently, B counted.
265 let mem = AxiWrite {
266 aw_valid: r.beat_valid && !r.aw_done,
267 aw_addr: r.beat_addr,
268 w_valid: r.beat_valid && !r.w_done,
269 w_data: r.beat_data,
270 w_strb: r.beat_strb,
271 b_ready: true,
272 };
273 let aw_now = mem.aw_valid && i.mem.aw_ready;
274 let w_now = mem.w_valid && i.mem.w_ready;
275 let aw_all = r.aw_done || aw_now;
276 let w_all = r.w_done || w_now;
277 let sent = r.beat_valid && aw_all && w_all;
278 s.aw_done = aw_all && !sent;
279 s.w_done = w_all && !sent;
280 if sent {
281 s.beat_valid = false;
282 }
283 let pending_after = r.pending + (if sent { b8(1) } else { b8(0) }) - (if i.mem.b_valid { b8(1) } else { b8(0) });
284 s.pending = pending_after;
285 let _ = mem;
286
287 // ------------------------------------------------ descriptors
288 if r.phase == IDLE {
289 meta_ready = true;
290 if i.meta.valid {
291 let lane0 = i.meta.data[0];
292 let lane1 = i.meta.data[1];
293 let lane2 = i.meta.data[2];
294 let op: b3 = (lane0 & b64(7)).resize();
295 let qpn: b32 = ((lane0 >> 3) & b64(0xff_ffff)).resize();
296 let raddr: b64 = (lane1 >> 11) & b64(0xffff_ffff_ffff);
297 let len: b32 = ((lane1 >> 59) | ((lane2 & b64(0x7ff_ffff)) << 5)).resize();
298 let n: b32 = i.n.resize();
299 let id: b32 = i.id.resize();
300 // Host-programmed table: (N−1)·id + r reaches peer r or r + 1.
301 let first = (n - b32(1)) * id;
302 let mapped = qpn >= first && qpn < first + n - b32(1);
303 let rank = qpn - first;
304 let dest: b32 = if rank < id { rank } else { rank + b32(1) };
305 // The peer upstream's id·N + j indexing meant.
306 let meant = qpn - n * id;
307 let mut base = b64(0);
308 for k in 0..16 {
309 if dest == b32(k as u128) {
310 base = i.bases[k];
311 }
312 }
313 // The whole interval, checked before any AXI write.
314 let verdict = authorize_write::<W>(
315 WriteCheck {
316 members: i.n,
317 source: i.id,
318 destination: dest.resize::<5>(),
319 offset: raddr,
320 length: len,
321 base,
322 region: i.region,
323 },
324 i.windows,
325 );
326 // Region bases are 64-byte aligned (a line is one AXI beat).
327 let config_ok = n >= b32(2) && n <= b32(16) && id < n && (base & b64(63)) == b64(0);
328 if !config_ok {
329 c.bad_config = r.counters.bad_config + b32(1);
330 s.phase = DROP;
331 } else if op != b3(1) {
332 c.not_write = r.counters.not_write + b32(1);
333 s.phase = DROP;
334 } else if !mapped {
335 c.unmapped = r.counters.unmapped + b32(1);
336 s.phase = DROP;
337 } else if (raddr & b64(3)) != b64(0) {
338 c.misaligned = r.counters.misaligned + b32(1);
339 s.phase = DROP;
340 } else if !verdict.allowed {
341 if verdict.reason == b3(1) {
342 c.bad_config = r.counters.bad_config + b32(1);
343 } else if verdict.reason == b3(2) {
344 c.zero_length = r.counters.zero_length + b32(1);
345 } else if verdict.reason == b3(5) {
346 c.unauthorized = r.counters.unauthorized + b32(1);
347 } else {
348 c.out_of_bounds = r.counters.out_of_bounds + b32(1);
349 }
350 s.phase = DROP;
351 } else {
352 if i.upstream_qpns && meant != dest {
353 c.misrouted = r.counters.misrouted + b32(1);
354 }
355 // Lines are 64-byte aligned; the payload starts `offset`
356 // bytes into the first one.
357 let at = base + raddr;
358 let offset: b32 = (at & b64(63)).resize();
359 s.addr = at & !b64(63);
360 s.shift = (offset >> 2).resize();
361 s.prev_data = [b64(0); 8];
362 s.prev_keep = b64(0);
363 s.rem = offset + len;
364 s.left = len;
365 s.zero = false;
366 s.wait = i.latency;
367 s.last_seen = false;
368 s.phase = DELAY;
369 }
370 }
371 } else if r.phase == DELAY {
372 if r.wait == b16(0) {
373 s.phase = DATA;
374 } else {
375 s.wait = r.wait - b16(1);
376 }
377 } else if r.phase == DATA {
378 // Take a beat when the beat register is free.
379 // At most 254 beats await their B response, so `pending` never wraps.
380 data_ready = (!r.beat_valid || sent) && r.pending < b8(254);
381 if i.data.valid && data_ready {
382 // Bytes past the declared length never enter a line.
383 let keep_in = i.data.keep & strobe(r.left);
384 let rem_after = if r.rem > b32(64) { r.rem - b32(64) } else { b32(0) };
385 if r.left > b32(0) {
386 s.beat_valid = true;
387 s.beat_data = realign(r.prev_data, i.data.data, r.shift);
388 s.beat_strb = realign_keep(r.prev_keep, keep_in, r.shift) & strobe(r.rem);
389 s.beat_addr = r.addr;
390 s.addr = r.addr + b64(64);
391 s.left = if r.left > b32(64) { r.left - b32(64) } else { b32(0) };
392 s.prev_data = i.data.data;
393 s.prev_keep = keep_in;
394 s.rem = rem_after;
395 } else {
396 c.oversize = r.counters.oversize + b32(1);
397 }
398 if i.data.last {
399 if r.left > b32(64) {
400 c.short_packet = r.counters.short_packet + b32(1);
401 }
402 // A shifted write spills its last in-length beat's tail into
403 // one more line. The bytes still owed to memory decide it,
404 // not the input still expected: when `tlast` comes on an
405 // oversize beat (`left` already 0), the tail of the beat
406 // before is still in `prev_data` and `rem` counts it.
407 let owed = if r.left > b32(0) { rem_after } else { r.rem };
408 let spill = r.shift != b4(0) && owed > b32(0);
409 s.phase = if spill { FLUSH } else { DRAIN };
410 }
411 }
412 } else if r.phase == FLUSH {
413 // The last line: the previous beat's tail only.
414 if !r.beat_valid || sent {
415 s.beat_valid = true;
416 s.beat_data = realign(r.prev_data, [b64(0); 8], r.shift);
417 s.beat_strb = realign_keep(r.prev_keep, b64(0), r.shift) & strobe(r.rem);
418 s.beat_addr = r.addr;
419 s.addr = r.addr + b64(64);
420 s.rem = b32(0);
421 s.phase = DRAIN;
422 }
423 } else if r.phase == DROP {
424 data_ready = true;
425 if i.data.valid && i.data.last {
426 s.phase = IDLE;
427 }
428 } else {
429 // DRAIN: every written byte acknowledged before the next write.
430 if !s.beat_valid && pending_after == b8(0) {
431 c.delivered = r.counters.delivered + b32(1);
432 s.phase = IDLE;
433 }
434 }
435 s.counters = c;
436 let d = FabricLinkD::<W> { state: s };
437 (
438 LinkOut {
439 meta_ready,
440 data_ready,
441 mem: AxiWrite {
442 aw_valid: r.beat_valid && !r.aw_done,
443 aw_addr: r.beat_addr,
444 w_valid: r.beat_valid && !r.w_done,
445 w_data: r.beat_data,
446 w_strb: r.beat_strb,
447 b_ready: true,
448 },
449 counters: r.counters,
450 },
451 d,
452 )
453}