CXL · Module 5
CXL Link Training
What the link settles on after negotiation: the 2.5 GT/s start, the 8.0 GT/s floor, native versus degraded widths and rates, and why recovery must be bounded and must degrade rather than retry. Six RTL models simulated, thirteen mutations, thirteen killed.
Chapter 5.3 answered what protocol the link will speak. It left the link at the Gen 1 rate, having just decided.
This chapter answers the other half: what operating point does the link actually settle on, and what happens when it cannot hold it?
1. The Engineering Problem — "The Link Trained" Is Not a Result
A bring-up report says the link trained. That sentence is compatible with all of the following:
- x16 at 64 GT/s, which is what the design was sized for.
- x16 at 8 GT/s, which is a quarter of the bandwidth and completely legal.
- x2 at 64 GT/s, because fourteen lanes did not train.
- x16 at 64 GT/s after eleven recovery cycles, and it will drop again under load.
- Nothing at all, because the link has been retrying at the same operating point for four hours and nobody set a bound.
All five are "trained". Only one is the result anyone wanted, and none of the other four report an error — which is why the operating point, not the training status, is the number that matters.
2. The One-Sentence Model
Training resolves to an operating point — a width and a rate together — that must clear a floor but need not reach the native set; and when the link cannot hold that point, the correct response is to narrow it and try again, never to retry the same point forever.
Call it narrow, then conclude. A recovery policy that can neither narrow nor conclude is the failure this chapter is built around.
3. What This Chapter Owns
| Question | Owned by |
|---|---|
| Why CXL rides on PCIe at all | 5.1 |
| Native versus degraded, as a reuse class | 5.2 |
| How the two ends agree on the protocol | 5.3 |
| The operating point, and recovery | this chapter |
| How software learns what was agreed | 5.5 |
Chapter 5.2 introduced the native/degraded partition as an example of a revision-dependent reuse class. This chapter uses it as the operating-point space and adds what 5.2 did not need: the floor, recovery, and the cost of each axis.
4. Three Rates, Three Different Jobs
The single most common confusion in this area is treating "the rate" as one number. There are three, and they answer different questions.
| Rate | Value | Question it answers |
|---|---|---|
| Start rate | 2.5 GT/s (Gen 1) | at what rate is the capability conversation held? |
| Floor | 8.0 GT/s | what must the link clear to be a CXL link at all? |
| Native set | 32.0 / 64.0 GT/s | what was the design sized for? |
The start rate is below the floor, and that is not a contradiction. Chapter 5.3 explains why: the link must come up before capability is known, so the conversation happens at the slowest and most robust rate every PCIe device can do. The rate a link trains at and the rate it operates at are different questions, and a design that conflates them will either reject its own bring-up or accept an operating point it cannot use.
5. Teaching-model boundary
6. RTL 1 — Training as a State Machine
Three structural properties carry the chapter.
SETUP is on top of BASE, not beside it. There is no edge into SETUP that does not come from BASE, because CXL setup requires a working base link. Mutation M1 removes the SETUP step entirely; the testbench catches it by checking the route, not the destination.
BASE requires train_ok. Mutation M2 makes the transition unconditional, and the link reaches a "base" state that has not trained.
RECOV has two exits and a bound. One back to training, one to FAILED. A recovery state with only the first exit is Chapter 5.3's unbounded wait in a new costume.
module training_fsm #(
parameter int unsigned MAX_RECOVERY = 3,
parameter int unsigned REC_DWELL = 4, // bound on ONE recovery attempt
parameter bit RETRY_FOREVER = 1'b0 // 1 = the unbounded shape
) (
input logic clk, rst_n, start,
input logic train_ok, // base training reached a usable state
input logic cxl_agreed, // negotiation resolved to CXL
input logic link_error, recovered,
output logic [2:0] state_q,
output logic operational, failed, in_recovery,
output logic [7:0] n_recovery_q, rec_dwell_q,
output logic cxl_before_base_err, op_without_training_err,
output logic unbounded_recovery_err
);
localparam logic [2:0] S_DOWN = 3'd0, S_TRAIN = 3'd1, S_BASE = 3'd2,
S_SETUP = 3'd3, S_OP = 3'd4, S_REC = 3'd5, S_FAIL = 3'd6;
logic [2:0] nxt;
logic may_retry, dwell_done;
assign may_retry = RETRY_FOREVER || (n_recovery_q < MAX_RECOVERY[7:0]);
// An ATTEMPT COUNTER IS NOT A BOUND if the attempt itself can hang. Counting
// entries into recovery bounds nothing while the machine sits in recovery,
// so one attempt needs its own dwell bound before the attempt count can
// advance at all.
assign dwell_done = (rec_dwell_q >= REC_DWELL[7:0]);
assign operational = (state_q == S_OP);
assign failed = (state_q == S_FAIL);
assign in_recovery = (state_q == S_REC);
always_comb begin
nxt = state_q;
case (state_q)
S_DOWN : if (start) nxt = S_TRAIN;
// Base training is PCIe's. Nothing CXL happens here.
S_TRAIN : if (train_ok) nxt = S_BASE;
// The base link exists. CXL setup rides on top of it, never beside it.
S_BASE : nxt = cxl_agreed ? S_SETUP : S_OP;
S_SETUP : nxt = S_OP;
S_OP : if (link_error) nxt = S_REC;
// Recovery re-enters training. It is bounded, and exhausting the bound
// is a FAILURE, not a silent forever-retry.
S_REC : if (recovered) nxt = S_TRAIN;
else if (dwell_done) nxt = may_retry ? S_TRAIN : S_FAIL;
S_FAIL : ;
default : nxt = S_FAIL;
endcase
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state_q <= S_DOWN; n_recovery_q <= 8'd0; rec_dwell_q <= 8'd0;
cxl_before_base_err <= 1'b0; op_without_training_err <= 1'b0;
unbounded_recovery_err <= 1'b0;
end else begin
state_q <= nxt;
rec_dwell_q <= (nxt == S_REC) ? (rec_dwell_q + 8'd1) : 8'd0;
if ((state_q != S_REC) && (nxt == S_REC)) n_recovery_q <= n_recovery_q + 8'd1;
if ((nxt == S_SETUP) && (state_q != S_BASE)) cxl_before_base_err <= 1'b1;
if ((nxt == S_OP) &&
!((state_q == S_BASE) || (state_q == S_SETUP) || (state_q == S_OP)))
op_without_training_err <= 1'b1;
// Stated about the DWELL, which is what actually advances. A check
// written against n_recovery_q cannot fail while the machine is stuck,
// because the thing it counts is exactly what has stopped happening.
if (in_recovery && (rec_dwell_q > REC_DWELL[7:0] + 8'd2))
unbounded_recovery_err <= 1'b1;
end
end
endmodule7. Waveform — Success, and Recovery
A clean bring-up: train, set CXL up on top, operate
11 cyclesRecovery: an error re-enters training and returns to operational
12 cyclesTwo things in the second trace are worth naming.
Recovery re-enters training; it does not resume. The link goes back through TRAIN, BASE and SETUP — five cycles here, and in real hardware a full retrain. That is why a link that recovers frequently can be operational almost never, which §13 measures.
n_recovery advances exactly once per entry. That is correct, and it is also precisely the property that made the first version of the bound useless.
8. RTL 2 — The Start Rate and the Floor
module rate_ladder #(
parameter bit ALLOW_BELOW_FLOOR = 1'b0 // 1 = the broken shape
) (
input logic clk, rst_n,
input logic negotiated, // alternate protocol negotiation done
input logic [2:0] rate_code, // 0:2.5 1:5 2:8 3:16 4:32 5:64
input logic rate_valid,
output logic rate_legal, is_native_rate,
output logic below_floor_err, start_rate_err
);
localparam logic [2:0] START_RATE = 3'd0; // 2.5 GT/s
localparam logic [2:0] FLOOR = 3'd2; // 8.0 GT/s
assign is_native_rate = rate_valid && (rate_code >= 3'd4);
// Before negotiation the link is at the start rate. After it, the rate must
// be at or above the floor.
assign rate_legal = rate_valid &&
(negotiated ? (ALLOW_BELOW_FLOOR ? 1'b1
: (rate_code >= FLOOR))
: (rate_code == START_RATE));
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
below_floor_err <= 1'b0; start_rate_err <= 1'b0;
end else if (rate_valid) begin
if (negotiated && rate_legal && (rate_code < FLOOR)) below_floor_err <= 1'b1;
if (!negotiated && (rate_code != START_RATE)) start_rate_err <= 1'b1;
end
end
endmodule pre-negotiation 2.5 GT/s : legal=1
post-negotiation rate[0] : legal=0 native=0 | allow-below-floor legal=1
post-negotiation rate[1] : legal=0 native=0 | allow-below-floor legal=1
post-negotiation rate[2] : legal=1 native=0 | allow-below-floor legal=1
post-negotiation rate[3] : legal=1 native=0 | allow-below-floor legal=1
post-negotiation rate[4] : legal=1 native=1 | allow-below-floor legal=1
post-negotiation rate[5] : legal=1 native=1 | allow-below-floor legal=1Rate 0 is legal before negotiation and illegal after it. The same value, the same signal, opposite verdicts — because rate_legal is not a property of the rate, it is a property of the rate at a point in bring-up. A design that evaluates it context-free will either reject its own start rate or accept an operating point below the floor, and both mistakes are one missing term.
Note also the four legal post-negotiation rates split 2–2 between degraded and native. Clearing the floor is not the same as being fast, and a report that says "the link is legal" has said almost nothing about performance.
9. RTL 3 — Narrow Is Legal; Wider Than Trained Is Not
module width_negotiate #(
parameter bit DEGRADED_IS_FAILURE = 1'b0 // 1 = the broken shape
) (
input logic clk, rst_n,
input logic [4:0] lanes_requested, lanes_trained,
input logic valid,
output logic [4:0] width_out,
output logic width_legal, is_native_width, degraded, train_ok,
output logic overclaim_err, degraded_rejected_err
);
// You cannot operate wider than the lanes that actually trained.
assign width_out = (lanes_trained < lanes_requested) ? lanes_trained
: lanes_requested;
assign is_native_width = valid && (width_out >= 5'd4); // x4, x8, x16
assign degraded = valid && (width_out >= 5'd1) && (width_out < 5'd4);
assign width_legal = valid && (width_out >= 5'd1);
// The broken shape refuses to come up narrow at all.
assign train_ok = width_legal &&
(DEGRADED_IS_FAILURE ? is_native_width : 1'b1);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
overclaim_err <= 1'b0; degraded_rejected_err <= 1'b0;
end else if (valid) begin
if (width_out > lanes_trained) overclaim_err <= 1'b1;
// Diagnostic on the broken variant: a usable narrow link was refused.
if (DEGRADED_IS_FAILURE && degraded && !train_ok) degraded_rejected_err <= 1'b1;
end
end
endmodule request x16 trained 16 : width=16 native=1 degraded=0 train_ok=1
request x16 trained 8 : width=8 native=1 degraded=0 train_ok=1
request x16 trained 2 : width=2 native=0 degraded=1 train_ok=1 | reject-degraded train_ok=0
request x4 trained 16 : width=4 <-- never wider than requestedRow 2 is the one people misread: x8 from a requested x16 is still a native width. Half the bandwidth, and not degraded mode — because native/degraded classifies the width itself, not the shortfall against what was asked for. Row 3 is degraded, and it is still train_ok on the correct design.
The min in width_out is bidirectional on purpose. It clamps down when fewer lanes trained than were requested, and clamps up — that is, refuses to widen — when more trained than were requested. Mutation M7 replaces it with lanes_requested and the design claims lanes that never trained.
10. RTL 4 — Recovery Must Narrow, Then Conclude
Public material does not specify a recovery policy, so this module models the invariants any such policy must satisfy rather than a specified behaviour: bounded attempts at each operating point, degradation when the bound is hit, and a conclusion when there is nothing left to narrow.
module recovery_policy #(
parameter int unsigned MAX_AT_POINT = 2,
parameter bit NEVER_DEGRADE = 1'b0 // 1 = the broken shape
) (
input logic clk, rst_n, error_seen, retrain_ok,
input logic [2:0] rate_in,
output logic [2:0] rate_out,
output logic [7:0] tries_at_point_q, n_degrade_q,
output logic give_up, stuck_err, degraded_past_floor_err
);
localparam logic [2:0] FLOOR = 3'd2; // 8.0 GT/s
logic [2:0] rate_q;
logic exhausted;
assign exhausted = (tries_at_point_q >= MAX_AT_POINT[7:0]);
assign rate_out = rate_q;
// Give up only when the operating point cannot be narrowed any further.
// NEVER_DEGRADE models the real bug shape: retry at the SAME point forever,
// neither narrowing nor concluding.
assign give_up = exhausted && !NEVER_DEGRADE && (rate_q <= FLOOR);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
rate_q <= 3'd5; tries_at_point_q <= 8'd0; n_degrade_q <= 8'd0;
stuck_err <= 1'b0; degraded_past_floor_err <= 1'b0;
end else begin
if (retrain_ok) begin
tries_at_point_q <= 8'd0;
end else if (error_seen) begin
if (exhausted && !NEVER_DEGRADE && (rate_q > FLOOR)) begin
// Narrow the operating point and reset the attempt count.
rate_q <= rate_q - 3'd1;
n_degrade_q <= n_degrade_q + 8'd1;
tries_at_point_q <= 8'd0;
end else begin
tries_at_point_q <= tries_at_point_q + 8'd1;
end
end
if ((tries_at_point_q > MAX_AT_POINT[7:0] + 8'd1) && !give_up) stuck_err <= 1'b1;
if (rate_q < FLOOR) degraded_past_floor_err <= 1'b1;
end
end
endmodule step error rate_out tries degrades give_up | never-degrade rate give_up
2 1 4 0 1 0 | 5 0
5 1 3 0 2 0 | 5 0
8 1 2 0 3 0 | 5 0
11 1 2 3 3 1 | 5 0
14 1 2 6 3 1 | 5 0
correct policy : rate reached 2 after 3 degrades, give_up=1
never-degrade : rate still 5, tries=16, give_up=0The correct policy walks the rate down 5 → 4 → 3 → 2, stops at the floor, and concludes. The broken one is still at rate 5 after sixteen attempts with no conclusion — not slower, non-terminating.
Note degraded_past_floor_err. Degradation has a limit: a CXL link that cannot hold 8.0 GT/s is not a slower CXL link, it is not a CXL link. Mutation M9 removes the floor check from the degrade path and the policy walks below it.
11. RTL 5 — The Operating Point Is the Pair
module operating_point (
input logic clk, rst_n,
input logic [4:0] width,
input logic [2:0] rate_code,
input logic valid,
output logic [15:0] bw_proxy,
output logic point_native, point_legal,
output logic illegal_point_used_err, bw_mismatch_err
);
logic [15:0] rate_units;
logic w_ok, r_ok, w_native, r_native;
// Teaching proxy: 2.5/5/8/16/32/64 -> 1/2/3/6/13/26 units.
always_comb begin
case (rate_code)
3'd0: rate_units = 16'd1; 3'd1: rate_units = 16'd2;
3'd2: rate_units = 16'd3; 3'd3: rate_units = 16'd6;
3'd4: rate_units = 16'd13; 3'd5: rate_units = 16'd26;
default: rate_units = 16'd0;
endcase
end
assign w_ok = (width >= 5'd1) && (width <= 5'd16);
assign r_ok = (rate_code >= 3'd2); // at or above the 8 GT/s floor
assign w_native = (width >= 5'd4);
assign r_native = (rate_code >= 3'd4);
assign point_legal = valid && w_ok && r_ok;
// Native requires BOTH axes native -- the same AND as Chapter 5.2.
assign point_native = point_legal && w_native && r_native;
assign bw_proxy = point_legal ? ({11'd0, width} * rate_units) : 16'd0;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
illegal_point_used_err <= 1'b0; bw_mismatch_err <= 1'b0;
end else if (valid) begin
if (!point_legal && (bw_proxy != 16'd0)) illegal_point_used_err <= 1'b1;
// Independent restatement: bandwidth is the product, always.
if (point_legal && (bw_proxy != ({11'd0, width} * rate_units)))
bw_mismatch_err <= 1'b1;
end
end
endmodule over 4 widths x 4 rates: native=6 legal-but-degraded=10 illegal=0
x16 @ rate5 : bw=416 <-- the reference point
x8 @ rate5 : bw=208 (half the width)
x16 @ rate4 : bw=208 (one rate step down)Two different degradations, identical bandwidth. Half the width and one rate step down both land on 208 — which means "the link degraded" tells you nothing about how much was lost until you know which axis, and the two have very different causes. A narrow link points at lanes: a broken trace, a bad connector pin, a marginal via. A slow link points at the channel as a whole: insertion loss, crosstalk, equalisation.
Note also native = 6 of 16. This is the same AND-on-both-axes as Chapter 5.2, and mutation M11 replaces it with OR — pushing the native count from 6 to 14, which is exactly the over-claim a datasheet reader makes.
12. Where the Bandwidth Actually Goes
Using the §11 proxy, with x16 at 64 GT/s as the reference:
| Point | of reference | class |
|---|---|---|
| x16 @ 64 | 100% | native |
| x16 @ 32 | 50% | native |
| x8 @ 64 | 50% | native |
| x4 @ 64 | 25% | native |
| x16 @ 16 | 23% | degraded |
| x16 @ 8 | 12% | degraded |
| x1 @ 64 | 6% | degraded |
The last row is the one to sit with. x1 at 64 GT/s clears the rate floor, is a perfectly legal operating point, and delivers about 6% of the reference. Nothing in "the link trained and is legal" distinguishes it from the first row, which is §1's entire complaint restated as a number.
13. RTL 6 — Where Training Time Goes
module training_residency (
input logic clk, rst_n, tick,
input logic in_train, in_setup, in_op, in_rec,
output logic [15:0] t_total_q, t_train_q, t_setup_q, t_op_q, t_rec_q,
output logic accounting_err, multi_state_err
);
logic [2:0] hot;
assign hot = 3'd0 + in_train + in_setup + in_op + in_rec;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
t_total_q <= '0; t_train_q <= '0; t_setup_q <= '0; t_op_q <= '0; t_rec_q <= '0;
accounting_err <= 1'b0; multi_state_err <= 1'b0;
end else begin
if (tick) begin
t_total_q <= t_total_q + 16'd1;
if (in_train) t_train_q <= t_train_q + 16'd1;
if (in_setup) t_setup_q <= t_setup_q + 16'd1;
if (in_op) t_op_q <= t_op_q + 16'd1;
if (in_rec) t_rec_q <= t_rec_q + 16'd1;
end
if (t_total_q != t_train_q + t_setup_q + t_op_q + t_rec_q) accounting_err <= 1'b1;
if (hot > 3'd1) multi_state_err <= 1'b1;
end
end
endmodule total=100 train=30 setup=8 op=50 recovery=12
conservation total == train+setup+op+recovery : 100 == 100
fraction of link life actually operational: 50%Half the link's life was not operational, and no error was reported at any point — every recovery succeeded. This is Chapter 5.1's silent-downgrade problem in the time domain: a link that recovers successfully and often is a link that is mostly retraining, and "recovery succeeded" is the wrong thing to count.
The conservation law is what makes the number trustworthy. Mutation M13 drops recovery time from the sum, which is exactly the mistake that would produce a report claiming 100% accounted-for while 12% of the time is unattributed.
14. Assertions
Concurrent SVA execution: NOT SUPPORTED BY Icarus Verilog. Not executed; each maps to a procedural stand-in and a mutation.
// SAFETY -------------------------------------------------------------------
// V1 — CXL setup is entered only from the base link state.
a_setup_on_base: assert property (@(posedge clk) disable iff (!rst_n)
(state_q == S_SETUP) |-> ($past(state_q) == S_BASE));
// V2 — the base state is never entered before training succeeded.
a_base_needs_trainok: assert property (@(posedge clk) disable iff (!rst_n)
(state_q == S_BASE) |-> $past(train_ok));
// V3 — operational is reached only from base, setup, or itself.
a_op_route: assert property (@(posedge clk) disable iff (!rst_n)
operational |-> $past(state_q) inside {S_BASE, S_SETUP, S_OP});
// V4 — after negotiation the rate is at or above the floor.
a_rate_floor: assert property (@(posedge clk) disable iff (!rst_n)
(rate_valid && negotiated && rate_legal) |-> (rate_code >= FLOOR));
// V5 — before negotiation the link is at the start rate.
a_start_rate: assert property (@(posedge clk) disable iff (!rst_n)
(rate_valid && !negotiated) |-> (rate_code == START_RATE));
// V6 — the operating width never exceeds the lanes that trained.
a_width_bounded: assert property (@(posedge clk) disable iff (!rst_n)
valid |-> (width_out <= lanes_trained));
// V7 — a point is native only if BOTH axes are native.
a_native_both: assert property (@(posedge clk) disable iff (!rst_n)
point_native |-> (w_native && r_native));
// V8 — the rate never degrades below the floor.
a_never_below_floor: assert property (@(posedge clk) disable iff (!rst_n)
rate_q >= FLOOR);
// V9 — CONSERVATION: every tick is in exactly one training state.
a_residency_conserved: assert property (@(posedge clk) disable iff (!rst_n)
t_total_q == t_train_q + t_setup_q + t_op_q + t_rec_q);
// V10 — one recovery attempt is bounded by its dwell.
a_dwell_bounded: assert property (@(posedge clk) disable iff (!rst_n)
in_recovery |-> (rec_dwell_q <= REC_DWELL + 1));
// LIVENESS ------------------------------------------------------------------
// V11 — recovery always terminates: retrain or fail, never neither.
// Provable BECAUSE of V10. Without the dwell bound this needs an
// environment assumption that the link eventually recovers, which is
// exactly the assumption a failing link violates.
a_recovery_terminates: assert property (@(posedge clk) disable iff (!rst_n)
in_recovery |-> s_eventually (operational || failed));V10 and V11 repeat Chapter 5.3's pattern: the liveness property you want, discharged by a bound you can prove. What is new here is that the bound has to be on the inner attempt, not the outer count — V11 would not follow from a bound on n_recovery_q alone, for the reason §6's callout gives.
15. Mutation Testing
Thirteen mutations. Clean code restored after each.
| ID | Mutation | Result |
|---|---|---|
| M1 | CXL setup skipped even when agreed | KILLED — route check |
| M2 | base reached without training succeeding | KILLED — route check |
| M3 | recovery retries regardless of the bound | KILLED — did not give up |
| M4 | the recovery dwell never advances | KILLED — did not give up |
| M5 | no rate floor after negotiation | KILLED — rate table |
| M6 | 16 GT/s reclassified as native | KILLED — rate table |
| M7 | operate at the requested width regardless | KILLED — overclaim_err |
| M8 | x2 reclassified as a native width | KILLED — positive test |
| M9 | degrade below the rate floor | KILLED — degraded_past_floor_err |
| M10 | the attempt count never advances | KILLED — never degraded |
| M11 | one native axis is enough | KILLED — point classification |
| M12 | bandwidth is the sum, not the product | KILLED — bw_mismatch_err |
| M13 | recovery time omitted from conservation | KILLED — conservation |
13/13 killed, 0 escapedFive escaped on the first run, and one of them was different in kind from anything in the previous chapters.
M1, M2, M6 and M11 were missing checks — the same "printed, not asserted" gap that Chapter 5.2 and Chapter 5.3 each hit. Four scenarios displayed their traces and asserted only the final outcome. Fixing M1 and M2 required a distinction the earlier chapters did not need:
Asserting the outcome does not check the route. Every shortcut bug in this chapter reaches the right terminal state by a wrong path, so the testbench has to record which states were visited, not only where it ended.
M2 additionally needed a stimulus change. With train_ok rising on the same cycle a shortcut would enter BASE, there is no window in which "BASE without train_ok" is observable — the route check was correct and could not see anything. Holding train_ok low for four cycles opened the window. It is the Chapter 5.3 identity-value problem in the time domain: a stimulus edge that coincides with the event you are checking hides the difference.
The fifth escape was not a testbench problem at all. It is in §6's callout: the FSM's bound was written against a counter that stops advancing precisely when the design gets stuck, and the diagnostic was written against the same counter. The design and its checker shared a blind spot, which no amount of stimulus would have exposed — only running the bounded and unbounded variants side by side and noticing they behaved identically.
16. Debug Lab
A link retries for hours and reports no error
RECOVERY-NEVER-CONCLUDES// Keep trying to recover.
S_REC : if (recovered) nxt = S_TRAIN;A port on a marginal channel goes down and never comes back. No error is logged, no failure is reported, and the port draws power and produces nothing. Compared against a design with a bound, on the same stimulus:
bounded : state=FAILED failed=1 n_recovery=3
retry-forever: state=OP failed=0 n_recovery=3The recovery state has one exit and it depends on success. If recovery never succeeds — the channel is genuinely bad, the partner is gone, a retimer has failed — there is no path out.
The correct behaviour is not to try harder. It is to narrow the operating point and try again, and to conclude when there is nothing left to narrow. A link that cannot hold 64 GT/s may hold 8; a link that can hold nothing should say so.
S_REC : if (recovered) nxt = S_TRAIN;
else if (dwell_done) nxt = may_retry ? S_TRAIN : S_FAIL;A recovery policy must be able to do three things: retry, narrow, and give up. Missing any one of them produces a link that either fails too eagerly or never fails at all, and the second is worse because it reports nothing. This is Chapter 5.3's unbounded-wait lesson at a different layer — anything that waits on the physical world needs a bound that leads somewhere.
The retry bound is present and does nothing
COUNTER-MEASURES-WHAT-STOPPEDassign may_retry = (n_recovery_q < MAX_RECOVERY);
// n_recovery_q increments only on ENTERING recovery:
if ((state_q != S_REC) && (nxt == S_REC)) n_recovery_q <= n_recovery_q + 8'd1;
// and the diagnostic is written against the same counter:
if (in_recovery && (n_recovery_q > MAX_RECOVERY + 1)) unbounded_recovery_err <= 1'b1;A design with an explicit retry bound behaves identically to one without. Both sit in recovery indefinitely, and the bound's own error flag never fires.
bounded : state=RECOV failed=0 n_recovery=2
retry-forever: state=RECOV failed=0 n_recovery=2
unbounded_recovery_err=0n_recovery_q counts entries into recovery. A link that enters recovery once and never completes the attempt stays there, so the counter stops advancing — and it stops advancing for exactly the reason the bound was supposed to catch. The counter measures the thing that has stopped happening.
The diagnostic was written against the same counter, so the checker had the same blind spot as the design. Two independent-looking safeguards, one shared assumption.
// Bound ONE attempt, then let the attempt count advance.
assign dwell_done = (rec_dwell_q >= REC_DWELL);
rec_dwell_q <= (nxt == S_REC) ? (rec_dwell_q + 8'd1) : 8'd0;
S_REC : if (recovered) nxt = S_TRAIN;
else if (dwell_done) nxt = may_retry ? S_TRAIN : S_FAIL;
// restate the diagnostic against the thing that actually advances:
if (in_recovery && (rec_dwell_q > REC_DWELL + 2)) unbounded_recovery_err <= 1'b1;A bound on retries needs a bound on each try, or the outer bound is decorative. And a diagnostic derived from the same quantity as the mechanism it checks inherits its failure modes — the check must be stated against something that keeps moving when the design is stuck. Ask of any counter-based bound: what advances this, and can the failure I am guarding against stop it?
A link with fourteen bad lanes refuses to come up at all
DEGRADED-TREATED-AS-FAILURE// Only a native width is acceptable.
assign train_ok = width_legal && is_native_width;A board with damage on most lanes brings up nothing. The same board in a PCIe-only slot comes up narrow and works. The failure is reported as a link-training failure with no indication that a usable narrow link was available.
request x16 trained 2 : width=2 native=0 degraded=1 train_ok=1 | reject-degraded train_ok=0
degraded_rejected_err=1Degraded mode was treated as a failure condition rather than an outcome. Public material describes x2 and x1 widths in degraded mode — these are supported operating points, not errors.
Refusing them turns a recoverable situation (a slow link plus a clear diagnostic pointing at specific lanes) into an unrecoverable one (no link, and no information about why). The system loses both the service and the evidence.
assign train_ok = width_legal; // narrow is still up
assign degraded = width_legal && !is_native_width; // and say so, separatelyCome up degraded and report it; do not refuse to come up. The reporting half is not optional — a narrow link that does not announce itself is Chapter 5.1's silent downgrade. The pattern throughout Module 5 is the same: the fallback path is legal, and it must be visible.
A link operates at a width whose lanes never trained
WIDTH-OVERCLAIM// We asked for x16, so use x16.
assign width_out = lanes_requested;A link reports x16 and delivers roughly half the expected bandwidth, with correctable errors clustered on a subset of lanes. The error rate scales with load rather than being constant.
request x16 trained 8 : width=8 (correct)
overclaim_err=1 (mutation M7)The operating width was taken from the request rather than from what actually trained. Traffic is striped across lanes that are not carrying anything, so a fraction of every transfer goes into lanes that did not come up.
It presents as an error-rate problem because that is the visible symptom, and it sends debugging to the channel — but the channel is fine on the eight lanes that trained and irrelevant on the eight that did not.
// The min in both directions: never wider than trained, never wider than asked.
assign width_out = (lanes_trained < lanes_requested) ? lanes_trained : lanes_requested;
if (width_out > lanes_trained) overclaim_err <= 1'b1;A request is not a result. The same distinction runs through Chapter 5.3's negotiation — what is enabled is the intersection, never the request — and it appears here as a width. Any signal named *_requested should be treated as an upper bound, and the assertion result <= requested is cheap and catches the whole family.
The link is legal, and delivers 6% of the expected bandwidth
LEGAL-IS-NOT-THE-OPERATING-POINT// Training done. Report status.
assign link_ok = rate_legal && width_legal;Bring-up reports success on every port. Application throughput on a subset of ports is an order of magnitude below the pilot, and no port reports an error or a degraded state.
x16 @ rate5 : bw=416 <-- the reference point
x1 @ rate5 : bw=26 (6% of reference) legal=1 native=0link_ok reports legality, and both operating points are legal. x1 at the top rate clears the rate floor and is a supported width in degraded mode — the design is behaving exactly as specified.
The problem is that legality is a binary floor check and the operating point is a two-dimensional result. Collapsing the second into the first throws away everything anyone wanted to know.
assign link_ok = rate_legal && width_legal; // the floor
assign point_native = is_native_width && is_native_rate; // the target
assign bw_proxy = width * rate_units; // the number
assign under_target = link_ok && (bw_proxy < expected_bw); // the alarmReport the operating point, not the training status. A boolean cannot carry a two-dimensional result, and the four ways to be "legal but wrong" in §12 are indistinguishable from the top row without the width, the rate, and a comparison against what was expected. Note also that the two axes have different root causes — narrow points at lanes, slow points at the channel — so reporting them merged loses the diagnosis as well as the magnitude.
Every recovery succeeds and the link is operational half the time
SUCCESSFUL-RECOVERY-IS-STILL-DOWNTIME// Count failures.
if (recovery_failed) n_problem_q <= n_problem_q + 16'd1;A fleet reports zero recovery failures and consistently poor throughput on a subset of links. Every recovery on those links succeeds. Latency-sensitive workloads see periodic stalls that do not correlate with anything in the error logs.
total=100 train=30 setup=8 op=50 recovery=12
fraction of link life actually operational: 50%
recovery failures: 0Only failed recoveries were counted. A link that drops and successfully recovers every few seconds registers zero problems while spending a large fraction of its life not carrying traffic — and because recovery re-enters training rather than resuming (§7), each event costs the full training sequence.
Successful recovery is not free. It is downtime that reports success.
// Measure residency, and conserve it.
if (tick) begin
t_total_q <= t_total_q + 16'd1;
if (in_train) t_train_q <= t_train_q + 16'd1;
if (in_rec) t_rec_q <= t_rec_q + 16'd1;
if (in_op) t_op_q <= t_op_q + 16'd1;
end
if (t_total_q != t_train_q + t_setup_q + t_op_q + t_rec_q) accounting_err <= 1'b1;Count time, not events. Recovery count and recovery cost are different measurements, and only the second predicts what a workload experiences. The conservation law is what makes the number defensible — without it, a residency report can add to less than the total and nobody notices, which is exactly what mutation M13 does.
17. Verification Plan
| Item | Approach and goal |
|---|---|
| Training route | record visited states — TRAIN, BASE, SETUP each asserted |
| Guard windows | hold train_ok low across the shortcut — guard observable |
| Rate context | cross negotiated x all rates — legality flips at the floor |
| Native rates | assert the class per rate — boundary at 32 GT/s exact |
| Width clamp | trained above, below and equal to requested |
| Degraded accept | x2, x1 on a healthy channel — up, and reported degraded |
| Recovery policy | repeated failures to the floor — degrades, then concludes |
| Bound integrity | bounded and unbounded side by side — they must differ |
| Operating points | cross width x rate — native count = 6 of 16 |
| Residency | mixed trace with recovery — conserves every tick |
Row 8 is the one that would have caught §6's shared blind spot. Running a bounded design and an unbounded one on the same stimulus and asserting that they diverge is a cheap and unusually effective check, because it tests the bound rather than the behaviour the bound is supposed to constrain.
18. Design Review
- Can recovery retry, narrow, and give up? Missing any one produces a link that never fails or fails too soon.
- What advances every counter used as a bound, and can the failure being guarded against stop it?
- Is any diagnostic derived from the same quantity as the mechanism it checks?
- Is a degraded width accepted and reported, or refused?
- Is the operating width clamped to the lanes that trained, in both directions?
- Does anything report a two-dimensional operating point as a boolean?
- Is rate legality evaluated with the negotiation context, or context-free?
- Is recovery time measured, or only recovery failures?
- Does degradation stop at the floor?
19. How This Appears in Real Engineering
Bring-up reports say "trained" and stop. §1's five outcomes all produce that word. Teams that add the operating point and a comparison against expectation find whole classes of problem that were previously invisible — usually on a subset of boards nobody had reason to look at.
Recovery loops are found by power, not by logs. A port stuck in the Debug Lab 1 pattern logs nothing. It is typically noticed because a system draws more power than expected, or because someone happens to look at a link that should be carrying traffic.
Width and rate degradation get triaged to the same team, wrongly. Narrow points at specific lanes — a trace, a connector pin, a via. Slow points at the channel as a whole — insertion loss, crosstalk, equalisation. Reporting a merged "degraded" flag sends both to whoever owns signal integrity, and half of those investigations are looking at the wrong thing.
The floor is a compatibility cliff, not a gradient. A link that cannot hold 8.0 GT/s is not a slow CXL link; it is not a CXL link. That discontinuity surprises people who expect graceful degradation all the way down, and it is why degraded_past_floor_err exists as a distinct check.
20. Common Misconceptions
| Claim | Why it is wrong |
|---|---|
| "CXL defines its own link training state machine" | It does not. Training is PCIe's; what is CXL-specific is the alternate protocol negotiation carried within it and the constraints on the resulting operating point. |
| "The link trains at 8 GT/s minimum" | It starts training at 2.5 GT/s (Gen 1) and must reach 8.0 GT/s or higher after negotiation. Start rate and operating rate are different questions. |
| "x8 from a requested x16 is degraded mode" | No — x16, x8 and x4 are native widths. Native/degraded classifies the width itself, not the shortfall against the request. |
| "Degraded mode means something is broken" | Degraded mode is a supported operating point. Refusing to come up narrow turns a recoverable situation into an unrecoverable one. |
| "A legal link is a good link" | x1 at 64 GT/s is legal and delivers about 6% of an x16 at 64 GT/s. Legality is a floor check; the operating point is a two-dimensional result. |
| "Successful recovery costs nothing" | Recovery re-enters training, it does not resume. A link that recovers often can be operational half the time with zero recovery failures logged. |
| "A retry counter makes recovery bounded" | Only if each attempt is itself bounded. A counter that advances on entering recovery stops advancing exactly when the design gets stuck. |
| "Degradation can always go one step further" | It stops at the floor. Below 8.0 GT/s there is no CXL link to degrade to. |
21. Interview Reasoning
22. Exercises
-
Calculate. Using §12's proxy, find every operating point delivering between 20% and 60% of the x16-at-64 reference. Group them by which axis was degraded, and state which group a lane-level fault would produce.
-
Trace. Using §6's FSM with
MAX_RECOVERY = 3andREC_DWELL = 4, write the state sequence for a link that errors immediately after each successful retrain and never recovers within the dwell. State the cycle at which it reachesFAILED, and how that changes if only the attempt count is bounded. -
DV task. Write the check that would have caught the shared blind spot in §6 — a bound whose counter stops advancing when the design gets stuck. Explain why no amount of stimulus on the bounded design alone would have found it.
-
Debug task. A fleet shows: 100% links legal, 0 recovery failures, 40% of links below expected throughput, all on one board revision. Give your investigation order, and say which measurement distinguishes a width problem from a rate problem before you touch any hardware.
-
Design. Extend §10's policy to degrade width as well as rate. State the ordering you would use between the two axes, justify it from §12's table, and say which new diagnostic the second axis requires.
-
Critique. Argue that the 8.0 GT/s floor should not exist and that CXL should degrade to any PCIe rate. Give the strongest case, then identify what the floor buys and which failure it prevents.
23. Summary
Training resolves to an operating point — a width and a rate together — and "the link trained" says almost nothing about it.
- Three rates, three jobs: 2.5 GT/s start, 8.0 GT/s floor, 32/64 GT/s native. The start rate is below the floor, and that is by design.
- Native widths x16/x8/x4; degraded x2/x1. Native classifies the width itself, not the shortfall against the request — x8 from a requested x16 is native.
- Degraded is an outcome, not a failure. Come up narrow and report it; refusing loses both the service and the evidence.
- 6 of 16 width-by-rate points are native, because the axes combine with AND. x1 at 64 GT/s is legal and delivers about 6% of the reference.
- Recovery must retry, narrow, and conclude. It re-enters training rather than resuming, so successful recovery is still downtime — measure residency, not events.
- A bound on retries needs a bound on each try, and a diagnostic derived from the same counter as the mechanism inherits its blind spot. Run bounded and unbounded designs side by side and assert they diverge.
- Verification lesson: asserting the outcome does not check the route, and a stimulus edge coinciding with the checked event hides the difference.
Chapter 5.5 closes the module with the last question: once the link has decided and settled, how does software find out what it got?
Standards & specifications
- Governing standard
- CXL Specification (CXL Consortium)(opens CXL Consortium in a new tab)
Defines CXL.io, CXL.cache and CXL.mem, and the coherence and memory-pooling behaviour built on them. System design and deployment topology are not mandated.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the CXL curriculum.
