Skip to main content

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}