Skip to content

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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 memory

Every 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:

PCIeNVMe
defineslinks, TLPs, routing, flow control, Completionscommands, queues, doorbells, completion entries
unit of worka transactiona command
"completion" meansa TLP answering a Non-Posted requesta queue entry written into host memory
identity(Requester ID, Tag)command identifier within a queue
owned byPCI-SIGNVM 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 returns

The 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

A host with CPU, DRAM containing submission and completion queues, and a root complex, connects over PCIe to an SSD controller. Inside the controller a doorbell register interface, queue manager, DMA engine, command scheduler, buffer, flash translation layer and NAND channels are shown. Four flows are labelled: command fetch, write data, read data and completion.Host CPUSubmission queueCompletion queueHost data buffersRoot ComplexPCIe EndpointDoorbell apertureQueue managerDMA engineCommand schedulerController bufferFlash translationNAND channelsdoorbellfetch SQECQE write12
Figure 1 — the four distinct transaction flows of an NVMe controller. The command flow fetches submission queue entries from host memory. The write-data flow reads host buffers into the controller. The read-data flow writes media contents back into host buffers. The completion flow writes a completion queue entry into host memory and then raises an interrupt.

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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 reusable

Step 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

InstrumentAnswers
nvme_doorbell_capturewhich queue advanced, and to where?
nvme_queue_ctxper-queue state: enabled, depth, head, tail
nvme_cmd_fetchissue the SQE read; own the command only on return
nvme_xfer_descriptorthe normalized data-movement request
nvme_buffer_creditdecouple the PCIe side from the media side
nvme_cq_writerpublish the completion entry, once
nvme_int_gateraise the interrupt strictly after publication
nvme_counterswhere is time going — Link, media, or queueing?

9. RTL — Queues, Fetch, and Publication

Block 1 — the package. COMPILE-TIME.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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;
 
endpackage

Block 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
 
endmodule

Block 3 — the per-queue context table. SYNTHESIZABLE. Normalized local controller state, not an NVMe structure.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
 
endmodule

Block 4 — the command fetch owner. SYNTHESIZABLE. §5's step 4, and the module §17's timing analysis is about.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
 
endmodule

Block 5 — the transfer descriptor generator. SYNTHESIZABLE. One command becomes one or more normalized movement requests.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
 
endmodule

Block 6 — buffer credit between PCIe and media. SYNTHESIZABLE. §13's decoupling.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
 
endmodule

Block 7 — the completion queue writer. SYNTHESIZABLE. §7 step 6, and the module §17's load analysis is about.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
 
endmodule

Block 8 — the interrupt gate. SYNTHESIZABLE. §7 step 7, strictly after step 6.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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;
 
endmodule

Block 9 — attribution counters. VERIFICATION-ONLY. §14's reasoning.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
 
endmodule

10. Same-Cycle Audit

11. Assertions

Doorbell and queue-context properties — §5.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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).

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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).

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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).

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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 limitsignature
the PCIe Linkcredit or Tag stalls dominate (§9 Block 9)
NAND/media latency and bandwidthmedia stalls dominate; scales with queue depth
controller processingneither Link nor media stalls; commands queue at the scheduler
flash channel parallelismthroughput plateaus below both Link and media capability
queue depththroughput rises with outstanding commands, then flattens
host memory and CPUCQ-full stalls — the host is the limiter
transaction overheadsmall 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 sizeSQE fetchdata TLPsCQE writeMSI writetotalPCIe Completions the controller receives
4 KiB116111917 (read command)
16 KiB164116765
128 KiB151211515513
1 MiB14,096114,0994,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

configurationsubmitteddonestale execCQ fullwrong CIDearly intphase errbyte deficit
correct99,66199,661000000
doorbell before SQE is visible99,94499,943000000
execute without fetching the SQE99,67899,677000000
CQ full check removed99,66199,661000000
CQ phase not toggled at wrap99,66199,66100003,1110
final payload chunk dropped99,66199,6610000025,513,216
interrupt before CQE write99,66199,66100099,60400
CQE carries the wrong command ID99,97399,973002,909000

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 delaysubmittedstale executions
fetch 3–15 steps, entry lands in 1–3 (typical pipeline)29,9310
fetch 1–2 steps, entry lands in 1–3 (fast fetch)29,88336
fetch 1 step, entry lands in 2–6 (SQE prefetch/cache)29,96398

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 ratecompletedCQ-full hitsstalledbacklog
0.55/step/queue — host keeps up29,964000
0.08/step/queue — host lags19,247236,442236,44210,735
0.02/step/queue — host lags badly4,907238,200238,20025,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.

#MutationSymptomCaught by
1Doorbell captured without an accepted BAR writephantom queue advanceP1
2Pending cleared unconditionally on acceptan announcement is lost (§10 audit A)P3
3Doorbell treated as an increment, not a pointerdrifts from the host's viewP4
4Each doorbell treated as exactly one commandwork dropped when the host batchesP4
5Doorbell value above the queue depth acceptedqueue appears to hold entries that do not existP4
6Queue id wrapped instead of range-checkedexecution on an unrelated queueP2
7Queue id 0 aliased with "no queue"admin queue collides with an IO queueP2
8Disabled queue still fetchedcommands executed from a torn-down queueP5
9Command executed on the doorbell, without fetchingload-dependent; 98 stale execs at fast fetch (§17)P8
10Head advanced on fetch return, not on issuethe same entry fetched twiceP9
11Head advanced twice per fetchevery other command skippedP9
12Fetch slot allocated while busytwo fetches share one slotP10
13Fetch data released the wrong slota slot leaks; the pool drainsP11
14Fetch issued for an empty queuea stale entry executedP12
15Queue re-created with stale head/tailphantom commands after re-enumerationP6
16Pointers allowed past the configured depthreads outside the queue's memoryP7
17Chunk count by truncating division25,513,216-byte deficit (§16)P13, P14
18Byte conservation checked one-sidedan over-read is reported as successP13
19Zero-byte command emits one descriptora transfer for a command describing noneP15
20Chunk larger than the configured limita single request exceeds MPS handlingP16
21Command id not carried onto descriptorsdata delivered against the wrong commandP17
22Buffer slot granted with none freethe controller buffer overrunsP18, P19
23Only the PCIe direction throttledmedia output overwrites buffered dataP18
24Only the media direction throttledinbound host data overruns the bufferP18
25Overflow detected but not reporteddetected and discardedP20
26CQ full check removed796 clobbered entries; 6× apparent throughput (§17)P21, P22
27CQ-full condition drops the completionthe command is never reported at allP22
28Phase not toggled at wrap3,111 wraps; host cannot tell new from stale (§16)P23
29Phase toggled on every entryevery entry looks stale to the hostP24
30Two completion entries per commandthe host retires a command twiceP25
31Completion entry carries the wrong command id2,909 misattributed completions (§16)P26
32Interrupt raised with the CQE write99,604 early interrupts (§16)P27
33Testbench runs only at a host rate that keeps upP21, P22, P23 all vacuous (§17)P28 (c1, c2)
34Testbench uses only chunk-multiple command sizesthe partial-chunk path is unreachableP28 (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.

ChapterThe device, and what it owns
26.1 CPUsthe host — address ownership and translation
26.2 GPUscontrol path and data path as separate resources
26.3 (this)a device that fetches its own work; two layers of "completion"
26.4 Network Adaptersa device driven by traffic it does not control
26.5 FPGA Cardsa 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.