CXL · Module 10
Type 1 Devices
The device that borrows host memory and offers none of its own: what a caching accelerator must contain, why every eviction is an obligation to the host, and why caching devices are the scarce resource in a topology. Seven RTL models, twenty-four mutations, twenty-four killed.
Module 9 ended by pricing what CXL.mem guarantees cost. Module 10 changes the question — from what must a device guarantee to what kind of device is it, and the answer turns out to be determined entirely by which protocols it speaks.
This chapter takes the first of the three: a device that caches host memory and owns none of its own.
1. The Engineering Problem — Compute That Lives on Someone Else's Data
An accelerator has a job to do on data the host owns. It has three options, and two of them are bad.
It can read the data over and over. Every access is a full round trip, and the accelerator spends its life waiting rather than computing.
It can copy the data into private memory. Now it is fast, and the copy is a snapshot: the moment the host writes, the accelerator is working on data that is quietly wrong. Every DMA-offload programming model in history has spent its complexity budget on exactly this problem — explicit copies, explicit invalidations, and a class of bug where the answer is stale rather than incorrect.
Or it can hold coherent copies of host lines, close enough to compute against, with the system responsible for telling it when a line it holds has changed.
The third option is a Type 1 device, and the whole of its architecture follows from that one decision.
2. The One-Sentence Model
A Type 1 device borrows. Every line it holds belongs to the host, it must give each one back when asked, it exposes no memory of its own, and its cache is finite — so it must also give lines back when it simply runs out of room.
Call it the borrower. That single word decides the hardware: something to hold the borrowed lines, something to track what has been borrowed, something to return them, and nothing at all on the other side — because a borrower lends nothing.
3. What This Chapter Owns
Three published chapters already touch device types, and the boundaries are worth stating before anything else.
| Ground | Owner |
|---|---|
| The engine frame — a device as optional contents | 3.3 |
| The CXL.cache protocol itself — flows, ownership, transfer | Module 8 |
| Deriving a protocol set from requirements | 6.4 |
| The discipline of selecting protocols and pricing them | 6.5 |
| What a Type 1 device must contain, and what it must not | this chapter |
| Type 2, Type 3, and choosing between them | 10.2 · 10.3 · 10.4 |
3.3 says it deliberately "introduces the device types precisely enough to reason about hardware, and stops there", and defers the full treatment here. Module 8 taught CXL.cache as a protocol; this chapter is about the device that speaks it — the structures it must build, the obligations those structures create, and the ones it is forbidden to have.
4. The Type Is Derived, Not Declared
// .io is present in every CXL device; the coherent engines decide the type.
assign proto_mask = {HAS_MEM, HAS_CACHE, 1'b1};
wire [1:0] derived = ( HAS_CACHE && HAS_MEM) ? 2'd2 :
( HAS_CACHE && !HAS_MEM) ? 2'd1 :
(!HAS_CACHE && HAS_MEM) ? 2'd3 : 2'd0;=== EXP1: the device type is derived from the engines ===
cache only : mask=011 type=1
cache + memory : mask=111 type=2
memory only : mask=101 type=3
neither : mask=001 type=0 (io-only, not a CXL device type)
cache without memory derives Type 1 : ok
cache with memory derives Type 2 : ok
memory without cache derives Type 3 : ok
neither engine is io-only, not a type : ok
.io is present in every device : ok
declared Type 2, no engines : type=2 mismatch=1
a declared type with no engines was caught : ok3.3 put this well: you do not choose a type and then implement it; you choose which engines to build, and the type is what you get. The RTL makes that literal — dev_type is a function of the parameters, not a stored field.
The last two lines are why that matters in hardware. The abuse instance declares Type 2 with no cache engine and no memory engine anywhere in it, and the mismatch checker catches it. A design that stores its type in a separate register can disagree with itself, and the disagreement is discovered by software that enables a protocol the silicon cannot speak.
Note the fourth row. Neither engine gives .io alone — a PCIe-style device on a CXL link. It has no CXL device type at all, because the types enumerate the coherent combinations. That is not a gap in the taxonomy; it is the taxonomy being about coherence.
5. Teaching-model boundary
6. RTL 2 — The Cache Is Finite, and Eviction Is an Obligation
This is the structure that makes the device a Type 1, and its most important property is the one people design around rather than for: it runs out.
if (need_evict) begin
// Evicting a modified line without telling the host loses the only
// up-to-date copy in the system.
if (!SILENT_EVICT) begin
evict_notify <= 1'b1;
evict_addr <= tag_q[victim_q];
end else if (mod_q[victim_q]) begin
n_silent_q <= n_silent_q + 16'd1;
lost_line_err <= 1'b1;
end=== EXP2: a Type 1 cache is finite, and eviction is an obligation ===
8 fills into 8 lines : occupancy=8 peak=8 evictions=0
the array filled to exactly 8 lines : ok
no eviction was needed while lines were free : ok
9th fill : evict_notify=1 evict_addr=0 evictions=1
the 9th fill raised an eviction notification : ok
silent-evict variant : evictions=1 silent losses=1 lost-line flag=1
the silent variant notified nobody : ok
it dropped exactly one modified line : ok
and the lost-line checker caught the only copy going away : ok
an evicting fill is one in and one out, so occupancy held : ok
three more evictions : victims were 1, 2 and 3 in order : okIn a private cache, an eviction is housekeeping. You drop a line, and if it was dirty you write it back to memory you own. Nothing outside the device has an opinion.
In a coherent device cache, an eviction is a message. The line belongs to the host, the host is tracking that this device holds it, and dropping it without saying so leaves the system believing a copy exists where none does — and if the line was modified, the device has just destroyed the only up-to-date copy in the system.
That is the difference this chapter exists to make, and it is why evict_notify is an output rather than an internal signal.
Occupancy held at 8 through four evictions
The assertion c_occ == 8 after the evicting fills is not bookkeeping trivia. An evicting fill is one in and one out, so a design that counts it as a net gain over-reports its own occupancy and will size the next generation's array from a number that was never real. The mutation that made an evicting fill increment occupancy was killed by exactly that assertion.
Why the victims had to be 1, 2, 3 — and not merely different
The rotation check was the last mutation to fall, and it taught something worth keeping.
The first attempt asserted the three evicted addresses were distinct. That passes on a design whose victim pointer never rotates — because a stuck pointer keeps evicting the line it just filled, and those addresses are all different too. Distinctness was true and useless.
// Distinctness is NOT enough: a stuck victim pointer also produces three
// different addresses, because it keeps re-evicting the line it just
// filled. Only the exact victims prove the pointer rotated.
chk((ev1==8'd1) && (ev2==8'd2) && (ev3==8'd3), "the victims were lines 1, 2 and 3 in order");A stuck victim turns an 8-line cache into a 1-line cache with seven lines of dead storage, and every occupancy counter reports it as full and healthy.
The last two rows are the same hardware event with opposite consequences. The notification is not an optimisation or a courtesy — without it the host still believes this device holds the line, and the modified data has ceased to exist anywhere in the system.
7. RTL 3 — Every Line It Holds Was Borrowed
if (rsp_valid) begin
// A response whose tag is not live belongs to nothing.
if (!live_q[rsp_tag] && !(do_req && (req_tag == rsp_tag)))
orphan_rsp_err <= 1'b1;
live_q[rsp_tag] <= 1'b0;=== EXP3: every line a Type 1 holds was borrowed ===
4 borrows outstanding : live=00001111 requests=4
four tags are live : ok
all returned : live=00000000 responses=4 orphan=0 dup=0
every borrow was returned : ok
abuse: response for an unissued tag : orphan=1
a response matching nothing was caught : ok
abuse: same tag issued twice while live : duplicate=1
reusing a live tag was caught : okSame transaction-identity machinery as 9.2's host-side table, on the other end of the link — and the symmetry is the point. 9.2's host tracked its accesses into device memory; here a device tracks its borrows from host memory. Same obligation, opposite direction, and a Type 1 device is the side that only ever does the second.
Note the same-cycle exemption in the orphan check: a response arriving in the cycle its request is issued is legal, and a checker that ignores that possibility fires on correct behaviour — the defect 9.5 hit from the other direction.
Both illegal-stimulus cases needed an abuse instance. Driving a response for an unissued tag, or reusing a live tag, into the instance under test would trip that instance's own checker and score as a failure. This is now standing practice across the track: illegal stimulus gets its own DUT.
8. RTL 4 — What a Type 1 Must Not Have
The defining negative. A Type 1 device offers the host no memory, so an inbound host-memory access has no correct answer — and the tempting wrong behaviour is to produce one anyway.
// A Type 1 device has no host-visible memory, so only configuration space
// is answerable. Serving a memory request implies storage it does not have.
assign serve = cfg_req || (SERVE_ANYWAY && host_mem_req);
assign refuse = host_mem_req && !SERVE_ANYWAY;=== EXP4: a Type 1 exposes no memory to the host ===
config access : serve=0 refuse=0 cfg count=1
configuration space is answerable : ok
host memory read : correct serve=0 refuse=1 | serve-anyway serve=1
a host memory access was refused : ok
the serve-anyway variant answered it : ok
refusals=1 | serve-anyway flagged serving absent memory=1
the variant flagged answering for memory it does not have : okA device that answers for memory it does not have is worse than one that fails. It returns something — whatever its buffers held — and the requester has no way to know. The refusal is the correct behaviour precisely because it is visible.
This is also the cleanest way to state the Type 1/Type 2 boundary in hardware terms. The difference is not that Type 2 is bigger. It is that Type 2 has an answer to this request and Type 1 does not, and that single asymmetry is what 10.2 will show costs so much.
9. RTL 5 — The Cache Is a Performance Structure, Not Only a Correctness One
assign compute = work_valid && (NO_STALL || line_present);
assign request_line = work_valid && !line_present;=== EXP5: compute stalls on a line the device does not hold ===
5 resident + 3 missing : computes=5 misses=3 stalls=3
compute happened only on resident lines : ok
three misses each stalled the pipeline : ok
no-stall variant : computes=8 absent-data flag=1
the no-stall variant computed on all eight : ok
...and flagged producing a result from data it never had : okEverything above is about correctness. This module is about why anyone builds the thing: the accelerator's throughput is gated by residency. Work arrives every cycle; compute happens only on cycles where the line is present.
The no-stall variant is the accelerator version of 9.6's no-merge write. It computes on all eight — a 60% throughput improvement on every counter — by producing results from data it never had. The performance gain and the wrong answer are the same event, and this is the third time in this track that the fastest variant has been the incorrect one.
10. Waveform — Residency Gates Compute
Work is available every cycle; compute is not
9 cyclesTeaching-model timing derived from the simplified RTL in this chapter. Not CXL wire timing.
work_valid is high for all nine cycles and computes advances on six of them. The cache is why that number is six rather than two, and it is also why it is six rather than nine.
Read cache_occupancy alongside stalls: the array is filling exactly while the pipeline is stalled. That is the accelerator's entire performance story in one picture — the device is not computing, it is borrowing, and the ratio of those two activities is what a Type 1 design is optimising.
11. RTL 6 — Caching Devices Are the Scarce Resource
// Exceeding the DECLARED limit is always reportable -- guarding this with
// the check-enable would switch it off on the only case that can trip it.
if (n_caching_q > LIMIT[7:0]) over_limit_err <= 1'b1;=== EXP6: caching devices are the scarce resource ===
6 attach attempts, limit 4 : caching=4 admitted=4 refused=2 peak=4
exactly four were admitted : ok
the other two were refused, not silently accepted : ok
peak attachment was exactly the limit : ok
no-check variant : caching=6 over-limit flag=1
the unchecked variant went past its declared limit : ok
and the over-limit checker reported it : ok
after one detach : caching=3
3 cycles of attach+detach together : caching=3
simultaneous attach and detach left the count unchanged : okThis is a topology-level property that constrains a device-level design, and it is specific to caching devices — memory devices do not carry it.
The published figure is that CXL 3.0 fanout supports up to 16 CXL.cache devices. The reason a limit exists at all is asymmetric coherence: the host resolves coherence and tracks which devices hold which lines, so every caching device adds tracking state on the host side. A memory expander does not, which is why the two classes scale so differently and why 10.3 will look so much easier to deploy in quantity.
The simultaneous attach-and-detach check exists because the attachment count is the tenth appearance of the two-assignment counter defect in this track. One device leaving as another arrives must leave the count unchanged; two separate non-blocking assignments make it fall by one, and the topology slowly under-reports how many caching devices it is tracking.
12. RTL 7 — Is the Cache Earning Its Area?
=== EXP7: is the cache earning its area? ===
accesses=40 hits=26 fills=14 evictions=3 writebacks=2
oracle: accesses=40 hits=26 fills=14 evictions=3
accesses and hits matched an independent oracle : ok
hits are strictly fewer than accesses : ok
both conservation laws held : ok
abuse: hits with no accesses, evictions with no fills : flags=1 1A 65% hit rate on 40 accesses. The two conservation laws — you cannot hit more often than you looked, and you cannot evict more lines than you filled — are what make the hit rate trustworthy rather than merely reported.
The mutation that counted fills as hits is the one to remember. It reports a 100% hit rate on the same workload. Every fill is a miss being serviced, so counting it as a hit means the counter reports its best number precisely when the cache is performing worst. That is not a rounding error; it is a metric that inverts under load, and a team tuning against it will optimise in the wrong direction.
13. Quantitative Reasoning
Illustrative, with assumptions stated.
What residency is worth
From §9: 8 work items, 5 computed immediately, 3 stalled. Suppose a miss costs a full round trip of 250 ns and a hit costs 1 cycle at 1 GHz.
hit rate 0.65 : 0.65 x 1 ns + 0.35 x 250 ns = 88 ns per access
hit rate 0.90 : 0.90 x 1 ns + 0.10 x 250 ns = 25.9 ns per access
hit rate 0.99 : 0.99 x 1 ns + 0.01 x 250 ns = 3.5 ns per accessGoing from 65% to 90% is a 3.4× improvement; from 90% to 99% is another 7.4×. The curve is dominated entirely by the miss term, which is why accelerator cache sizing is so sensitive near the working-set boundary — and why the honest question is never "how big is the cache" but "does the working set fit".
What the cache costs
storage = lines x (line bytes + tag bits + state bits)For 1024 lines of 64 bytes with a 40-bit tag and 3 state bits:
data = 1024 x 64 B = 64 KB
meta = 1024 x 43 bits = 5.4 KB
total ~ 69.4 KB, of which 8% is metadataThe metadata fraction is the number worth carrying: 8% overhead to make 64 KB coherent. That is the price of the third option in §1, against a private copy that costs nothing extra in hardware and everything in software correctness.
What the fanout limit means for a rack
With a published limit of 16 CXL.cache devices in a CXL 3.0 fanout, a design that needs 24 caching accelerators does not need a bigger switch — it needs a different device type for some of them, or more root complexes. That is an architecture decision forced by a protocol limit, and it is the kind of constraint that is much cheaper to discover at architecture time than at bring-up.
14. Assertions
Icarus Verilog 13.0 does not support concurrent SVA here, so every property is synthesisable checker logic verified in simulation. 50 assertions.
Safety
| Property | Intent |
|---|---|
| Type derivation | dev_type is a function of the engines present |
| Type agreement | a declared type must equal the derived one |
.io universal | every device's mask includes .io |
| Eviction notified | dropping a held line raises a notification |
| No lost line | a modified line is never dropped silently |
| Victim rotation | successive evictions choose successive victims |
| Occupancy integrity | an evicting fill is one in and one out |
| Borrow identity | no orphan response, no duplicate live tag |
| No absent memory | a Type 1 never serves a host-memory access |
| No absent data | compute never happens on a line not held |
| Fanout bound | attachments never exceed the declared limit |
| Counter conservation | hits ≤ accesses, evictions ≤ fills |
Liveness
| Property | Assumption it needs |
|---|---|
| A borrowed line is eventually returned | the device makes progress |
| A stalled work item eventually computes | its line is eventually filled |
| A refused attachment eventually succeeds | some device detaches |
Performance goals
| Goal | Measured by |
|---|---|
| Hit rate high enough to justify the array | hits over accesses |
| Stall fraction bounded | stalls over work items |
| Eviction rate not thrashing | evictions over fills |
15. Mutation Testing
Twenty-four mutations. Twenty-four killed.
| Mutation | Result |
|---|---|
| Type 1 and Type 3 swapped | killed |
.io dropped from the protocol mask | killed |
| Declared/derived mismatch never reported | killed |
| Eviction never notified | killed |
| Silently dropped modified line not flagged | killed |
| Victim pointer never rotates | killed |
| An evicting fill counted as a net gain | killed |
| Every fill evicts, even with lines free | killed |
| Duplicate live tag not reported | killed |
| Orphan response not reported | killed |
| Borrow never recorded as outstanding | killed |
| Host memory access neither served nor refused | killed |
| A Type 1 answers for memory it does not have | killed |
| Serving absent memory not reported | killed |
| Compute proceeds without the line | killed |
| A line is requested even when resident | killed |
| Computing on absent data not reported | killed |
| Fanout limit off by one | killed |
| Refused attachments not counted | killed |
| Attach count uses two assignments | killed |
| Over-limit checker guarded by the fault it detects | killed |
| Fills counted as hits | killed |
| Hit conservation law disabled | killed |
| Eviction conservation law disabled | killed |
The first run scored 21 of 24, and the three escapes are worth separating because only one was a missing check.
Two needed stimulus that did not exist: a duplicate live tag, and a simultaneous attach and detach. Both went to abuse instances.
One was an assertion that was true and useless — the victim-rotation check described in §6. Asserting the evicted addresses were distinct passes on a design whose victim pointer is frozen, because a stuck pointer re-evicts the line it just filled and those addresses differ too. Only the exact victims — 1, 2, 3 — distinguish the two designs. This is the "assert the exact value, not a bound" rule from earlier batches appearing in a new disguise: here the useless assertion was a property rather than a bound, and it was equally hollow.
Note also over_limit_err, written deliberately without a guard on the check-enable parameter. That is the self-disabling-checker defect from 9.6 and 9.1, and the mutation that adds the guard back is killed by an abuse instance that exceeds its own declared limit.
16. Verification Plan
| Area | Approach |
|---|---|
| Type derivation | All four engine combinations; a declared-mismatch abuse instance |
| Cache capacity | Fill to capacity, then beyond; occupancy across evicting fills |
| Eviction | Modified victims; a silent-evict variant; victim rotation by exact index |
| Borrowing | Tag issue and return; orphan and duplicate abuse instances |
| Absent memory | Config versus memory access; a serve-anyway variant |
| Residency | Resident and missing work; a no-stall variant |
| Fanout | Attach past the limit, detach, simultaneous attach and detach; an unchecked abuse instance |
| Counters | Independent oracle; two conservation abuse instances |
The coverage cross is cache state × line state × request type: free / full / evicting, crossed with clean / modified, crossed with fill / probe / config. The full × modified × fill point is where the only-copy-in-the-system risk lives, and it must be directed — a random workload reaches it rarely and never labels it.
17. Silicon Observability
| Counter | Diagnoses |
|---|---|
| hit rate | whether the array is earning its area |
| fills and evictions | capacity pressure |
| eviction rate versus fill rate | thrashing — a working set that does not fit |
| writebacks | how much modified data the device is holding |
| stall cycles waiting on residency | the accelerator's real throughput limit |
| outstanding borrows, peak | whether the request port is the limit |
| refused attachments | topology fanout pressure |
lost_line | correctness alarm — must be zero forever |
orphan_rsp, dup_tag | correctness alarms — must be zero forever |
served_absent, computed_absent | correctness alarms — must be zero forever |
The most valuable pair is eviction rate against fill rate. When they converge, the working set does not fit and the device is thrashing — every fill immediately evicts something that will be needed again. Hit rate alone shows this only indirectly and late; the ratio shows it immediately and says what to do about it.
18. Debug Lab
An 8-line cache behaving like a 1-line cache
STUCK-VICTIMvictim_q <= victim_q; // rotation dropped in a late editHit rate collapses on any working set larger than one line. Occupancy reports the array full and healthy. Every fill is immediately followed by an eviction. Cache size increases do nothing at all.
three more evictions : victims were 1, 2, 3
the victims were lines 1, 2 and 3 in order : okLog the evicted addresses across several evictions. If they are the addresses that were just filled, the pointer is frozen — the array is re-evicting its own newest line and seven lines are dead storage.
The victim pointer never advances, so one entry absorbs every eviction. Occupancy is genuinely 8 and utilisation is genuinely 1.
Distinct evicted addresses do not disprove this — a stuck pointer produces different addresses too, because the line it evicts changes each time. That is why the assertion checks the exact victims.
victim_q <= (victim_q == (LINES-1)) ? 4'd0 : victim_q + 4'd1;And assert the exact victim sequence, not merely that victims differ.
Compare eviction rate against fill rate in silicon. Convergence means thrashing, and a frozen victim is the extreme case.
The only up-to-date copy in the system, silently discarded
SILENT-EVICT// line is clean enough -- just drop it
vld_q[victim_q] <= 1'b0;Data written by the accelerator is missing later. No error anywhere. The host reads the address and gets the old value. It correlates with cache pressure, so it appears only on large working sets.
silent-evict variant : evictions=1 silent losses=1 lost-line flag=1
it dropped exactly one modified line : okCompare evictions against eviction notifications. They must be equal. A gap means lines left the device without the system being told.
In a private cache, dropping a clean line is free housekeeping. In a coherent device cache the line belongs to the host and the host is tracking that this device holds it — and if the line was modified, the device just destroyed the only current copy.
The bug is a correct instinct applied in the wrong context, which is why it survives review by anyone whose cache experience is private-cache experience.
evict_notify <= 1'b1;
evict_addr <= tag_q[victim_q];Every eviction is a message. Assert evictions == notifications continuously.
Fill the array entirely with modified lines before forcing eviction. A test that fills with clean lines cannot distinguish the two designs — which is exactly what happened on this chapter's first run.
A device that answers for memory it does not have
SERVE-ABSENTassign serve = cfg_req || host_mem_req; // "just respond to everything"A host access to an address the device does not own returns data — whatever the response buffers held. No error, no timeout. The corruption appears far from the device, in whatever consumed the fabricated value.
host memory read : correct serve=0 refuse=1 | serve-anyway serve=1
the variant flagged answering for memory it does not have : okIssue a host memory read to a Type 1 device and check for a refusal, not merely for a response. "A response came back" passes on the broken design.
Responding is the default behaviour of a request handler, and refusing is an explicit case someone has to write. A Type 1 device has no host-visible memory, so the request has no correct answer and the only correct action is a visible refusal.
assign serve = cfg_req;
assign refuse = host_mem_req;
if (host_mem_req && serve) served_absent_err <= 1'b1;Test what the device must not do, not only what it must. A Type 1 device's negative obligations are half its specification and are routinely untested.
A 60% throughput gain that produced wrong answers
COMPUTE-ON-ABSENTassign compute = work_valid; // don't gate on residency, it's slowAccelerator throughput improves markedly. Results are intermittently wrong in a way that correlates with cache pressure, so small test inputs are always correct and production inputs are not.
5 resident + 3 missing : computes=5 misses=3 stalls=3
no-stall variant : computes=8 absent-data flag=1
...and flagged producing a result from data it never had : okAssert compute implies line_present. Then compare compute count against hit count — they cannot exceed residency.
The pipeline computed on lines the device did not hold, so it operated on stale or uninitialised buffer contents. The speedup and the wrong answer are the same event — the third time in this track that the fastest variant was the incorrect one.
assign compute = work_valid && line_present;
if (compute && !line_present) computed_absent_err <= 1'b1;Treat any optimisation that beats the reference by doing less as a correctness question first. Ask what work disappeared and who depended on it.
A hit rate that improves as the cache gets worse
FILLS-AS-HITSwire counted_hit = hit || fill; // "a fill satisfies the access too"Reported hit rate is 100% on every workload, including ones that obviously thrash. Cache sizing experiments show no difference between 8 lines and 8192, so the team concludes the cache does not matter.
accesses=40 hits=26 fills=14 evictions=3
accesses and hits matched an independent oracle : ok
hits are strictly fewer than accesses : okCheck hits < accesses strictly on any workload with a miss. A hit rate that never moves is not a good cache; it is a broken counter.
Every fill is a miss being serviced. Counting it as a hit means the metric reports its best number exactly when the cache is performing worst — the counter inverts under load, so tuning against it moves the design the wrong way.
wire counted_hit = hit;
if (n_hit_q > n_access_q) hit_exceeds_access_err <= 1'b1;Give every derived metric a conservation law. Hits cannot exceed accesses; evictions cannot exceed fills. Both are one comparator and both catch a whole class of counter fiction.
The topology quietly forgot how many caching devices it had
TWO-ASSIGN-ATTACHif (admit) n_caching_q <= n_caching_q + 8'd1;
if (do_detach) n_caching_q <= n_caching_q - 8'd1;The count of attached caching devices drifts downward over a long uptime with hot-plug activity. Eventually the topology admits more devices than it can track. Attach-then-detach test sequences pass perfectly.
3 cycles of attach+detach together : caching=3
simultaneous attach and detach left the count unchanged : okDrive attach and detach in the same cycle and check the count holds. Any drift means one of the two paths lost.
Two non-blocking assignments to one counter: the second wins, so a cycle with both an arrival and a departure decrements instead of holding.
Tenth appearance of this defect family in this track, and the consequence here is admitting more caching devices than the host can track — which is a correctness failure, not a statistics one.
case ({admit, do_detach})
2'b10: n_caching_q <= n_caching_q + 8'd1;
2'b01: n_caching_q <= n_caching_q - 8'd1;
default: ; // both or neither: hold
endcaseTest the cross of the two events, not the sequence. Attach-then-detach passes on both designs; attach-with-detach passes on only one.
Software enabled a protocol the silicon does not implement
DECLARED-TYPElocalparam [1:0] DEVICE_TYPE = 2'd2; // a stored field, set by handEnumeration succeeds and the device presents as Type 2. Software enables the memory path. Accesses to the device's advertised memory return nothing coherent, and the failure looks like a host or switch problem because the device reported itself correctly.
declared Type 2, no engines : type=2 mismatch=1
a declared type with no engines was caught : okCompare the declared type against one derived from which engines are actually instantiated. If a design cannot derive it, that is the finding.
The type was stored rather than derived, so it could disagree with the silicon. A hand-set constant survived an engine being removed for area.
The type is a consequence of the engines, not an input to them — and a design that treats it as an input can lie about itself.
wire [1:0] derived = (HAS_CACHE && HAS_MEM) ? 2'd2 : ...;
if (dev_type != derived) type_mismatch_err <= 1'b1;Derive it from the same parameters that instantiate the engines.
Never store a fact that can be computed from the configuration. Anything stored can drift from what it describes.
A response arrived for a borrow that no longer existed
ORPHAN-RESPONSEif (do_req) live_q[req_tag] <= 1'b1; // reused without checkingThe device occasionally installs a cache line at the wrong address. It requires the tag pool to wrap under load, so it appears only at high outstanding counts and never in directed tests.
abuse: response for an unissued tag : orphan=1
abuse: same tag issued twice while live : duplicate=1
reusing a live tag was caught : okCheck whether a tag is live before issuing on it, and whether an arriving response's tag is live. Either violation means an identity collision.
A tag was reused while its earlier borrow was still outstanding. When the first response arrives it is matched to the second request, and the line lands under the wrong address.
Same identity machinery as 9.2's host table, on the device side of the link.
if (live_q[req_tag]) dup_tag_err <= 1'b1;
if (!live_q[rsp_tag] && !(do_req && (req_tag == rsp_tag)))
orphan_rsp_err <= 1'b1;Note the same-cycle exemption: a response arriving in the cycle its request issues is legal.
Drive both violations into a separate abuse instance. Injecting them into the instance under test trips its own checker and scores as a failure.
19. Design Review
- Is the device type derived from the engines, or stored somewhere it can drift?
- What happens on the fill that finds every line valid?
- Is every eviction notified, including of clean lines?
- What happens when a modified line is evicted, and what proves it?
- Does the victim pointer rotate, and what assertion proves it rather than merely suggesting it?
- What does the device do with an inbound host-memory access?
- Can compute proceed on a line the device does not hold?
- Can a tag be reused while its borrow is outstanding?
- What is the hit rate, and what conservation law makes it trustworthy?
- How many caching devices does the topology admit, and what happens to the one after that?
- What state survives reset while lines are still borrowed?
20. How This Appears in Real Engineering
Architecture. The Type 1/Type 2 decision is settled by one question — does anything outside the device need to see its memory? If not, the memory engine is pure cost, and 6.5 prices it.
RTL. The cache array is small and carries four of this chapter's alarms. The eviction path is where private-cache instincts do the most damage.
DV. Fill-with-modified-lines-then-evict is the directed test that matters most, and a clean-line test cannot substitute for it.
Performance. Hit rate drives everything, and the miss term dominates the average so completely that working-set fit is the only sizing question worth asking.
Firmware. The device type determines which enable paths are legal; a stored type that disagrees with the silicon fails here first and looks like someone else's bug.
Post-silicon. Eviction rate against fill rate identifies thrashing immediately, where hit rate identifies it slowly and ambiguously.
21. Common Misconceptions
| Belief | Correction |
|---|---|
| You choose a device type and implement it | You choose engines; the type is the consequence |
| A Type 1 device has a small amount of memory | It has none the host can see — that is the definition |
| Evicting a clean line is free housekeeping | The host is tracking that copy; the eviction is a message |
| A cache miss is only a performance event | Computing through one is a correctness event |
| Distinct evicted addresses prove the victim rotates | A stuck pointer produces distinct addresses too |
| A fill satisfies the access, so it counts as a hit | A fill is a miss; counting it inverts the metric |
| More caching devices is a switch capacity question | It is a host tracking-state question, and it is bounded |
| Type 2 is just Type 1 with more memory | It is Type 1 plus an obligation the two engines create together |
22. Interview Reasoning
23. Exercises
-
Calculation. A Type 1 accelerator has a 2048-line, 64-byte cache with 40-bit tags and 3 state bits. Compute total storage and the metadata fraction. Then compute average access time at hit rates of 0.7, 0.9 and 0.98 given a 1 ns hit and a 250 ns miss, and state which improvement is worth more.
-
Analysis. A device reports a 99% hit rate, a fill count equal to its eviction count, and poor accelerator throughput. Explain why those three facts are consistent, name the defect, and give the counter that distinguishes it from a genuinely well-performing cache.
-
RTL task. Extend
dev_cache_arrayto track, per line, whether the host has requested it back but the eviction has not yet completed. State the new invariant that creates and the failure mode if the state is omitted. -
Assertion task. Write the property that proves the victim pointer rotates. Explain why a property over the distinctness of evicted addresses does not prove it, and construct the design that satisfies distinctness while being wrong.
-
Waveform analysis. Using §10's trace, compute the achieved compute rate over the nine cycles and what it would have been with a hit on every cycle. Then state what the trace would look like if the fill latency were four cycles rather than one, and which counter would reveal it.
-
Debug task. An accelerator produces intermittently wrong results that correlate with input size. Give your investigation order across the four correctness alarms in this chapter, and the single directed test that separates a silent eviction from a compute-on-absent defect.
-
Design review. A colleague proposes adding a small
.memengine to a Type 1 device to expose its scratchpad, arguing it is "only a few registers of extra state". Give the strongest version of that argument, then state precisely what it changes about the device's obligations and what it makes the device.
24. Summary
A Type 1 device borrows.
- The type is derived from the engines present, never declared — a stored type can disagree with the silicon and enumerate as something the hardware cannot serve.
.iois in every device; the coherent engines decide the type, and neither engine means no CXL device type at all.- The cache is finite, so it must evict — and every eviction is a message, because the host is tracking that copy. Dropping a modified line silently destroys the only up-to-date copy in the system.
- An evicting fill is one in and one out. Counting it as a net gain over-reports occupancy and mis-sizes the next generation.
- Every line it holds was borrowed, tracked by identity — the mirror of 9.2's host-side table, on the other end of the link.
- A Type 1 has no host-visible memory, so an inbound memory access must be refused, not answered. That single asymmetry is the whole Type 1 / Type 2 boundary.
- Residency gates compute: work valid on nine cycles, compute on six. The cache is a performance structure, and the miss term dominates so heavily that working-set fit is the only sizing question.
- Caching devices are the scarce resource — every one adds host-side tracking state, and published fanout is up to 16 CXL.cache devices.
- Verification: 24 of 24 mutations killed, 50 assertions. The last to fall taught that an assertion can be true and useless — distinct evicted addresses do not prove a victim pointer rotates, because a frozen pointer produces distinct addresses too.
- The two-assignment counter defect appeared for the tenth time, here under-counting attached caching devices.
Next: 10.2 — Type 2 Devices, where the device both caches host memory and offers its own, and the two relationships interact in a way that makes it the hardest class in the taxonomy.
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.
