PCIe · Module 26
SSD Controllers — Two Meanings of the Word Completion
PCIe is the transport; NVMe is the command architecture above it. One 1 MiB command costs 4,099 PCIe transactions and produces exactly one NVMe Completion Queue entry — and the two are not the same object.
Chapter 26.2 showed a device that is told what to do. This chapter shows one that fetches its own work.
An NVMe SSD controller is a PCIe Endpoint that reads its commands out of host memory, moves data in both directions, and writes its results back into host memory. PCIe is the transport. NVMe is the command and queue architecture above it. Almost every confusion in this area comes from collapsing those two layers into one, and §4 is the collapse that costs the most.
1. Sources, Scope, and What This Chapter Will Not Invent
2. The Device That Fetches Its Own Work
The structural difference from 26.2 is that almost nothing is pushed to this device.
host controller
write command into a submission queue (in HOST memory)
ring the doorbell ─────────────────► queue manager notices
DMA READ the queue entry ◄── the controller
command is now owned fetches its own work
DMA READ or WRITE payload
media pipeline
DMA WRITE the completion entry
interrupt
read the completion entry from HOST memoryEvery arrow except the doorbell is initiated by the controller. The host writes a small register; the controller then performs the reads and writes that constitute the work.
Three consequences shape the rest of the chapter.
A doorbell announces; it does not deliver. The command lives in host memory and must be fetched. §5 is that step, and skipping it — executing on the doorbell alone — is a fault whose visibility depends on timing (§17).
The controller is overwhelmingly a requester. Its Memory Reads consume Tags and completion-buffer space (23.5); its Memory Writes consume posted credit and receive nothing back. A controller's PCIe complexity is requester-side, exactly as a GPU's is.
And results travel back the same way work came in — through host memory. The completion entry is a DMA write, not a response. §4 is why that distinction is the most important sentence in the chapter.
3. PCIe Is the Transport; NVMe Is the Architecture Above It
Two layers, cleanly separated:
| PCIe | NVMe | |
|---|---|---|
| defines | links, TLPs, routing, flow control, Completions | commands, queues, doorbells, completion entries |
| unit of work | a transaction | a command |
| "completion" means | a TLP answering a Non-Posted request | a queue entry written into host memory |
| identity | (Requester ID, Tag) | command identifier within a queue |
| owned by | PCI-SIG | NVM Express |
The controller uses many PCIe transactions to execute one NVMe command, and §15 counts them. Nothing about that is unusual — it is what a transport is for — but it means the two layers' objects are never in one-to-one correspondence, and reasoning that assumes they are goes wrong immediately.
4. A PCIe Completion Is Not an NVMe Completion
Read the last row twice, because it is where the confusion becomes concrete.
When the controller fetches a command, it issues a Memory Read. That read is Non-Posted, so a PCIe Completion comes back to the controller carrying the command entry. This is PCIe answering the controller.
When the controller finishes that command, it issues a Memory Write containing the NVMe Completion Queue entry. That write is Posted. It receives no PCIe Completion at all (12.2). This is the controller reporting to software.
They travel in opposite directions, at different layers, for different reasons.
§15 quantifies the asymmetry. For a 1 MiB read command the controller issues 4,099 PCIe transactions and produces one NVMe completion entry. For a write command, the payload transactions are Memory Reads — Non-Posted — so thousands of PCIe Completions flow toward the controller while still producing exactly one NVMe completion entry going the other way.
The practical consequence is §18 case 5's question, and it is worth stating as a rule:
The absence of a PCIe Completion for the CQE write is not an error. It is the definition of a Posted write.
An engineer looking at a trace and asking "where is the Completion for the completion?" has conflated the layers. There is not supposed to be one.
5. The Doorbell, and What It Does Not Contain
A doorbell write tells the controller that a queue's producer pointer has advanced. It does not carry the command.
host: writes one or more command entries into the submission queue in memory
host: writes the new tail value to that queue's doorbell register
ctrl: notices the queue is non-empty
ctrl: issues a Memory Read to fetch the entry (or entries)
ctrl: the command is owned only when that read's data returnsThe exact doorbell register layout and the queue-entry format are NVMe-defined and are not reproduced here (§1). What is architectural, and what §9's RTL models, is the sequence: a pointer write announcing memory-resident work.
Two rules follow.
The command must be visible before the doorbell announces it. This is the host's obligation, and it is 25.6 §5's ordering window again.
And the command is owned only after its fetch returns. A controller that begins execution when the doorbell arrives is acting on data it has not read. §17 measured why this fault is so easy to miss: the controller's own fetch latency usually hides it.
6. The Block Diagram
Read the flows as four separate conversations, because they fail separately. A controller that fetches commands but never moves payload has a working command flow and a broken data flow. §18's cases are organised by which flow stopped.
7. Where Ownership Moves
Six transfers of ownership, and every fault in §16 breaks exactly one.
1 host builds the command host owns it
2 host advances the doorbell announced; still host-visible memory
3 controller fetches the entry in flight
4 fetch data returns CONTROLLER OWNS THE COMMAND
5 payload moves, media completes controller owns the result
6 CQE written into host memory result published
7 interrupt raised host is told to look
8 host consumes the CQE, advances head queue slot reusableStep 4 is the one designs get wrong — beginning at step 5 on the strength of step 2 (§5).
Step 6 before step 7 is non-negotiable, and it is the same rule 26.2 §11 established: the interrupt carries no information, so the record must exist first. §16 measured the inversion at 99,604 early interrupts.
And step 8 is what makes step 6 safe. The controller must not write a completion entry into a slot the host has not yet consumed. §17 measured that this only becomes a problem when the host lags — and that removing the check makes the controller look faster while destroying completion records.
8. The Instruments, Named
| Instrument | Answers |
|---|---|
nvme_doorbell_capture | which queue advanced, and to where? |
nvme_queue_ctx | per-queue state: enabled, depth, head, tail |
nvme_cmd_fetch | issue the SQE read; own the command only on return |
nvme_xfer_descriptor | the normalized data-movement request |
nvme_buffer_credit | decouple the PCIe side from the media side |
nvme_cq_writer | publish the completion entry, once |
nvme_int_gate | raise the interrupt strictly after publication |
nvme_counters | where is time going — Link, media, or queueing? |
9. RTL — Queues, Fetch, and Publication
Block 1 — the package. COMPILE-TIME.
package nvme_dbg_pkg;
// The controller's own view of a command's lifetime. This is LOCAL state,
// not an NVMe-defined status: §7's ownership steps, made observable.
typedef enum logic [2:0] {
CS_IDLE = 3'd0,
CS_FETCHING = 3'd1, // SQE read issued, data not yet returned
CS_OWNED = 3'd2, // fetch returned; the command is ours (step 4)
CS_MOVING = 3'd3, // payload in flight
CS_MEDIA = 3'd4, // media pipeline working
CS_PUBLISH = 3'd5, // CQE write issued
CS_NOTIFY = 3'd6 // interrupt pending
} cmd_state_e;
typedef enum logic [0:0] { DIR_READ = 1'b0, DIR_WRITE = 1'b1 } xfer_dir_e;
function automatic int unsigned gw(input int unsigned n);
return (n <= 1) ? 1 : $clog2(n);
endfunction
// A normalized data-movement request. NVMe's actual data-pointer structures
// (PRP lists, SGLs) are STANDARD-DEFINED and are deliberately not modelled
// here — see 20.4 for scatter-gather handling and the NVMe specification
// for the real encodings.
typedef struct packed {
logic [15:0] cmd_id;
logic [15:0] queue_id;
xfer_dir_e dir;
logic [63:0] host_addr;
logic [31:0] buf_addr; // controller-local buffer
logic [31:0] bytes;
} xfer_desc_t;
endpackageBlock 2 — doorbell capture. SYNTHESIZABLE. §5's mechanism.
Input owner: the BAR write path, for the accepted cycle. Output owner: this module, until the queue manager consumes it. Stall: a newer doorbell for the same queue supersedes the older value; pointers are cumulative. Reset: clears all pending doorbells. The host re-announces by writing again.
module nvme_doorbell_capture #(
parameter int unsigned NQUEUE = 8
)(
input logic clk,
input logic rst_n,
input logic bar_wr_valid,
input logic [31:0] bar_wr_offset,
input logic [31:0] bar_wr_data,
input logic [nvme_dbg_pkg::gw(NQUEUE)-1:0] decoded_qid, // decode owned by 23.2
input logic decoded_is_sq,
output logic db_valid,
output logic [nvme_dbg_pkg::gw(NQUEUE)-1:0] db_qid,
output logic db_is_sq,
output logic [31:0] db_value,
input logic db_ready,
output logic qid_range_error
);
import nvme_dbg_pkg::*;
logic [NQUEUE-1:0] pend_sq, pend_cq;
logic [31:0] val_sq [NQUEUE];
logic [31:0] val_cq [NQUEUE];
logic [gw(NQUEUE)-1:0] sel;
logic sel_is_sq;
// A doorbell naming a queue outside the configured range is REPORTED, not
// wrapped. Wrapping turns a host software error into execution on an
// unrelated queue (mutation 6), and the resulting corruption is attributed
// to whichever queue the index happened to alias onto.
assign qid_range_error = bar_wr_valid && (decoded_qid >= gw(NQUEUE)'(NQUEUE));
always_comb begin
sel = '0; sel_is_sq = 1'b1;
for (int i = NQUEUE-1; i >= 0; i--) if (pend_cq[i]) begin sel = gw(NQUEUE)'(i); sel_is_sq = 1'b0; end
for (int i = NQUEUE-1; i >= 0; i--) if (pend_sq[i]) begin sel = gw(NQUEUE)'(i); sel_is_sq = 1'b1; end
db_valid = (pend_sq != '0) || (pend_cq != '0);
db_qid = sel;
db_is_sq = sel_is_sq;
db_value = sel_is_sq ? val_sq[sel] : val_cq[sel];
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
pend_sq <= '0; pend_cq <= '0;
for (int i = 0; i < NQUEUE; i++) begin val_sq[i] <= '0; val_cq[i] <= '0; end
end else begin
if (bar_wr_valid && !qid_range_error) begin
if (decoded_is_sq) begin val_sq[decoded_qid] <= bar_wr_data; pend_sq[decoded_qid] <= 1'b1; end
else begin val_cq[decoded_qid] <= bar_wr_data; pend_cq[decoded_qid] <= 1'b1; end
end
if (db_valid && db_ready) begin
// Suppress the clear if a newer doorbell for the same queue and
// direction landed this cycle — otherwise that announcement is lost.
if (!(bar_wr_valid && !qid_range_error &&
(decoded_qid == sel) && (decoded_is_sq == sel_is_sq))) begin
if (sel_is_sq) pend_sq[sel] <= 1'b0;
else pend_cq[sel] <= 1'b0;
end
end
end
end
endmoduleBlock 3 — the per-queue context table. SYNTHESIZABLE. Normalized local controller state, not an NVMe structure.
module nvme_queue_ctx #(
parameter int unsigned NQUEUE = 8,
parameter int unsigned MAXDEPTH = 1024
)(
input logic clk,
input logic rst_n,
// configuration (created and deleted by admin commands; the COMMAND
// formats are NVMe-defined and are not modelled here)
input logic cfg_valid,
input logic [nvme_dbg_pkg::gw(NQUEUE)-1:0] cfg_qid,
input logic cfg_enable,
input logic [nvme_dbg_pkg::gw(MAXDEPTH+1)-1:0] cfg_depth,
input logic [63:0] cfg_base,
// doorbell updates
input logic db_valid,
input logic [nvme_dbg_pkg::gw(NQUEUE)-1:0] db_qid,
input logic [31:0] db_value,
output logic db_range_error,
// query
input logic [nvme_dbg_pkg::gw(NQUEUE)-1:0] q_sel,
output logic q_enabled,
output logic q_nonempty,
output logic [nvme_dbg_pkg::gw(MAXDEPTH)-1:0] q_head, q_tail,
output logic [63:0] q_base,
// head advance when a command is fetched
input logic head_adv,
input logic [nvme_dbg_pkg::gw(NQUEUE)-1:0] head_qid
);
import nvme_dbg_pkg::*;
logic [NQUEUE-1:0] en;
logic [gw(MAXDEPTH+1)-1:0] depth [NQUEUE];
logic [gw(MAXDEPTH)-1:0] head [NQUEUE], tail [NQUEUE];
logic [63:0] base [NQUEUE];
// A doorbell value at or beyond the queue depth is a host error. It is
// REPORTED and the tail is not updated — accepting it would make the
// queue appear to contain entries that do not exist (mutation 7).
assign db_range_error = db_valid && en[db_qid] && (db_value >= 32'(depth[db_qid]));
assign q_enabled = en[q_sel];
assign q_head = head[q_sel];
assign q_tail = tail[q_sel];
assign q_base = base[q_sel];
assign q_nonempty = en[q_sel] && (head[q_sel] != tail[q_sel]);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
en <= '0;
for (int i = 0; i < NQUEUE; i++) begin
depth[i] <= '0; head[i] <= '0; tail[i] <= '0; base[i] <= '0;
end
end else begin
if (cfg_valid) begin
en[cfg_qid] <= cfg_enable;
depth[cfg_qid] <= cfg_depth;
base[cfg_qid] <= cfg_base;
// Creating or deleting a queue resets its pointers. A queue that is
// re-created with stale head/tail executes phantom commands.
head[cfg_qid] <= '0;
tail[cfg_qid] <= '0;
end
if (db_valid && en[db_qid] && !db_range_error)
tail[db_qid] <= gw(MAXDEPTH)'(db_value);
if (head_adv && en[head_qid])
head[head_qid] <= (head[head_qid] == gw(MAXDEPTH)'(depth[head_qid]-1))
? '0 : head[head_qid] + 1'b1;
end
end
endmoduleBlock 4 — the command fetch owner. SYNTHESIZABLE. §5's step 4, and the module §17's timing analysis is about.
module nvme_cmd_fetch #(
parameter int unsigned NQUEUE = 8,
parameter int unsigned NSLOT = 32
)(
input logic clk,
input logic rst_n,
input logic q_nonempty,
input logic [nvme_dbg_pkg::gw(NQUEUE)-1:0] q_sel,
input logic [63:0] q_base,
input logic [nvme_dbg_pkg::gw(1024)-1:0] q_head,
// outbound Memory Read for the queue entry
output logic rd_valid,
output logic [63:0] rd_addr,
output logic [11:0] rd_bytes,
output logic [nvme_dbg_pkg::gw(NSLOT)-1:0] rd_slot,
input logic rd_ready,
// the read's data returning (a PCIe Completion carrying the entry, §4)
input logic fetch_data_valid,
input logic [nvme_dbg_pkg::gw(NSLOT)-1:0] fetch_slot,
input logic [15:0] fetch_cmd_id,
// the command becomes OWNED here, and only here
output logic cmd_owned,
output logic [nvme_dbg_pkg::gw(NSLOT)-1:0] cmd_slot,
output logic [15:0] cmd_id,
output logic head_adv,
output logic [nvme_dbg_pkg::gw(NQUEUE)-1:0] head_qid
);
import nvme_dbg_pkg::*;
localparam int unsigned SQE_BYTES = 64; // IMPLEMENTATION POLICY
logic [NSLOT-1:0] slot_busy;
logic [gw(NSLOT)-1:0] alloc;
always_comb begin
alloc = '0;
for (int i = NSLOT-1; i >= 0; i--) if (!slot_busy[i]) alloc = gw(NSLOT)'(i);
// A fetch is ISSUED when the queue is non-empty and a slot is free.
rd_valid = q_nonempty && (slot_busy != '1);
rd_addr = q_base + 64'(q_head) * SQE_BYTES;
rd_bytes = 12'(SQE_BYTES);
rd_slot = alloc;
// The head advances when the FETCH IS ISSUED, so the same entry is not
// fetched twice. The command is not owned yet.
head_adv = rd_valid && rd_ready;
head_qid = q_sel;
// OWNERSHIP happens only when the fetch data returns. §7 step 4.
// A controller that sets cmd_owned from the doorbell is executing on
// data it has not read (mutation 9) — and §17 measured that the fetch
// latency usually HIDES this, which is why it survives to silicon.
cmd_owned = fetch_data_valid;
cmd_slot = fetch_slot;
cmd_id = fetch_cmd_id;
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) slot_busy <= '0;
else begin
if (rd_valid && rd_ready) slot_busy[alloc] <= 1'b1;
if (fetch_data_valid) slot_busy[fetch_slot] <= 1'b0;
end
end
endmoduleBlock 5 — the transfer descriptor generator. SYNTHESIZABLE. One command becomes one or more normalized movement requests.
module nvme_xfer_descriptor #(
parameter int unsigned MAX_CHUNK = 4096 // IMPLEMENTATION POLICY
)(
input logic clk,
input logic rst_n,
input logic cmd_valid,
input logic [15:0] cmd_id,
input logic [15:0] queue_id,
input nvme_dbg_pkg::xfer_dir_e dir,
input logic [63:0] host_addr,
input logic [31:0] total_bytes,
output logic cmd_ready,
output logic desc_valid,
output nvme_dbg_pkg::xfer_desc_t desc,
input logic desc_ready,
output logic cmd_complete,
output logic [31:0] bytes_emitted
);
import nvme_dbg_pkg::*;
logic [31:0] remaining, offset;
logic active;
logic [31:0] this_chunk;
// NVMe's real data pointers (PRP lists, SGLs) are STANDARD-DEFINED and are
// not modelled. What IS modelled is the conservation obligation: the chunks
// emitted must sum EXACTLY to the command's byte count, in both directions.
// 25.6 §4 law 1 measured what a one-sided check misses.
always_comb begin
this_chunk = (remaining > MAX_CHUNK) ? MAX_CHUNK : remaining;
desc_valid = active && (remaining != 0);
desc = '{cmd_id: cmd_id,
queue_id: queue_id,
dir: dir,
host_addr: host_addr + 64'(offset),
buf_addr: offset,
bytes: this_chunk};
cmd_ready = !active;
cmd_complete = active && (remaining == 0);
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
active <= 1'b0; remaining <= '0; offset <= '0; bytes_emitted <= '0;
end else begin
if (cmd_valid && cmd_ready) begin
active <= 1'b1; remaining <= total_bytes; offset <= '0; bytes_emitted <= '0;
end else if (desc_valid && desc_ready) begin
// The final chunk is PARTIAL whenever total_bytes is not a multiple
// of MAX_CHUNK. Computing a chunk count by truncating division drops
// it — §16 measured a 25,513,216-byte deficit from exactly that.
remaining <= remaining - this_chunk;
offset <= offset + this_chunk;
bytes_emitted <= bytes_emitted + this_chunk;
end else if (cmd_complete) begin
active <= 1'b0;
end
end
end
endmoduleBlock 6 — buffer credit between PCIe and media. SYNTHESIZABLE. §13's decoupling.
module nvme_buffer_credit #(
parameter int unsigned NSLOT = 64
)(
input logic clk,
input logic rst_n,
input logic pcie_wants_slot, // inbound write data
output logic pcie_slot_grant,
input logic media_wants_slot, // outbound read data
output logic media_slot_grant,
input logic pcie_releases,
input logic media_releases,
output logic [nvme_dbg_pkg::gw(NSLOT+1)-1:0] free_slots,
output logic would_overflow
);
import nvme_dbg_pkg::*;
logic [gw(NSLOT+1)-1:0] used;
// Explicit free-slot accounting in BOTH directions. The PCIe side and the
// media side run at unrelated rates, and neither may overrun the buffer
// when the other stalls (§13). A design that only throttles one direction
// corrupts the other under the opposite load pattern (mutations 20, 21).
always_comb begin
free_slots = gw(NSLOT+1)'(NSLOT) - used;
pcie_slot_grant = pcie_wants_slot && (free_slots != 0);
media_slot_grant = media_wants_slot && (free_slots != 0);
would_overflow = (pcie_wants_slot || media_wants_slot) && (free_slots == 0);
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) used <= '0;
else begin
case ({(pcie_slot_grant || media_slot_grant), (pcie_releases || media_releases)})
2'b10: used <= used + 1'b1;
2'b01: used <= used - 1'b1;
default: ;
endcase
end
end
endmoduleBlock 7 — the completion queue writer. SYNTHESIZABLE. §7 step 6, and the module §17's load analysis is about.
module nvme_cq_writer #(
parameter int unsigned NQUEUE = 8,
parameter int unsigned MAXDEPTH = 1024
)(
input logic clk,
input logic rst_n,
input logic cmd_done,
input logic [15:0] done_cmd_id,
input logic [nvme_dbg_pkg::gw(NQUEUE)-1:0] done_cq,
input logic [15:0] done_status,
// per-CQ state
input logic [nvme_dbg_pkg::gw(MAXDEPTH)-1:0] cq_head, // host consumed to here
input logic [nvme_dbg_pkg::gw(MAXDEPTH)-1:0] cq_tail,
input logic [nvme_dbg_pkg::gw(MAXDEPTH+1)-1:0] cq_depth,
input logic cq_phase,
// outbound Memory Write carrying the completion entry (Posted, §4)
output logic cqe_valid,
output logic [15:0] cqe_cmd_id,
output logic [15:0] cqe_status,
output logic cqe_phase,
input logic cqe_ready,
output logic cq_full_stall,
output logic tail_adv,
output logic phase_toggle,
output logic [31:0] full_stall_count
);
import nvme_dbg_pkg::*;
logic [gw(MAXDEPTH)-1:0] next_tail;
always_comb begin
next_tail = (cq_tail == gw(MAXDEPTH)'(cq_depth-1)) ? '0 : cq_tail + 1'b1;
// The controller must NOT write into a slot the host has not consumed.
// §17 measured the alternative: removing this check makes the controller
// report HIGHER completion throughput while destroying completion
// records the host had not yet read — 25.6 §6's broken ring-full test.
cq_full_stall = cmd_done && (next_tail == cq_head);
cqe_valid = cmd_done && !cq_full_stall;
cqe_cmd_id = done_cmd_id;
cqe_status = done_status;
// The phase value published with this entry is the CURRENT phase. It is
// the host's only means of distinguishing a new entry from a stale one
// left in the ring from the previous lap — a STANDARD-DEFINED mechanism
// whose encoding lives in the NVMe specification (§1).
cqe_phase = cq_phase;
tail_adv = cqe_valid && cqe_ready;
phase_toggle = tail_adv && (next_tail == '0);
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) full_stall_count <= '0;
else if (cq_full_stall && full_stall_count != 32'hFFFF_FFFF)
full_stall_count <= full_stall_count + 32'd1;
end
endmoduleBlock 8 — the interrupt gate. SYNTHESIZABLE. §7 step 7, strictly after step 6.
module nvme_int_gate #(
parameter int unsigned NQUEUE = 8
)(
input logic clk,
input logic rst_n,
input logic cqe_written, // the CQE write was ACCEPTED
input logic [nvme_dbg_pkg::gw(NQUEUE)-1:0] cqe_cq,
input logic int_enabled,
output logic int_req,
output logic [nvme_dbg_pkg::gw(NQUEUE)-1:0] int_cq,
input logic int_ack,
output logic [31:0] early_attempts
);
import nvme_dbg_pkg::*;
logic pending;
logic [gw(NQUEUE)-1:0] pend_cq;
// The interrupt is raised ONLY after the completion entry write has been
// accepted. §16 measured the inverted order at 99,604 early interrupts —
// every one an opportunity for the host to read a stale ring entry.
//
// 19.x owns the interrupt mechanism. What this module owns is the ORDER:
// publish the durable record, then notify.
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
pending <= 1'b0; pend_cq <= '0; early_attempts <= '0;
end else begin
if (cqe_written) begin pending <= 1'b1; pend_cq <= cqe_cq; end
else if (int_req && int_ack) pending <= 1'b0;
end
end
assign int_req = pending && int_enabled;
assign int_cq = pend_cq;
endmoduleBlock 9 — attribution counters. VERIFICATION-ONLY. §14's reasoning.
module nvme_counters (
input logic clk,
input logic rst_n,
input logic sqe_fetch, cqe_write, msi_write,
input logic data_beat, input logic [15:0] data_bytes,
input logic stall_link, // waiting for credit or Tags
input logic stall_media, // waiting for the flash pipeline
input logic stall_buffer, // waiting for a controller buffer slot
input logic stall_cqfull, // waiting for the host to consume completions
input logic clear,
output logic [31:0] c_sqe, c_cqe, c_msi,
output logic [63:0] c_data_bytes,
output logic [31:0] c_link, c_media, c_buffer, c_cqfull
);
// Four separate stall attributions, because §14's whole point is that
// "the SSD is slow" is not a diagnosis. A media stall and a Link stall
// demand opposite responses, and a CQ-full stall means the HOST is the
// limiter — which no amount of controller work will fix.
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n || clear) begin
c_sqe <= '0; c_cqe <= '0; c_msi <= '0; c_data_bytes <= '0;
c_link <= '0; c_media <= '0; c_buffer <= '0; c_cqfull <= '0;
end else begin
if (sqe_fetch && c_sqe != '1) c_sqe <= c_sqe + 1'b1;
if (cqe_write && c_cqe != '1) c_cqe <= c_cqe + 1'b1;
if (msi_write && c_msi != '1) c_msi <= c_msi + 1'b1;
if (stall_link && c_link != '1) c_link <= c_link + 1'b1;
if (stall_media && c_media != '1) c_media <= c_media + 1'b1;
if (stall_buffer && c_buffer != '1) c_buffer <= c_buffer + 1'b1;
if (stall_cqfull && c_cqfull != '1) c_cqfull <= c_cqfull + 1'b1;
if (data_beat && c_data_bytes != '1) c_data_bytes <= c_data_bytes + 64'(data_bytes);
end
end
endmodule10. Same-Cycle Audit
11. Assertions
Doorbell and queue-context properties — §5.
// P1 — a doorbell is captured only from an accepted BAR write.
property p1_db_on_accepted_write;
@(posedge clk) disable iff (!rst_n)
(bar_wr_valid && !qid_range_error) |=> (pend_sq[$past(decoded_qid)] ||
pend_cq[$past(decoded_qid)]);
endproperty
a_p1: assert property (p1_db_on_accepted_write);
// P2 — an out-of-range queue id is reported, never wrapped.
property p2_qid_range;
@(posedge clk) disable iff (!rst_n)
(bar_wr_valid && (decoded_qid >= NQUEUE)) |-> qid_range_error;
endproperty
a_p2: assert property (p2_qid_range);
// P3 — a newer doorbell for the same queue is not lost to a concurrent accept.
property p3_db_not_lost;
@(posedge clk) disable iff (!rst_n)
(db_valid && db_ready && bar_wr_valid && (decoded_qid == db_qid) &&
(decoded_is_sq == db_is_sq)) |=> (pend_sq[$past(db_qid)] || pend_cq[$past(db_qid)]);
endproperty
a_p3: assert property (p3_db_not_lost);
// P4 — a doorbell value beyond the queue depth is rejected, not applied.
property p4_db_value_bounded;
@(posedge clk) disable iff (!rst_n)
(db_valid && q_enabled && (db_value >= cfg_depth)) |-> db_range_error;
endproperty
a_p4: assert property (p4_db_value_bounded);
// P5 — a disabled queue accepts no work.
property p5_disabled_queue_inert;
@(posedge clk) disable iff (!rst_n)
!q_enabled |-> !q_nonempty;
endproperty
a_p5: assert property (p5_disabled_queue_inert);
// P6 — creating or deleting a queue clears its pointers.
property p6_cfg_resets_pointers;
@(posedge clk) disable iff (!rst_n)
cfg_valid |=> ((head[$past(cfg_qid)] == '0) && (tail[$past(cfg_qid)] == '0));
endproperty
a_p6: assert property (p6_cfg_resets_pointers);
// P7 — head and tail stay within the configured depth.
property p7_pointers_bounded;
@(posedge clk) disable iff (!rst_n)
q_enabled |-> ((q_head < cfg_depth) && (q_tail < cfg_depth));
endproperty
a_p7: assert property (p7_pointers_bounded);Fetch and ownership properties — §7 step 4.
// P8 — a command is owned only when its fetch data returns, never on the
// doorbell. §17 measured that the fetch latency usually HIDES the violation.
property p8_owned_on_fetch_return;
@(posedge clk) disable iff (!rst_n)
cmd_owned |-> fetch_data_valid;
endproperty
a_p8: assert property (p8_owned_on_fetch_return);
// P9 — the head advances on fetch ISSUE, so no entry is fetched twice.
property p9_head_on_issue;
@(posedge clk) disable iff (!rst_n)
head_adv |-> (rd_valid && rd_ready);
endproperty
a_p9: assert property (p9_head_on_issue);
// P10 — a fetch slot is allocated only if free.
property p10_slot_free_on_alloc;
@(posedge clk) disable iff (!rst_n)
(rd_valid && rd_ready) |-> !slot_busy[rd_slot];
endproperty
a_p10: assert property (p10_slot_free_on_alloc);
// P11 — returning data releases exactly the slot that requested it.
property p11_slot_release_matches;
@(posedge clk) disable iff (!rst_n)
fetch_data_valid |-> slot_busy[fetch_slot];
endproperty
a_p11: assert property (p11_slot_release_matches);
// P12 — no fetch is issued for an empty queue.
property p12_no_fetch_when_empty;
@(posedge clk) disable iff (!rst_n)
rd_valid |-> q_nonempty;
endproperty
a_p12: assert property (p12_no_fetch_when_empty);Transfer conservation properties — §9 Block 5.
// P13 — emitted chunks sum EXACTLY to the command's byte count.
// 25.6 §4 law 1: the check must be two-sided.
property p13_bytes_conserved;
@(posedge clk) disable iff (!rst_n)
cmd_complete |-> (bytes_emitted == total_bytes);
endproperty
a_p13: assert property (p13_bytes_conserved);
// P14 — the final chunk may be partial and must not be dropped.
// §16 measured a truncating chunk count losing 25,513,216 bytes.
property p14_final_partial_chunk;
@(posedge clk) disable iff (!rst_n)
(active && (remaining > 0) && (remaining < MAX_CHUNK))
|-> (desc_valid && (desc.bytes == remaining));
endproperty
a_p14: assert property (p14_final_partial_chunk);
// P15 — a zero-byte command emits no descriptor.
property p15_zero_bytes_no_desc;
@(posedge clk) disable iff (!rst_n)
(cmd_valid && cmd_ready && (total_bytes == 32'd0)) |=> !desc_valid;
endproperty
a_p15: assert property (p15_zero_bytes_no_desc);
// P16 — a descriptor's byte count never exceeds the chunk limit.
property p16_chunk_bounded;
@(posedge clk) disable iff (!rst_n)
desc_valid |-> (desc.bytes <= MAX_CHUNK) && (desc.bytes != 0);
endproperty
a_p16: assert property (p16_chunk_bounded);
// P17 — the command id is carried unchanged onto every descriptor.
property p17_cmd_id_propagated;
@(posedge clk) disable iff (!rst_n)
desc_valid |-> (desc.cmd_id == cmd_id);
endproperty
a_p17: assert property (p17_cmd_id_propagated);Buffer credit properties — §13.
// P18 — a slot is granted only when one is free, from either direction.
property p18_grant_needs_free;
@(posedge clk) disable iff (!rst_n)
(pcie_slot_grant || media_slot_grant) |-> (free_slots != 0);
endproperty
a_p18: assert property (p18_grant_needs_free);
// P19 — occupancy never exceeds the buffer size.
property p19_occupancy_bounded;
@(posedge clk) disable iff (!rst_n)
(used <= NSLOT);
endproperty
a_p19: assert property (p19_occupancy_bounded);
// P20 — an overflow attempt is reported, never silently absorbed.
property p20_overflow_reported;
@(posedge clk) disable iff (!rst_n)
((pcie_wants_slot || media_wants_slot) && (free_slots == 0)) |-> would_overflow;
endproperty
a_p20: assert property (p20_overflow_reported);Completion publication properties — §7 steps 6 and 7.
// P21 — no completion entry is written into an unconsumed slot.
// §17 measured removing this: 796 clobbered entries at a slow host rate.
property p21_no_cq_overwrite;
@(posedge clk) disable iff (!rst_n)
cqe_valid |-> (next_tail != cq_head);
endproperty
a_p21: assert property (p21_no_cq_overwrite);
// P22 — a CQ-full condition stalls and is counted, never dropped.
property p22_cq_full_stalls;
@(posedge clk) disable iff (!rst_n)
(cmd_done && (next_tail == cq_head)) |-> (cq_full_stall && !cqe_valid);
endproperty
a_p22: assert property (p22_cq_full_stalls);
// P23 — the phase toggles exactly at wrap. It is the host's only means of
// distinguishing a new entry from last lap's (§16: 3,111 missed toggles).
property p23_phase_toggles_at_wrap;
@(posedge clk) disable iff (!rst_n)
(tail_adv && (next_tail == '0)) |-> phase_toggle;
endproperty
a_p23: assert property (p23_phase_toggles_at_wrap);
// P24 — the phase does not toggle anywhere else.
property p24_phase_stable_otherwise;
@(posedge clk) disable iff (!rst_n)
(tail_adv && (next_tail != '0)) |-> !phase_toggle;
endproperty
a_p24: assert property (p24_phase_stable_otherwise);
// P25 — exactly one completion entry per completed command.
property p25_one_cqe_per_command;
@(posedge clk) disable iff (!rst_n)
(cqe_valid && cqe_ready) |=> !(cqe_valid && (cqe_cmd_id == $past(cqe_cmd_id)));
endproperty
a_p25: assert property (p25_one_cqe_per_command);
// P26 — the completion entry carries the command id that finished.
// §16 measured 2,909 entries carrying the wrong one.
property p26_cqe_id_correct;
@(posedge clk) disable iff (!rst_n)
cqe_valid |-> (cqe_cmd_id == done_cmd_id);
endproperty
a_p26: assert property (p26_cqe_id_correct);
// P27 — the interrupt is raised only after the CQE write was ACCEPTED.
// §16 measured the inversion at 99,604 early interrupts.
property p27_int_after_cqe;
@(posedge clk) disable iff (!rst_n)
int_req |-> $past(cqe_written, 1) or pending;
endproperty
a_p27: assert property (p27_int_after_cqe);Cover — the anti-vacuity set.
// P28 — a command reaches exactly one terminal outcome: a completion entry
// is published, or it is still owned. A command that leaves the controller's
// state without publishing is lost silently, and §7's ownership chain has
// no step that would report it.
property p28_command_terminal;
@(posedge clk) disable iff (!rst_n)
(cmd_done && !cq_full_stall) |-> cqe_valid;
endproperty
a_p28: assert property (p28_command_terminal);
// P28's covers — the load-dependent paths must actually be exercised. §17 measured
// the CQ never filling at all when the host keeps up, which makes P21, P22
// and P23 vacuous against any light-load testbench.
c1_cq_full: cover property (@(posedge clk) disable iff (!rst_n) cq_full_stall);
c2_cq_wrap: cover property (@(posedge clk) disable iff (!rst_n) phase_toggle);
c3_db_collide: cover property (@(posedge clk) disable iff (!rst_n)
db_valid && db_ready && bar_wr_valid && (decoded_qid == db_qid));
c4_qid_error: cover property (@(posedge clk) disable iff (!rst_n) qid_range_error);
c5_partial: cover property (@(posedge clk) disable iff (!rst_n)
desc_valid && (desc.bytes < MAX_CHUNK));
c6_buf_full: cover property (@(posedge clk) disable iff (!rst_n) would_overflow);
c7_slots_busy: cover property (@(posedge clk) disable iff (!rst_n) slot_busy == '1);
c8_zero_len: cover property (@(posedge clk) disable iff (!rst_n)
cmd_valid && (total_bytes == 32'd0));12. Executable Counterexamples
Three minimal designs, each violating one property, each with the failing stimulus stated.
Counterexample A — execution on the doorbell (violates P8).
// The controller begins executing as soon as a doorbell advances the tail,
// using whatever the submission-queue slot currently holds.
module ce_a_exec_on_doorbell (
input logic clk, rst_n,
input logic db_valid,
input logic [15:0] sq_slot_cmd_id, // reflects host memory, live
output logic cmd_owned,
output logic [15:0] cmd_id
);
assign cmd_owned = db_valid; // <-- no fetch, no return
assign cmd_id = sq_slot_cmd_id;
endmodule
// Failing stimulus: the host advances the doorbell before its submission
// queue entry has become visible in memory.
// Golden: the controller issues a Memory Read for the entry and takes
// ownership only when that read's data returns (§7 step 4).
// This: it executes against a slot that may still hold the previous
// occupant's command, or nothing at all.
// P8 fails.
// §16 measured this producing 0 stale executions at a typical fetch latency
// and 98 once the fetch was shortened to a single step. The fault is present
// in both; only the controller's own read latency hid it.Counterexample B — the completion queue with no full check (violates P21, P22).
// A completion entry is written whenever a command finishes, without
// checking whether the host has consumed the slot being overwritten.
module ce_b_no_cq_full_check #(parameter int unsigned CQD = 32)(
input logic clk, rst_n,
input logic cmd_done,
input logic [15:0] done_cmd_id,
output logic cqe_valid,
output logic [4:0] cq_tail
);
assign cqe_valid = cmd_done; // <-- never consults cq_head
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) cq_tail <= '0;
else if (cmd_done) cq_tail <= (cq_tail == 5'(CQD-1)) ? '0 : cq_tail + 1'b1;
endmodule
// Failing stimulus: a host that consumes completion entries slowly while
// the controller retires commands quickly.
// Golden: the controller stalls, holding the completion until space exists.
// This: it writes over entries the host has not yet read.
// P21 fails, P22 fails.
// §16 measured 30,118 completions written against a correct design's 4,907,
// with 796 of them clobbering unread entries. The faulty controller reports
// SIX TIMES the completion throughput — achieved by destroying completion
// records. This is 25.6 §6's broken ring-full test in a new place.Counterexample C — the interrupt that precedes its completion entry (violates P27).
// The interrupt is raised in the same cycle the command reaches its terminal
// state, without waiting for the completion-entry write to be accepted.
module ce_c_int_before_cqe (
input logic cmd_terminal,
output logic cqe_valid, int_req
);
assign cqe_valid = cmd_terminal;
assign int_req = cmd_terminal; // <-- concurrent, not sequenced
endmodule
// Failing stimulus: the completion-entry write is back-pressured for several
// cycles while the interrupt message is delivered immediately.
// Golden: the interrupt waits for the CQE write to be accepted (§7 step 7).
// This: the host is interrupted, reads the completion queue, and finds the
// PREVIOUS lap's entry — which the phase bit correctly marks as
// stale, so the host sees no new completion at all and the command
// appears to hang.
// P27 fails.
// §15 measured 99,604 such interrupts in one run. The failure is
// characteristically intermittent: it appears only when the CQE write path
// is slower than the interrupt path.All three share the shape this module keeps producing. Each is a step in §7's ownership chain performed before the step it depends on — execution before fetch, publication before space, notification before publication. None of them is detectable from the PCIe link, because every transaction involved is well-formed.
13. Backpressure in Two Directions
The PCIe side and the media side run at unrelated rates, and the controller buffer sits between them. Both directions must be throttled, and throttling only one is a fault that appears under the opposite load pattern.
If the media path slows — a read that must wait on flash, a write that must wait for a program operation — inbound host data keeps arriving. The controller must stop accepting it before the buffer overruns.
If the PCIe path slows — credit exhaustion (16.1), host memory backpressure, a busy Root Port — outbound read data keeps arriving from the media. The controller must stop the media pipeline before it overwrites buffered data waiting to be sent.
§9 Block 6 makes this explicit with free-slot accounting rather than implicit with a FIFO's full signal, and the reason is the same one 25.8 §7 gives: a resource must be reserved before the action that commits to needing it. A media read that starts without a buffer slot has committed to producing data with nowhere to put it.
One thing this section deliberately does not claim. PCIe does not specify what a storage controller does when its buffers fill — whether it stalls the media, defers commands, or applies some other policy is IMPLEMENTATION POLICY. What PCIe does specify is that the controller may not overrun a receiver's buffers, which is what credits are for (16.5).
14. Where SSD Throughput Actually Comes From
A storage device has more candidate bottlenecks than any other in this module.
| candidate limit | signature |
|---|---|
| the PCIe Link | credit or Tag stalls dominate (§9 Block 9) |
| NAND/media latency and bandwidth | media stalls dominate; scales with queue depth |
| controller processing | neither Link nor media stalls; commands queue at the scheduler |
| flash channel parallelism | throughput plateaus below both Link and media capability |
| queue depth | throughput rises with outstanding commands, then flattens |
| host memory and CPU | CQ-full stalls — the host is the limiter |
| transaction overhead | small commands cost proportionally more (§15) |
The last row is the one this chapter can quantify. §15 shows a 4 KiB command costing 19 PCIe transactions for 4,096 bytes of payload, while a 1 MiB command costs 4,099 for 1,048,576. The fixed cost — one SQE fetch, one CQE write, one interrupt message — is amortised very differently, and any throughput comparison across command sizes that ignores it is comparing different things.
And the CQ-full row is the one engineers least expect. §17 measured that when the host consumes completions slowly, the controller stalls waiting for ring space — with every PCIe counter clean and the media idle. §19 case 9 is that case.
A PCIe generation number alone predicts none of this. It bounds one of seven candidates.
15. Measured Behaviour — PCIe Cost of One NVMe Command
| command size | SQE fetch | data TLPs | CQE write | MSI write | total | PCIe Completions the controller receives |
|---|---|---|---|---|---|---|
| 4 KiB | 1 | 16 | 1 | 1 | 19 | 17 (read command) |
| 16 KiB | 1 | 64 | 1 | 1 | 67 | 65 |
| 128 KiB | 1 | 512 | 1 | 1 | 515 | 513 |
| 1 MiB | 1 | 4,096 | 1 | 1 | 4,099 | 4,097 |
Three readings.
The ratio between the layers is enormous and size-dependent. One NVMe command; up to 4,099 PCIe transactions; exactly one NVMe completion entry.
The last column is the §4 distinction made concrete. For a read command the controller issues Memory Reads to fetch the SQE and Memory Writes to deliver payload — so it receives a Completion for the fetch but none for the payload writes. For a write command the payload transactions are Memory Reads, so thousands of PCIe Completions arrive at the controller, and still exactly one NVMe completion entry goes back to the host.
And the fixed cost is three transactions. At 4 KiB that is 3 of 19 — nearly 16% overhead. At 1 MiB it is 3 of 4,099 — under 0.1%.
16. Measured Behaviour — Queue Ownership
| configuration | submitted | done | stale exec | CQ full | wrong CID | early int | phase err | byte deficit |
|---|---|---|---|---|---|---|---|---|
| correct | 99,661 | 99,661 | 0 | 0 | 0 | 0 | 0 | 0 |
| doorbell before SQE is visible | 99,944 | 99,943 | 0 | 0 | 0 | 0 | 0 | 0 |
| execute without fetching the SQE | 99,678 | 99,677 | 0 | 0 | 0 | 0 | 0 | 0 |
| CQ full check removed | 99,661 | 99,661 | 0 | 0 | 0 | 0 | 0 | 0 |
| CQ phase not toggled at wrap | 99,661 | 99,661 | 0 | 0 | 0 | 0 | 3,111 | 0 |
| final payload chunk dropped | 99,661 | 99,661 | 0 | 0 | 0 | 0 | 0 | 25,513,216 |
| interrupt before CQE write | 99,661 | 99,661 | 0 | 0 | 0 | 99,604 | 0 | 0 |
| CQE carries the wrong command ID | 99,973 | 99,973 | 0 | 0 | 2,909 | 0 | 0 | 0 |
Four readings, and the third is the interesting one.
The baseline is exactly zero on every fault metric, asserted before any other row is produced.
Four faults are caught outright. A missed phase toggle at wrap leaves the host unable to distinguish a new entry from last lap's. A truncating chunk count loses 25.5 MB. An inverted interrupt order produces 99,604 early notifications. A wrong command id misattributes 2,909 completions.
And three rows show nothing at all — doorbell-before-SQE, execute-without-fetch, and CQ-full-check-removed. That is not a modelling failure; it is a finding, and §17 is the investigation.
17. Measured Behaviour — Two Faults That Only Bite Under Load
A. The doorbell-ordering fault is hidden by the controller's own fetch latency.
| SQE fetch latency vs entry landing delay | submitted | stale executions |
|---|---|---|
| fetch 3–15 steps, entry lands in 1–3 (typical pipeline) | 29,931 | 0 |
| fetch 1–2 steps, entry lands in 1–3 (fast fetch) | 29,883 | 36 |
| fetch 1 step, entry lands in 2–6 (SQE prefetch/cache) | 29,963 | 98 |
The bug is present in all three rows. It is invisible in the first because the controller's read of the queue entry takes longer than the host's write takes to become visible — the fetch latency accidentally protects the design.
The consequence is the part worth remembering. A later optimisation that speeds up SQE fetch — a prefetch, a cache, a shorter pipeline — turns a latent ordering bug into a live one, with nobody having touched the ordering code. The change that exposes it looks entirely unrelated to it.
B. The completion queue only fills when the host lags.
| host CQ consumption rate | completed | CQ-full hits | stalled | backlog |
|---|---|---|---|---|
| 0.55/step/queue — host keeps up | 29,964 | 0 | 0 | 0 |
| 0.08/step/queue — host lags | 19,247 | 236,442 | 236,442 | 10,735 |
| 0.02/step/queue — host lags badly | 4,907 | 238,200 | 238,200 | 25,067 |
With the full check removed at the 0.02 rate: 30,118 completions written, 796 of them clobbering an entry the host had not read. With the check present at the same rate: 4,907 completions, 238,200 correctly stalled, 25,067 queued.
The controller without the check reports six times the completion throughput. It achieves that by destroying completion records — the same shape as 25.6 §6's broken ring-full test, where the faulty ring posted 11% more work by overwriting it.
And the first row is why this survives testing. At a host rate that keeps up, the CQ never fills, the full check never evaluates, and a testbench sees no difference between the correct and faulty designs.
18. Verification — Mutations
Thirty-four mutations. Every "Caught by" entry names a property from §11.
| # | Mutation | Symptom | Caught by |
|---|---|---|---|
| 1 | Doorbell captured without an accepted BAR write | phantom queue advance | P1 |
| 2 | Pending cleared unconditionally on accept | an announcement is lost (§10 audit A) | P3 |
| 3 | Doorbell treated as an increment, not a pointer | drifts from the host's view | P4 |
| 4 | Each doorbell treated as exactly one command | work dropped when the host batches | P4 |
| 5 | Doorbell value above the queue depth accepted | queue appears to hold entries that do not exist | P4 |
| 6 | Queue id wrapped instead of range-checked | execution on an unrelated queue | P2 |
| 7 | Queue id 0 aliased with "no queue" | admin queue collides with an IO queue | P2 |
| 8 | Disabled queue still fetched | commands executed from a torn-down queue | P5 |
| 9 | Command executed on the doorbell, without fetching | load-dependent; 98 stale execs at fast fetch (§17) | P8 |
| 10 | Head advanced on fetch return, not on issue | the same entry fetched twice | P9 |
| 11 | Head advanced twice per fetch | every other command skipped | P9 |
| 12 | Fetch slot allocated while busy | two fetches share one slot | P10 |
| 13 | Fetch data released the wrong slot | a slot leaks; the pool drains | P11 |
| 14 | Fetch issued for an empty queue | a stale entry executed | P12 |
| 15 | Queue re-created with stale head/tail | phantom commands after re-enumeration | P6 |
| 16 | Pointers allowed past the configured depth | reads outside the queue's memory | P7 |
| 17 | Chunk count by truncating division | 25,513,216-byte deficit (§16) | P13, P14 |
| 18 | Byte conservation checked one-sided | an over-read is reported as success | P13 |
| 19 | Zero-byte command emits one descriptor | a transfer for a command describing none | P15 |
| 20 | Chunk larger than the configured limit | a single request exceeds MPS handling | P16 |
| 21 | Command id not carried onto descriptors | data delivered against the wrong command | P17 |
| 22 | Buffer slot granted with none free | the controller buffer overruns | P18, P19 |
| 23 | Only the PCIe direction throttled | media output overwrites buffered data | P18 |
| 24 | Only the media direction throttled | inbound host data overruns the buffer | P18 |
| 25 | Overflow detected but not reported | detected and discarded | P20 |
| 26 | CQ full check removed | 796 clobbered entries; 6× apparent throughput (§17) | P21, P22 |
| 27 | CQ-full condition drops the completion | the command is never reported at all | P22 |
| 28 | Phase not toggled at wrap | 3,111 wraps; host cannot tell new from stale (§16) | P23 |
| 29 | Phase toggled on every entry | every entry looks stale to the host | P24 |
| 30 | Two completion entries per command | the host retires a command twice | P25 |
| 31 | Completion entry carries the wrong command id | 2,909 misattributed completions (§16) | P26 |
| 32 | Interrupt raised with the CQE write | 99,604 early interrupts (§16) | P27 |
| 33 | Testbench runs only at a host rate that keeps up | P21, P22, P23 all vacuous (§17) | P28 (c1, c2) |
| 34 | Testbench uses only chunk-multiple command sizes | the partial-chunk path is unreachable | P28 (c5) |
Mutations 9 and 26 are the pair this chapter exists for. Both are invisible under the load a functional test naturally applies, and both become live under conditions a deployed system reaches routinely.
19. Debugging
20. Misconceptions
"A PCIe Completion and an NVMe completion are the same thing." They are different objects at different layers travelling in different directions (§4). One is a TLP answering a Non-Posted request; the other is a data structure the controller writes into host DRAM.
"The CQE write should get a Completion." It is a Posted Memory Write and receives none by definition (12.2, §19 case 5).
"One NVMe command is one PCIe transaction." §15 measured a 1 MiB command costing 4,099.
"The doorbell contains the command." It carries a queue pointer; the command lives in host memory (§5) and must be fetched.
"The doorbell means the controller can start." It means an entry has been announced. The command is owned only when its fetch returns (§7 step 4) — and §17 measured a design that skipped this being invisible until someone made the fetch faster.
"Our ordering is fine — we tested it." §17 measured the same fault producing 0, 36 and 98 stale executions purely from changing fetch latency. A passing test at one timing says nothing about another.
"The submission and completion queues are one structure." They are separate rings, with separate pointers, written by different agents. The host produces into the SQ; the controller produces into the CQ.
"Head and tail mean the same thing on both queues." The producer differs, so the roles invert. Confusing them is mutation 3 and it produces a queue that appears permanently empty or permanently full.
"If the phase bit is wrong the host will notice." The phase is how the host notices anything. §16 measured 3,111 wraps with no toggle, and the failure mode is that the host stops seeing new completions entirely.
"The controller finished the command, so the host has been told." Local completion, the CQE write and the interrupt are three separate steps (§7). Only the second makes the result visible.
"An interrupt means the completion entry is ready." Only if it was sequenced after the write was accepted (P27). §16 measured 99,604 interrupts that were not.
"PCIe generation determines SSD performance." It bounds one of seven candidate limits (§14). Media, channel parallelism, queue depth, controller processing and the host's own completion consumption are the others.
"If PCIe counters are clean the bottleneck is the media." §17 measured a controller stalled on CQ-full with the media idle — the host was the limiter (§19 case 9).
"Removing the CQ-full check improved throughput." It reported six times the completions by destroying records the host had not read (§17). That is 25.6 §6's fault in a new place.
"Small and large commands are comparable throughput measurements." The fixed cost is three transactions per command — 16% overhead at 4 KiB and under 0.1% at 1 MiB (§15). They are different measurements.
"We can read the NVMe field layouts from this chapter." Deliberately not (§1). Entry formats, doorbell offsets, phase-bit positions and data-pointer structures are standard-defined; read the NVM Express specification.
21. Understanding Check
Q1. The controller wrote an NVMe Completion Queue entry into host memory and no PCIe Completion TLP was returned for that write. Is that wrong? Explain the two meanings of completion.
Not wrong — it is the definition of a Posted write (§4, §19 case 5). The CQE write is a Memory Write, which is Posted and receives no PCIe Completion (12.2). A PCIe Completion is a TLP answering a Non-Posted request, identified by (Requester ID, Tag), consumed by the requester's hardware. An NVMe Completion Queue entry is a data structure in host DRAM, identified by a command id, consumed by host software, and defined by NVM Express rather than PCIe. They travel in opposite directions at different layers. §15 quantifies the gap: a 1 MiB command costs 4,099 PCIe transactions and produces one completion entry.
Q2. Why is a doorbell not enough to begin executing a command, and why might a design that gets this wrong pass every test?
Because the doorbell announces a pointer, not a command (§5, §7 step 4) — the entry lives in host memory and must be fetched, so a controller starting on the doorbell alone is acting on data it has not read. It passes tests because the controller's own fetch latency usually hides it: §17 measured 0 stale executions when the SQE fetch took 3–15 steps and the host's write landed in 1–3, rising to 98 once the fetch was shortened to a single step. The bug is present in every configuration; only its observability changes — and a later prefetch optimisation makes it live with nobody touching the ordering code.
Q3. Your controller reports six times the completion throughput after a change. What should you check first?
Whether it is still checking that the completion queue has room (§17, mutation 26). §17 measured exactly this: removing the CQ-full check produced 30,118 completions against 4,907, achieved by writing 796 entries over slots the host had not yet read. A throughput gain with no change to the data path is a signal to check for destroyed work, not a result — the identical shape 25.6 §6 measured, where a broken ring-full test posted 11% more work by overwriting it. P21 is the property.
Q4. What is the phase mechanism for, and what does a missed toggle look like to the host?
It is how the host distinguishes a newly written completion entry from one left in the ring from the previous lap (§9 Block 7, P23). The ring is reused continuously, so position alone carries no information about freshness. A missed toggle at wrap makes every entry after the wrap look stale, so the host stops recognising completions and commands appear to hang — while the controller believes it has reported all of them. §16 measured 3,111 wraps with no toggle. The encoding is NVMe-defined and is not reproduced here (§1).
Q5. Read commands work and write commands fail. What does the direction tell you about which resource to examine?
That the fault is in a resource only one direction consumes (§19 case 4). A read command's payload moves controller→host as Memory Writes: Posted, consuming posted credit, receiving no Completions. A write command's payload moves host→controller as Memory Reads: Non-Posted, consuming Tags and requiring completion-buffer space for the replies (23.5). So a write-only failure points at Tag exhaustion or completion-space reservation, and a read-only failure points at posted credit — and a healthy Link is consistent with both.
Q6. Throughput is far below the Link's capability and every PCIe counter is clean. Name three candidates and how to separate them.
Media, controller buffering, and the host itself (§14, §19 case 9). Separate them with the four stall counters of §9 Block 9: a media stall means the flash pipeline is the limit and queue depth may help; a buffer stall means §13's credit accounting is the constraint; a CQ-full stall means the host is not consuming completions fast enough — which §17 measured happening with the media idle and the Link uncongested, and which no controller change will fix. "The SSD is slow" is not a diagnosis until one of these is named.
Q7. Why does §9's fetch module advance the queue head on fetch issue but take ownership of the command on fetch return?
Because the two events answer different questions (P8, P9). Advancing the head on issue prevents the same entry being fetched twice while its read is outstanding — the entry has been claimed. Ownership on return is what guarantees the controller has the actual command data before acting on it (§7 step 4). Tying both to one event breaks one of the two: tying them to issue executes on unread data (mutation 9); tying them to return fetches the same entry repeatedly until the first response arrives (mutation 10).
Q8. A 4 KiB command and a 1 MiB command are both measured for throughput. Why are these not directly comparable numbers?
Because the fixed per-command cost is amortised completely differently (§15, §14). Every command costs one SQE fetch, one CQE write and one interrupt message regardless of size. At 4 KiB that is 3 transactions out of 19 — nearly 16% overhead; at 1 MiB it is 3 out of 4,099 — under 0.1%. Comparing the two without stating the command size is comparing different workloads, which is 22.1's measurement discipline: define what is being counted before comparing any two rates.
22. What Comes Next
Two chapters remain, and each removes another assumption.
| Chapter | The device, and what it owns |
|---|---|
| 26.1 CPUs | the host — address ownership and translation |
| 26.2 GPUs | control path and data path as separate resources |
| 26.3 (this) | a device that fetches its own work; two layers of "completion" |
| 26.4 Network Adapters | a device driven by traffic it does not control |
| 26.5 FPGA Cards | a device whose DMA engine you write yourself |
This chapter's device chose when to work. A command sits in a queue until the controller fetches it, and if the controller is busy the queue simply grows. 26.4 removes that: packets arrive whether or not the adapter is ready, and the consequence of a slow PCIe path is not a longer queue but lost packets on the wire — a PCIe-side limit that surfaces as a network statistic.
And §17's result is the one to carry forward. Two faults were invisible under the load a functional test naturally applies. The conditions that expose a bug are often unrelated to the bug — a faster fetch, a slower host — which is why §11's covers are written against load states rather than against functional events.