UCIe · Module 12
Data Flow
How payload bytes cross differently sized, differently clocked, independently stalled stages without losing their transaction identity — transaction versus payload versus beat, width conversion both ways, the partial final beat and width-safe byte masks, progress only on a handshake, bundling versus reassociation, replay payload retention, and the source-to-sink byte scoreboard.
Chapter 12.1 followed a request to the far side. Chapter 12.2 followed the obligation back. Both chapters treated the payload as something that travelled with the object — one bundled struct, advanced by one enable.
That was a deliberate simplification, and it is the one this chapter removes.
Real datapaths change the payload's shape. The Protocol Layer's beat width is not the Adapter's internal width, which is not the width the lanes present. A payload that arrives as one wide value leaves as several narrow ones, or the reverse. It waits in queues that stall independently. It crosses a clock domain. It is striped across lanes whose count can change. And through every one of those transformations, the bytes must stay attached to the transaction they belong to.
When they do not, the failure is not a protocol error. It is a memory location holding another transaction's data, with every count legal and every CRC passing.
1. The One-Sentence Model
Control says what the transaction means. Data flow says where the bytes are right now.
Those are different questions with different state, and almost every bug in this chapter is one of them being answered with the other's information.
2. Sourcing, and What Is Symbolic
3. Four Words That Are Not Synonyms
Most datapath confusion is vocabulary confusion. These four are routinely used interchangeably and they denote different things with different lifetimes.
| Term | What it is | Lifetime | Who defines its size |
|---|---|---|---|
| Transaction | a semantic operation — read this address, take ownership of this line | until the semantic obligation resolves | the carried protocol |
| Payload | the data associated with a transaction | until the last byte has been consumed by whatever needs it | the transaction's length |
| Beat | one cycle-level unit transferred across one interface | one accepted handshake | that interface's width |
| Transport unit | the Adapter's representation on the link | until transport confirms delivery | negotiation |
The two that matter most for this chapter are the last two, because they are the ones whose sizes differ. A beat is an interface concept: the same payload has a different beat count at FDI than it does inside the Adapter than it does across the lanes. A payload does not "have" a beat count — it has one per interface it crosses.
And note the asymmetry in the lifetime column. A transaction outlives its payload's transmission; a beat is gone the cycle after it is accepted. Confusing those two is §4.
4. Transaction Lifetime and Data Lifetime Overlap But Are Not Identical
A transaction can be outstanding while:
- no payload has arrived yet — a read request has been issued and its data is still remote;
- part of the payload is buffered — a multi-beat write is mid-transfer;
- the entire payload has been transmitted and confirmed — and the transaction is still open, waiting for a completion.
That last case is Chapter 12.1 §20's final row, and it is the one that generalises: the payload can be entirely finished with while the transaction is not.
The reverse also occurs. A read transaction's payload does not exist locally at issue time and appears only on the return path — so for a read, the response carries the payload and the request carries none.
Payload state answers "where are the bytes"; transaction state answers "what do we still owe". Neither can be derived from the other.
The practical consequence is a design rule: do not index payload state by transaction slot and assume they retire together. A payload buffer freed when its transaction retires holds storage for the entire round trip; a transaction retired when its payload is sent is Chapter 12.2 §8's bug. §34's table separates them properly.
5. The Beat Object
// ILLUSTRATIVE datapath object — NOT a UCIe format. No FDI/RDI signal name,
// width, or encoding is asserted; every width below is a symbolic parameter.
localparam int DATA_W = 512; // symbolic interface width
localparam int STRB_W = DATA_W / 8;
localparam int TXN_ID_W = 8; // symbolic
typedef struct packed {
logic [TXN_ID_W-1:0] txn_id; // which transaction these bytes belong to
logic [DATA_W-1:0] data; // the payload slice carried this beat
logic [STRB_W-1:0] byte_valid; // which bytes of `data` are meaningful
logic first; // this is the payload's first beat
logic last; // this is the payload's final beat
logic [MON_ID_W-1:0] mon_id; // VERIFICATION ONLY — see below
} data_beat_t;Every field, and why it is present rather than derivable:
txn_id — the association. Without it, a beat is anonymous bytes. §20 is the section about what happens when this drifts relative to data.
data — the slice. Note slice: this is not the payload, it is one interface-width piece of it.
byte_valid — which bytes matter. Necessary because a payload length is not required to be a multiple of the interface width (§12), and because a partial write legitimately touches a subset of bytes. Carrying it and then ignoring it is §14.
first and last — the message boundary. last in particular cannot be derived downstream: a stage that has been handed slices does not know the payload's total length unless told, and reconstructing it from a byte count requires knowing that count, which is the same information.
mon_id — a verification-only tag. Not a protocol field, not present in silicon. It exists because txn_id is reused (Chapter 12.2 §9), so it cannot answer "is this the same payload I saw earlier, or a different one that inherited the identity?" — which is precisely what §36's scoreboard must answer.
On first/last versus a counter. Both are viable. Flags are cheap and local; a counter (§16) is needed anyway for slice indexing, so a design that has one can derive the flags. What is not viable is maintaining both independently, because then they can disagree — and §16's DV note is about exactly that.
6. The Payload's Journey
Read the two self-directed arrows as the chapter's subject. Re-slicing and framing are where the payload's shape changes, and both happen inside one block — which is why "the Adapter" is a single actor in the figure and three sections in this chapter.
And note what the dashed arrow implies. The confirmation returns after the remote side has the bytes, which is why §33's payload retention extends past transmission.
7. Why Width Conversion Exists
Three independent reasons, and no design escapes all three.
The interfaces are specified separately. FDI and RDI are distinct interfaces with distinct definitions, standardised so that Protocol Layer, Adapter, and PHY can come from different vendors. Nothing requires their widths to match, and requiring it would defeat the purpose.
The link's usable width is not fixed. The UCIe material describes 1, 2, or 4 modules forming a Link, with 16 or 64 data lanes per module, plus spare lanes in advanced packaging and width degradation in standard packaging. So the number of bytes the lanes can carry per unit time is a configuration, and it can change (§31).
Frequency and width trade against each other. A narrower datapath at a higher clock moves the same bytes as a wider one at a lower clock, and different blocks optimise that trade differently. Chapter 7.4 §11 makes the general argument that width changes the datapath but not one-to-one.
So the invariant to hold is a byte invariant, not a beat invariant:
One upstream beat may become several downstream beats, or several upstream beats may combine into one. What must be preserved is the byte sequence, the byte validity, the boundaries, and the identity — never the beat count.
A design that asserts "beats in equals beats out" has encoded an assumption that width conversion violates by construction. §35's conservation equation is in bytes for exactly this reason.
8. Wide-to-Narrow Conversion
// ILLUSTRATIVE wide-to-narrow width converter. Symbolic widths; not a UCIe
// interface. Emits RATIO narrow slices for each accepted wide beat.
module wide_to_narrow #(
parameter int IN_W = 512,
parameter int OUT_W = 128,
parameter int RATIO = IN_W / OUT_W, // 4 here
parameter int RATIO_W = $clog2(RATIO)
) (
input logic clk,
input logic rst_n,
input logic in_valid,
output logic in_ready,
input logic [IN_W-1:0] in_data,
input logic [IN_W/8-1:0] in_strb,
input logic [TXN_ID_W-1:0] in_id,
input logic in_last,
output logic out_valid,
input logic out_ready,
output logic [OUT_W-1:0] out_data,
output logic [OUT_W/8-1:0] out_strb,
output logic [TXN_ID_W-1:0] out_id,
output logic out_last
);
// ---- State: the captured wide beat plus where we are within it.
logic [IN_W-1:0] data_q;
logic [IN_W/8-1:0] strb_q;
logic [TXN_ID_W-1:0] id_q;
logic last_q;
logic [RATIO_W-1:0] slice_idx_q;
logic active_q; // a wide beat is captured and unfinished
// The identity travels WITH the captured data in the same registers, so a
// slice cannot be emitted with another beat's id (§20).
assign out_valid = active_q;
assign out_data = data_q[slice_idx_q*OUT_W +: OUT_W ];
assign out_strb = strb_q[slice_idx_q*OUT_W/8 +: OUT_W/8];
assign out_id = id_q;
// `last` is asserted only on the FINAL slice of a wide beat that was itself
// the payload's last. Asserting it on every slice would end the payload
// three slices early.
assign out_last = last_q && (slice_idx_q == RATIO_W'(RATIO-1));
// ---- THE ACCEPTANCE RULE. New input is taken only when no capture is
// outstanding, or when the last slice of the current capture is being taken
// this cycle. Anything looser is §9.
wire final_slice_going = active_q && out_ready
&& (slice_idx_q == RATIO_W'(RATIO-1));
assign in_ready = !active_q || final_slice_going;
wire in_fire = in_valid && in_ready;
wire out_fire = out_valid && out_ready;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
active_q <= 1'b0;
slice_idx_q <= '0;
data_q <= '0;
strb_q <= '0;
id_q <= '0;
last_q <= 1'b0;
end else begin
// Advance the slice pointer ONLY on an actual output transfer (§22).
if (out_fire) begin
if (slice_idx_q == RATIO_W'(RATIO-1)) begin
slice_idx_q <= '0;
active_q <= 1'b0; // capture consumed
end else begin
slice_idx_q <= slice_idx_q + RATIO_W'(1);
end
end
// Capture may coincide with the final slice leaving — hence the ordering
// here: the capture assignment follows, so back-to-back wide beats run
// at full rate without a bubble.
if (in_fire) begin
data_q <= in_data;
strb_q <= in_strb;
id_q <= in_id;
last_q <= in_last;
active_q <= 1'b1;
slice_idx_q <= '0;
end
end
end
endmoduleClassification: synthesizable, illustrative.
Architecture. One capture register and one slice pointer. It exists because the upstream interface's width and the downstream interface's width are set independently (§7), and something must bridge them without losing bytes, boundaries, or identity.
State. The captured wide beat (data_q, strb_q, id_q, last_q) with per-payload-beat lifetime, plus slice_idx_q and active_q with per-beat lifetime. Two lifetimes in one module, and the distinction is why §9's bug is possible: the capture must outlive the slices.
Cycle behaviour. A wide beat is captured. Each downstream transfer emits one slice and advances the pointer. On the final slice, the capture is released — and in_ready is asserted in that same cycle so a new wide beat can be captured with no bubble, which is the only reason this converter can sustain full throughput.
Contract. Upstream relies on in_ready meaning the whole wide beat will be delivered, not just its first slice. Downstream relies on each slice being stable while it stalls, and on out_last marking the payload's true end.
Failure. §9 for the overwrite; asserting out_last on every slice ends the payload early, which the receiver sees as a short payload with no error.
DV. Sustained back-to-back wide beats at full rate; a stall on each slice index in turn — including the final one, which is where in_ready is subtlest; and a single-slice payload where first and last coincide.
9. Wrong RTL — Overwriting the Capture Before Its Slices Are Sent
// WRONG — in_ready ignores whether a capture is still being drained.
assign in_ready = 1'b1; // "we can always take a beat"Architecture. The converter has one capture register and is behaving as though it had none — as though slicing were combinational (§35's misconception). It is not: slicing takes RATIO cycles, and during those cycles the capture is live state.
Cycle behaviour. A new wide beat arrives while slices 2 and 3 of the previous one are still unsent. data_q is overwritten. Slices 2 and 3 are then emitted from the new beat's data, with the old beat's id_q... or the new one's, depending on whether the identity was overwritten in the same assignment.
Failure, and it is one of the nastiest in the module. The payload of transaction A ends with bytes belonging to transaction B:
- The first slices are A's. The last slices are B's. There is no boundary marker where the switch happened.
out_lastmay assert at the right count, so the payload is the right length. It is simply the wrong contents in its tail.- The CRC is computed downstream, over the object as assembled — so the corrupted object is faithfully protected and delivered.
- And B is now short its first bytes, so B's payload is also wrong, in a different way.
Two transactions corrupted by one bug, and the corruption is positional: always the tail of one and the head of the next.
Why it survives testing. With a downstream that never stalls and an upstream that presents beats no faster than RATIO cycles apart, the overlap never occurs. It requires upstream pressure, which is a stimulus property. A directed test that sends one wide beat and waits will never see it.
// Illustrative — no capture is overwritten while it still has slices to send.
property p_no_capture_overwrite;
@(posedge clk) disable iff (!rst_n)
(active_q && !final_slice_going) |-> !in_fire;
endproperty
a_no_capture_overwrite: assert property (p_no_capture_overwrite);
// Illustrative — every slice of one capture carries that capture's identity.
// Uses the verification-only tag, because txn_id is reused.
property p_slices_share_source;
@(posedge clk) disable iff (!rst_n)
out_fire |-> (out_mon_id == captured_mon_id);
endproperty
a_slices_share_source: assert property (p_slices_share_source);10. Narrow-to-Wide Conversion
// ILLUSTRATIVE narrow-to-wide width converter. Accumulates RATIO narrow beats
// into one wide beat, and FLUSHES a partial accumulation when `last` arrives
// early — which is the case that makes this harder than its mirror image.
module narrow_to_wide #(
parameter int IN_W = 128,
parameter int OUT_W = 512,
parameter int RATIO = OUT_W / IN_W,
parameter int RATIO_W = $clog2(RATIO)
) (
input logic clk,
input logic rst_n,
input logic in_valid,
output logic in_ready,
input logic [IN_W-1:0] in_data,
input logic [IN_W/8-1:0] in_strb,
input logic [TXN_ID_W-1:0] in_id,
input logic in_last,
output logic out_valid,
input logic out_ready,
output logic [OUT_W-1:0] out_data,
output logic [OUT_W/8-1:0] out_strb,
output logic [TXN_ID_W-1:0] out_id,
output logic out_last
);
logic [OUT_W-1:0] acc_data_q;
logic [OUT_W/8-1:0] acc_strb_q; // doubles as the valid mask (§12)
logic [TXN_ID_W-1:0] acc_id_q;
logic [RATIO_W-1:0] seg_idx_q;
logic acc_open_q; // an accumulation is in progress
logic out_valid_q;
logic out_last_q;
assign in_ready = !out_valid_q; // hold input while an output waits
wire in_fire = in_valid && in_ready;
wire out_fire = out_valid_q && out_ready;
assign out_valid = out_valid_q;
assign out_data = acc_data_q;
assign out_strb = acc_strb_q;
assign out_id = acc_id_q;
assign out_last = out_last_q;
// Emit when the accumulator is FULL, or when `last` arrives before it is —
// the partial flush. Both conditions produce one output beat.
wire seg_is_final = (seg_idx_q == RATIO_W'(RATIO-1));
wire emit_now = in_fire && (seg_is_final || in_last);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
acc_data_q <= '0;
acc_strb_q <= '0; // zeroed: unwritten segments read INVALID
acc_id_q <= '0;
seg_idx_q <= '0;
acc_open_q <= 1'b0;
out_valid_q <= 1'b0;
out_last_q <= 1'b0;
end else begin
if (out_fire) out_valid_q <= 1'b0;
if (in_fire) begin
// Place this segment. Note the strobe is placed alongside the data, so
// a partial flush leaves the unwritten segments' strobe bits at zero
// and the receiver sees exactly the bytes that were sent (§12).
acc_data_q[seg_idx_q*IN_W +: IN_W ] <= in_data;
acc_strb_q[seg_idx_q*IN_W/8 +: IN_W/8] <= in_strb;
// Identity is captured from the FIRST segment of an accumulation and
// must not change mid-accumulation — §11 asserts that.
if (!acc_open_q) acc_id_q <= in_id;
if (emit_now) begin
out_valid_q <= 1'b1;
out_last_q <= in_last;
seg_idx_q <= '0;
acc_open_q <= 1'b0;
// Clear the strobe for the NEXT accumulation. Without this, a short
// payload inherits the previous payload's valid bits in the
// segments it does not write — a stale-byte bug (§14's cousin).
acc_strb_q <= '0;
acc_strb_q[seg_idx_q*IN_W/8 +: IN_W/8] <= in_strb;
end else begin
seg_idx_q <= seg_idx_q + RATIO_W'(1);
acc_open_q <= 1'b1;
end
end
end
end
endmoduleClassification: synthesizable, illustrative.
Architecture. An accumulator plus a segment index. Harder than its mirror image for one reason: the input can end early. A payload whose length is not a multiple of OUT_W produces a final accumulation that is only partly written, and that partial value must still be emitted — with a mask saying which parts are real.
State. The accumulator with per-output-beat lifetime, the segment index with per-segment lifetime, and the captured identity with per-accumulation lifetime. Three lifetimes, and the identity's is the one people get wrong: it belongs to the accumulation, not to each segment.
Cycle behaviour. Each accepted narrow beat is written into its segment slot. Emission is triggered by the accumulator filling or by last arriving. The strobe array is cleared on emission so the next accumulation starts with everything invalid.
Contract. Downstream relies on out_strb describing exactly the bytes that were supplied, including on a partial final beat. Upstream relies on in_ready falling when an output is waiting.
Failure. Not clearing the strobe on emission means a short payload inherits the previous one's valid bits, so the receiver writes stale bytes past the real end — §14's failure arriving through the accumulator rather than through a mask calculation. And letting acc_id_q update on every segment means an accumulation can silently change owner mid-way.
DV. Payload lengths of exactly one segment, exactly RATIO segments, and RATIO+1 segments; last arriving at every segment index; and a short payload immediately following a full one, which is the sequence that exposes an uncleared strobe.
// Illustrative — an accumulation does not change identity part-way.
property p_accumulation_single_owner;
@(posedge clk) disable iff (!rst_n)
(acc_open_q && in_fire) |-> (in_id == acc_id_q);
endproperty
a_accumulation_single_owner: assert property (p_accumulation_single_owner);11. The Partial Final Beat
The case that makes byte masks load-bearing rather than decorative.
A payload of N bytes crossing an interface of W bytes needs ceil(N/W) beats. Unless N is a multiple of W, the final beat carries fewer than W meaningful bytes — and there is no way for the receiver to know how many except by being told.
Three mechanisms exist and they are not equivalent:
| Mechanism | What it carries | Strength | Weakness |
|---|---|---|---|
| Byte mask | one bit per byte | expresses any pattern, including sparse writes | W bits per beat |
| Valid-byte count | how many bytes from byte 0 | compact | cannot express a sparse or offset pattern |
| Length-derived | receiver computes from the transaction's length | no per-beat cost | requires the length to be known and correct at both ends |
A design must pick one and mean it. The failure mode of not picking is a receiver that trusts the count while the transmitter varies the mask, so any pattern that is not "the first k bytes" is silently mis-written.
And note that a mask is required, not merely convenient, when partial writes exist. A byte-enabled write that touches bytes 3 and 7 of a beat is not expressible as a count. Chapter 11.4 §8 recorded that the CXL link layer optimises the common all-bytes-enabled case by not transmitting the mask and clearing a header field instead, with the receiver required to regenerate all-ones — which is itself a mask mechanism with a compression trick, not a count.
12. Width-Safe Byte-Mask Generation
// ILLUSTRATIVE mask generation from a valid-byte count. This block exists
// mostly to be a warning about arithmetic: the boundary case where the count
// equals the full bus width is where naive versions break.
module byte_mask_gen #(
parameter int BYTES = 64, // bytes per beat, symbolic
parameter int CNT_W = $clog2(BYTES) + 1 // NOTE the +1 — see below
) (
input logic [CNT_W-1:0] valid_bytes, // 0 .. BYTES inclusive
output logic [BYTES-1:0] mask,
output logic count_legal
);
// WHY CNT_W IS $clog2(BYTES)+1 AND NOT $clog2(BYTES):
// a count must be able to express BYTES itself — a full beat. With 64 bytes,
// $clog2(64) = 6 bits holds 0..63 and CANNOT represent 64, so a full beat
// wraps to zero and the mask comes out empty. One extra bit fixes it, and
// this is the single most common width bug in mask generation.
assign count_legal = (valid_bytes <= CNT_W'(BYTES));
// The mask itself. Built as a shift of an explicitly-sized one, then minus
// one, in a width WIDER than BYTES so the shift by BYTES does not overflow
// before the subtraction.
localparam int ACC_W = BYTES + 1;
wire [ACC_W-1:0] one_shifted = ACC_W'(1) << valid_bytes; // 1 .. 2**BYTES
wire [ACC_W-1:0] mask_full = one_shifted - ACC_W'(1);
assign mask = count_legal ? mask_full[BYTES-1:0] : '0;
endmoduleClassification: synthesizable, illustrative.
Architecture. Turning a count into a mask. Trivial in concept and a reliable source of two width bugs, both of which appear only at the boundary.
State. None — purely combinational.
Cycle behaviour. None. But note count_legal: an out-of-range count produces an empty mask rather than an arbitrary one, so a configuration error fails visibly instead of writing a plausible subset.
Contract. Downstream relies on the mask describing the payload's real extent. §15 asserts it against the transaction's length.
Failure — two of them, and both are boundary-only:
The count width. $clog2(BYTES) cannot represent BYTES. With 64 bytes per beat, a full beat's count of 64 wraps to 0 in six bits and the mask is empty — so a full final beat writes nothing. The bug appears only when the payload length happens to be an exact multiple of the beat width, which a random test hits about one time in BYTES.
The shift width. (1 << valid_bytes) - 1 computed in BYTES bits overflows to zero when valid_bytes == BYTES, giving a mask of all ones after the subtraction by accident on some tools and zero on others. Computing in BYTES+1 bits makes it deterministic. Using an unsized 1 is worse still, because the shift then happens in 32 bits and any bus wider than 32 bytes silently truncates.
DV. Sweep valid_bytes from 0 to BYTES inclusive and compare against an independently computed mask. The inclusive upper end is the entire point; a loop to BYTES-1 passes on a broken design.
13. Wrong RTL — Treating Unused Bytes as Valid
// WRONG — the final beat is emitted with a full mask regardless of extent.
assign out_strb = '1; // "the data bus is full anyway"Architecture. It confuses "the bus carries W bytes" with "the payload has W more bytes to give". The bus is always full; the payload is not.
Cycle behaviour. Every beat, including the last, claims all its bytes are meaningful.
Failure, and for a write it is memory corruption. The receiver writes W bytes when the transaction specified fewer. The bytes past the real end are whatever the datapath happened to be holding — the tail of the previous payload, or reset values, or uninitialised register contents.
Why it is particularly serious for a memory write:
- The overwritten bytes belong to something else. They are within the same beat-aligned region but past the transaction's extent, so they may be another data structure entirely.
- Nothing reports it. The write was well formed, the address was legal, the CRC was correct. The receiver did exactly what it was told.
- And the corruption is silent until something reads those bytes, which may be much later and in unrelated code.
The read case is milder and still wrong: the requester receives extra bytes it must ignore, and a requester that trusts the mask will consume garbage.
Why testing misses it. Payloads whose length is an exact multiple of the beat width have a genuinely full final beat, so the bug is invisible for them. It requires a payload length that is not a multiple of the width — which means the coverage bins in §37 must include beat+1 and single-byte payloads specifically, not just "some payload sizes".
// Illustrative — the final beat's mask matches the transaction's remaining
// extent. Requires the checker to know the length independently, which is why
// this lives against a reference model rather than being self-checking.
property p_final_mask_matches_extent;
@(posedge clk) disable iff (!rst_n)
(out_fire && out_last)
|-> ($countones(out_strb) == expected_final_bytes[out_mon_id]);
endproperty
a_final_mask_matches_extent: assert property (p_final_mask_matches_extent);
// Illustrative — a non-final beat is full. Catches a design that partially
// fills intermediate beats, which wastes bandwidth and usually indicates a
// segment-placement bug rather than an intentional choice.
property p_nonfinal_beats_full;
@(posedge clk) disable iff (!rst_n)
(out_fire && !out_last) |-> (out_strb == '1);
endproperty14. Beat Sequencing State
// ILLUSTRATIVE beat sequencing for one payload in flight. Deliberately does
// NOT keep a separate `last` register: `last` is derived, so the two cannot
// disagree.
logic [BEAT_IDX_W-1:0] beat_idx_q; // which beat is being presented
logic [BEAT_CNT_W-1:0] beat_total_q; // how many this payload has
logic payload_open_q;
wire beat_is_last = (beat_idx_q == beat_total_q - BEAT_CNT_W'(1));Architecture. Two counters and a flag. beat_total_q comes from the transaction's length divided by the interface width, rounded up — a computation done once, at payload start, rather than per beat.
State. beat_idx_q with per-beat lifetime; beat_total_q and payload_open_q with per-payload lifetime. The distinction matters: a reset of the index must not clear the total.
Cycle behaviour. §15 and §16.
Contract. Downstream relies on beat_is_last marking the true final beat. Nothing else may derive the end independently.
Failure. Maintaining a separate last_q register alongside these counters creates two sources of truth for one fact. When they disagree — and a stall plus an off-by-one is enough — the payload either ends early, truncating it, or never ends, so the receiver waits for a beat that will not come.
DV. Single-beat payloads, where beat_idx_q is both first and last on the same beat; and the maximum beat count, where beat_total_q is at its width limit.
15. Wrong RTL — Advancing Without a Handshake
// WRONG — the beat index follows `valid` rather than an actual transfer.
always_ff @(posedge clk) begin
if (out_valid) beat_idx_q <= beat_idx_q + 1'b1;
endArchitecture. The most common ready/valid bug in existence, and it is worth being precise about why: valid means the producer is offering. It does not mean the consumer took it. Only valid && ready means that.
Cycle behaviour. Downstream stalls with out_valid high. The index increments anyway. Next cycle the datapath presents beat N+1 — but beat N was never accepted.
Failure. Beat N is skipped. Concretely, for a four-beat payload with a one-cycle stall on beat 1:
| Cycle | out_valid | out_ready | Beat presented | Beat actually transferred |
|---|---|---|---|---|
| 1 | 1 | 1 | 0 | 0 |
| 2 | 1 | 0 | 1 | — (stalled) |
| 3 | 1 | 1 | 2 | 2 — beat 1 lost |
| 4 | 1 | 1 | 3 | 3 |
The receiver gets beats 0, 2, 3. It received three beats where four were expected, so either it waits forever for a fourth, or — worse — if last is derived from a count on the receiving side too, it accepts three beats as a complete payload with a hole in the middle.
And the corruption is positional and stall-dependent. The missing bytes are wherever the stall happened, so the same transaction is correct on an idle link and wrong under congestion. This is why §33's taxonomy has a row for "corruption only during stalls" — it is a signature, and it points straight here.
The rule, and it applies to every progress-tracking register in a datapath:
Advance the beat index, the slice pointer, the segment index, the FIFO pointer, and the byte counter only on
valid && ready. Never onvalidalone, and never unconditionally.
// Illustrative — the correct form.
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
beat_idx_q <= '0;
end else if (out_valid && out_ready) begin // the transfer, not the offer
beat_idx_q <= beat_is_last ? '0 : beat_idx_q + BEAT_IDX_W'(1);
end
end// Illustrative — nothing advances during a stall. The single most valuable
// datapath assertion in this chapter.
property p_no_progress_under_stall;
@(posedge clk) disable iff (!rst_n)
(out_valid && !out_ready) |=> ($stable(beat_idx_q) && $stable(slice_idx_q));
endproperty
a_no_progress_under_stall: assert property (p_no_progress_under_stall);
// Illustrative — and the whole presented object holds still with it, so a
// stalled consumer sees the same bytes it saw last cycle.
property p_beat_stable_under_stall;
@(posedge clk) disable iff (!rst_n)
(out_valid && !out_ready)
|=> (out_valid && $stable(out_data) && $stable(out_strb)
&& $stable(out_id) && $stable(out_last));
endproperty
a_beat_stable_under_stall: assert property (p_beat_stable_under_stall);On p_beat_stable_under_stall being one assertion over four fields. Writing it as four separate properties is equivalent and worse: the four fields must hold still together, and a single property over the packed object states that directly. If the design carries them as a struct (§5), $stable(out_beat) covers all of it in one term.
16. Transaction Identity Alignment
The chapter's most severe failure class, and it is a pipeline-depth bug rather than a logic bug.
// WRONG — data and identity pipelined to different depths. Each always_ff is
// individually correct; the relationship between them is not.
always_ff @(posedge clk) data_q1 <= in_data;
always_ff @(posedge clk) data_q2 <= data_q1;
always_ff @(posedge clk) data_q3 <= data_q2; // data: 3 stages
always_ff @(posedge clk) id_q1 <= in_id;
always_ff @(posedge clk) id_q2 <= id_q1; // identity: 2 stages
// The output pairs data_q3 with id_q2 — payload N with the identity of N+1.Architecture. Someone added a pipeline stage to the wide data path for timing — the data path is hundreds of bits and the identity path is eight, so only one of them had a problem. The fix was applied where the problem was.
Cycle behaviour. Every cycle thereafter, the emitted beat carries one payload's bytes and a different payload's identity.
Failure, and it is worse than any width bug. The bytes of transaction A are delivered as transaction B:
- For a write, A's data is written to B's address. Two memory locations are now wrong: B's holds A's data, and A's holds whatever B's data was.
- The CRC is correct — the object was assembled and then protected.
- The response returns for B and matches B's outstanding entry correctly, so Chapter 12.2's entire matching apparatus reports success. The bookkeeping is flawless.
- And conservation balances: every transaction got a response, every count is right, nothing was lost or duplicated. Only the pairing is wrong.
Why nothing else catches it. Stability assertions pass — both paths are stable under stall. Beat counters are correct. Masks are correct. Occupancy is correct. The only check that sees it carries an independent notion of which payload this is, which is what the verification-only tag exists for.
Two legitimate architectures solve it, and the difference is where the association lives.
17. Architecture One — Bundle
// Illustrative — data and identity in ONE packed object advanced by ONE
// enable. Misalignment is not prevented by review; it is unrepresentable.
data_beat_t beat_q1, beat_q2, beat_q3;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
beat_q1 <= '0;
beat_q2 <= '0;
beat_q3 <= '0;
end else if (pipe_en) begin
beat_q1 <= beat_in;
beat_q2 <= beat_q1;
beat_q3 <= beat_q2;
end
endArchitecture. The identity travels in the same register as the bytes it names. Adding a stage adds it to both, because there is only one thing to add it to.
Why it is the default. It removes an entire bug class structurally, and the cost is that the identity is re-registered alongside a wide bus — a few flops per stage. For most datapaths that is the right trade, and §8's converter uses it.
When it stops being viable. When the payload passes through a structure that the identity should not — a wide SRAM, an asynchronous FIFO sized for data, a striping network. Carrying eight bits of identity through a 512-bit-wide memory costs a wider memory, and at that point §18 becomes the better answer.
18. Architecture Two — Reassociate
// ILLUSTRATIVE metadata table for a split-path datapath. The payload travels
// through the wide datapath; the metadata waits here; the identity reunites
// them. NOT a UCIe structure.
typedef struct packed {
logic valid;
logic [TXN_ID_W-1:0] txn_id;
logic [META_W-1:0] meta; // address, length, direction, attributes
logic [MON_ID_W-1:0] mon_id; // VERIFICATION ONLY
} meta_entry_t;
meta_entry_t meta_q [MAX_INFLIGHT];
// Lookup by identity when the payload emerges from the wide path.
logic [MAX_INFLIGHT-1:0] meta_match;
always_comb begin
for (int e = 0; e < MAX_INFLIGHT; e++)
meta_match[e] = meta_q[e].valid && (meta_q[e].txn_id == payload_out_id);
endArchitecture. The payload and its description travel separately and are rejoined by identity. This is a correct architecture, not a compromise — it is what lets a wide datapath stay narrow in metadata.
But it changes what the identity is. In the bundled architecture the identity is a passenger. Here it is the association mechanism itself, which means:
The identity must accompany the payload through the wide path. It cannot be left behind, or there is nothing to look up with. So the wide path carries data plus identity — just not the full metadata.
The lookup must be unambiguous. Exactly one live entry per identity, which is Chapter 12.2 §9's rule appearing in the datapath rather than on the response path.
And the metadata's lifetime is now the payload's, not the transaction's. It cannot be freed when the request is sent, because the payload has not emerged yet.
State. MAX_INFLIGHT entries with per-payload lifetime.
Failure. §19.
DV. A payload emerging for an identity with no live entry — which must be reported, not absorbed; and two payloads in the wide path simultaneously, which is the case the lookup exists for.
// Illustrative — exactly one metadata entry matches an emerging payload.
property p_meta_match_onehot;
@(posedge clk) disable iff (!rst_n)
payload_out_fire |-> $onehot(meta_match);
endproperty
a_meta_match_onehot: assert property (p_meta_match_onehot);
// Illustrative — and the entry it matched is the one that was allocated for
// THIS payload, which only the verification tag can confirm.
property p_meta_match_is_correct_entry;
@(posedge clk) disable iff (!rst_n)
payload_out_fire |-> (meta_q[meta_idx].mon_id == payload_out_mon_id);
endproperty19. Wrong Reassociation — Two Live Entries, One Identity
// WRONG — allocation checks for a free slot, not for an identity collision.
assign meta_alloc_ok = (meta_free_vec != '0);Failure. Two payloads in flight with the same txn_id. The lookup matches both; whichever the priority encoder picks wins; the payload is written to the other transaction's address.
And note the compounding: $onehot(meta_match) — §18's own assertion — is what catches this, and it catches it at the lookup, which is after the damage is determined. The cheaper fix is at allocation:
// Illustrative — refuse to allocate an identity that is already live in the
// datapath. The same discipline as Chapter 12.2 §9, applied one layer down.
logic [MAX_INFLIGHT-1:0] id_collide;
always_comb begin
for (int e = 0; e < MAX_INFLIGHT; e++)
id_collide[e] = meta_q[e].valid && (meta_q[e].txn_id == alloc_id);
end
assign meta_alloc_ok = (meta_free_vec != '0) && (id_collide == '0);Why this is the datapath's problem and not only the transaction layer's. Chapter 12.2 §9 prevents duplicate live identities in the outstanding table. That is a necessary condition and not a sufficient one: a datapath with deeper pipelining than the outstanding table's turnaround can hold a payload for an identity that has already retired and been reallocated. The datapath needs its own collision check, scoped to its own in-flight window.
20. Datapath FIFO Staging
// ILLUSTRATIVE beat FIFO. Basic FIFO theory is not re-taught here — see
// Chapter 5.5 for the elastic-buffer argument. What matters is that the
// occupancy is maintained with ONE writer and that the stored unit is the
// whole bundled beat.
data_beat_t fifo_q [FIFO_DEPTH];
logic [FIFO_W-1:0] wptr_q, rptr_q;
logic [OCC_W-1:0] occ_q;
wire push = in_valid && in_ready;
wire pop = out_valid && out_ready;
assign in_ready = (occ_q != FIFO_DEPTH);
assign out_valid = (occ_q != '0);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wptr_q <= '0;
rptr_q <= '0;
occ_q <= '0;
end else begin
if (push) begin
fifo_q[wptr_q] <= in_beat;
wptr_q <= (wptr_q == FIFO_W'(FIFO_DEPTH-1)) ? '0 : wptr_q + FIFO_W'(1);
end
if (pop) begin
rptr_q <= (rptr_q == FIFO_W'(FIFO_DEPTH-1)) ? '0 : rptr_q + FIFO_W'(1);
end
// ONE writer for the occupancy, four enumerated outcomes. The 2'b11 row is
// the one that must be stated explicitly (§21).
unique case ({push, pop})
2'b10: occ_q <= occ_q + OCC_W'(1);
2'b01: occ_q <= occ_q - OCC_W'(1);
2'b11: occ_q <= occ_q; // one in, one out — UNCHANGED
2'b00: occ_q <= occ_q;
endcase
end
endArchitecture. Storage so that upstream and downstream stall independently. The stored unit is the whole bundled beat — data, mask, identity, and boundary flags together — because storing them in separate arrays reintroduces §16 inside the FIFO.
State. FIFO_DEPTH beats with per-beat lifetime, plus two pointers and an occupancy count.
Cycle behaviour. Push and pop are independent and may coincide. The pointers each have exactly one writer so they are safe as separate if statements; the occupancy does not, which is §21.
Contract. in_ready means the beat will be retained. out_valid means a beat is available and stable.
Failure. §21 for the counting bug. Storing fields in separate arrays for area reasons is the §16 failure with a FIFO in the middle.
DV. Every occupancy from empty to full; simultaneous push and pop at every occupancy; and a long alternating push/pop run verifying the occupancy returns to its starting value — the cheapest drift detector available.
21. Wrong RTL — Two Independent Occupancy Updates
// WRONG — two assignments to one register with no stated interaction.
always_ff @(posedge clk) begin
if (push) occ_q <= occ_q + 1'b1;
if (pop) occ_q <= occ_q - 1'b1;
endCycle behaviour. When both fire, the last assignment executed wins. The count decrements, losing the increment.
Failure, and the datapath consequence is specific. The occupancy drifts downward, so in_ready is asserted when the FIFO is genuinely full, and a beat is pushed over an unread entry. That beat is lost.
Now trace what a lost beat means as opposed to a lost request. The payload arrives at the far side one beat short:
- If
lastis carried per beat, the receiver never seeslastand waits forever for a beat that was overwritten. - If the receiver counts beats against an expected total, it sees a short payload and — depending on its error handling — either stalls or accepts a payload with a hole.
- And the hole is in the middle, not at the end, so a length check on the receiving side may still pass while the contents are wrong.
Every count stays a small legal number throughout. No bound is violated, no CRC fails, and the symptom is a hung transfer or corrupt data far from the drift.
Note the drift direction matters, as it did in Chapter 12.1 §19. Downward drift loses data. Reverse the two statements and the drift is upward, which makes the FIFO refuse beats it could hold — a throughput bug that destroys nothing. Same bug, opposite ordering, completely different severity.
// Illustrative — the cheapest possible detector, and it needs no model.
property p_occupancy_matches_pointers;
@(posedge clk) disable iff (!rst_n)
occ_q == pointer_difference(wptr_q, rptr_q, wrapped_q);
endproperty
// Illustrative — conservation over the whole run. A drift of one is invisible
// per cycle and obvious in aggregate.
property p_occupancy_conserved;
@(posedge clk) disable iff (!rst_n)
occ_q == (total_pushes - total_pops);
endproperty
a_occupancy_conserved: assert property (p_occupancy_conserved);22. Cut-Through or Store-and-Forward
An architectural choice that changes the error contract, not just the latency.
| Cut-through | Store-and-forward | |
|---|---|---|
| When transmission starts | as soon as the first beat is available | after the whole payload is buffered |
| Latency | low — one beat's worth | high — the whole payload's worth |
| Buffering | one beat plus slack | a full payload per in-flight transaction |
| Length validation | impossible before transmitting | possible before anything leaves |
| Abort after start | partially transmitted — §23 | clean; nothing has left |
| Error containment | a corrupt payload is partly delivered | a corrupt payload is never sent |
Neither is universally right and this chapter does not claim UCIe requires either. What it claims is that the choice has consequences the design must own.
Cut-through's real cost is not latency-versus-buffering. It is that a partial object can cross a boundary, and once bytes have left, "cancel this payload" is no longer a local operation. §23.
Store-and-forward's real benefit is not integrity. It is that the transmitter can validate before committing — check the length against what was declared, check the mask against the extent, refuse a malformed object entirely. A cut-through design discovers a length mismatch after it has already sent most of the payload.
23. Cut-Through's Failure Case
Concretely: a four-beat payload, cut-through, with an abort or error arriving upstream after beat 1 has been transmitted.
Two beats are across the boundary and two are not. The far side has a partially reconstructed object and no reason yet to think anything is wrong.
Three things must now be true, and none is automatic:
The receiver must be able to recognise an abandoned object. A reconstruction that never completes must eventually be reclaimed rather than occupying buffer forever — and the reclaim must not be mistaken for a completed short payload, which is Chapter 11.4 §16's exact-extent-match rule.
The abort must be signalled, not merely acted upon locally. Stopping transmission silently leaves the receiver holding partial state with no event to release it.
And the buffer must not be reused until the abandonment is acknowledged, or a subsequent payload's beats will be appended to the abandoned one's reconstruction.
Cut-through converts a local decision into a distributed one. The moment the first beat leaves, cancelling requires the far side's cooperation.
Which is the honest argument for store-and-forward in error-prone or safety-relevant paths, and the honest argument against it where latency dominates. The decision belongs to the design; what does not belong to the design is leaving it implicit.
24. Width Conversion and the Clock Domain
If the datapath crosses a clock domain — and Chapter 7.5 §9 establishes where the real boundary is — then width conversion and the crossing are two separate mechanisms that can be composed in two orders.
| Order | Consequence |
|---|---|
| Convert, then cross | the asynchronous FIFO is at the narrower width, so it is cheaper; but the conversion state sits in the source domain and its backpressure crosses the boundary |
| Cross, then convert | the asynchronous FIFO is wider and more expensive; but conversion happens entirely in the destination domain, where its stall behaviour is local |
The rule that matters more than the choice: the two mechanisms must remain separate. A converter whose slice pointer is sampled in one domain and whose capture register is written in another is not a converter with a CDC problem — it is a structure that emits bytes that never existed together.
And this chapter does not re-teach the crossing itself. Chapter 7.5 §10 builds the case against synchronising a bus bit by bit, and Chapter 5.3 §7–8 build the safe structures. The one thing worth adding is the datapath-specific consequence: a beat assembled from bits captured across two source-domain values is not a corrupted beat, it is a fabricated one — plausible bytes, plausible mask, and a value no transmitter ever presented.
25. From Beats to Lanes
At the top of the PHY, a beat stops being a word and becomes a distribution across physical resources.
What the UCIe material establishes: the Physical Layer's unit is a Module, with 1, 2, or 4 modules forming a Link, each carrying 16 or 64 data lanes plus Valid and Track, with lane reversal on the transmit side, and spare lanes in advanced packaging or width degradation in standard packaging.
What follows for the datapath, without inventing a mapping:
A logical beat is distributed across the active lanes. How — which byte goes to which lane — is a mapping this chapter does not assert. Chapter 7.3 §10 covers striping and reconstruction, and §5 covers the mapping table.
The number of active lanes is configuration, and it can change. Degradation and repair both alter it. So the byte-to-lane mapping is not fixed at design time, which is §26.
And reconstruction on the far side must use the same mapping. This is the same class of requirement as Chapter 11.2 §21's interleaving: a deterministic function that both ends must compute identically, with no negotiation per beat and no error detection on the result.
26. Wrong RTL — Changing the Lane Map Mid-Beat
// WRONG — the active-lane map is used directly from its configuration source.
assign lane_sel = lane_map_cfg; // changes whenever software writes itArchitecture. The mapping is being treated as a wire rather than as committed state with an epoch. Chapter 7.3 §7 establishes that the mapping has a lifetime; this is what ignoring it costs in the datapath.
Failure. A beat is distributed using one mapping and, part-way through, the mapping changes. The remainder of the beat goes to different lanes than its beginning. On the far side, reconstruction uses whichever mapping it has — so bytes land at the wrong offsets within the beat.
This is deterministic corruption, not a race. Every affected beat is wrong in the same way, and the pattern is a byte permutation rather than random damage — which is actually the diagnostic signature: corrupt data whose bytes are all present but in the wrong positions points at a mapping problem, not at a transmission problem.
And the correlation is with a configuration event. Chapter 7.3 §14 covers spare-lane repair and Chapter 8.3 covers when the physical configuration is re-established; the datapath symptom is corruption that begins at a repair or retrain and not before.
// Illustrative — requested versus committed, with the commit gated on the
// datapath being quiescent. The same discipline as Chapter 11.4 §10's flit
// format epoch, applied to the lane map.
logic [LANE_MAP_W-1:0] lane_map_req_q; // what configuration asked for
logic [LANE_MAP_W-1:0] lane_map_active_q; // what the datapath uses
logic map_change_armed_q;
wire datapath_quiescent = (fifo_occupancy == '0) && !converter_active
&& (replay_occupancy == '0);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
lane_map_active_q <= LANE_MAP_RESET;
map_change_armed_q <= 1'b0;
end else begin
if (lane_map_write) begin
lane_map_req_q <= lane_map_wdata;
map_change_armed_q <= (lane_map_wdata != lane_map_active_q);
end
if (map_change_armed_q && datapath_quiescent) begin
lane_map_active_q <= lane_map_req_q;
map_change_armed_q <= 1'b0;
end
end
end
// New payloads are refused while a change is armed, so quiescence arrives.
assign accept_new_payload = !map_change_armed_q;// Illustrative — the map does not move while a beat is in flight.
property p_lane_map_stable_in_flight;
@(posedge clk) disable iff (!rst_n)
(beat_in_flight || converter_active) |=> $stable(lane_map_active_q);
endproperty
a_lane_map_stable_in_flight: assert property (p_lane_map_stable_in_flight);
// Illustrative — every beat was striped under the map that is still active,
// which is what makes a replay after a map change impossible rather than
// merely unlikely (§33).
property p_replay_entry_matches_active_map;
@(posedge clk) disable iff (!rst_n)
replay_valid_q[e] |-> (replay_map_q[e] == lane_map_active_q);
endproperty27. Replay and Payload Retention
The rule that ties Chapter 9.4 to actual bytes.
The Adapter's reliability may replay — the UCIe material states that the CRC covers a 128-byte payload with replay if CRC fails. A replay retransmits the same bytes. Which means:
The payload must remain retrievable until transport retirement, not until first transmission.
// WRONG — the source buffer freed when the beat is handed downstream.
always_ff @(posedge clk) begin
if (beat_sent) src_buf_valid_q[beat_slot] <= 1'b0; // "it's gone"
endFailure. The first CRC failure on that object asks for bytes the design no longer has. The available outcomes are all bad: replay a slot that has been refilled and send a different payload under the original's framing; replay nothing and declare success, losing the payload silently; or escalate the link to an error, which converts a single-bit flip into a link failure. Chapter 12.1 §11 made this argument for replay capacity; this is the same argument for replay contents.
And the retained copy must be immutable while retained (Chapter 12.1 §17). A replay buffer that shares storage with a live FIFO slot re-sends whatever now occupies it.
The subtler consequence, and it is the one that sizes buffers. Retention lasts until confirmation, which is a round trip away. So the amount of payload storage a design needs is set by the bandwidth-delay product (Chapter 9.5 §11) — not by the payload size. Undersize it and the link idles while waiting for confirmations to free space, which looks like a link bandwidth problem and is a buffer-sizing problem.
28. Buffer Lifetimes
The table this chapter exists to produce.
| Buffer | Holds | Allocated when | Retired when | On transport retry |
|---|---|---|---|---|
| Source payload queue | the semantic payload | the transaction is accepted | the Adapter has taken and retained it | unaffected |
| Converter capture | one wide beat mid-slicing | the wide beat is accepted | its final slice is transferred | unaffected |
| Converter accumulator | partial wide beat | the first segment arrives | the output beat is emitted | unaffected |
| Datapath FIFO entry | one bundled beat | pushed | popped | unaffected |
| Adapter framing state | header + CRC + payload | framing begins | handed toward RDI | unaffected |
| Replay entry | the retransmittable object | the Adapter accepts | transport confirms delivery | unchanged — no re-allocation |
| PHY transmit state | bytes being driven onto lanes | transmission starts | the bits are gone | re-driven |
| CDC FIFO entry | one beat, mid-crossing | written in the source domain | read in the destination domain | unaffected |
| Remote reconstruction | partial object | the first unit arrives | the object is complete | held, not rebuilt |
| Remote payload queue | the reconstructed payload | reconstruction completes | the semantic consumer accepts | unaffected |
| Diagnostics | first-failure history | first event | broad deliberate reset only | survive |
Three rows carry the weight.
The source payload queue is not freed on handoff — it is freed when the Adapter has taken and retained it, which is §27's rule expressed as a lifetime.
The replay entry does not re-allocate on retry. Occupancy is unchanged; only the send pointer moves (Chapter 9.4 §8).
And remote reconstruction is held across a retry rather than rebuilt (Chapter 12.1 §20's cycle 14). Discarding it and then suppressing the rebuild as a duplicate loses the payload entirely — which is worse than duplicating it.
29. Data Conservation
The invariant that makes a lost byte a detectable event.
bytes_accepted = bytes_delivered + bytes_in_flight + bytes_abortedChecked continuously, and at end of test with bytes_in_flight required to be zero.
Why bytes and not beats. Width conversion changes the beat count by design (§7), so a beat-based equation is violated by correct behaviour. Bytes are the invariant quantity, and the equation is meaningful only in bytes.
The care that replay requires, and it is the trap. A replayed object retransmits bytes that were already counted as accepted. If the model counts transmissions, the left and right sides diverge on every retry and the equation reports a failure that is not one.
Count bytes at the semantic boundaries — accepted from the source, delivered to the sink — and never at the physical layer. A retry moves bytes across the link a second time and moves zero bytes across either semantic boundary.
Three checks the equation supports:
The equation itself, continuously. A dropped beat (§21) makes the left side exceed the right in the cycle it is lost.
bytes_delivered per transaction equals bytes_accepted per transaction. The aggregate can balance while individual transactions are wrong — which is exactly §16's misalignment, where A's bytes are delivered as B's. The per-transaction form is what catches it.
And every byte's value matches. Which is §30, and it is the only check that catches a permutation (§26) or a stale tail (§13), both of which have perfect byte counts.
30. The Source-to-Sink Payload Scoreboard
JOIN KEY — the verification-only monitor ID (§5). Required because txn_id is
reused and the beat count differs per interface.
per payload (by mon_id):
txn_id, address, direction
expected_bytes[] — the exact byte sequence the source presented
expected_length — in BYTES, not beats
expected_final_mask — derived from the length independently
observed_bytes[] — reassembled at the sink
beat_counts{} — per interface: FDI, Adapter-internal, RDI
first_seen, last_seen — for latency and for stall attribution
aggregate:
bytes_accepted, bytes_delivered, bytes_in_flight, bytes_abortedThe five checks, and the bug each one uniquely catches.
Byte-for-byte equality of observed_bytes against expected_bytes. The end-to-end check. It catches §9's tail corruption, §13's stale trailing bytes, §21's missing middle beat, and §26's byte permutation — none of which any count-based check sees, because all four have plausible lengths.
Length in bytes matches, per payload. Catches a truncated payload where last asserted early (§8's out_last on every slice) and an over-long one where the mask claimed too much.
The final beat's mask matches the length-derived mask, computed independently. Catches §12's width bugs and §13. The model must derive the mask from the length itself, never by reading the design's mask generator — a model sharing that arithmetic agrees with the design about the $clog2 off-by-one, which is the bug it exists to find.
Beat counts per interface are consistent with that interface's width. Not equal to each other — that would be wrong (§7) — but each equal to ceil(length / width) for its own width. Catches a converter emitting the wrong number of slices, which a byte comparison would also catch but this localises to a stage.
And the payload's identity at the sink equals its identity at the source. §16's check, and it needs the monitor tag because txn_id alone cannot distinguish "this payload" from "a later payload with a recycled identity".
On what the model must not do. It must not reconstruct the expected byte sequence by replaying the design's converter, and it must not derive bytes_in_flight from the design's occupancy counters — §21's counter is a suspect, not a source. Observe at the interfaces; compute independently.
31. Coverage
// Illustrative datapath coverage. Not UCIe-defined. Every bin exists to reach
// a specific failure named in this chapter.
covergroup cg_data_flow @(posedge clk iff beat_event);
// Payload length classes — the axis §13 and §12 both need.
cp_len : coverpoint payload_length_class {
bins one_byte = {LEN_1B}; // §12 — smallest possible mask
bins sub_beat = {LEN_PARTIAL}; // §13 — partial FIRST and last beat
bins exact_beat = {LEN_EXACT_1}; // §12 — count == BYTES boundary
bins beat_plus_1 = {LEN_BEAT_P1}; // §13 — the classic partial final beat
bins multi_beat = {LEN_MULTI};
bins max_beats = {LEN_MAX};
}
cp_mask_pop : coverpoint $countones(final_beat_mask) {
bins one = {1}; bins some = {[2:BYTES-1]}; bins full = {BYTES}; // inclusive!
}
cp_stall_pos : coverpoint stall_beat_position {
bins first = {0}; bins middle = {[1:$-1]}; bins last_beat = {LAST};
}
cp_conv : coverpoint converter_state {
bins idle = {0}; bins slicing = {1}; bins accumulating = {2}; bins flushing = {3};
}
cp_fifo_occ : coverpoint fifo_occ_q {
bins empty = {0}; bins mid = {[1:$-1]}; bins full = {FIFO_DEPTH};
}
cp_simul : coverpoint fifo_push_pop_same_cycle; // §20/§21
cp_retry : coverpoint payload_retry_count { bins none = {0}; bins one = {1}; bins many = {[2:$]}; }
cp_lanemap : coverpoint lane_map_change_requested; // §26
cp_cdc_occ : coverpoint cdc_fifo_near_full;
cp_btb : coverpoint back_to_back_payloads; // §10's uncleared strobe
// A stall at every beat position for every length class — the cross that
// reaches §15's skipped beat wherever it can occur.
x_stall_len : cross cp_stall_pos, cp_len;
// A partial final beat under upstream pressure — §9 needs pressure, §13
// needs a partial beat, and the bug that needs both lives here.
x_partial_conv : cross cp_len, cp_conv;
// Simultaneous push/pop at every occupancy — §21's drift.
x_simul_occ : cross cp_simul, cp_fifo_occ;
// A lane-map change requested while payloads are in flight — §26.
x_map_inflight : cross cp_lanemap, cp_conv;
// A retry with a partial final beat, which exercises retention of a payload
// whose last beat is not full (§27 plus §13).
x_retry_partial : cross cp_retry, cp_len;
// Back-to-back payloads where a short one follows a long one — §10.
x_btb_len : cross cp_btb, cp_len;
endgroupThree notes, because the bins encode arguments.
cp_mask_pop's full bin must include BYTES itself, because that is precisely the value §12's count-width bug cannot represent. A bin range of [1:BYTES-1] passes on a broken design.
cp_len distinguishes exact_beat from beat_plus_1 deliberately. They exercise opposite bugs: the exact case finds the mask-width overflow, and the plus-one case finds the unused-bytes-valid bug. A single "multi-beat" bin reaches neither reliably.
And x_btb_len is the bin most suites skip. A short payload immediately following a long one is what exposes an accumulator whose strobe was not cleared (§10) — and it requires the sequence, not either payload alone.
32. The Flagship Trace — A Multi-Beat Write With a Stall and a Retry
Illustrative latencies throughout. A write payload of 10 bytes on a datapath where the source beat is 8 bytes and the Adapter's internal width is 4 bytes — so two source beats become five internal beats, and the final one carries 2 valid bytes.
| Cyc | Src beat | Converter | FIFO | Replay | PHY | Remote | Note |
|---|---|---|---|---|---|---|---|
| 1 | B0 valid (8B, full) | — | — | — | — | — | source owns B0 |
| 2 | B0 accepted | capture B0, slice 0 | — | — | — | — | ownership moves |
| 3 | B1 valid (2B, partial) | slice 0 out, → 1 | s0 | — | — | — | in_ready low: capture busy |
| 4 | B1 held | slice 1 out, → 2 | s0,s1 | — | — | — | §9's rule holding B1 back |
| 5 | B1 held | slice 2 out, → 3 | s0..s2 | — | — | — | — |
| 6 | B1 accepted | final slice out; capture B1 | s0..s3 | — | — | — | no bubble — §8's in_ready |
| 7 | — | slice 0 of B1, mask=0x3 | s0..s4 | — | — | — | partial mask, last set |
| 8 | — | idle | s0..s4 | object | s0..s2 | — | replay allocated |
| 9 | — | idle | s3,s4 | object | PHY stalls | — | nothing advances (§15) |
| 10 | — | idle | s3,s4 | object | stalled | — | indices stable, data stable |
| 12 | — | idle | — | object | s3,s4 out | s0..s2 | PHY resumes |
| 14 | — | — | — | object | — | CRC fails | bytes were fine; a flit was corrupt |
| 15 | — | — | — | object, re-sent | replay | — | no re-allocation (§28) |
| 18 | — | — | — | object | — | 10 bytes reassembled | mask honoured: 2 bytes on the last |
| 19 | — | — | — | object | — | write committed once | exactly-once semantics |
| 21 | — | — | — | — | — | — | confirmed → replay retires |
Seven things to read off it.
Cycles 3–5: in_ready is low and B1 waits. This is §9's rule doing its work. A design with in_ready = 1'b1 would have captured B1 at cycle 3 and destroyed slices 1–3 of B0.
Cycle 6 does two things at once. The final slice of B0 leaves and B1 is captured. That simultaneity is what keeps the converter at full rate, and it is the subtlest line in §8.
Cycle 7 carries a mask of 0x3 — two valid bytes out of four. §13's bug would send 0xF here and the far side would write two bytes of garbage past the payload's end.
Cycles 9–10: the PHY stalls and nothing advances. No index moves, no data changes. §15's assertion is what makes this a property rather than a hope, and §15's bug would skip a slice right here.
Cycle 14: CRC fails and the bytes were never wrong. The corruption was in transmission. This distinction matters for §33's taxonomy: a CRC failure is not a datapath bug.
Cycle 15: the replay re-sends without allocating. Occupancy is unchanged.
Cycle 19: the write commits once, despite the object crossing the link twice. That is Chapter 11.4 §16's delivery fence, and it is why §29's conservation counts semantic boundaries rather than transmissions.
And cycle 21 is the row that sizes the buffer. The replay entry — holding the payload — was live from cycle 8 to cycle 21. Thirteen cycles of storage for a ten-byte payload, set by the round trip and not by the payload.
33. Debug Taxonomy
| Symptom | Where it is | First move |
|---|---|---|
| Correct control, wrong data | width conversion or masking | §30's byte comparison — find the first diverging byte |
| Every Nth beat corrupt | slice or segment index | §8/§10 — N is the conversion ratio, which names the stage |
| Final bytes wrong or extra | byte mask | §12's count width, then §13 |
| Data of the wrong transaction | identity alignment | §16 — compare pipeline depths; then §19's collision check |
| Corruption only during stalls | progress without handshake | §15 — the signature is unambiguous |
| Corruption only after a repair or retrain | lane-map commit | §26 — and the bytes will be permuted, not random |
| Transfer hangs waiting for a final beat | a lost beat, or last never asserted | §21's occupancy drift, then §14's derived last |
| Duplicate bytes with correct identities | semantic delivery keyed on arrival | 12.2 §21 — not a datapath bug |
| Bytes fine, throughput poor | retention sizing | §27 — replay held for a round trip, buffer undersized |
| Corruption at a payload boundary only | uncleared accumulator strobe | §10 — a short payload after a long one |
Two rows deserve emphasis because their signatures are decisive.
"Corruption only during stalls" points at exactly one bug class — state advancing on valid rather than valid && ready — and nowhere else. It is the fastest diagnosis in the chapter.
"Bytes permuted, not random" points at the lane map. Random-looking corruption is a transmission or masking problem; a permutation means every byte arrived and was placed wrongly, which only a mapping disagreement produces.
34. Debug Checklist
- What is the transaction identity, and what is the payload's total length in bytes? Both, before anything else.
- How many beats should that be at each interface?
ceil(length / width)per width — they will differ, and that is correct. - Which beat should be active, and which is?
- Did every progress register advance only on
valid && ready? §15 — check the assertion, not the intent. - Is the byte mask correct on the final beat, and derived from the length? §12.
- Is a width converter active, and is its capture intact? §9.
- Do the data and identity pipelines have equal depth? §16 — this is a structural question answerable by reading, not by simulating.
- If the metadata path is split, did the lookup match exactly one entry? §18.
- Which FIFO holds the beat, and does its occupancy match its pointers? §21.
- Is the replay copy still retained, and immutable? §27.
- Did the PHY stall, and did anything advance during it? §15.
- Did the lane map change, and was the change committed at a quiescent point? §26.
- Did the payload cross a clock domain, and through a proper structure? §24, and Chapter 7.5 §10.
- Does the per-transaction byte count match, and does the byte content match? §29 and §30 — count first, then content.
- Which exact byte first diverges? The single highest-yield observation in this chapter: its offset within the payload names the stage. Byte 0 wrong means identity or address; a wrong byte at a slice boundary means conversion; wrong bytes only past the extent means masking; a wrong byte at a stall point means §15.
35. Common Misconceptions
"Data flow is just request flow plus payload." The payload changes shape at every interface, so its beat count is per-interface rather than per-payload. Only the byte sequence is invariant, which is why conservation is counted in bytes (§7, §29).
"Beat counters can advance whenever valid is high." valid is an offer; valid && ready is a transfer. Advancing on the offer skips the beat that was stalled, producing a hole in the middle of the payload whose position depends on where the congestion was (§15).
"Unused bytes on the final beat do not matter." For a write they are committed to memory. The bytes past the payload's extent are whatever the datapath was holding, they belong to something else, and nothing reports the overwrite (§13).
"Width conversion is combinational slicing." It takes as many cycles as the ratio, during which the capture is live state that must not be overwritten. Treating it as combinational is the bug that corrupts the tail of one payload and the head of the next (§9).
"Metadata may be pipelined independently from payload." Then one payload's bytes are delivered under another's identity — with a correct CRC, a correctly matched response, and balanced conservation. Only a check carrying an independent notion of which payload this is can see it (§16).
"A transmitted payload can immediately be freed." It must be retained until transport confirms, because replay retransmits the same bytes. Freeing on transmission means the first CRC failure asks for bytes that no longer exist (§27).
"Replay duplicates the semantic payload." It duplicates the transmission. The delivery fence makes the semantic effect happen once, which is why conservation counts at semantic boundaries and not at the link (§29, §32 cycle 19).
"Changing the lane map between beats is harmless." The beat is reconstructed under two configurations, producing a byte permutation. It is deterministic, it correlates with a repair or retrain rather than with traffic, and the map must be committed at a quiescent point (§26).
"A FIFO count that stays in range proves the data is correct." Two independent occupancy updates drift the count downward by one per simultaneous push and pop until a beat is overwritten. Every count remains a small legal number throughout (§21).
"CRC detects metadata/data association bugs." CRC proves the bytes that arrived are the bytes that were sent. The misalignment happened before the CRC was computed, so the wrong pairing is faithfully protected and delivered (§16, and Chapter 12.2 §22).
36. Understanding Check
37. Summary and What Comes Next
Control says what the transaction means. Data flow says where the bytes are right now.
The vocabulary first, because most confusion is vocabulary: transaction, payload, beat, and transport unit are four things with four lifetimes, and a payload has one beat count per interface it crosses. So the invariant is a byte invariant — conservation is counted in bytes, at semantic boundaries, never at the link.
The mechanisms: width conversion in both directions, where the wide-to-narrow capture must not be overwritten while it drains and the narrow-to-wide accumulator must clear its strobe on emission. A width-safe byte mask, whose count needs $clog2(BYTES)+1 bits because a full beat must be representable. Progress only on valid && ready, for every index, pointer, and counter in the datapath. Data and identity bundled, or reassociated through a table with a one-hot lookup and a collision check at allocation. One writer for every occupancy count. A lane map committed at a quiescent point, because in-flight data depends on it. And payload retained until transport confirms, which sizes the buffer by the round trip rather than by the payload.
The four signatures worth recognising on sight: corruption only under stalls means progress without a handshake; bytes permuted but all present means the lane map; wrong bytes only past the extent means the mask; and another transaction's data entirely means the identity pipeline. Each points at exactly one stage.
And the check that finds all of them: byte-for-byte comparison of the reassembled payload against what the source presented, joined by a verification-only tag. Every count-based check in this chapter passes on §9, §13, §21, and §26 — because all four produce plausible lengths. The first diverging byte's offset within the payload is what names the stage.
We now know how request, response, and payload each move. What has not been said is when the transaction itself begins to exist, which states represent it while it is in flight, and the exact event that allows every layer to forget it:
- 12.4 — Transaction Lifecycle — dispatch, traversal, response, retirement.
Browse the full path on the UCIe tutorials index.