CXL · Module 15
Resource Discovery
Discovery is a walk over a fabric that changes while you are walking it. This chapter builds the walk, what a device is allowed to claim about itself, how an inventory goes stale, telling absent from slow, and the invariant that a published inventory is one the fabric actually held.
15.1 built the manager. 15.2 built the shape it manages.
Both assumed the manager knows what is out there. This chapter is where that knowledge comes from, and why it is harder than a scan.
1. The Engineering Problem — The Fabric Does Not Hold Still While You Walk It
Discovery sounds like a loop over slots. Five things make it not that.
A walk that skips is a walk that reports a smaller fabric with complete confidence. The property worth checking is not "did it find devices" but "did it visit every slot" — and those are different claims with different failure modes. Section 5 builds both.
Everything a device says about itself is a claim. Its type, its capacity, its capabilities. A manager that binds on an unverified claim binds capacity a host will fault on. Section 6 separates what was verified from what was asserted.
The fabric changes during the scan. A device arriving behind the cursor is missed; one leaving in front of it is recorded as present. Either way the published inventory describes a fabric that existed at no single instant. Section 8 builds the detection and the restart.
An inventory has an age, and age is not the only way it goes wrong. An inventory seconds old is already wrong if something changed; one hours old is still correct if nothing did. Section 10 makes freshness two conditions rather than one.
An element that does not answer is either absent or slow, and the two demand opposite responses. Section 11 builds the retry budget that separates them.
And one that surprises people: a device reachable by two paths is found twice. Counting it twice inflates the fabric's capacity by exactly the capacity that does not exist. Section 12 builds identity properly.
This chapter against 15.1, stated precisely. That chapter owns changing the fabric safely. This one owns knowing what the fabric contains before you change it. If a section here could be moved into 15.1 without loss, it is in the wrong chapter.
2. The One-Sentence Model
Discovery is a complete walk, over a fabric that is moving, producing a versioned inventory in which what was verified is recorded separately from what was claimed — and every failure below is one of complete, moving, versioned or verified missing.
3. What This Chapter Owns
| Ground | Owner |
|---|---|
| The manager's authority and configuration machinery | 15.1 |
| The fabric's shape and its six properties | 15.2 |
| Pool architecture: many hosts, many devices, one fabric | 12.1 |
| Learning what is present, and how far that knowledge can be trusted | this chapter |
Deferred:
| Deferred ground | Owner |
|---|---|
| Binding a discovered device to a host | 15.4 |
| What composable infrastructure needs of all this | 15.5 |
| Device internals and capability negotiation | Modules 20 and 21 |
4. Teaching-Model Boundary
Eight slots, four device types, four generations. A real fabric has hundreds of endpoints and discovery takes far longer relative to the rate of change — which makes every problem in this chapter worse, not better.
What is faithful: the completeness check, the claim-versus-verification split, the cursor-relative change detection, the two-condition freshness rule, the retry budget, identity by device rather than by path, and the generation on the published inventory.
What is not: the slot count, the timeout of four cycles, the capacities in gigabytes, and every width.
Every model is parameterised so the correct behaviour and a specific plausible failure are the same source under a different parameter, instantiated together, driven by one stimulus stream.
5. RTL 1 — A Walk That Visits Everything
module enum_walk #(parameter int SKIP_SUBTREE = 0) (
input logic clk, rst_n,
input logic start, step_en,
input logic [7:0] populated, // which of 8 slots actually hold a device
output logic [2:0] cursor,
output logic [7:0] visited, found,
output logic walking, done,
output logic missed_err, // a populated slot never visited
output logic [7:0] n_walks, n_found, n_visited
);
// The check that makes a walk trustworthy: after it finishes, every
// populated slot must be in the VISITED set -- not merely in the found one.
assign missed_err = done_q && ((populated & ~vis_q) != 8'd0);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
cur_q <= 3'd0; vis_q <= 8'd0; fnd_q <= 8'd0;
walk_q <= 1'b0; done_q <= 1'b0;
n_walks <= 8'd0; n_found <= 8'd0; n_visited <= 8'd0;
end else if (start) begin
// A new walk starts from nothing. Carrying the last walk's visited set
// forward means the second scan of a shrunken fabric never notices.
cur_q <= 3'd0; vis_q <= 8'd0; fnd_q <= 8'd0;
walk_q <= 1'b1; done_q <= 1'b0;
n_walks <= n_walks + 8'd1;
end else if (walk_q && step_en) begin
// SKIP_SUBTREE stops when the cursor reaches the second branch: a walk
// that descends one subtree fully and never comes back for the other.
if ((SKIP_SUBTREE != 0) && (cur_q == 3'd4)) begin
walk_q <= 1'b0; done_q <= 1'b1;
end else begin
vis_q[cur_q] <= 1'b1;
n_visited <= n_visited + 8'd1;
if (populated[cur_q]) begin
fnd_q[cur_q] <= 1'b1;
n_found <= n_found + 8'd1;
end
if (cur_q == 3'd7) begin walk_q <= 1'b0; done_q <= 1'b1; end
else cur_q <= cur_q + 3'd1;
end
end
end
endmoduleEight slots, four of them populated, two of those in the far half:
walk : visited=8 of 8 found=4 missed=0 | skipping walk visited=4 found=2The skipping build reports itself finished. That is the whole point of the section: it sets done, it has a coherent found-set, and it is wrong about half the fabric. Nothing about its output says so except missed_err, which exists precisely because "the walk completed" and "the walk visited everything" are different facts.
Two details are load-bearing and each was a mutation that survived until the testbench was fixed:
- The monitor must not fire mid-walk. During the walk, populated slots are legitimately unvisited. It is gated on
done_q, and the testbench samples it two slots in to prove the gate is doing work. - A new walk starts from nothing. The stimulus runs a second walk over a different fabric — two devices where there were four — and asserts the visited and found sets begin empty. A walk that inherits the previous one's state never notices a fabric that shrank.
6. RTL 2 — Believing What A Device Says About Itself
Finding a device is not knowing what it is.
module dev_identify #(parameter int TRUST_DEVICE = 0) (
input logic clk, rst_n,
input logic identify,
input logic [2:0] rpt_type, // 1 = mem, 2 = accel, 3 = switch
input logic [7:0] rpt_capacity, // in GB, as the device reports it
input logic [7:0] observed_cap, // what the manager can actually see
output logic ident_ok,
output logic [7:0] accepted_cap,
output logic unknown_type_err,
output logic overclaim_err,
output logic [7:0] n_ident, n_rejected, n_overclaim, total_cap
);
logic known_type, capacity_ok;
assign known_type = (rpt_type == 3'd1) || (rpt_type == 3'd2) || (rpt_type == 3'd3);
// A device claiming more capacity than the manager can observe is not an
// error in the device's arithmetic -- it is capacity the fabric will bind
// and a host will fault on.
assign capacity_ok = (rpt_capacity != 8'd0) && (rpt_capacity <= observed_cap);
assign ident_ok = identify && (TRUST_DEVICE != 0 || (known_type && capacity_ok));
assign accepted_cap = (TRUST_DEVICE != 0) ? rpt_capacity
: ((rpt_capacity <= observed_cap) ? rpt_capacity : observed_cap);
assign unknown_type_err = identify && !known_type;
assign overclaim_err = identify && (rpt_capacity > observed_cap);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
n_ident <= 8'd0; n_rejected <= 8'd0; n_overclaim <= 8'd0; total_cap <= 8'd0;
end else if (identify) begin
n_ident <= n_ident + 8'd1;
// Capacity is accumulated only for a device that identified.
if (ident_ok) total_cap <= total_cap + accepted_cap;
else n_rejected <= n_rejected + 8'd1;
if (overclaim_err) n_overclaim <= n_overclaim + 8'd1;
end
end
endmoduleFive devices are presented: an honest one, an overclaiming one, a switch, an unknown type, and one reporting no capacity at all.
identify: 5 seen, 3 refused, 80GB accepted | trusting build recorded 240GB, overclaim=180GB against 240GB. The trusting build's inventory is three times the size of the fabric, and every gigabyte of the difference is capacity a host will be given and will fault on.
Three separate refusal reasons are kept separate because they call for different responses:
| Reason | What it means | What to do |
|---|---|---|
| Unknown type | a device newer than the manager | update the manager, do not bind |
| Overclaim | reported capacity exceeds observable | bind the observable amount, investigate |
| Zero capacity | a device that identified but offers nothing | not a type problem — a broken device |
All three are checked against an oracle written as three plain-integer rules with no reference to the design's expression, and the boundary case is driven explicitly: a device claiming exactly what is observable is accepted. <=, not <. One off here refuses every honest device that fills its capacity exactly.
7. Waveform — Nine Cycles Of A Walk Under Change
Transcribed from the printed trace. Both builds see one stimulus stream.
A fabric changing behind the cursor, and a walk that skips
9 cyclesRead behind against change. Two changes, one cycle apart in effect, and only one of them matters — because only one of them landed where the walk had already been.
8. RTL 3 — The Fabric Changing Under The Walk
module disc_change #(parameter int NO_RESCAN = 0) (
input logic clk, rst_n,
input logic scanning,
input logic [2:0] cursor,
input logic change_ev,
input logic [2:0] change_slot,
input logic scan_done,
output logic change_behind, change_ahead,
output logic rescan_req,
output logic torn_inventory_err,
output logic [7:0] n_changes, n_behind, n_rescans, n_torn
);
logic dirty_q;
// Behind the cursor is the dangerous half: the walk will not come back.
assign change_behind = scanning && change_ev && (change_slot < cursor);
assign change_ahead = scanning && change_ev && (change_slot >= cursor);
assign rescan_req = dirty_q && (NO_RESCAN == 0);
// An inventory published while a slot behind the cursor has changed is an
// inventory of a fabric that never existed at any instant.
assign torn_inventory_err = scan_done && dirty_q && (NO_RESCAN != 0);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
dirty_q <= 1'b0;
n_changes <= 8'd0; n_behind <= 8'd0; n_rescans <= 8'd0; n_torn <= 8'd0;
end else begin
if (change_ev && scanning) n_changes <= n_changes + 8'd1;
if (change_behind) begin
dirty_q <= 1'b1;
n_behind <= n_behind + 8'd1;
end
if (scan_done) begin
if (torn_inventory_err) n_torn <= n_torn + 8'd1;
if (rescan_req) n_rescans <= n_rescans + 8'd1;
// Cleared, or every scan after a dirty one restarts forever.
dirty_q <= 1'b0;
end
end
end
endmodule change : behind=1 ahead=1 rescans=1 | no-rescan build published a torn inventory=1Three boundaries, all of them driven:
- The slot at the cursor is not behind it.
<, not<=. That slot is being visited now, so a change there is seen. A comparison one off here restarts a scan for every change that the scan was about to pick up anyway. - A change outside a scan is neither behind nor ahead. There is no cursor to be relative to. The testbench drives a change with
scanninglow and asserts both outputs stay clear. - A clean scan is clean. After the dirty scan restarts, the next one runs undisturbed and requests no rescan. Without that check, a dirty flag that is never cleared makes every subsequent scan restart forever.
The word for what NO_RESCAN publishes is torn: an inventory assembled from slots read at different times, describing a fabric that held that configuration at no single instant. It is not "slightly out of date". Section 15 makes not publishing one the assembled machine's invariant.
9. RTL 4 — What A Device May Claim Versus What May Be Bound
module claim_check #(parameter int MERGE_CLAIMS = 0) (
input logic clk, rst_n,
input logic assess,
input logic [7:0] claimed_cap, verified_cap,
input logic claims_coherent, verified_coherent,
output logic [7:0] bindable_cap,
output logic bindable_coherent,
output logic unverified_bind_err,
output logic [7:0] n_assessed, n_gaps, total_bindable, total_claimed
);
// What may be bound is what was verified.
assign bindable_cap = (MERGE_CLAIMS != 0) ? claimed_cap
: ((claimed_cap <= verified_cap) ? claimed_cap : verified_cap);
assign bindable_coherent = (MERGE_CLAIMS != 0) ? claims_coherent
: verified_coherent;
assign unverified_bind_err = assess &&
((bindable_cap > verified_cap) || (bindable_coherent && !verified_coherent));
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
n_assessed <= 8'd0; n_gaps <= 8'd0;
total_bindable <= 8'd0; total_claimed <= 8'd0;
end else if (assess) begin
n_assessed <= n_assessed + 8'd1;
// Both numbers are recorded. Only the first may be bound.
total_bindable <= total_bindable + bindable_cap;
total_claimed <= total_claimed + claimed_cap;
if (claimed_cap != verified_cap) n_gaps <= n_gaps + 8'd1;
end
end
endmodule claims : bindable=160GB of 224GB claimed, gaps=1 | merging build binds 224GB, err=1The section exists to make one distinction structural: the inventory records both numbers, and only one of them may be bound.
n_gaps counts the devices where they differ, and that count is more useful than either total. A fabric where claim and verification always agree needs no policy; a fabric where they diverge on one device in twenty has something systematic going on, and the count is what surfaces it.
The coherency half of the check is the one worth naming. A device claiming it supports coherent access, bound into a coherency domain on that claim alone, is 13.3's problem arriving through a door that chapter did not guard. The check has two halves and the testbench drives each independently, because a mutation removing either one leaves the other passing.
10. RTL 5 — An Inventory Going Out Of Date
module inv_freshness #(parameter int NO_EXPIRY = 0) (
input logic clk, rst_n,
input logic scan_complete,
input logic fabric_event, // something changed out there
input logic use_inventory, // the manager acts on it
output logic [7:0] age, events_since,
output logic fresh,
output logic stale_use_err,
output logic [7:0] n_uses, n_stale_uses, max_age, max_events
);
localparam logic [7:0] MAX_AGE = 8'd8;
logic [7:0] age_q, ev_q;
assign age = age_q;
assign events_since = ev_q;
// Fresh is TWO conditions. An inventory can be seconds old and already
// wrong, or hours old and still correct.
assign fresh = (NO_EXPIRY != 0) || ((age_q < MAX_AGE) && (ev_q == 8'd0));
assign stale_use_err = use_inventory && !fresh;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
age_q <= 8'd0; ev_q <= 8'd0;
n_uses <= 8'd0; n_stale_uses <= 8'd0; max_age <= 8'd0; max_events <= 8'd0;
end else begin
if (scan_complete) begin
// A completed scan clears BOTH -- the events are now accounted for.
age_q <= 8'd0; ev_q <= 8'd0;
end else begin
age_q <= age_q + 8'd1;
if (age_q + 8'd1 > max_age) max_age <= age_q + 8'd1;
if (fabric_event) begin
ev_q <= ev_q + 8'd1;
if (ev_q + 8'd1 > max_events) max_events <= ev_q + 8'd1;
end
end
if (use_inventory) begin
n_uses <= n_uses + 8'd1;
if (!fresh) n_stale_uses <= n_stale_uses + 8'd1;
end
end
end
endmodule freshness: fresh=1 stale by age=1 stale by event=1 | no-expiry build stale uses=0 of 3Both staleness paths are driven separately. The inventory expires on age with no events at all, and it expires on a single event while still young. Neither condition subsumes the other, and a design with only one of them is wrong in a different direction:
- Age only: a fabric that changed a millisecond after the scan is trusted for the full window.
- Events only: an inventory nobody refreshed is trusted forever as long as nothing was noticed.
The monitor fires on the use, not on the age. An old inventory nobody consulted has misled nothing, and the testbench asserts exactly that — fresh low and stale_use_err low at the same instant. That mutation survived until the check was added, and the distinction is the difference between a metric that alarms on real harm and one that alarms on a timer.
A completed scan clears both the age and the event count, and the testbench accumulates two events before the scan to prove the second half happens.
11. RTL 6 — Absent, Or Merely Slow
module probe_timeout #(parameter int NO_RETRY = 0) (
input logic clk, rst_n,
input logic probe, response,
output logic answered, timed_out, retrying,
output logic declared_absent,
output logic premature_absent_err,
output logic [7:0] wait_q_o, attempts, n_answered, n_absent, max_wait
);
localparam logic [7:0] TIMEOUT = 8'd4;
localparam logic [7:0] RETRIES = 8'd2;
logic [7:0] w_q, try_q;
logic pending_q;
assign wait_q_o = w_q;
assign attempts = try_q;
assign answered = pending_q && response;
assign timed_out = pending_q && (w_q >= TIMEOUT);
assign retrying = timed_out && (NO_RETRY == 0) && (try_q < RETRIES);
// Absent is a CONCLUSION, and it needs every retry spent before it is drawn.
assign declared_absent = timed_out && !retrying;
assign premature_absent_err = declared_absent && (try_q < RETRIES);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
w_q <= 8'd0; try_q <= 8'd0; pending_q <= 1'b0;
n_answered <= 8'd0; n_absent <= 8'd0; max_wait <= 8'd0;
end else begin
// A probe request while one is outstanding must not restart it, or the
// wait never expires and a slow device is re-probed forever.
if (probe && !pending_q) begin
pending_q <= 1'b1; w_q <= 8'd0; try_q <= 8'd0;
end else if (pending_q) begin
if (response) begin
pending_q <= 1'b0;
n_answered <= n_answered + 8'd1;
end else if (w_q >= TIMEOUT) begin
if (retrying) begin
// The retry starts its wait again, or the budget is spent at once.
try_q <= try_q + 8'd1; w_q <= 8'd0;
end else begin
pending_q <= 1'b0;
n_absent <= n_absent + 8'd1;
end
end else begin
w_q <= w_q + 8'd1;
if (w_q + 8'd1 > max_wait) max_wait <= w_q + 8'd1;
end
end
end
end
endmodule timeout : waited=4 retried=2 absent=1 | no-retry build declared absent early=1The NO_RETRY build declares the device absent on its first timeout, with both retries unspent. A device that was merely slow is removed from the fabric, and everything downstream — allocation, binding, capacity planning — proceeds on an inventory missing a device that is sitting right there.
Three properties the testbench had to be strengthened to cover, each a mutation that survived:
- A probe request while one is outstanding does not restart it. Otherwise a slow device is re-probed forever and never concluded absent — the timeout never expires because the wait keeps resetting. The stimulus asserts a second probe at wait 2 and checks the wait reaches 3, not 0.
- A retry restarts the wait. A retry that inherits the expired wait times out again immediately, which spends the whole retry budget in three cycles and produces the same premature absence by a different route.
- An answer closes the probe. The stimulus lets a device answer late, then runs sixteen idle cycles — long enough for every retry an unclosed probe would have spent — and asserts nothing times out and nothing is declared absent.
12. RTL 7 — One Device, Two Paths
module dup_detect #(parameter int PATH_KEYED = 0) (
input logic clk, rst_n,
input logic record,
input logic [3:0] dev_uid, // the device's own identity
input logic [1:0] via_port, // the path it was found through
input logic [7:0] capacity,
output logic is_new, is_dup,
output logic double_count_err,
output logic [15:0] seen_mask,
output logic [7:0] n_records, n_unique, n_dups, total_cap
);
logic [15:0] seen_q, key_bit;
// Identity is the DEVICE, not the path it was found through.
assign key_bit = (PATH_KEYED != 0)
? (16'd1 << {2'd0, dev_uid[1:0], via_port})
: (16'd1 << {12'd0, dev_uid});
assign is_dup = record && ((seen_q & key_bit) != 16'd0);
assign is_new = record && !is_dup;
assign double_count_err = is_new && ((seen_q & (16'd1 << {12'd0, dev_uid})) != 16'd0);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
seen_q <= 16'd0;
n_records <= 8'd0; n_unique <= 8'd0; n_dups <= 8'd0; total_cap <= 8'd0;
end else if (record) begin
n_records <= n_records + 8'd1;
if (is_new) begin
// Both keys are recorded: the path it came in on, and who it IS.
seen_q <= seen_q | key_bit | (16'd1 << {12'd0, dev_uid});
n_unique <= n_unique + 8'd1;
total_cap <= total_cap + capacity;
end else n_dups <= n_dups + 8'd1;
end
end
endmodule duplicate: 2 unique 96GB from 4 sightings | path-keyed build 3 unique 160GB, double-count=1Same four sightings. One fabric has 96GB in it and the path-keyed build's inventory says 160GB.
The failure is silent all the way until something is bound to the half that does not exist, and then it presents as a device that "lost" capacity — which sends everyone to the device.
double_count_err is the chapter's unreachable monitor. In the correct build is_new implies the device's identity bit is clear, so it cannot fire; testing it required the PATH_KEYED build, and sampling it in the delta before the edge that records the sighting. That is the general shape from 15.1 section 18 again: a monitor that says nothing was counted twice will survive every mutation until you build something that counts twice.
The seen-set is also asserted to remember. The stimulus records the first device again after two others have been recorded, and checks it is still a duplicate. A seen-set overwritten rather than accumulated passes every two-sighting test there is.
13. RTL 8 — What A Scan Costs The Fabric
module scan_cost (
input logic clk, rst_n,
input logic full_scan_done, incr_scan_done,
input logic [7:0] full_cycles, incr_cycles,
input logic fabric_cycle,
output logic [15:0] n_full, n_incr, total_scan, n_fabric,
output logic [7:0] mean_full, mean_incr, scan_share_pct, incr_saving_pct
);
logic [31:0] weighted_scan, weighted_save;
logic [15:0] full_total, incr_total;
assign weighted_scan = {16'd0, total_scan} * 32'd100;
assign scan_share_pct = (n_fabric == 16'd0) ? 8'd0
: (weighted_scan / {16'd0, n_fabric});
assign mean_full = (n_full == 16'd0) ? 8'd0 : (full_total / n_full);
assign mean_incr = (n_incr == 16'd0) ? 8'd0 : (incr_total / n_incr);
// What the incremental scan saves, as a share of the full one.
assign weighted_save = ({24'd0, mean_full} - {24'd0, mean_incr}) * 32'd100;
assign incr_saving_pct = (mean_full == 8'd0) ? 8'd0
: (weighted_save / {24'd0, mean_full});
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
n_full <= 16'd0; n_incr <= 16'd0; total_scan <= 16'd0; n_fabric <= 16'd0;
full_total <= 16'd0; incr_total <= 16'd0;
end else begin
if (full_scan_done) begin
n_full <= n_full + 16'd1;
full_total <= full_total + {8'd0, full_cycles};
total_scan <= total_scan + {8'd0, full_cycles};
end
if (incr_scan_done) begin
n_incr <= n_incr + 16'd1;
incr_total <= incr_total + {8'd0, incr_cycles};
total_scan <= total_scan + {8'd0, incr_cycles};
end
if (fabric_cycle) n_fabric <= n_fabric + 16'd1;
end
end
endmodule cost : full mean=18 incr mean=3 | scanning was 42% of fabric time, incremental saves 83%Two numbers, two different arguments.
83 percent is the case for incremental discovery, and it is the number that gets quoted. 42 percent is the case for discovering less often, and it is the one that matters more: the fabric spent nearly half its time being scanned. Reducing the cost of each scan does not help nearly as much as scanning when something changed rather than on a timer — which is exactly what section 10's event counter enables.
Both guards return 0, not 100, on an empty sample, and both are asserted before any scan is driven. With no fabric time measured the scan share is not 100 percent, and with no full scan ever run there is no baseline for the incremental one to have saved anything against.
14. RTL 9 — The Inventory As A Versioned Structure
module inventory #(parameter int NO_GENERATION = 0) (
input logic clk, rst_n,
input logic publish, read_inv,
input logic [3:0] reader_gen, // the generation the reader last saw
input logic [7:0] new_count,
output logic [3:0] generation,
output logic [7:0] dev_count,
output logic read_current,
output logic stale_read_err,
output logic [7:0] n_publishes, n_reads, n_stale_reads, max_lag
);
logic [3:0] gen_q, lag;
logic [7:0] cnt_q;
assign generation = gen_q;
assign dev_count = cnt_q;
assign lag = gen_q - reader_gen;
// A reader holding an older generation is acting on a fabric that has been
// re-enumerated since. NO_GENERATION cannot tell.
assign read_current = (NO_GENERATION != 0) || (reader_gen == gen_q);
assign stale_read_err = read_inv && !read_current;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
gen_q <= 4'd0; cnt_q <= 8'd0;
n_publishes <= 8'd0; n_reads <= 8'd0; n_stale_reads <= 8'd0; max_lag <= 8'd0;
end else begin
if (publish) begin
gen_q <= gen_q + 4'd1;
cnt_q <= new_count;
n_publishes <= n_publishes + 8'd1;
end
if (read_inv) begin
n_reads <= n_reads + 8'd1;
if (!read_current) begin
n_stale_reads <= n_stale_reads + 8'd1;
// One generation behind is a race; several is a consumer that
// stopped refreshing.
if ({4'd0, lag} > max_lag) max_lag <= {4'd0, lag};
end
end
end
end
endmodule version : gen=4 stale reads=2 worst lag=3 | unversioned build stale reads=0The lag is what makes this more than a boolean, and its two values in the transcript mean different things:
- One generation behind is a race. The inventory was republished between the reader taking it and acting on it, and that will happen occasionally in any system.
- Three generations behind is a consumer that stopped refreshing. It is not a race; something is holding a reference and never checking it, and it will keep making decisions on that view indefinitely.
The peak is latched, and the testbench brings the reader up to date and asserts the latched worst case does not fall.
As in section 10, the monitor fires on the read, not on the lag. A reader that is behind and does not read has misled nothing, and the testbench asserts read_current low with stale_read_err low simultaneously.
15. RTL 10 — Discovery Assembled
module disc_top #(parameter int PUBLISH_DIRTY = 0) (
input logic clk, rst_n,
input logic start, walk_done, ident_done, verify_done,
input logic fabric_changed,
output logic [2:0] phase, // 0 idle 1 walk 2 identify 3 verify 4 publish
output logic publishing, dirty,
output logic published_dirty_err,
output logic [7:0] n_published, n_restarted, scan_cycles, max_scan
);
assign publishing = (ph_q == PUB);
// Publishing an inventory built across a change is publishing a fabric that
// never existed at any single instant.
assign published_dirty_err = publishing && dirty_q && (PUBLISH_DIRTY != 0);
...
if (fabric_changed && (ph_q != IDLE)) dirty_q <= 1'b1;
case (ph_q)
IDLE: if (start) begin ph_q <= WALK; sc_q <= 8'd0; dirty_q <= 1'b0; end
WALK: if (walk_done) ph_q <= IDENT;
IDENT: if (ident_done) ph_q <= VERIFY;
VERIFY: if (verify_done) begin
if (dirty_q && (PUBLISH_DIRTY == 0)) begin
ph_q <= WALK; sc_q <= 8'd0; dirty_q <= 1'b0;
n_restarted <= n_restarted + 8'd1;
end else ph_q <= PUB;
end
PUB: begin ph_q <= IDLE; n_published <= n_published + 8'd1; end
default: ph_q <= IDLE;
endcaseAnd the interval measurement running underneath it:
if (ph_q != IDLE) begin
sc_q <= sc_q + 8'd1;
if (sc_q + 8'd1 > max_scan) max_scan <= sc_q + 8'd1;
end assembled: restarted=1 published=1 longest scan=8 | dirty-publish build err=1The fabric changes during the verify phase. The correct build throws the scan away and starts again; the other publishes what it has. One stimulus, one parameter apart.
Every guard in the case statement is held open by the testbench to prove it is load-bearing: the machine sits in WALK until the walk finishes, in IDENT until identification does. And the restart clears the dirty flag, which the testbench checks — a restart that inherits the flag restarts forever, and a discovery that never completes is worse than one that publishes something slightly wrong.
The cost is measured, not assumed: the longest scan was eight cycles, latched, and the restart doubled the work for that round.
16. Quantitative Reasoning
| Quantity | Value, and where it comes from |
|---|---|
| Slots visited by a complete walk | 8 of 8 — the completeness check |
| Devices found | 4 — of eight slots |
| Slots visited by the skipping walk | 4 — and it reports itself finished |
| Devices it found | 2 — half the fabric, full confidence |
| Devices presented for identification | 5 — honest, overclaim, switch, unknown, zero |
| Refused | 3 — three different reasons |
| Capacity accepted | 80GB — verified |
| Capacity the trusting build recorded | 240GB — three times the fabric |
| Changes during the scan | 3 — two ahead, one behind |
| Changes behind the cursor | 1 — the only one that matters |
| Rescans requested | 1 — by the correct build |
| Torn inventories published | 0 / 1 — correct build / no-rescan build |
| Capacity claimed across three devices | 224GB — asserted |
| Capacity bindable | 160GB — verified |
| Devices where the two differed | 1 — the count that surfaces a pattern |
| Inventory age limit | 8 cycles — one of two freshness conditions |
| Stale uses, correct build | 2 of 3 — one by age, one by event |
| Stale uses, no-expiry build | 0 — it cannot tell |
| Probe timeout | 4 cycles — per attempt |
| Retries before declaring absence | 2 — spent in full |
| Premature absences, no-retry build | 1 — a slow device removed |
| Sightings recorded | 4 — two of them duplicates |
| Unique devices, correct | 2 (96GB) — identity by device |
| Unique devices, path-keyed | 3 (160GB) — identity by path |
| Mean full scan | 18 cycles — two samples |
| Mean incremental scan | 3 cycles — two samples |
| Share of fabric time spent scanning | 42% — the cost imposed |
| Saving from incremental scanning | 83% — per scan |
| Inventory generations published | 4 — versioned |
| Stale reads | 2 — worst lag 3 |
| Scans restarted | 1 — rather than published torn |
Three worth a sentence.
80GB against 240GB. One parameter apart, same five devices. Every gigabyte of the difference is capacity something will be allocated and will fault on.
42 percent against 83 percent. The saving per scan is the number that gets quoted; the share of fabric time is the number that should drive the decision, and it argues for scanning less often rather than scanning faster.
Lag 1 against lag 3. Same counter, two different diagnoses — a race, and a consumer that stopped refreshing. Without the lag, both read as "stale".
17. Assertions
Every property is an immediate check written as cond !== 1'b1 and sampled after a settle.
| # | Property | Model |
|---|---|---|
| 1 | The walk starts with nothing visited | walk |
| 2 | Mid-walk, unvisited populated slots are not "missed" | walk |
| 3 | A complete walk visits every slot | walk |
| 4 | And finds exactly the populated ones | walk |
| 5 | With nothing missed | walk |
| 6 | The skipping walk reports itself finished | walk |
| 7 | Having visited only the first half | walk |
| 8 | And found half the devices | walk |
| 9 | Which is a populated slot it never visited | walk |
| 10 | A second walk starts from nothing | walk |
| 11 | And finds exactly what is there now | walk |
| 12 | Identification matches an independent oracle | identify |
| 13 | A verified claim is bindable in full | identify |
| 14 | A device claiming more than is observable is refused | identify |
| 15 | And the manager takes what it can see | identify |
| 16 | The trusting build records capacity that does not exist | identify |
| 17 | A switch is a type the fabric recognises | identify |
| 18 | An unrecognised type is refused and named | identify |
| 19 | A zero-capacity device is refused — not a type problem | identify |
| 20 | Only verified capacity is accumulated | identify |
| 21 | A change ahead of the cursor is still to be visited | change |
| 22 | A change behind it has already been passed | change |
| 23 | The slot at the cursor is not behind it | change |
| 24 | A change outside a scan is neither | change |
| 25 | A dirty scan must be repeated | change |
| 26 | The no-rescan build publishes a torn inventory | change |
| 27 | A second, undisturbed scan starts clean | change |
| 28 | The bindable capacity matches an independent oracle | claims |
| 29 | Only what was verified may be bound | claims |
| 30 | The merging build binds the claim | claims |
| 31 | An unverified coherency claim is not bindable | claims |
| 32 | Both halves of the check fire independently | claims |
| 33 | Claimed and bindable totals are recorded separately | claims |
| 34 | A fresh scan is age zero with no events | freshness |
| 35 | Freshness matches an independent two-condition oracle | freshness |
| 36 | It expires on age with no events at all | freshness |
| 37 | And on an event while still young | freshness |
| 38 | A stale inventory nobody reads has misled nothing | freshness |
| 39 | A completed scan clears the age and the events | freshness |
| 40 | The no-expiry build sees nothing wrong | freshness |
| 41 | A probe's wait counts up | timeout |
| 42 | A second probe does not restart an outstanding one | timeout |
| 43 | Four cycles with no answer is a timeout | timeout |
| 44 | The correct build retries rather than concluding | timeout |
| 45 | A retry starts its wait again from the beginning | timeout |
| 46 | The no-retry build declares absence with retries unspent | timeout |
| 47 | Absence is concluded only after every retry | timeout |
| 48 | A device answering late is found, not removed | timeout |
| 49 | And an answered probe never times out later | timeout |
| 50 | The first sighting of a device is new | duplicate |
| 51 | The same device down a second path is a duplicate | duplicate |
| 52 | Its capacity is not added again | duplicate |
| 53 | The path-keyed build counts it twice | duplicate |
| 54 | And is about to double-count before the edge that records it | duplicate |
| 55 | A different device is a different device | duplicate |
| 56 | A device seen earlier is still remembered | duplicate |
| 57 | Before any fabric time the scan share is 0, not 100 | cost |
| 58 | With no full scan there is no baseline saving | cost |
| 59 | Full and incremental means divide by their own counts | cost |
| 60 | The scan share is measured against fabric time | cost |
| 61 | The first published inventory is generation 1 | inventory |
| 62 | A reader holding the current generation is current | inventory |
| 63 | A reader one behind is stale — a race | inventory |
| 64 | A reader three behind stopped refreshing | inventory |
| 65 | A stale view nobody reads has misled nothing | inventory |
| 66 | A refreshed reader is current again | inventory |
| 67 | Without lowering the latched worst lag | inventory |
| 68 | The unversioned build cannot tell | inventory |
| 69 | The assembled scan waits in each gated phase | assembled |
| 70 | A change mid-scan marks it dirty | assembled |
| 71 | The correct build restarts rather than publishing | assembled |
| 72 | With the scan clean again | assembled |
| 73 | The other build publishes a torn inventory | assembled |
| 74 | The clean scan reaches publish | assembled |
| 75 | And the longest scan was latched | assembled |
18. Mutation Testing
110 mutations, one at a time, each required to make the baseline print RESULT: FAIL.
110 of 110 were killed.
The first run killed 94 and left 16 survivors. The classification:
| Class | Count | The fix |
|---|---|---|
| Stimulus gap | 11 | drive the case |
| Unreachable checker | 2 | sample the broken build, in the right delta |
| Provably equivalent | 1 | replace the mutation |
| Second-order state gap | 2 | run the thing twice |
The last row is new in this chapter and worth naming. Two mutations — a walk that does not clear its visited set, and a dirty flag that is never cleared — are invisible to any test that runs the machinery once. Both need a second scan to expose, and both are exactly the kind of defect that survives bring-up and appears in production, because bring-up scans once and production scans forever.
The two unreachable checkers were double_count_err and, in a subtler way, the timing of its sample. Getting it observable required both the PATH_KEYED build and sampling in the delta before the recording edge — after the edge, the sighting is already recorded and is_new has gone low. A monitor can be reachable and still unobservable if you look one cycle late.
A representative sample:
| Mutation | Result |
|---|---|
| A missed slot is never flagged | KILLED |
| The missed test fires before the walk finishes | KILLED |
| A new walk does not clear the visited set | KILLED |
| The walk stops one slot early | KILLED |
| An empty slot is recorded as holding a device | KILLED |
| The skipping build walks the whole fabric | KILLED |
| Any type is a known type | KILLED |
| A device may report more than is observable | KILLED |
| The capacity test is exclusive at the boundary | KILLED |
| The manager records the reported capacity | KILLED |
| Capacity is recorded for a refused device | KILLED |
| Behind and ahead are the wrong way round | KILLED |
| The slot at the cursor counts as behind it | KILLED |
| Changes are detected outside a scan too | KILLED |
| The dirty flag is never cleared | KILLED |
| Age alone never expires the inventory | KILLED |
| An event does not make it stale | KILLED |
| Staleness is flagged without a use | KILLED |
| A completed scan does not clear the events | KILLED |
| A device is declared absent while retries remain | KILLED |
| A retry does not reset the wait | KILLED |
| A new probe restarts one already outstanding | KILLED |
| A response does not clear the probe | KILLED |
| Identity includes the path it was found through | KILLED |
| The seen set is overwritten each time | KILLED |
| A double count is never flagged | KILLED |
| The scan share is measured against the scanning | KILLED |
| The no-baseline guard returns 100 not 0 | KILLED |
| The lag is computed the wrong way round | KILLED |
| A dirty inventory is published anyway | KILLED |
19. Verification Strategy
Parameterised twin builds. SKIP_SUBTREE, TRUST_DEVICE, NO_RESCAN, MERGE_CLAIMS, NO_EXPIRY, NO_RETRY, PATH_KEYED, NO_GENERATION, PUBLISH_DIRTY. Nine failures, each the same source under one parameter, each measured against a correct build driven by the identical stimulus.
Independent oracles. Identification (three rules), freshness (two conditions) and bindable capacity (the smaller of two) are each checked against a plain-integer function that shares no structure with the design's expression.
Run it twice. A second walk over a different fabric, a second scan after a dirty one, a fourth sighting of the first device. Three distinct state-reset defects live only in the second run.
Sample in the right delta. double_count_err is asserted in the cycle before the edge that records the sighting. Sampled after, the sighting is recorded and the monitor has correctly gone low — the check would pass on a broken design.
Fire on the harm, not on the condition. Both stale_use_err and stale_read_err are gated on an actual use or read, and both are asserted low while their underlying condition is true. A stale inventory nobody consulted has misled nothing, and a monitor that alarms on the condition alone alarms constantly and gets disabled.
Boundaries in both directions. A device claiming exactly what is observable is accepted. The slot at the cursor is ahead, not behind. An empty sample returns 0, not 100.
Delta discipline. Every combinational sample follows a settle.
20. Synthesis and Implementation Reality
Most of this chapter is firmware, and being precise about which parts are not is the useful content.
The walk is software. A cursor over a slot list, a visited bitmask, a completeness check at the end. It runs on the fabric manager, and the reason it appears here as RTL is that expressing it as executable, mutation-tested logic is the only way to make "the scan was complete" a claim that can be wrong.
The completeness check should nonetheless be code, not a comment. populated & ~visited is one instruction. It is the difference between a discovery that can report a fabric half its real size and one that cannot, and it is routinely absent because a walk that finishes feels like a walk that worked.
The claim-versus-verification split is a data-structure decision made once and very hard to retrofit. If the inventory has one capacity field, every consumer downstream has already lost the distinction, and adding it later means auditing every reader. Two fields from the start costs a word per device.
The generation counter is the cheapest thing in the chapter and the one that makes every stale-view bug diagnosable rather than mysterious. Four bits, and every consumer records which generation it acted on.
What is genuinely hardware is thin: the timers that produce a probe timeout, and the counters. A real timeout is orders of magnitude longer than four cycles and is a programmable register, because the right value depends on the device and nobody knows it at design time.
The divisions are not synthesisable as written, and here that matters less than usual — every ratio in this chapter is computed by the manager's software anyway.
21. Silicon Observability
| Signal | Why it is worth recording |
|---|---|
missed_err per scan | a scan that finished without visiting everything |
n_visited against slot count | the completeness ratio, per scan |
n_overclaim | devices reporting more than is observable |
n_gaps | devices where claim and verification differ at all |
n_behind | changes that landed behind the cursor |
n_restarted | scans thrown away — the real cost of a moving fabric |
n_torn | inventories published across a change |
max_age, max_events | how stale an inventory was allowed to get |
n_stale_uses | decisions made on a stale view |
max_wait, n_absent | probe latency, and devices concluded missing |
premature_absent_err | devices removed with retries unspent |
n_dups | devices found down more than one path |
double_count_err | capacity counted twice |
max_lag | how far behind the worst consumer got |
scan_share_pct | fabric time spent being discovered |
Three to alarm on.
n_overclaim non-zero means a device is misreporting itself. It may be benign, and it may be the first sign of a device that will fault when the claimed region is touched — but either way, the manager caught it and something should know.
max_lag greater than one is a consumer that stopped refreshing. One is a race; more is a component holding a reference forever, and it will keep deciding on that view until someone notices.
premature_absent_err non-zero means a working device was removed from the fabric. Everything downstream will be correct about an inventory that is missing something.
22. Debug Lab
22.1 The manager reports fewer devices than are installed
Symptom. Discovery completes successfully. Some installed devices do not appear.
The wrong move. Debug the missing devices. They may be fine.
The signature. missed_err and n_visited.
| Reading | Diagnosis |
|---|---|
missed_err set, n_visited below the slot count | the walk is incomplete — it never looked |
n_visited complete, devices still missing | the walk looked; identification refused them |
n_visited complete, n_absent non-zero | they did not answer in time |
n_visited complete, n_dups unexpectedly high | they were found and folded into another device |
Four causes, four different investigations, and the first two are distinguished by one counter. Without it, "a device is missing" sends everyone to the device, and in the first row the device was never asked anything at all.
22.2 A host was given memory that faults when touched
Symptom. A host is allocated capacity, and accesses beyond some offset fault.
The reading. n_overclaim and the gap between claimed and bindable in the inventory.
The diagnosis. The device reported more capacity than the manager could verify, and something bound the claim rather than the verification. The offset where faults begin is the verified capacity.
Why the counter matters more than the fault. The fault happens at allocation time plus however long until a workload reaches that offset — potentially days. n_overclaim fires at discovery time, and it names the device.
22.3 The fabric's total capacity is wrong and no device is wrong
Symptom. Summed capacity across the inventory exceeds what is installed. Every individual device reports correctly.
The reading. n_dups, n_unique against the physical device count, and double_count_err.
The diagnosis. A device reachable by two paths was recorded twice. Its own reporting is perfectly correct; the inventory keyed it by path rather than by identity.
The confirmation. n_unique exceeds the number of physical devices. That comparison is the whole diagnosis, and it needs a source of truth outside the inventory — which is why the physical device count belongs in the fabric's configuration rather than being derived from a scan.
22.4 Allocations keep landing on devices that have moved
Symptom. The manager binds a device to a host and the binding fails, or lands on the wrong resource. Re-running the operation works.
The reading. n_stale_uses, max_lag, and max_events.
The diagnosis, in three sub-cases.
If max_lag is 1, this is a race: the inventory was republished between a consumer reading it and acting on it. The fix is re-validating the generation at the point of action, not at the point of read.
If max_lag is large, a consumer stopped refreshing and is deciding on a view several generations old. The fix is in that consumer.
If max_events is large while max_age is small, the fabric is changing faster than the scan interval and every inventory is stale on arrival. That is not a consumer problem and no consumer-side fix will touch it — the discovery cadence has to follow the events rather than a timer, and section 13's 42 percent is what that costs if you simply scan more often instead.
23. Design Review
1. Does the scan verify that it visited every slot, or only that it finished? One instruction apart, and completely different guarantees.
2. Does the inventory record what a device claimed separately from what was verified? If there is one capacity field, the distinction was lost at the first write and every consumer inherited the loss.
3. What happens if the fabric changes during a scan? "It gets picked up next time" is only true for changes ahead of the cursor.
4. Can a published inventory describe a state the fabric never held? If a change behind the cursor does not force a restart, yes.
5. How does the inventory expire — by age, by event, or both? Either alone is wrong in a specific, nameable direction.
6. Does the staleness monitor fire on the age, or on a use? Firing on the age alarms constantly and gets turned off.
7. How many times is an element probed before it is declared absent, and does each retry restart its wait? Both, or a slow device is removed as missing.
8. Is a device identified by what it is, or by where it was found? Two paths to one device is not exotic in a redundant fabric — it is what redundancy means.
9. Does the inventory carry a generation, and does every consumer record which one it acted on? Four bits, and it is the difference between a diagnosable stale-view bug and a mysterious one.
10. What fraction of fabric time is spent scanning, and is discovery triggered by events or by a timer? The second question is usually the answer to the first.
24. How This Appears In Real Engineering
Discovery is written for a static fabric and then deployed on a dynamic one. Every model in this chapter is trivial if nothing moves. The whole difficulty is that composable infrastructure means things move by design, and the discovery code usually predates that requirement.
The claim/verification split is the one that gets collapsed. It is two fields instead of one, and the second is only interesting when they differ — which is rarely, until a device misreports and there is no record of what it said versus what was checked.
Timeouts get tuned by making them longer. A device that occasionally does not answer produces a longer timeout, then a longer one. The retry budget is the structural fix and it is a different shape of change: it separates "slow" from "gone" instead of accommodating both.
Duplicate detection is discovered after a redundant fabric is deployed. In a tree there is one path to everything and identity by path works perfectly. Adding the second path for redundancy — which is 15.2 section 10's fix for single points of failure — is what breaks it, and the two changes are usually made by different people.
Nobody versions the inventory until a stale-view bug costs a week. The generation is trivial to add up front and requires touching every consumer to add later.
25. Common Misconceptions
"The scan completed, so we know what is out there." Completed and complete are different claims. The skipping build in section 5 completes, sets done, and is wrong about half the fabric.
"The device reported its capacity, so that is its capacity." It is what the device claims. Section 6's trusting build recorded 240GB in an 80GB fabric on exactly that reasoning.
"Changes during a scan get picked up next time." Only changes ahead of the cursor. A change behind it is not merely missed — it makes the current inventory describe a fabric that never existed.
"The inventory is only a few seconds old, so it is fine." Age is one of two conditions. An inventory seconds old is wrong if something changed in those seconds, which is precisely when discovery matters.
"An old inventory is a problem." Only when something acts on it. Both staleness monitors here fire on the use, because a metric that alarms on the condition alone alarms constantly.
"It did not answer, so it is not there." It did not answer yet. Section 11's no-retry build removes working devices on that reasoning, and everything downstream is then correct about an inventory missing a device.
"A device found twice is obviously a bug we would notice." It is silent until something is bound to the half that does not exist, and it then presents as a device that lost capacity — which sends everyone to the device.
"Incremental scanning solves the cost problem." It saves 83 percent per scan and the fabric still spent 42 percent of its time being scanned. Scanning less often is the larger lever, and it needs the event counter from section 10 to be safe.
26. Interview Reasoning
Q1. What is the property that makes a discovery scan trustworthy? That it visited every slot, not that it found devices or that it finished. Those are three different claims, and only the first rules out reporting a fabric half its real size.
Q2. How do you check it?
populated & ~visited at the end of the walk. One instruction, and it is the difference between a discovery that can silently under-report and one that cannot.
Q3. Why must that monitor be gated on the walk having finished? Because mid-walk, populated slots are legitimately unvisited. Ungated it fires on every scan from the first cycle and is disabled within a week.
Q4. A walk finishes and reports half the fabric. What does its output look like?
Entirely healthy. It sets done, its found-set is internally consistent, and every device in it is real. Nothing distinguishes it from a correct scan except the completeness check.
Q5. What is a device allowed to tell you about itself? Anything. The question is what you record and what you bind. Record both the claim and the verification; bind only the verification.
Q6. A device reports 128GB and 64GB is observable. What do you do? Bind 64, record both numbers, and count it as an overclaim. Refusing the device entirely loses 64GB that is genuinely there; binding 128 gives a host memory that faults.
Q7. Why keep three refusal reasons separate? Unknown type means update the manager. Overclaim means investigate the device and bind less. Zero capacity means the device is broken. Merging them into "rejected" loses the response.
Q8. Why is <= rather than < load-bearing in the capacity check?
Because a device claiming exactly what is observable is honest. One off refuses every device that fills its capacity exactly, which is most of them.
Q9. What is a torn inventory? One assembled from slots read at different times, describing a configuration the fabric held at no single instant. It is not "slightly out of date" — it never existed.
Q10. Why does a change behind the cursor matter more than one ahead? The walk will reach a slot ahead of it. It will not come back for one behind. Same event, two positions, entirely different consequences.
Q11. Is a change at the cursor behind it or ahead?
Ahead. That slot is being visited now, so the change is seen. < not <=, or every change the scan was about to pick up anyway triggers a restart.
Q12. Restart the scan, or publish and fix up? Restart. The fix-up path requires knowing which slots were read before the change, which is exactly the state a scan does not keep — and a partially corrected inventory is still an inventory of a fabric that never existed.
Q13. What does it cost? The scan again. Section 15 measures it: one restart doubled that round's work. That is the price of the invariant, and it is worth stating rather than hiding.
Q14. What makes an inventory stale? Two independent conditions: age, and events since the scan. Neither subsumes the other, and a design with only one is wrong in a specific direction.
Q15. Which direction, for each? Age only: a fabric that changed immediately after the scan is trusted for the whole window. Events only: an inventory nobody refreshed is trusted forever as long as nothing was noticed.
Q16. Should the staleness monitor fire when the inventory goes stale, or when something uses it? On the use. A stale inventory nobody consulted has misled nothing, and a monitor firing on the condition alone alarms constantly and gets disabled — which is worse than not having it.
Q17. An element does not answer. Absent or slow? Unknown until the retry budget is spent. Concluding either one early is a distinct failure: declaring absence removes a working device, and never concluding means discovery never finishes.
Q18. Why must a retry restart the wait? A retry inheriting the expired wait times out again immediately, spending the whole budget in a few cycles. It produces the same premature absence by a different route, and it looks like retries were tried.
Q19. Why must a probe request not restart an outstanding probe? Because the wait never expires, the timeout never fires, and a slow device is re-probed forever. That is the mirror image of Q18 and it is just as fatal.
Q20. A device is reachable by two paths. How many devices is it? One. Identity is the device, not the path. This is not an edge case — it is what redundancy means, and 15.2 section 10 is where the second path came from.
Q21. What does keying by path cost? Exactly the capacity that does not exist. Section 12: 160GB recorded in a 96GB fabric, silently, until something is bound to the phantom half.
Q22. How would you catch it? Compare unique devices against the physical count from somewhere other than the scan. An inventory cannot detect its own double-counting without an external source of truth.
Q23. Why does the inventory need a generation? So a consumer can say which view it acted on. Without it, a decision made on a stale inventory is indistinguishable from a decision made on a current one, forever.
Q24. Lag of 1 versus lag of 3 — same problem? No. One is a race and will happen occasionally in any system. Three is a consumer holding a reference and never refreshing, and it will keep deciding on that view indefinitely. Different fixes.
Q25. Full scans cost 18 cycles, incremental 3. What is the more important number? Neither. The 42 percent share of fabric time is, because it argues for scanning less often rather than faster — and the event counter is what makes that safe.
Q26. Why must an empty-sample guard return zero rather than a hundred? A fabric that has done nothing has not spent all its time scanning, and a scan that never ran has not saved anything. Both are asserted before any stimulus, which is the only moment the guard is reachable.
Q27. How do you test a monitor that says "nothing was counted twice"?
Build something that counts twice. And sample it in the right delta — after the recording edge, is_new has gone low and the monitor has correctly cleared, so the check would pass on a broken design.
Q28. What class of defect does running a model once never find? Anything that should be reset between runs: a visited set, a dirty flag, a seen-set, a cursor. Two mutations here lived entirely in that gap, and both are the shape of bug that survives bring-up and appears in production.
Q29. The manager reports fewer devices than are installed. First reading?
missed_err and n_visited. If the walk was incomplete, the missing devices were never asked anything and debugging them is wasted. If the walk was complete, identification, timeouts or duplicates folded them away, and each is a different investigation.
Q30. If you could add one field to the inventory, which? The generation. It is four bits, it makes every stale-view bug diagnosable instead of mysterious, and it is the one field that cannot be added later without auditing every consumer.
27. Exercises
1. Add a PARTIAL_ANSWER parameter to dev_identify where a device reports type but not capacity. Decide whether it is identified, and justify the decision from what could be bound.
2. Extend disc_change to track which slots changed behind the cursor, and implement a targeted re-scan of only those. Measure the saving against a full restart and decide whether the extra state is worth it.
3. Make inv_freshness expire on a weighted combination of age and events. Choose the weights from section 16's numbers and show a case where your rule is better than either condition alone.
4. Give probe_timeout an exponential backoff. Show that the total time to declare absence grows, and argue whether that is acceptable for a device that is genuinely gone.
5. Extend dup_detect to report the set of paths each device was found through. Show that this is strictly more useful than a duplicate count, and say what it enables in 15.2's SPOF analysis.
6. Add a reader to inventory that re-validates the generation at the point of action rather than the point of read. Show it closes the lag-1 race and does not close the lag-3 one.
7. Drive disc_top with a fabric that changes every scan. Show that it never publishes, and design the escape hatch — then argue for what it should publish when it takes it.
8. Take the 110-mutation suite and remove the second walk from the testbench. Confirm the two state-reset mutations return, and write down what each would have cost in a fabric that scans continuously.
28. Summary
Discovery is a complete walk over a moving fabric producing a versioned inventory in which what was verified is separate from what was claimed.
- Complete: the skipping walk reported itself finished having visited four slots of eight. Completed is not complete, and
populated & ~visitedis the whole difference. - Moving: three changes during one scan, and only the one behind the cursor mattered. The correct build threw the scan away; the other published a fabric that existed at no instant.
- Versioned: four generations, two stale reads, and a worst lag of three — a race and a consumer that stopped refreshing, told apart by one counter.
- Verified: 80GB accepted against 240GB recorded by the trusting build, and 96GB against 160GB when identity was keyed by path instead of by device.
And the cost, measured rather than assumed: 42 percent of fabric time spent scanning, which argues for discovering when something changes rather than on a timer — and that is only safe because section 10 counts the changes.
110 mutations, 110 killed. The two worth remembering are the ones a single run could never find: a walk that does not clear its visited set, and a dirty flag that is never cleared. Both work perfectly the first time. Run it twice.
15.4 takes the inventory as given and binds something to a host.
Continue learning
Related tutorials
- Related topic
CXL Discovery
How software finds out what a CXL device is: walking PCIe's capability list to the CXL DVSEC, the exactly-one rule on the primary function, which functions are not CXL, and why the walk must be bounded. Six RTL models simulated, twelve mutations, twelve killed.
- Related topic
Discovery Over CXL.io
How software builds a topology it has never seen: probing, the three answers a probe can give, bounded traversal, cycle protection, work queues, and why a device list is not a topology. Seven RTL models simulated, twenty mutations, twenty killed.
- Related topic
Device Number Assignment — Position Within a Bus
The device coordinate identifies a position within one bus and means nothing without it. Why PCIe's point-to-point links leave most of that space unused below a Root Port, where device numbers still vary, and how a target decoder and candidate scanner encode the locality rule.
- Related topic
Why CXL Matters
Why PCIe transaction semantics are insufficient for coherent memory and accelerator attach — CXL.io, CXL.cache and CXL.mem as the CXL Consortium defines them, device types, why coherence needs distributed per-line state, memory-window routing, what coherence costs, and why a clean transport proves nothing about coherence.
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.
