Skip to content

PCIe · Module 22

Benchmark Interpretation — What Was Actually Measured?

A benchmark number is a metric, a measurement point, a direction, a transfer size, a queue depth, a topology and an error state — and 99.94% of unqualified pairwise comparisons are invalid.

Someone hands you a result: "this device does 12 GB/s and 3 µs."

You cannot use that. Not because it is wrong, but because it is incomplete in a way that makes it uninterpretable — and the incompleteness is invisible, which is why the number gets quoted, compared and designed against anyway.

This chapter is the last of Module 22, and it is the one that makes the other five useful outside your own lab.

1. Sources, Scope, and What This Chapter Is Not

2. The Number Is Not a Property of the Device

"12 GB/s" describes an experiment, not a component. Change the transfer size and it changes (§7). Change the queue depth and it changes (§8). Change where you counted and it changes (22.1 §3). Change the direction, the memory target, or whether a switch was in the path, and it changes.

None of those changes indicate a fault. They indicate that the number was always conditional and the conditions were not written down.

Which produces the two failure modes this chapter addresses.

Comparing two results that measured different things — §12 Model 12 puts the base rate of this at 99.94% for unqualified pairs.

And designing against a single result, which is worse, because a throughput figure measured at QD 256 tells you nothing about the latency your control path will see, and a latency figure measured at QD 1 tells you nothing about the throughput your streaming path will get. §8 shows both numbers coming from the same system minutes apart.

3. The Comparability Contract

4. Bandwidth Metrics Are Not Interchangeable

All of these are called "bandwidth" and they measure different things:

MetricWhat it countsTypical shape
device→host write streamingpayload posted by the devicehighest; posted writes need no round trip
host→device read streamingpayload the device pulledbounded by round trip and outstanding work
bidirectional aggregateboth directions summedmust never be quoted as a one-way figure
small-transfer rateoperations per second × sizedominated by per-transfer fixed cost (§7)
descriptor / IOPS-like ratetransactions per secondsays nothing about bytes
application-copy throughputwhat software observedincludes driver and copy costs

Two rules follow, and both are violated routinely.

Never compare a bidirectional aggregate with a one-way number. Chapter 6.7 §4 owns why: PCIe is full duplex, so TX and RX capacities do not sum into a one-direction basis. §12 Model 13 measured what the error costs: dividing a measurement by a summed two-direction basis halves the percent-of-theoretical, from 76.17% to 38.09%.

And never compare a read result with a write result as though the difference is "the device". A posted write leaves and is done (12.5 §3); a read must survive the round trip and needs enough outstanding work to cover it (20.5 §5). They are structurally different transactions and a gap between them is expected, not diagnostic.

5. Latency Metrics Are Not Interchangeable

Chapter 22.2 §3 established the discipline and this chapter applies it: latency is an interval between two named events, and sixteen defensible start/end pairs exist.

Reported asActually measuresExcludes
software API latencydriver, syscall, scheduling, plus the transactionnothing — it is the largest
descriptor submit → doneengine queueing onwardsoftware overhead
request TX → first Completion bytethe round trip to first datathe rest of the payload
request TX → final Completion bytethe whole answerlocal processing
interrupt notification latencydelivery of the signalthe data path
end-to-end application latencyeverythingnothing

Two specific traps.

First-byte versus final-byte. Chapter 22.2 §13 measured a mean ratio of 4.51× across split reads. A benchmark quoting "latency" without saying which is off by that factor, and in the flattering direction.

And "write latency" for a Posted write. Chapter 22.2 §5 owns this: a Memory Write has no Completion, so a near-zero write latency is usually a true measurement of local TX acceptance and says nothing about host visibility. A result comparing that against a read latency is comparing an interface handshake with a round trip.

6. Percent of Theoretical — and the Basis That Ruins It

7. The Transfer-Size Sweep

8. The Queue-Depth Sweep

Throughput and latency are not two views of one quantity — they trade against each other, and queue depth is the dial.

§12 Model 15ILLUSTRATIVE: 4 KiB transfers, 3.00 µs unloaded latency, 15 GB/s capacity, using Little's Law as 12.5 §5 labels it:

QDBytes in flightThroughputLatencyRegime
14,0961.365 GB/s3.00 µslatency-exposed
416,3845.461 GB/s3.00 µslatency-exposed
1665,53615.000 GB/s7.10 µssaturated
64262,14415.000 GB/s20.20 µssaturated — QD adds queueing
2561,048,57615.000 GB/s72.63 µssaturated — QD adds queueing

