CXL · Module 9
CXL.mem Write Flows
The write path end to end: two completions rather than one, same-address visibility ordering, partial writes that must not clobber unnamed bytes, a buffer that may stall but never lose, newest-wins forwarding, and failures that must reach the writer. Seven RTL models simulated, twenty-one mutations, twenty-one killed.
Chapter 9.4 walked the read path: one question, one answer. The write path reuses that machinery and adds the thing 9.3 flagged as harder — a read is finished when it has a value, and a write is not finished until someone else can see it.
1. The Engineering Problem — A Write Has Two Endings
Ask when a read is done and there is one answer: when the value arrives. Ask when a write is done and there are two, and they are separated by real time:
| Ending | Meaning | Who cares |
|---|---|---|
| accepted | the device owns it and will not lose it | the writer, for flow control |
| visible | another observer reading that address sees it | everyone, for correctness |
Reporting the first as though it were the second is not an optimisation. It is the bug that breaks every producer/consumer handshake in software, and it is invisible to any test that writes and then reads back from the same agent.
Four more things follow from the gap between those two endings, and each is a section below: writes to one address must become visible in order, a sub-line write must read before it writes, an accepted write must survive until it is visible, and a read arriving in the gap must see the pending value.
2. The One-Sentence Model
A write is not done until someone else can see it. Acceptance is a flow-control event and visibility is a correctness event; between them the write lives in a buffer that must never lose it, must not let a later write to the same address overtake it, must not destroy bytes it never named, and must be searched by any read of that address.
Call it the second ending. Everything in this chapter lives in the gap between the two.
3. What This Chapter Owns
| Question | Owned by |
|---|---|
| The device's window contract | 9.1 |
| The host's path and tag pool | 9.2 |
| Ordering, atomicity, visibility as guarantees | 9.3 |
| The read path | 9.4 |
| The write path end to end | this chapter |
| Latency/throughput cost of CXL memory | 9.6 |
| Latency anatomy and modelling | Module 18 |
4. The Write, End to End
Architectural. The arrows are obligations, not named messages.
The distance between arrow two and arrow six is the whole chapter. Everything that can go wrong on a write path goes wrong in that gap, and a design that closes it by reporting completion at acceptance has not removed the gap — it has hidden it from the only party that could have accounted for it.
5. Teaching-model boundary
6. RTL 1 — Two Completions, and Only One Counts
assign cmp_valid = REPORT_ON_ACCEPT ? wr_accept : wr_visible;
...
// Reporting completion on a cycle that is not the visibility cycle.
if (cmp_valid && !wr_visible) early_completion_err <= 1'b1;=== EXP1: a write completes when it is VISIBLE, not when accepted ===
at acceptance : correct cmp_valid=0 | report-on-accept cmp_valid=1
acceptance does not complete the write : ok
the broken variant reported it immediately : ok
in flight : accepted=1 visible=0 inflight=1
at visibility : cmp_valid=1 id=2
visibility completes it, with the right id : ok
and the write left the in-flight set : ok
report-on-accept variant flagged early completion : okAcceptance is a flow-control event. It tells the writer the device has taken ownership, which is what a credit-returning scheme needs. It says nothing about observability, and using it as a completion converts a correctness guarantee into a throughput optimisation the writer did not ask for.
A checker that had to allow the legal case
The visible-without-acceptance check initially read:
if (!acc_mask_q[vis_id]) visible_without_accept_err <= 1'b1;The baseline failed on correct stimulus: a write accepted and made visible in the same cycle is entirely legal — a device with a fast path can do exactly that — and the acceptance mask is still being set by that edge. The corrected condition admits it:
// Becoming visible without ever being accepted is impossible. A write
// accepted and made visible in the SAME cycle is legal, so the mask --
// which this edge is still setting -- cannot be the whole condition.
if (!acc_mask_q[vis_id] && !(wr_accept && (wr_id == vis_id)))
visible_without_accept_err <= 1'b1;Same state-versus-transition shape as 9.3's premature-visibility checker, in the opposite direction: there the check was too narrow to fire, here it was too broad and fired on correct behaviour.
7. Waveform — Accepted Early, Visible Late, Out of Order
Two writes accepted, made visible in the opposite order
9 cyclesTwo things to read.
Between cycles 3 and 5, accepted is 2 and visible_cnt is 0. Two writes the device owns, neither observable, no completion sent. That interval is the accepted-but-invisible window 9.3 measured, drawn.
The completions come back in the opposite order to acceptance — id 3 then id 2. That is legal because they are different addresses; §8 is about the case where it is not.
8. RTL 2 — Same-Address Writes Become Visible in Order
For reads, ordering decides which value is returned. For writes it decides which value survives, and getting it wrong is permanent.
// A write may become visible only if every earlier write to this address
// already has. Sequence numbers make that checkable.
assign in_order = (vis_seq == expect_seq_q);
assign allowed = vis_valid && (NO_WAW_ORDER || in_order);=== EXP2: same-address writes become visible in order ===
the first write in sequence proceeds : ok
seq 2 before seq 1 : correct allowed=0 | no-WAW allowed=1
a later write cannot overtake an earlier one : ok
the no-WAW variant let it overtake : ok
the correct next write proceeds : ok
no-WAW variant flagged a write-after-write violation: okThe asymmetry with reads is worth dwelling on. A read served out of order returns a stale value once — bad, and recoverable by reading again. A write made visible out of order means an older value lands after a newer one and the newer value is gone. No subsequent read recovers it.
That is why the ordering here is expressed as a sequence check rather than a simple busy flag: the device must know not just that an earlier write exists but that it has already become visible.
9. RTL 3 — A Partial Write Must Read Before It Writes
Media writes whole lines. A sub-line write is therefore a read-modify-write inside the device, and skipping the read is the classic corruption.
// A full-line write needs no merge read; a partial one does.
assign needs_merge_read = wr_valid && (byte_en != 8'hFF);
assign can_write = wr_valid && (NO_MERGE_READ || !needs_merge_read || old_valid);
assign merged_line = (wr_data & mask) | (old_line & ~mask);=== EXP3: a partial write must not clobber unnamed bytes ===
full-line write : needs_merge_read=0 can_write=1
a full-line write needs no merge read : ok
partial, no old data : needs_merge_read=1 correct can_write=0 | no-merge can_write=1
it waits for the old line : ok
the no-merge variant wrote anyway : ok
old data available : can_write=1 merged=0x11111111AAAAAAAA
named bytes written, unnamed bytes preserved : ok
no-merge variant flagged a clobber : okRead the merged value: 0x11111111AAAAAAAA. The low four bytes came from the write (AAAAAAAA), the high four from the old line (11111111). The bytes the request never named survived, which is the entire requirement.
The broken variant writes wr_data straight through, so the unnamed bytes become whatever the merge buffer held — usually zeros, sometimes another request's data. It corrupts memory the writer never touched.
This is also a performance fact worth stating plainly. A partial write costs a media read and a media write, so on a write path dominated by media it is roughly twice a full-line write. Software that writes whole lines is not being fastidious; it is halving its media traffic.
10. RTL 4 — Stall, Never Lose
=== EXP4: an accepted write is never lost ===
12 pushes into depth 8 : correct level=8 pushed=8 stalled=4
four were stalled, none lost : ok
peak buffer level was exactly 8 : ok
the drop-on-full variant lost accepted writes : ok
and the buffer drained completely : ok
everything pushed was drained exactly once : okSame contract as 9.1's inbound path, with a harsher consequence. A dropped read hangs a requester — bad, and detectable, because someone is waiting. A dropped write produces no waiter at all: the data is simply gone, and the next read of that address returns the old value with no error anywhere.
n_drain_q == n_push_q is the conservation law that makes it checkable, and it is the one property worth asserting continuously rather than at the end of a test.
11. RTL 5 — A Read in the Gap Must See the Newest Write
if (NEWEST_WINS) begin
// Scan so the LAST match wins -- the newest write to this address.
for (k = 0; k < DEPTH; k = k + 1)
if (vld_q[k] && (addr_m[k] == rd_addr)) begin sel = k[3:0]; found = 1'b1; end
end=== EXP5: a read must see the NEWEST buffered write ===
two writes to one address : correct data=0x2222 | oldest-wins data=0x1111
the NEWEST buffered write was forwarded : ok
the oldest-wins variant returned the stale value : ok
an unbuffered address falls through to media : ok9.3 required forwarding; this is where it lives. The write buffer is precisely the reason a write can be accepted and not yet in the media, so the buffer is what a read must search.
The subtle part is which match wins. With several writes to one address buffered, the newest is the correct answer — and the difference between a scan that keeps the last match and one that stops at the first is one loop direction. Both are one line, both look right, and one silently returns stale data.
12. RTL 6 — A Failed Write Must Reach the Writer
assign cmp_is_error = drain && media_fail && !SILENT_FAIL;
...
// The media could not store it and the writer was told it succeeded.
if (media_fail && !cmp_is_error) silent_loss_err <= 1'b1;=== EXP6: a failed write must be reported ===
media failed : correct is_error=1 | silent variant is_error=0
the failed write was reported as an error : ok
the silent variant reported success : ok
silent-fail variant flagged a silent data loss : okA failed write is harder to notice than a failed read, and the asymmetry is worth stating. A failed read has nothing to return, so a device that does not report it must fabricate data — at least there is a value to be suspicious of. A failed write has nothing to return either way: success and failure look identical on the wire, so a device that omits the error is indistinguishable from one that worked.
The data is gone and every party believes it was stored. This is the single most dangerous failure in the write path.
13. RTL 7 — Write Accounting
=== EXP7: write accounting, against an oracle ===
accepted=36 buffered=36 visible=32 failed=2 max_uncommitted=4
partial=9 merge-reads=9 forwards=6
accept/visible matched an independent oracle : ok
nothing became visible that was not accepted : ok
peak uncommitted was exactly 4 : ok
every partial write took a merge read : okpartial == merge-reads is the invariant that catches §9's defect in telemetry rather than in data: every partial write must have taken a merge read, so a mismatch means some partial write went straight to media.
max_uncommitted is the write path's most important number. It is the size of the accepted-but-invisible window — how many writes the device owes visibility for at the worst moment — and it bounds both how wrong a report-on-accept design would be and how long a fence must wait.
Read it as a list of losses rather than a datapath. Every stage between acceptance and completion can destroy data, and each does so without a waiter: the order gate loses the newer value, the merge read loses unnamed bytes, the buffer loses the write outright, the forward path returns a value already superseded, and the media loses it while reporting success. That is why §18 lists four alarms — the write path earns them.
14. Quantitative Reasoning — What the Write Path Costs
Illustrative, from the measured runs.
Partial writes cost double
A full-line write is one media operation. A partial write is a media read plus a media write:
| Write mix | Media operations per 100 writes | Relative media load |
|---|---|---|
| all full-line | 100 | 1.00× |
| measured mix (25% partial) | 125 | 1.25× |
| all partial | 200 | 2.00× |
At 9.1's 100 ns media latency and 4-deep media concurrency, moving from all-partial to all-full-line halves the media occupancy of the write stream. That is a software alignment decision with a hardware-scale payoff.
The accepted-but-invisible window
accepted=36 visible=32 max_uncommitted=4Four writes owed visibility at the peak. Two consequences:
A fence issued at that moment waits for all four — at 100 ns media latency and 4-deep concurrency, roughly one media round trip.
A report-on-accept design would be wrong by up to four writes, which is exactly how many producer/consumer handshakes could observe a flag before its data.
Buffer sizing
12 pushes into depth 8 : pushed=8 stalled=4The buffer must cover the acceptance rate times the drain latency — the same bandwidth-delay reasoning as every other resource in this module. Undersizing it converts write acceptance into a stall the writer sees, which on a store-heavy workload is a core stall.
15. Assertions
Icarus Verilog 13.0 does not support concurrent SVA here, so every property is synthesisable checker logic verified in simulation.
Safety
| Property | Intent |
|---|---|
| Completion at visibility | cmp_valid |-> wr_visible |
| No visibility without acceptance | visible |-> accepted (or accepted this cycle) |
| Same-address write order | visible |-> all earlier same-address writes visible |
| No clobber | partial write |-> old line present |
| No lost write | push && ready |-> stored |
| Newest forward | a read returns the newest buffered write to that address |
| Failure reported | media_fail |-> cmp_is_error |
| Conservation | visible <= accepted, drained == pushed |
Liveness
| Property | Assumption it needs |
|---|---|
| An accepted write eventually becomes visible | the buffer drains |
| A stalled write is eventually accepted | the buffer has room |
| A blocked visibility eventually proceeds | the earlier same-address write becomes visible |
The first is the one that matters most: an accepted write that never becomes visible is silent data loss with no waiter — nothing anywhere is blocked on it, so nothing reports it. That is why buffer drain conservation is a continuously-checked safety property and not merely an end-of-test tally.
Performance goals
| Goal | Measured by |
|---|---|
| Partial writes rare | partial vs full counts |
| Buffer sized to drain latency | peak level, stall count |
| Forwarding effective | forward hits |
| Visibility window bounded | max_uncommitted |
16. Mutation Testing
Twenty-one mutations. Twenty-one killed.
| Mutation | Result |
|---|---|
| Write completes at acceptance | killed |
| Early completion not flagged | killed |
| Visibility without acceptance not flagged | killed |
| Write in-flight counted with two assignments | killed |
| Same-address writes may reorder | killed |
| Write-after-write violation not flagged | killed |
| Blocked writes not counted | killed |
| Partial write proceeds without the old line | killed |
| The merge discards unnamed bytes | killed |
| Clobbering unnamed bytes not flagged | killed |
| Buffer accepts when full | killed |
| Stalled pushes not counted | killed |
| Buffer peak lags by one | killed |
| Oldest buffered write forwarded | killed |
| Write buffer never searched on a read | killed |
| Failed write reported as success | killed |
| Silent write loss not flagged | killed |
| Acceptances counted as visible | killed |
| Peak uncommitted lags by one | killed |
| Merge reads not counted | killed |
| Visible conservation law disabled | killed |
The first run scored 18 of 21, and the escapes were the shapes this batch has established: visibility-without-acceptance and the conservation law both needed instances reserved for illegal stimulus, and the in-flight counter needed accept and visible in the same cycle — the ninth appearance of that defect family in this track.
Two repairs came from the baseline rather than from mutation testing, and both were mine.
The visible-without-acceptance checker fired on correct behaviour. A write accepted and made visible in the same cycle is legal, and the acceptance mask is still being set by that edge. The checker was too broad, which is the mirror image of 9.3's premature-visibility checker being too narrow.
My testbench accepted the same id four times and made it visible four times. An id may be accepted once and become visible once; the stimulus was illegal and the design was right to complain. Distinct ids fixed it. Across this batch the baseline found four defects the mutation suite could not, because mutation testing verifies the checkers and the baseline verifies the design.
17. Verification Plan
| Area | Approach |
|---|---|
| Completion | Acceptance and visibility driven separately; report-on-accept variant |
| Concurrency | Accept and visible in the same cycle, with distinct ids |
| Ordering | Sequence skipped and then supplied; no-WAW variant |
| Partial | Full line, partial without old data, partial with old data; merged value asserted |
| Buffer | Overfill, stall count, complete drain, push/drain conservation |
| Forwarding | Two writes to one address; newest asserted, oldest-wins variant |
| Failure | Good and failed drain; silent variant compared |
| Counters | Independent oracle; partial == merge_reads invariant |
The coverage cross is write size × buffer state × same-address relationship: full / partial, crossed with room / full / draining, crossed with unique / repeated address. The repeated-address-with-buffer-full point is where ordering, forwarding and back-pressure interact, and it must be directed.
18. Silicon Observability
| Counter | Diagnoses |
|---|---|
| accepted vs visible | the size of the observability gap |
max_uncommitted | the worst-case gap, and what a fence would wait for |
| buffer peak and stalls | whether the buffer is sized for the drain rate |
| partial vs full writes | media load from sub-line traffic |
| merge reads | confirms every partial took its read |
| forward hits | reads served from the buffer |
| blocked visibilities | same-address write contention |
| write failures | media health |
lost_write | a correctness alarm — must be zero forever |
silent_loss | a correctness alarm — must be zero forever |
waw_violation | a correctness alarm — must be zero forever |
clobber | a correctness alarm — must be zero forever |
Four alarms — more than any other chapter in this module, and that is the honest signature of the write path: it has more ways to lose data silently than the read path has to return it wrongly. A lost write, a silently failed write, an out-of-order visibility and a clobbered byte all destroy data with no waiter and no error.
The most useful tuning ratio is partial writes over total writes, because it converts directly into media load, and it is usually fixable in software.
19. Debug Lab
A consumer sees the flag set and the data stale
COMPLETE-ON-ACCEPTassign cmp_valid = wr_accept; // completion at acceptanceA producer/consumer handshake fails intermittently. The consumer reads the flag as set and the guarded data as stale. Inserting any delay in the consumer hides it completely, which sends the investigation to the consumer.
at acceptance : correct cmp_valid=0 | report-on-accept cmp_valid=1
report-on-accept variant flagged early completion : okAcceptance was reported as completion. The producer's data write was owned by the device but not observable, so the producer proceeded to the flag and the flag became visible first.
Acceptance is a flow-control event — useful for returning credit — and using it as a correctness signal converts a guarantee software depends on into a throughput optimisation it never asked for.
assign cmp_valid = wr_visible;
if (cmp_valid && !wr_visible) early_completion_err <= 1'b1;Probe the address between acceptance and visibility and assert the old value is returned. A test that probes after visibility passes on both designs.
An older value overwrites a newer one, permanently
WAW-REORDERassign allowed = vis_valid; // any buffered write may drainA location holds a value that was written and then overwritten — the earlier value survives. It appears only when two writes to one address are buffered together, so it scales with write locality, and no read ever recovers the lost value.
seq 2 before seq 1 : correct allowed=0 | no-WAW allowed=1
no-WAW variant flagged a write-after-write violation: okTwo writes to one address became visible out of acceptance order, so the older one landed last and won.
The asymmetry with reads is what makes this severe: an out-of-order read returns a stale value once and a re-read fixes it, while an out-of-order write destroys the newer value permanently.
assign in_order = (vis_seq == expect_seq_q);
assign allowed = vis_valid && in_order;
if (allowed && !in_order) waw_violation_err <= 1'b1;A sequence per address, so the device knows not just that an earlier write exists but that it has already become visible.
Buffer two writes to one address and drain them out of order deliberately. A random address stream essentially never buffers two writes to the same line.
A write corrupts bytes the writer never touched
NO-MERGE-READmedia_line <= wr_data; // partial write straight to mediaData adjacent to a written field is destroyed — usually zeroed, sometimes replaced with another request's data. It happens only for sub-line writes, so structures written field-by-field are corrupted while whole-line writes are fine.
old data available : can_write=1 merged=0x11111111AAAAAAAA
named bytes written, unnamed bytes preserved : okMedia writes whole lines, so a sub-line write must first read the line and merge. Writing wr_data straight through replaces the unnamed bytes with whatever the buffer held.
The corruption is adjacent to the intended write, which makes it look like a pointer or bounds bug in software rather than a memory-device defect.
assign needs_merge_read = wr_valid && (byte_en != 8'hFF);
assign can_write = wr_valid && (!needs_merge_read || old_valid);
assign merged_line = (wr_data & mask) | (old_line & ~mask);Read, merge, then write — and assert that a partial write never proceeds without the old line.
Write a partial line over a known non-zero background and assert the untouched bytes are unchanged. A test over a zeroed background cannot see the bug.
A write disappears and nothing is waiting for it
DROPPED-WRITEassign ready = 1'b1; // always accept
if (push && !full) store_write(); // ...but only store when there is roomData written is simply absent later. There is no hang, no error and no waiter — the next read of that address returns the old value as though the write never happened. It correlates with write bursts.
12 pushes into depth 8 : pushed=8 stalled=4
the drop-on-full variant lost accepted writes : okready and "actually stored" disagreed. The device claimed ownership of a write and discarded it.
This is worse than a dropped read, and the reason is structural: a dropped read leaves a requester waiting, which something eventually notices. A dropped write leaves nobody waiting at all, so the loss is discovered only when someone reads the address and gets an old value they cannot explain.
assign ready = !full;
assign do_push = push && ready;
// and assert continuously
if (n_drain_q != n_push_q) /* investigate */ ;Assert push/drain conservation continuously, not at end of test. A transient loss that self-corrects in the totals is exactly what an end-of-test tally misses.
A read returns a value that was already overwritten
OLDEST-FORWARDfor (k = DEPTH-1; k >= 0; k = k - 1)
if (vld[k] && addr[k] == rd_addr) begin sel = k; found = 1; end // first matchA read returns a stale value even though the newer write was accepted and acknowledged. It requires two buffered writes to one address, so it appears only under write bursts to hot locations.
two writes to one address : correct data=0x2222 | oldest-wins data=0x1111
the oldest-wins variant returned the stale value : okThe buffer scan kept the first match rather than the newest. With several writes to one address buffered, the first found is the oldest, so the read is answered with a value that has already been superseded.
The two loop directions differ by one character and both look correct in review.
for (k = 0; k < DEPTH; k = k + 1)
if (vld[k] && addr[k] == rd_addr) begin sel = k; found = 1; end // last match winsOr track the newest explicitly rather than relying on scan direction, which is what a real design with a wrapping pointer should do.
Buffer two writes to one address with different data and assert the newer is forwarded. A single buffered write cannot distinguish the implementations.
Data is lost and every party believes it was stored
SILENT-WRITE-FAILassign cmp_is_error = 1'b0; // completion never carries a failureWrites to an ageing region of media are silently lost. Reads return old values. No error, no machine check, no counter. Every diagnostic reports the system healthy, and the corruption is discovered by application-level checksums if at all.
media failed : correct is_error=1 | silent variant is_error=0
silent-fail variant flagged a silent data loss : okThe media could not store the write and the completion did not say so.
A failed write is harder to notice than a failed read. A failed read has nothing to return, so a device that hides it must fabricate a value — there is at least something to be suspicious of. A failed write has nothing to return either way, so success and failure are indistinguishable on the wire.
assign cmp_is_error = drain && media_fail;
if (media_fail && !cmp_is_error) silent_loss_err <= 1'b1;Inject media write failures and assert the completion is classified as an error, not merely that a completion arrived. Checking "a completion came back" passes on the broken design.
The in-flight count drifts and a fence passes early
TWO-ASSIGN-INFLIGHTif (wr_accept) inflight_q <= inflight_q + 1;
if (wr_visible) inflight_q <= inflight_q - 1;The reported in-flight write count falls under steady traffic until it reads near zero while writes are demonstrably outstanding. Anything gated on it — a fence, a drain check, a power-down handshake — then acts too early. Burst-then-drain tests pass.
3 cycles of accept+visible together : inflight=4
the case-based counter held steady : okTwo non-blocking assignments to one variable: the second wins, so a cycle with an acceptance and a visibility decrements instead of holding.
Ninth appearance of this defect family in this track, and the consequence here is the same as 9.3's: a counter defect becoming a correctness violation, because something waits on the count reaching zero.
case ({wr_accept, wr_visible})
2'b10: inflight_q <= inflight_q + 4'd1;
2'b01: inflight_q <= inflight_q - 4'd1;
default: ; // both or neither: hold
endcaseTest the cross of the two events with distinct ids — a write accepted and made visible in the same cycle is legal and must be exercised.
A correct device fails its own regression
CHECKER-TOO-BROADif (!acc_mask_q[vis_id]) visible_without_accept_err <= 1'b1;The regression fails on a design that is behaving correctly. The error fires exactly when a write is accepted and made visible in the same cycle — a legal fast-path completion.
3 cycles of accept+visible together : inflight=4
the case-based counter held steady : okThe acceptance mask is set by the same edge on which visibility is being evaluated, so on a same-cycle completion the mask is still zero and the checker sees an impossible state that is in fact legal.
It is the mirror of 9.3's premature-visibility checker: that one was too narrow to fire on a real violation, this one is too broad and fires on correct behaviour. Both come from reading state that the transition under test is still creating.
if (!acc_mask_q[vis_id] && !(wr_accept && (wr_id == vis_id)))
visible_without_accept_err <= 1'b1;Admit the same-cycle case explicitly.
For every checker, ask both questions: can it fire when it should, and can it fire when it should not? Mutation testing answers the first; only running legal corner-case stimulus answers the second.
20. Design Review
- Which event does a write completion report — acceptance or visibility?
- Can two writes to one address become visible out of order?
- Does a partial write read the line first, and what asserts it?
- Can an accepted write be dropped, and what conservation law would catch it?
- Does a read search the write buffer, and does the newest match win?
- Does a media write failure reach the writer?
- Is the in-flight count correct when an acceptance and a visibility coincide?
- What is
max_uncommittedunder real traffic, and what waits on it? - What fraction of writes are partial, and what does that cost in media?
- What happens on reset with accepted-but-invisible writes?
21. How This Appears in Real Engineering
Architecture. The partial-write fraction is a software-visible lever with a hardware-scale payoff — up to 2× media load on the write stream.
RTL. The write buffer is a small structure carrying four of this chapter's alarms: lost writes, out-of-order visibility, clobbered bytes and stale forwards.
DV. Two writes to one address, a partial write over a non-zero background, and a same-cycle accept-and-visible are all directed. None arises naturally.
Post-silicon. Four correctness alarms is more than any other chapter here, and each catches a failure whose only symptom is wrong data much later.
Software. Whole-line writes halve media traffic and avoid the merge-read path entirely, which makes alignment a performance decision rather than a stylistic one.
22. Common Misconceptions
| Belief | Correction |
|---|---|
| A write is done when accepted | It is done when others can see it |
| Write ordering is like read ordering | An out-of-order write destroys data permanently |
| A partial write is cheaper than a full one | It costs a read and a write |
| A dropped write is like a dropped read | Nobody is waiting, so nothing reports it |
| Forwarding just needs an address match | The newest match must win |
| A write failure is obvious | Success and failure look identical on the wire |
| Counter bugs are statistics bugs | Here one makes a fence pass early |
| A checker that never fires is safe | It may also fire when it should not |
23. Interview Reasoning
24. Exercises
-
Calculation. A workload issues 10,000 writes, 60% of them sub-line. Compute the media operations with and without a merge read, and the media time at 100 ns per operation. Then state the saving from aligning the workload to full lines.
-
Analysis. A device reports
accepted=10^6,visible=10^6,max_uncommitted=0, and a producer/consumer handshake that fails intermittently. Explain why those three counters are suspicious together and name the defect. -
RTL task. Extend
write_orderto track sequences per address rather than globally. State the storage cost and the new failure mode a shared sequence counter would have had on a multi-address workload. -
Assertion task. Write the property that catches a partial write proceeding without its merge read, and explain why it must be expressed over the byte enables rather than over the write's size field.
-
Debug task. Data adjacent to a written field is being zeroed. Give your investigation order and the single directed test that distinguishes a device merge bug from a software bounds bug.
-
Design review. A colleague proposes reporting write completion at acceptance to improve throughput, noting the device never loses an accepted write. Give the strongest version of that argument, then name exactly what it breaks and what would have to be true system-wide for it to be safe.
25. Summary
A write is not done until someone else can see it.
- A write has two endings: accepted (flow control) and visible (correctness). Reporting the first as the second breaks every producer/consumer handshake.
- Same-address writes must become visible in acceptance order. An out-of-order write destroys the newer value permanently, unlike an out-of-order read.
- A partial write must read before it writes, or it corrupts bytes the writer never named — and it costs two media operations, up to 2× media load.
- Stall, never lose. A dropped write has no waiter, so nothing reports it; push-equals-drain is the law that catches it.
- A read in the gap must see the newest buffered write. The scan direction that returns the oldest is one character away and silently stale.
- A failed write must reach the writer, because success and failure are otherwise indistinguishable on the wire.
- Measured: peak accepted-but-invisible was exactly 4 — the fence cost and the bound on how wrong a report-on-accept design would be.
- Four correctness alarms — lost write, silent failure, write-after-write violation, clobber — more than any other chapter in this module.
- The two-assignment counter defect appeared for the ninth time, and again turned a counter bug into a correctness violation.
- Verification lesson: the baseline found two defects mutation testing could not — a checker that fired on legal same-cycle completion, and a testbench driving illegal stimulus the design was right to reject. Mutation testing verifies the checkers; the baseline verifies the design.
That completes the read and write flows. Chapter 9.6 — CXL.mem Performance Implications turns these obligations into their latency and throughput cost, and it is not part of this batch.
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.