Read the first and last rows together. The same system reports 1.365 GB/s / 3.00 µs and 15.000 GB/s / 72.63 µs. A vendor quoting the QD-256 throughput and the QD-1 latency in one sentence is quoting two experiments — and each number is honest in isolation.

Two structural facts.

Below saturation, adding queue depth buys throughput almost for free — the requests fill time that was already being spent waiting (20.5 §5's bandwidth-delay product).

Above saturation, adding queue depth buys nothing and costs latency linearly — the extra work simply queues, which is 22.2 §7's measured effect. There is no ideal QD; there is a QD that suits a workload's latency tolerance.

9. A PCIe Benchmark Can Be Limited Outside PCIe

The result you are holding may have nothing to do with the link.

Chapter 22.1 §6 already supplies the evidence structure: its measured run showed 49.39% utilisation with no_work as the single largest blocking category — a source-starved system whose PCIe link was never the constraint.

Candidates for a non-PCIe limit, each of which changes a benchmark without touching the link:

LimitSymptom
the data source or sinkhigh no_work (22.1 §6)
host memory bandwidth or placementthroughput varies with which CPU or memory the buffer lives on
driver and software pathapplication-layer result far below the DMA-interface result (22.1 §3)
fabric contentionfine alone, degraded when other endpoints are active (21.4 §7)
credit availabilityhigh no_credit (22.3 §5)

On CPU and memory placement specifically, this chapter states only the conclusion: a PCIe benchmark can be sensitive to which CPU and memory the buffers are associated with, and two machines with identical cards can therefore differ. It makes no exact platform claim — that would need vendor and OS documentation this chapter does not have (§1), and the engineering point survives without it.

The actionable form is a question, not a theory: before attributing a result to PCIe, what does the stall attribution say? If the largest category is no_work, the answer is that PCIe was not the subject of the measurement at all.

10. RTL — Making a Hardware Result Trustworthy

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// SYNTHESIZABLE. Benchmark instrumentation types.
package bench_pkg;
 
  parameter int CNT_W  = 48;
  parameter int RUN_W  = 16;
  parameter int BYTES_W = 13;
 
  typedef enum logic [2:0] {
    RUN_IDLE   = 3'd0,
    RUN_ACTIVE = 3'd1,
    RUN_FROZEN = 3'd2               // result readable, immutable
  } run_state_e;
 
  function automatic logic [CNT_W-1:0] sat_add(input logic [CNT_W-1:0] v,
                                               input logic [CNT_W-1:0] inc);
    logic [CNT_W:0] wide;
    wide = {1'b0, v} + {1'b0, inc};
    return wide[CNT_W] ? {CNT_W{1'b1}} : wide[CNT_W-1:0];
  endfunction
 
endpackage
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import bench_pkg::*;
 
// SYNTHESIZABLE. Benchmark run controller with EPOCH ISOLATION (§10).
// A result is frozen at stop and cannot be mutated by anything that
// happens afterwards. Without this, a late transfer or a late error from
// run N lands in run N+1's result and neither run is trustworthy.
module benchmark_run_controller (
  input  logic clk,
  input  logic rst_n,
 
  input  logic               start,
  input  logic               stop,
  input  logic               xfer_valid,      // an ACCEPTED transfer
  input  logic [BYTES_W-1:0] xfer_bytes,
  input  logic               error_event,
  input  logic               replay_event,
 
  output run_state_e         state,
  output logic [RUN_W-1:0]   run_id,
  output logic               running,
  output logic [CNT_W-1:0]   run_cycles,
  output logic [CNT_W-1:0]   run_bytes,
  output logic [CNT_W-1:0]   run_transactions,
  output logic [31:0]        run_errors,
  output logic [31:0]        run_replays,
  output logic               result_valid,
  output logic               result_dirty     // errors or replay occurred
);
  run_state_e      st_q;
  logic [RUN_W-1:0] id_q;
  logic [CNT_W-1:0] cyc_q, byt_q, txn_q;
  logic [31:0]      err_q, rep_q;
 
  assign state            = st_q;
  assign run_id           = id_q;
  assign running          = (st_q == RUN_ACTIVE);
  assign run_cycles       = cyc_q;
  assign run_bytes        = byt_q;
  assign run_transactions = txn_q;
  assign run_errors       = err_q;
  assign run_replays      = rep_q;
  assign result_valid     = (st_q == RUN_FROZEN);
  // A run with errors or replay is NOT a clean-link result (§4). It is
  // reported, never silently discarded -- discarding is how a flaky link
  // becomes an impressive average.
  assign result_dirty     = (err_q != '0) || (rep_q != '0);
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      st_q<=RUN_IDLE; id_q<='0; cyc_q<='0; byt_q<='0; txn_q<='0;
      err_q<='0; rep_q<='0;
    end else begin
      unique case (st_q)
        RUN_IDLE, RUN_FROZEN: begin
          if (start) begin
            // ZERO EVERYTHING at start. A snapshot-difference scheme leaks
            // whatever happened between runs into the new one.
            st_q  <= RUN_ACTIVE;
            id_q  <= id_q + RUN_W'(1);
            cyc_q <= '0; byt_q <= '0; txn_q <= '0; err_q <= '0; rep_q <= '0;
          end
        end
        RUN_ACTIVE: begin
          cyc_q <= sat_add(cyc_q, CNT_W'(1));
          if (xfer_valid) begin
            byt_q <= sat_add(byt_q, CNT_W'(xfer_bytes));
            txn_q <= sat_add(txn_q, CNT_W'(1));
          end
          if (error_event  && !(&err_q)) err_q <= err_q + 32'd1;
          if (replay_event && !(&rep_q)) rep_q <= rep_q + 32'd1;
          // SAME-CYCLE RULE (§11): a transfer ON the stop cycle is INSIDE
          // the run. The counters above have already taken it this cycle.
          if (stop) st_q <= RUN_FROZEN;
        end
        default: st_q <= RUN_IDLE;
      endcase
    end
  end
endmodule
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import bench_pkg::*;
 
// SYNTHESIZABLE. First/last transfer tracker (§7).
// A run's REQUESTED duration and its ACTIVE interval are different
// numbers. §12 Model 14: a short run is dominated by fixed overhead, so
// dividing bytes by requested duration understates the streaming rate.
module active_interval_tracker (
  input  logic clk,
  input  logic rst_n,
  input  logic             running,
  input  logic             start,
  input  logic             xfer_valid,
  input  logic [CNT_W-1:0] run_cycles,
 
  output logic [CNT_W-1:0] first_xfer_cycle,
  output logic [CNT_W-1:0] last_xfer_cycle,
  output logic             any_transfer,
  output logic [CNT_W-1:0] active_span      // last - first, inclusive
);
  logic [CNT_W-1:0] f_q, l_q; logic seen_q;
 
  assign first_xfer_cycle = f_q;
  assign last_xfer_cycle  = l_q;
  assign any_transfer     = seen_q;
  // Guarded so an empty run reports 0, never a wrapped difference.
  assign active_span = seen_q ? (l_q - f_q + CNT_W'(1)) : '0;
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin f_q<='0; l_q<='0; seen_q<=1'b0; end
    else if (start) begin f_q<='0; l_q<='0; seen_q<=1'b0; end
    else if (running && xfer_valid) begin
      if (!seen_q) begin f_q <= run_cycles; seen_q <= 1'b1; end  // FIRST, once
      l_q <= run_cycles;                                          // LAST, every time
    end
  end
endmodule
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import bench_pkg::*;
 
// SYNTHESIZABLE. Per-cause stall snapshot, captured in the SAME epoch as
// the throughput result (§9). Chapter 22.1 §9 owns the classification;
// this block only ensures the attribution and the bytes describe the same
// run -- a result without a "why" is a number, not a finding.
module benchmark_stall_snapshot (
  input  logic clk,
  input  logic rst_n,
  input  logic             start,
  input  logic             stop,
  input  logic [RUN_W-1:0] run_id,
 
  // live counters from the 22.1 attribution block
  input  logic [CNT_W-1:0] c_transfer,
  input  logic [CNT_W-1:0] c_stalled,
  input  logic [CNT_W-1:0] c_no_credit,
  input  logic [CNT_W-1:0] c_no_work,
  input  logic [CNT_W-1:0] c_link_down,
 
  output logic [CNT_W-1:0] base_transfer,
  output logic [CNT_W-1:0] snap_transfer,
  output logic [CNT_W-1:0] snap_stalled,
  output logic [CNT_W-1:0] snap_no_credit,
  output logic [CNT_W-1:0] snap_no_work,
  output logic [CNT_W-1:0] snap_link_down,
  output logic [RUN_W-1:0] snap_run_id,
  output logic             snap_valid
);
  logic [CNT_W-1:0] b_t, b_s, b_c, b_w, b_l;
 
  assign base_transfer = b_t;
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      b_t<='0; b_s<='0; b_c<='0; b_w<='0; b_l<='0;
      snap_transfer<='0; snap_stalled<='0; snap_no_credit<='0;
      snap_no_work<='0; snap_link_down<='0; snap_run_id<='0; snap_valid<=1'b0;
    end else begin
      if (start) begin
        // Rebase, so the snapshot is a DELTA over this run only.
        b_t<=c_transfer; b_s<=c_stalled; b_c<=c_no_credit;
        b_w<=c_no_work;  b_l<=c_link_down; snap_valid<=1'b0;
      end else if (stop) begin
        snap_transfer  <= c_transfer  - b_t;
        snap_stalled   <= c_stalled   - b_s;
        snap_no_credit <= c_no_credit - b_c;
        snap_no_work   <= c_no_work   - b_w;
        snap_link_down <= c_link_down - b_l;
        snap_run_id    <= run_id;                 // the epoch, carried along
        snap_valid     <= 1'b1;
      end
    end
  end
endmodule
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import bench_pkg::*;
 
// SYNTHESIZABLE. Result record. Everything a consumer needs to decide
// whether this result may be compared with another one (§3) -- including
// the fields that say it MAY NOT be.
module benchmark_result_record (
  input  logic clk,
  input  logic rst_n,
  input  logic             freeze,
  input  logic [RUN_W-1:0] run_id,
  input  logic [CNT_W-1:0] bytes,
  input  logic [CNT_W-1:0] cycles,
  input  logic [CNT_W-1:0] active_span,
  input  logic [CNT_W-1:0] transactions,
  input  logic             dirty,
  input  logic             saturated,
  input  logic [5:0]       negotiated_width,     // read from the LINK, not the datasheet
  input  logic [2:0]       negotiated_speed,
  input  logic [1:0]       direction,
  input  logic [1:0]       measure_point,
 
  output logic             rec_valid,
  output logic [RUN_W-1:0] rec_run_id,
  output logic [CNT_W-1:0] rec_bytes,
  output logic [CNT_W-1:0] rec_cycles,
  output logic [CNT_W-1:0] rec_active_span,
  output logic [CNT_W-1:0] rec_transactions,
  output logic             rec_dirty,
  output logic             rec_saturated,
  output logic [5:0]       rec_width,
  output logic [2:0]       rec_speed,
  output logic [1:0]       rec_direction,
  output logic [1:0]       rec_measure_point
);
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      rec_valid<=1'b0; rec_run_id<='0; rec_bytes<='0; rec_cycles<='0;
      rec_active_span<='0; rec_transactions<='0; rec_dirty<=1'b0;
      rec_saturated<=1'b0; rec_width<='0; rec_speed<='0;
      rec_direction<='0; rec_measure_point<='0;
    end else if (freeze) begin
      // ONE cycle, ALL fields -- the atomicity rule of 22.5 §11, applied
      // to a benchmark record. A record assembled by successive reads can
      // describe two different runs.
      rec_valid        <= 1'b1;
      rec_run_id       <= run_id;
      rec_bytes        <= bytes;
      rec_cycles       <= cycles;
      rec_active_span  <= active_span;
      rec_transactions <= transactions;
      rec_dirty        <= dirty;
      rec_saturated    <= saturated;
      rec_width        <= negotiated_width;
      rec_speed        <= negotiated_speed;
      rec_direction    <= direction;
      rec_measure_point<= measure_point;
    end
  end
endmodule
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import bench_pkg::*;
 
// VERIFICATION-ONLY. Guards the arithmetic software is about to do.
// A result that fails any of these must not be normalized (§6).
module benchmark_result_monitor (
  input logic             rec_valid,
  input logic [CNT_W-1:0] rec_bytes,
  input logic [CNT_W-1:0] rec_cycles,
  input logic [CNT_W-1:0] rec_active_span,
  input logic             rec_saturated,
  input logic [5:0]       rec_width,
 
  output logic err_zero_duration,
  output logic err_span_exceeds_run,
  output logic err_width_zero,
  output logic err_unusable
);
  always_comb begin
    // Dividing by a zero duration is the first thing software does wrong.
    err_zero_duration    = rec_valid && (rec_cycles == '0) && (rec_bytes != '0);
    err_span_exceeds_run = rec_valid && (rec_active_span > rec_cycles);
    // Normalizing against a zero or unknown negotiated width (§6).
    err_width_zero       = rec_valid && (rec_width == '0);
    err_unusable         = err_zero_duration || err_span_exceeds_run
                        || err_width_zero || rec_saturated;
  end
endmodule

Classification: five synthesizable, one verification-only.

Failure — six. Snapshot-difference counters instead of zeroing at start (the gap between runs leaks in). A stop-cycle transfer counted in both runs or neither. Reporting requested duration as the active interval (§7). A throughput result with no stall attribution (a number with no explanation). Silently discarding dirty runs (a flaky link becomes an impressive average). And normalizing against capability width rather than the negotiated width read from the link (§6).

11. Same-Cycle Audit and Assertions

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ==================================================================
// RUN LIFECYCLE AND EPOCH ISOLATION (§10).
// ==================================================================
 
// P1: a run begins only on start, and only from a non-active state.
property p_start_from_non_active;
  @(posedge clk) disable iff (!rst_n)
    $rose(running) |-> $past(start && (state != RUN_ACTIVE));
endproperty
 
// P2: run_id advances exactly once per accepted start.
property p_run_id_once_per_start;
  @(posedge clk) disable iff (!rst_n)
    (run_id != $past(run_id)) |-> $past(start && (state != RUN_ACTIVE));
endproperty
 
// P3: every counter is ZERO at the start of a run. A snapshot-difference
// scheme leaks whatever happened between runs into the new one.
property p_counters_zero_at_start;
  @(posedge clk) disable iff (!rst_n)
    (start && (state != RUN_ACTIVE)) |=>
      ((run_bytes == '0) && (run_transactions == '0) && (run_errors == '0));
endproperty
 
// P4: a transfer on the start cycle belongs to the NEW run.
property p_start_cycle_transfer_new_run;
  @(posedge clk) disable iff (!rst_n)
    (start && (state != RUN_ACTIVE) && xfer_valid) |=> (run_transactions <= CNT_W'(1));
endproperty
 
// P5: nothing is counted while not running.
property p_no_count_when_idle;
  @(posedge clk) disable iff (!rst_n)
    (!running) |=> ($stable(run_bytes) && $stable(run_transactions));
endproperty
 
// P6: stop while idle publishes nothing.
property p_stop_while_idle_noop;
  @(posedge clk) disable iff (!rst_n)
    (stop && (state == RUN_IDLE)) |=> (state == RUN_IDLE);
endproperty
 
// ==================================================================
// FREEZING (§10) -- a result must be immutable.
// ==================================================================
 
// P7: a transfer on the stop cycle is INSIDE the closing run.
property p_stop_cycle_transfer_counted;
  @(posedge clk) disable iff (!rst_n)
    (running && stop && xfer_valid) |=>
      (run_transactions == $past(run_transactions) + CNT_W'(1));
endproperty
 
// P8: stop freezes; the result becomes readable exactly once.
property p_stop_freezes;
  @(posedge clk) disable iff (!rst_n)
    (running && stop) |=> (result_valid && (state == RUN_FROZEN));
endproperty
 
// P9: a frozen result cannot be mutated by later events.
property p_frozen_result_immutable;
  @(posedge clk) disable iff (!rst_n)
    ((state == RUN_FROZEN) && !start) |=>
      ($stable(run_bytes) && $stable(run_cycles) && $stable(run_errors));
endproperty
 
// P10: an error after stop belongs to no run.
property p_late_error_not_attributed;
  @(posedge clk) disable iff (!rst_n)
    ((state == RUN_FROZEN) && error_event && !start) |=> $stable(run_errors);
endproperty
 
// P11: errors and replays are STICKY within a run -- a dirty run is
// reported as dirty, never quietly cleaned up.
property p_dirty_sticky_within_run;
  @(posedge clk) disable iff (!rst_n)
    (running && result_dirty) |=> (result_dirty || start);
endproperty
 
// ==================================================================
// ACTIVE INTERVAL (§7) -- requested duration is not active duration.
// ==================================================================
 
// P12: the first-transfer cycle is captured exactly once per run.
property p_first_transfer_once;
  @(posedge clk) disable iff (!rst_n)
    ($rose(any_transfer)) |-> $past(running && xfer_valid);
endproperty
 
// P13: the last-transfer cycle updates on every accepted transfer.
property p_last_transfer_updates;
  @(posedge clk) disable iff (!rst_n)
    (running && xfer_valid) |=> (last_xfer_cycle == $past(run_cycles));
endproperty
 
// P14: the active span never exceeds the run, and an empty run reports 0
// rather than a wrapped difference.
property p_span_bounded;
  @(posedge clk) disable iff (!rst_n)
    (active_span <= run_cycles) && (!any_transfer |-> (active_span == '0));
endproperty
 
// ==================================================================
// ATTRIBUTION AND RECORD (§9, §10).
// ==================================================================
 
// P15: the stall snapshot carries the run_id of the run it describes --
// an attribution from a different epoch is worse than none.
property p_snapshot_epoch_matches;
  @(posedge clk) disable iff (!rst_n)
    snap_valid |-> (snap_run_id == $past(run_id));
endproperty
 
// P16: the record is assembled from ONE cycle (22.5 §11's atomicity rule).
property p_record_atomic;
  @(posedge clk) disable iff (!rst_n)
    rec_valid && $rose(rec_valid) |->
      ((rec_bytes == $past(bytes)) && (rec_cycles == $past(cycles))
        && (rec_run_id == $past(run_id)));
endproperty
 
// P17: the harness never drives the datapath. A benchmark controller that
// throttles is measuring a system that does not otherwise exist.
property p_harness_does_not_drive;
  @(posedge clk) disable iff (!rst_n)
    $stable(xfer_valid) or !$stable({run_bytes, run_cycles});
endproperty
 
// P18: reset clears run state and publishes nothing.
property p_reset_clears_run;
  @(posedge clk)
    (!rst_n) |=> (!running && !result_valid && (run_bytes == '0));
endproperty
 
// P19: a zero-duration run with bytes is flagged before software divides.
property p_zero_duration_flagged;
  @(posedge clk) disable iff (!rst_n)
    (rec_valid && (rec_cycles == '0) && (rec_bytes != '0)) |-> err_zero_duration;
endproperty
 
// P20: a result normalized against an unknown width is rejected (§6).
property p_width_required_for_normalization;
  @(posedge clk) disable iff (!rst_n)
    (rec_valid && (rec_width == '0)) |-> err_unusable;
endproperty

Twenty properties. P1–P11 are epoch isolation, which is the difference between a benchmark harness and a set of counters. P12–P14 separate the requested duration from the active one — the distinction §7 showed dominating short runs. And P19, P20 are guards on arithmetic software has not done yet, which is the right place to catch a division by zero or a normalization against an unknown basis.

12. Measured Behaviour

13. Verification — DV and Mutations

DV, against an independent run-accounting model — never the DUT's own counters: a run with no transfers · a single transfer · a transfer on the start cycle · a transfer on the stop cycle · start while active · stop while idle · two back-to-back runs · an error after stop · an error before start · counter saturation · reset mid-run · a zero-duration run · an unknown negotiated width · a run whose active span is far shorter than its requested duration.

#MutationSymptomCaught by
1Compare a bidirectional aggregate with a one-way resultthroughput apparently doubled (§4)interpreter
2Compare QD 1 with QD 64 and call the gap "the device"11× difference from one parameter (§8)interpreter
3Mix GB/s and GiB/sa 7.4% error that looks like a real differenceinterpreter
4Normalize against capability width76.17% reported as 38.09% (§6)P20
5Normalize against a generation the link did not train tosame, from a different wrong basisP20
6Include warm-up in one run and not the otherthe shorter run is dominated by fixed overhead (§7)P12, P14
7Count the stop-cycle transfer in both runsone transfer inflates two resultsP7, P9
8Use snapshot differences instead of zeroing at startbetween-run activity leaks into the resultP3
9Let a late error mutate a frozen resultrun N's error attributed to run N+1P9, P10
10Allow start while a run is activetwo runs share counters; neither is validP1, P2
11Report requested duration as the active intervalstreaming rate understated on short runs (§7)P13, P14
12Publish throughput with no stall attributiona number with no explanation (§9)P15
13Attach a stall snapshot from a different epochan attribution describing another runP15
14Silently discard dirty runsa flaky link becomes an impressive averageP11
15Report a replay-heavy run as a clean-link resultlink quality hidden in the headline (22.5 §6)P11
16Assemble the record by successive readsfields describe two different runsP16
17Compare API latency with wire latencydifferent event pairs (22.2 §3)interpreter
18Compare first-byte latency with full-completion latency4.51× apart on average (22.2 §13)interpreter
19Quote a Posted write's TX-acceptance time as write latencymeasures an interface, not the host (22.2 §5)interpreter
20Blame PCIe for a source-starved runlargest category was no_work (22.1 §6)P15
21Attribute a platform difference to PCIe without evidencetwo machines differ for reasons outside the link (§9)design review
22Divide by a zero durationcrash, or an infinite rateP19
23Let the harness throttle the datapaththe measured system is not the shipping oneP17
24Accept a record with no measurement definitionan uninterpretable number treated as data (§3)interpreter
25Ignore counter saturation when normalizinga truncated numerator understates the resultP20
26Type the sweep tables by handthe 8.3× and 11× spreads are invisible without a scriptdesign review

Two counterexamples worth stating explicitly.

Mutation 8 is the hardware bug that survives every review, because snapshot differencing is the obvious implementation: read the counters at start, read them at stop, subtract. It is correct exactly when nothing happens between runs — and something always does, because the DMA engine does not stop just because the harness did. Whatever moved in the gap lands in the next run's result, and the corruption scales with how long the gap was. §10's controller zeroes at start instead, and P3 is the one-line property; the cost is one extra mux.

Mutation 14 is not a coding bug at all — it is a methodology bug with a hardware root, and it is the most consequential item in this table. A run reports errors or replays, so the harness discards it and reports the average of the clean ones. The published number is then a description of the link on its good days, and the reader has no way to know. §10 exposes result_dirty and P11 makes it sticky so a dirty run can be reported as dirty rather than deleted. The honest form is "15 GB/s, 3 of 20 runs dirty, dirty runs excluded" — which is a real result. "15 GB/s" alone is not.

14. Debugging

Symptom — two machines, identical cards, very different throughput. Normalize before theorizing (§3). In order: negotiated generation and width (17.3) · direction · transfer size · queue depth · measurement point · memory placement (§9) · error and replay counts. Most such gaps close at one of the first three, and the ones that do not are usually §9's — the link was never the difference.

Symptom — the benchmark reaches near-theoretical and the application sees far less. Different measurement layers (22.1 §3). Neither number is wrong. Name both points, and the difference becomes a locatable quantity — driver path, copy, or buffer drain — rather than a contradiction.

Symptom — latency excellent at QD 1, terrible at QD 128. Queueing, and expected (§8). §12 Model 15 moved latency from 3.00 µs to 72.63 µs across that range with throughput flat above QD 16. The finding is the saturation point, and the action is to pick a QD matching the workload's latency tolerance, not to "fix" the latency.

Symptom — bandwidth drops after inserting a switch. Two independent effects, and they are separable. Latency rises with hop count (21.3 §6), which reduces throughput at fixed queue depth via the bandwidth-delay product. And contention appears if other endpoints share the uplink (21.4 §7). Re-run with the other endpoints idle: if the loss disappears, it is contention; if it persists, it is latency, and raising queue depth will partly recover it.

Symptom — the result varies wildly run to run. Check, in order: active span versus requested duration (§7 — a short run measures its own overhead) · result_dirty (§10 — a flaky link) · the stall attribution (§9 — a source that starves intermittently) · and whether the harness zeroes at start (mutation 8 — leakage between runs). Variance is usually one of those four and rarely the link itself.

Symptom — a vendor result you cannot reproduce. Do not start by doubting it. Work through §3's twelve fields and note which are unstated. §12 measured that 99.94% of unqualified pairs are incomparable, so the prior probability is that you are running a different experiment. The productive output of this exercise is a list of questions, and it is usually short.

15. Misconceptions

"12 GB/s is a property of the device." It is a property of an experiment (§2), and the same device spanned 8.3× on transfer size alone (§7).

"Two results on the same card are comparable." 0.06% of random plausible pairs were (§12).

"Full duplex means I can add TX and RX." Not into a one-way figure (6.7 §4, §4 here).

"Percent of theoretical is unambiguous." The same measurement read 76.17% or 38.09% (§6).

"The card is x16, so normalize against x16." Normalize against the negotiated width read from the link (17.3, §6).

"Higher queue depth is better." Above saturation it buys latency and nothing else — 3.00 µs to 72.63 µs (§8).

"Latency is one number." Six distinct definitions in §5; first-byte and final-byte alone differ by 4.51× (22.2 §13).

"Write latency is comparable to read latency." A Posted write has no Completion (22.2 §5).

"Discard the runs that had errors." Then the published number describes the link on its good days (mutation 14).

"A low result means PCIe is the bottleneck." Chapter 22.1 §6's measured run was 49.39% utilised with no_work the largest category — the link was never the subject (§9).

"A short benchmark is fine if the rate is high." A short run measures its own fixed overhead — 12.0% of streaming rate at 4 KiB (§7).

"Snapshot the counters at start and stop and subtract." Everything between runs leaks in (mutation 8).

"GB/s and GiB/s are close enough." A 7.4% error, comparable to the differences people argue about (6.7 §5).

16. Understanding Check

Q1. A vendor reports 25 GB/s on a Gen4 x8 link. Is that impossible? Not necessarily — check the direction basis first. One-direction Gen4 x8 capacity is 15.7538 GB/s (6.7 §2), so 25 GB/s cannot be one-way. It is plausible as a bidirectional aggregate, where the basis is 31.5077 GB/s and the result is 79.3%. The number is not wrong; quoting it beside a one-way number would be (§4).

Q2. Result A is 15 GB/s at 1 MiB, QD 64, D2H. Result B is 8 GB/s at 4 KiB, QD 1, H2D. What does the difference tell you about the device? Nothing. Four fields differ, each independently worth multiples: §7 measured 8.3× from transfer size and §8 measured 11× from queue depth. The honest output is the list of fields to equalize — and after equalizing, the remaining difference is the finding (§3).

Q3. Your harness snapshots counters at start and stop and subtracts. Why is that wrong? Because the DMA engine does not stop between runs. Anything that moves in the gap is inside the difference and is attributed to the new run (mutation 8). Zeroing at start is the fix, P3 is the property, and the cost is one mux.

Q4. A benchmark reports 15 GB/s and discards three runs that had replays. What should be published? "15 GB/s, 3 of 20 runs dirty, dirty runs excluded." Discarding silently makes the headline a description of the link's good days (mutation 14). §10 exposes result_dirty and P11 keeps it sticky so the qualification survives to the report.

Q5. A result is 40% of theoretical. Name three explanations that are not "PCIe is slow". The basis is wrong — capability width or the wrong generation (§6). The workload is in the fixed-overhead regime — 4 KiB transfers read 12.0% of streaming rate on a healthy link (§7). Or the link was never the constraint22.1 §6's attribution showed no_work dominating a 49.39% run (§9). All three are more likely than the link underperforming, and all three are checkable.

Q6. Why does §10's hardware carry negotiated_width inside the result record rather than reading it later? Because the record must be self-describing and atomic (P16). A width read later may reflect a retrain, and a normalization against the wrong width is §6's 2× error. Carrying the basis with the measurement is what makes a result comparable at all — it is the hardware expression of §3's contract.

17. Module 22 Complete — One Method

Six chapters, six questions, one workflow.

ChapterQuestion
22.1 ThroughputHow much useful work moved, and where did the rest of the cycles go?
22.2 LatencyHow long did one transaction take, between which two events?
22.3 Credit BottlenecksWas Flow Control the resource that stopped the pipeline?
22.4 Payload Size EffectsHow did MPS and MRRS shape the packets and requests?
22.5 Link EfficiencyOf what was transmitted, how much was payload — over which denominator?
22.6 Benchmark InterpretationWas the comparison valid in the first place?

The reusable procedure, in order:

1. Verify the negotiated generation and width — not the capability (17.3). 2. Declare the measurement point (22.1 §3). 3. Measure useful throughput at that point. 4. Define the latency event pair before measuring any interval (22.2 §3). 5. Attribute every non-transferring cycle to exactly one cause (22.1 §6). 6. If no_credit dominates, identify the class and check pendency (22.3 §5). 7. Check packet and request sizing against the workload (22.4 §3). 8. Quantify protocol efficiency with a named denominator (22.5 §5). 9. Check replay and error state (22.5 §6). 10. Only then compare with anyone else's number (§3).

One idea unifies all six. A measurement is only as meaningful as the basis it declares — the point where it counted, the events it bounded, the denominator it divided by, and the conditions it held fixed. In every chapter the naive instrument failed in the flattering direction, which is precisely why it survives review: counting on valid overstated throughput by 17.7%, one global timestamp was wrong for 80.0% of completions, an unqualified credit counter inflated by 81.8%, a live configuration register violated its own packet contract 15.3% of the time, a replay-blind counter reported unchanged efficiency at every replay rate, and an unqualified comparison was invalid 99.94% of the time.

Module 23 turns from measuring an endpoint to building one. 23.1 Endpoint Architecture lays out the blocks and the contracts between them; 23.2 BAR Logic builds the decoder that decides which of a device's apertures an incoming address belongs to.