DDR · Module 23
DDR Latency Anatomy
Chapter 10.5 asks whether a read met its timing. This asks where the time went — a decomposition into six components that sum exactly, of which only one has no upper bound.
Chapter 10.5 built a read transaction tracker and asked a verification question: did the data arrive when the timing said it should? Its outputs are first_beat_early, first_beat_late, txn_overdue — a verdict on legality.
This chapter asks a different question about the same transaction: where did the time go?
Those are not the same question, and the second one has an answer the first cannot produce. A read can meet every device timing rule perfectly and still take four times longer than another read to the same bank, because most of a request's latency is often spent somewhere the device timing rules do not reach.
The module's central law, stated once and used throughout:
Peak bandwidth is a property of the interface. Achieved bandwidth is a property of the workload meeting the timing rules. The gap between them is not waste — it is the cost of constraints that cannot be removed, plus decisions that can be improved.
For latency the same split applies, and it is this chapter's organising idea: some components of a request's latency are fixed by the device and cannot be reduced by any controller. Exactly one component has no upper bound at all. Telling them apart is the whole skill.
1. Two Different Questions About One Transaction
It is worth being precise about the difference, because conflating the two produces a controller that is verifiably correct and inexplicably slow.
| 10.5's question | This chapter's question | |
|---|---|---|
| Asks | Did the data arrive legally? | Where did the time go? |
| Answer shape | A verdict — early, late, overdue | A decomposition that sums |
| Observes | The device path | The whole path, including the queue |
| Fails when | A timing rule is violated | Nothing — it always has an answer |
| Useful for | Verification | Deciding what to change |
The fourth row is the one that matters. A timing checker is silent when everything is legal, and that silence is its success condition. A decomposition is never silent — every request has a breakdown, including the ones that behaved perfectly, and comparing breakdowns across requests is where the information is.
The fifth row is why this module exists. A verification failure tells you what to fix. A decomposition tells you what is worth fixing, which is a different and usually harder question.
2. The Verified Configuration
Every derivation in this module uses one configuration, fixed here so that no chapter can quietly change an assumption between comparisons.
DDR4-1600K, x8 device, 8Gb density, 1KB page. Chosen for one reason beyond being a real JEDEC bin: at this grade every timing parameter is an exact integer number of clock cycles, so the arithmetic below is hand-checkable rather than rounded.
| Parameter | Value | nCK | Category |
|---|---|---|---|
tCK(avg) | 1.25 ns | — | VERIFIED |
CL | 13.75 ns | 11 | VERIFIED |
tRCD | 13.75 ns | 11 | VERIFIED |
tRP | 13.75 ns | 11 | VERIFIED |
tRAS | 35 ns | 28 | VERIFIED |
tRC | 48.75 ns | 39 | VERIFIED |
CWL | — | 9 or 11 | VERIFIED |
tCCD_S / tCCD_L | — | 4 / 5 | VERIFIED |
tRRD_S / tRRD_L (x8) | — | 4 / 5 | VERIFIED |
tFAW (1KB page) | 25 ns | 20 | VERIFIED |
tWTR_S / tWTR_L | — | 2 / 6 | VERIFIED |
tWR | 15 ns | 12 | VERIFIED |
tRTP | max(4 nCK, 7.5 ns) | 6 | VERIFIED |
tRFC1 (8Gb) | 350 ns | 280 | VERIFIED |
Derived from the above, and used throughout: peak bandwidth is 1600 MT/s × 8 bytes = 12.8 GB/s; the interface carries 2 transfer slots per clock cycle, so 16 bytes per cycle; and a BL8 burst occupies 4 clock cycles. The 64-bit data width is the DDR4 DIMM's verified organisation — 64 data bits plus 8 for ECC.
3. The Components, Named and Exhaustive
A decomposition is only useful if it is exhaustive — every cycle of the total belongs to exactly one component. Here is the set this chapter uses, and the reason each is separate.
| Component | From | To | Owner |
|---|---|---|---|
| Ingress | Request presented | Accepted into the queue | The interconnect and the controller's admission |
| Queue wait | Accepted | Selected by the arbiter | Policy — 17.4 and 23.4 |
| Arbitration | Selected | Command committed | The commit point of 17.1 |
| Row work | First command | Row ready for a column command | The device — tRP and/or tRCD |
| CAS | Column command | First data beat | The device — CL |
| Burst | First beat | Last beat | The device and the burst length |
| Return | Last beat | Delivered to the requester | The controller's return path |
Two properties of that set are what make it a decomposition rather than a list.
The components are disjoint. A cycle spent waiting in the queue is not also a tRCD cycle. This sounds obvious and is the single most common modelling error: a naive measurement that starts a tRCD timer at request arrival rather than at the ACT command double-counts the queue wait into the device's column.
They are exhaustive by construction. The seven intervals are contiguous and abut exactly — each one's end is the next one's start. So their sum is the total, not approximately, and §12's first property asserts precisely that.
4. A Row Miss, Decomposed
Take one read to an idle bank. DERIVED from §2's verified parameters, with the queue wait marked ILLUSTRATIVE because it is a property of the workload and the policy rather than of the device.
| Component | Cycles | Category | Why this value |
|---|---|---|---|
| Ingress | 0 | ILLUSTRATIVE | Assumed immediate acceptance |
| Queue wait | 6 | ILLUSTRATIVE | A chosen value; §8 is about why no other kind exists |
| Arbitration | 1 | ILLUSTRATIVE | One cycle from selection to commit |
Row work (tRCD) | 11 | DERIVED from VERIFIED | Bank idle, so one ACT then tRCD |
CAS (CL) | 11 | DERIVED from VERIFIED | CL = 11 at this bin |
| Burst (BL8) | 4 | DERIVED from VERIFIED | 8 beats at 2 per cycle |
| Return | 0 | ILLUSTRATIVE | Assumed immediate delivery |
| TOTAL | 33 | = 41.25 ns |
The sum is 6 + 1 + 11 + 11 + 4 = 33, and 33 × 1.25 ns = 41.25 ns. Both recomputed.
Note what fraction is device time. Of 33 cycles, 26 are tRCD + CL + burst — device timing, unreducible by any controller decision. Seven cycles are queueing and arbitration. So on this request, 79% of the latency is irreducible, and a scheduler that eliminated all queueing entirely would improve it by 21%.
That ratio is the reason performance work on memory controllers is hard, and it is also why the ratio itself is the first thing worth measuring.
5. The Same Request as a Conflict
Change one thing — the bank has a different row open — and re-derive. Nothing else changes.
| Component | Miss | Conflict | Change |
|---|---|---|---|
| Queue wait | 6 | 6 | — |
| Arbitration | 1 | 1 | — |
| Row work | 11 | 22 | +11 — a PRE before the ACT |
| CAS | 11 | 11 | — |
| Burst | 4 | 4 | — |
| TOTAL | 33 | 44 | +11 |
44 × 1.25 ns = 55.00 ns. And the difference is exactly tRP = 11 cycles, verified by subtraction: 44 − 33 = 11.
That exactness is worth pausing on. The cost of a conflict over a miss is precisely one tRP, not approximately, and not something requiring measurement — it is a subtraction over published parameters. Which means a controller that knows its hit, miss and conflict counts can compute the row-work contribution to average latency without measuring latency at all.
Chapter 23.3 builds on exactly that, and it is why 9.6's class counters are a performance instrument rather than merely diagnostic telemetry.
6. And as a Hit
The third class completes the set.
| Component | Hit | Miss | Conflict |
|---|---|---|---|
| Queue wait | 6 | 6 | 6 |
| Arbitration | 1 | 1 | 1 |
| Row work | 0 | 11 | 22 |
| CAS | 11 | 11 | 11 |
| Burst | 4 | 4 | 4 |
| TOTAL | 22 | 33 | 44 |
| Nanoseconds | 27.50 | 41.25 | 55.00 |
Three numbers worth extracting, all recomputed.
A conflict is exactly twice a hit's latency under this configuration — 44 = 2 × 22. That is an artefact of tRP = tRCD = CL = 11 at this bin rather than a general truth, and saying so is the difference between an observation and a claim.
The spread is 22 cycles, and all of it is row work. Queueing, arbitration, CAS and burst are identical across the three classes. Locality is the only thing that moved.
The minimum possible latency here is 15 cycles — a hit with zero queueing and zero arbitration, so CL + burst = 11 + 4. That is the floor for this configuration, and no policy reaches below it. DERIVED, and worth knowing because it is the denominator any latency improvement should be measured against.
7. Irreducible Versus Avoidable
§3's ownership column divides the components into two groups, and the division is the practical content of the chapter.
| Irreducible — device timing | Avoidable — policy |
|---|---|
Row work (tRP, tRCD) | Queue wait |
CAS (CL) | Arbitration |
| Burst | Ingress, Return |
But that table is too simple in one important way, and the correction is the section's point.
Row work is irreducible per access and avoidable in aggregate. A controller cannot make tRCD shorter. It can change how often a request pays it at all, by changing which row is open when the request arrives — which is page policy (23.5) and address mapping (18.2).
So the honest three-way split is:
TRULY IRREDUCIBLE CL, burst
every access pays these, always
IRREDUCIBLE PER ACCESS, tRP, tRCD
AVOIDABLE IN AGGREGATE paid only on a miss or conflict;
how often is a POLICY outcome
AVOIDABLE queue wait, arbitration
entirely a policy outcomeCL and burst are the floor. Nothing in five chapters reduces them. Everything else is a question about how often a cost is incurred rather than how large it is.
8. The One Component With No Upper Bound
Six of §3's seven components have hard upper bounds. CL, burst, tRCD and tRP are published constants. Arbitration and return are small fixed pipeline depths.
Queue wait has no upper bound at all.
It is bounded by nothing in the device specification, nothing in the controller's structure, and nothing in the timing rules. A request can wait in a queue indefinitely if the arbitration policy never selects it — and 17.4's clue about an arbiter with no defined behaviour when two candidates are equally legal is exactly this hazard.
Three consequences follow, and they shape the rest of the module.
Latency distributions have a tail, and the tail is queueing. The device components are constant, so all of the variance in a latency distribution comes from queue wait. A histogram of request latencies is, up to a constant offset, a histogram of queue waits.
The worst case is a policy property, not a device property. “What is the worst latency this memory system can produce?” has no answer from the datasheet. It is set by the arbitration policy's fairness guarantee — and if the policy has no such guarantee, the honest answer is unbounded.
So a latency bound must be designed, not measured. Chapter 23.4 builds the starvation bound that provides one. This chapter's job is to make the unbounded component visible and separately attributed, which is what §10's block does.
9. Why the Average Is the Wrong Statistic
The problem with a mean is not that it is inaccurate. It is that latency distributions in a memory controller are not symmetric, and a mean is only a good summary of a symmetric distribution.
§8's structure explains the asymmetry precisely: latency is a constant (device time) plus a queue wait that is bounded below by zero and unbounded above. That is a right-skewed distribution by construction, and for right-skewed data the mean sits above the mode and below the tail — describing neither.
What to report instead, in increasing order of usefulness:
| Statistic | Tells you | Cost to compute |
|---|---|---|
| Mean | Total time / total requests | One accumulator, one counter |
| Maximum | The worst thing that happened | One register and a compare |
| Distribution | The shape, so the tail is visible | One counter per bucket |
| High percentile | What most requests stay under | A distribution plus a scan |
The maximum is nearly free and is the single highest-value addition. One register, one comparison per request. It cannot be averaged away, it cannot be diluted by a large number of fast requests, and it is the statistic that answers §8's worst-case question.
A bucketed distribution is the honest summary, and §12's second block builds one. The reason to bucket rather than store every sample is bounded storage: a histogram over N buckets costs N counters regardless of how many requests pass through.
10. The Latency Decomposer
// ---------------------------------------------------------------------
// latency_decomposer -- attributes one request's end-to-end latency to
// named, disjoint, exhaustive components.
//
// CLASSIFICATION: educational, synthesisable.
//
// WHAT IT DOES NOT MODEL:
// - the device, its timing rules, or their legality (10.5 owns that)
// - the arbitration policy (17.4 / 23.4 own it)
// - any absolute time. Everything here is in CLOCK CYCLES; the
// conversion to nanoseconds needs tCK, which is a configuration
// fact and not this block's business.
//
// THE CENTRAL INVARIANT: the components SUM to the total, exactly, on
// every cycle of a tracked request's life. §14's P1 asserts it, and
// it is what makes this a decomposition rather than a set of timers.
// ---------------------------------------------------------------------
module latency_decomposer #(
// Widest latency this block can attribute, in cycles. Saturating,
// never wrapping: §12's note that a wrapped latency reads as a small
// one and inverts the conclusion.
parameter int MAX_LAT = 1023,
parameter int LAT_W = $clog2(MAX_LAT + 1),
// Number of stages. Fixed by §3's decomposition, not configurable --
// changing it would change what the block claims to measure.
parameter int STAGES = 6
) (
input logic clk,
input logic rst_n,
// ── Stage-transition events. Each is a ONE-CYCLE pulse marking the
// boundary between two components of §3. The controller already
// produces every one of these internally; the contribution here is
// attributing the intervals between them.
input logic ev_accept, // ingress -> queue wait
input logic ev_selected, // queue wait -> arbitration
input logic ev_committed, // arbitration -> row work
input logic ev_col_cmd, // row work -> CAS
input logic ev_first_beat, // CAS -> burst
input logic ev_last_beat, // burst -> return
input logic ev_delivered, // return -> done
// ── Per-component results, valid when `done` is asserted.
output logic [LAT_W-1:0] c_queue,
output logic [LAT_W-1:0] c_arb,
output logic [LAT_W-1:0] c_rowwork,
output logic [LAT_W-1:0] c_cas,
output logic [LAT_W-1:0] c_burst,
output logic [LAT_W-1:0] c_return,
output logic [LAT_W-1:0] total,
// ── Observability.
output logic busy,
output logic [2:0] stage,
output logic done,
// The invariant, published as a signal so a checker can require it
// rather than recompute it. Held high while the sum is correct.
output logic sum_ok,
// Which component is currently the largest. Costs a small comparison
// tree and answers the only question anyone asks of a breakdown.
output logic [2:0] dominant,
output logic err_saturated,
output logic err_out_of_order,
output logic err_restart_while_busy
);
initial begin
if (MAX_LAT < 8)
$fatal(1, "latency_decomposer: MAX_LAT (%0d) too small to be useful", MAX_LAT);
if (STAGES != 6)
$fatal(1, "latency_decomposer: STAGES is fixed at 6 by the decomposition");
end
localparam logic [2:0] S_IDLE = 3'd0;
localparam logic [2:0] S_QUEUE = 3'd1;
localparam logic [2:0] S_ARB = 3'd2;
localparam logic [2:0] S_ROW = 3'd3;
localparam logic [2:0] S_CAS = 3'd4;
localparam logic [2:0] S_BURST = 3'd5;
localparam logic [2:0] S_RET = 3'd6;
logic [2:0] st;
logic [LAT_W-1:0] acc [6]; // one accumulator per component
logic [LAT_W-1:0] tot;
logic sat;
logic dn;
logic ooo;
// Which accumulator the current stage charges. Declared above the
// assigns that read it.
logic [2:0] chg;
always_comb begin
unique case (st)
S_QUEUE : chg = 3'd0;
S_ARB : chg = 3'd1;
S_ROW : chg = 3'd2;
S_CAS : chg = 3'd3;
S_BURST : chg = 3'd4;
S_RET : chg = 3'd5;
default : chg = 3'd0;
endcase
end
// ── Legal next-event check. The stages are ORDERED (§3: each one's
// end is the next one's start), so an event arriving out of order
// is an integration error and must not silently mis-attribute.
logic ev_expected;
always_comb begin
unique case (st)
S_IDLE : ev_expected = ev_accept;
S_QUEUE : ev_expected = ev_selected;
S_ARB : ev_expected = ev_committed;
S_ROW : ev_expected = ev_col_cmd;
S_CAS : ev_expected = ev_first_beat;
S_BURST : ev_expected = ev_last_beat;
S_RET : ev_expected = ev_delivered;
default : ev_expected = 1'b0;
endcase
end
logic any_ev;
assign any_ev = ev_accept | ev_selected | ev_committed | ev_col_cmd
| ev_first_beat | ev_last_beat | ev_delivered;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
st <= S_IDLE;
tot <= '0;
sat <= 1'b0;
dn <= 1'b0;
ooo <= 1'b0;
for (int i = 0; i < 6; i++) acc[i] <= '0;
end else begin
dn <= 1'b0;
ooo <= 1'b0;
if (st == S_IDLE) begin
if (ev_accept) begin
// A fresh request clears every accumulator. Not clearing them
// is the defect that makes a second request's breakdown the
// SUM of two requests -- plausible-looking and wrong.
for (int i = 0; i < 6; i++) acc[i] <= '0;
tot <= '0;
sat <= 1'b0;
st <= S_QUEUE;
end
end else begin
// ── Charge this cycle to the current stage, and to the total.
// Both saturate, and they saturate TOGETHER so the invariant
// of §14's P1 survives saturation rather than breaking at it.
if (tot != LAT_W'(MAX_LAT)) begin
acc[chg] <= acc[chg] + LAT_W'(1);
tot <= tot + LAT_W'(1);
end else begin
sat <= 1'b1;
end
// ── Advance on the expected event.
if (ev_expected) begin
unique case (st)
S_QUEUE : st <= S_ARB;
S_ARB : st <= S_ROW;
S_ROW : st <= S_CAS;
S_CAS : st <= S_BURST;
S_BURST : st <= S_RET;
S_RET : begin st <= S_IDLE; dn <= 1'b1; end
default : st <= S_IDLE;
endcase
end else if (any_ev) begin
// An event arrived that is not the one this stage expects.
ooo <= 1'b1;
end
end
end
end
assign c_queue = acc[0];
assign c_arb = acc[1];
assign c_rowwork = acc[2];
assign c_cas = acc[3];
assign c_burst = acc[4];
assign c_return = acc[5];
assign total = tot;
assign busy = (st != S_IDLE);
assign stage = st;
assign done = dn;
// ── THE INVARIANT. Computed in a wider accumulator so the comparison
// itself cannot overflow and report a false pass.
logic [LAT_W+2:0] sum_wide;
always_comb begin
sum_wide = '0;
for (int i = 0; i < 6; i++) sum_wide = sum_wide + {3'b0, acc[i]};
end
assign sum_ok = (sum_wide == {3'b0, tot});
// ── Largest component. Answers "where did the time go" in one value.
always_comb begin
dominant = 3'd0;
for (int i = 1; i < 6; i++)
if (acc[i] > acc[dominant]) dominant = 3'(i);
end
assign err_saturated = sat;
assign err_out_of_order = ooo;
assign err_restart_while_busy = busy && ev_accept;
endmoduleThree details in that block are the chapter rather than the implementation.
The accumulators clear on ev_accept, not on done. Clearing at completion looks equivalent and is not: a request that never completes leaves stale values that the next request adds to. The breakdown then reports the sum of two requests, sums correctly against a total that is also the sum of two, and passes every property. Clearing at admission is what makes the block idempotent per request.
tot and acc[chg] saturate together. Saturating one without the other breaks the invariant exactly at the boundary where a long-latency request most needs measuring — so a design that saturated only the total would have its sum property fail precisely on the interesting requests.
sum_wide is three bits wider than LAT_W. Six accumulators each up to MAX_LAT can sum to 6 × MAX_LAT, which does not fit in LAT_W. Comparing in the narrow width would wrap and could report sum_ok on a genuinely broken decomposition.
11. A Request, Decomposed Cycle by Cycle
Stage progression with components accumulating — and the sum holding
10 cyclesThe row to watch is sum_ok, high on every cycle. At cycle 8 the components are 2 + 1 + 2 + 2 + 2 = 9 and the total is 9. That is not a coincidence of the trace — it is the property §14's P1 asserts, and it is what distinguishes a decomposition from five independent timers that happen to be running.
Also worth reading: exactly one accumulator moves per cycle. When c_cas starts charging at cycle 5, c_rowwork freezes at 2 and stays there. That disjointness is §3's first property, made visible.
Stage durations are compressed for legibility. §2's real values put tRCD and CL at 11 cycles each, so a true trace of the §4 request would be 33 cycles long. The shape is what this figure shows.
12. The Distribution Tracker
// ---------------------------------------------------------------------
// latency_distribution -- bounded-storage shape of a latency
// distribution, plus the statistics a histogram cannot provide.
//
// CLASSIFICATION: educational, synthesisable.
//
// WHAT IT DOES NOT MODEL:
// - causes. It reports shape, never why.
// - sub-bucket detail (§9's callout): within a bucket the
// distribution is unknown, and the top bucket is open-ended.
//
// WHY A SEPARATE MAXIMUM: §9 -- the top bucket is open-ended, so the
// worst case is NOT recoverable from the histogram. A design reporting
// only buckets has lost the statistic §8 says matters most.
// ---------------------------------------------------------------------
module latency_distribution #(
parameter int LAT_W = 10,
// Histogram buckets. Boundaries are POWERS OF TWO, so bucket
// selection is a leading-one search rather than a divide -- which is
// why the buckets are logarithmic and the resolution is coarse at
// the tail. That is a deliberate trade, stated in §9.
parameter int BUCKETS = 8,
parameter int CNT_W = 20,
parameter int BKT_W = (BUCKETS <= 1) ? 1 : $clog2(BUCKETS)
) (
input logic clk,
input logic rst_n,
// ── One completed request.
input logic sample_valid,
input logic [LAT_W-1:0] sample_lat,
// ── Shape.
output logic [CNT_W-1:0] bucket_count [BUCKETS],
output logic [BKT_W-1:0] last_bucket,
// ── Percentile query. §9's table names a high percentile the most
// useful statistic; §9's callout states its resolution limit. Both
// are honoured here: the answer is a BUCKET, never a value.
// Target is a fraction over 256, so 253 is approximately the 99th
// percentile and no divider is needed.
input logic [7:0] pctl_target_256,
output logic [BKT_W-1:0] pctl_bucket,
output logic pctl_valid,
// ── Statistics the histogram cannot give (§9's callout).
output logic [LAT_W-1:0] lat_min,
output logic [LAT_W-1:0] lat_max,
output logic [CNT_W-1:0] n_samples,
// Running sum, for a mean the CONSUMER computes -- this block does
// not divide, because a mean needs a stated denominator and §9 says
// the mean is the wrong summary anyway. Published so a consumer that
// wants it must state what it divided by.
output logic [CNT_W+LAT_W-1:0] lat_sum,
// ── Saturation. Sticky and per-source, because a saturated bucket
// and a saturated sum invalidate different conclusions.
output logic any_bucket_saturated,
output logic sum_saturated,
output logic counts_valid,
output logic err_sample_while_reset
);
initial begin
if (BUCKETS < 2)
$fatal(1, "latency_distribution: BUCKETS must be at least 2");
if (LAT_W < 2)
$fatal(1, "latency_distribution: LAT_W must be at least 2");
if (CNT_W < 4)
$fatal(1, "latency_distribution: CNT_W too small");
// With power-of-two boundaries, BUCKETS-1 doublings must cover the
// latency range or the top bucket swallows most of the data and the
// histogram stops being informative.
if ((BUCKETS - 1) > LAT_W)
$fatal(1, "latency_distribution: %0d buckets exceed a %0d-bit range",
BUCKETS, LAT_W);
end
// ── Bucket selection: a leading-one search. Bucket b holds latencies
// in [2^(b-1), 2^b), with bucket 0 holding 0 and 1, and the top
// bucket open-ended.
logic [BKT_W-1:0] sel;
always_comb begin
sel = '0;
for (int b = 1; b < BUCKETS; b++)
if (sample_lat >= (LAT_W'(1) << (b - 1))) sel = BKT_W'(b);
end
logic [CNT_W-1:0] bkt [BUCKETS];
logic [LAT_W-1:0] mn, mx;
logic [CNT_W-1:0] n;
logic [CNT_W+LAT_W-1:0] sm;
logic bsat, ssat, first;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int b = 0; b < BUCKETS; b++) bkt[b] <= '0;
// min starts at ALL ONES so the first sample always replaces it.
// Starting at zero would pin the minimum at zero forever.
mn <= {LAT_W{1'b1}};
mx <= '0;
n <= '0;
sm <= '0;
bsat <= 1'b0;
ssat <= 1'b0;
first <= 1'b1;
end else if (sample_valid) begin
// ── Bucket. Saturate rather than wrap.
if (bkt[sel] == {CNT_W{1'b1}}) bsat <= 1'b1;
else bkt[sel] <= bkt[sel] + CNT_W'(1);
// ── Extremes. The maximum is the statistic §8 cares about and it
// is one compare.
if (first || (sample_lat < mn)) mn <= sample_lat;
if (sample_lat > mx) mx <= sample_lat;
first <= 1'b0;
if (n != {CNT_W{1'b1}}) n <= n + CNT_W'(1);
// ── Running sum, saturating. A wrapped sum produces a mean that
// is not merely wrong but LOWER than the truth -- flattering,
// which is the worst direction for a performance metric.
if (sm > ({(CNT_W+LAT_W){1'b1}} - {{CNT_W{1'b0}}, sample_lat}))
ssat <= 1'b1;
else
sm <= sm + {{CNT_W{1'b0}}, sample_lat};
end
end
always_comb
for (int b = 0; b < BUCKETS; b++) bucket_count[b] = bkt[b];
// ── Percentile scan. A running prefix sum over the buckets, and the
// lowest bucket whose cumulative count reaches the target share of
// the samples. Combinational: BUCKETS is small and a prefix sum
// over it is a short adder chain, so no multi-cycle scan is needed.
//
// THE COMPARISON IS DONE BY CROSS-MULTIPLICATION, not by dividing.
// cum/n >= target/256 becomes cum*256 >= n*target, which is
// exact in integers. Dividing would truncate and bias the answer
// one bucket low on almost every query.
logic [CNT_W+8:0] cum;
logic [CNT_W+8:0] threshold;
logic [BKT_W-1:0] pb;
logic pfound;
always_comb begin
threshold = {{9{1'b0}}, n} * {{(CNT_W+1){1'b0}}, pctl_target_256};
cum = '0;
pb = BKT_W'(BUCKETS - 1);
pfound = 1'b0;
for (int b = 0; b < BUCKETS; b++) begin
cum = cum + {9'b0, bkt[b]};
if (!pfound && ((cum << 8) >= threshold)) begin
pb = BKT_W'(b);
pfound = 1'b1;
end
end
end
assign pctl_bucket = pb;
// A percentile is meaningless without samples, and untrustworthy if
// any bucket clamped -- the cumulative sum would then understate.
assign pctl_valid = pfound && (n != '0) && !bsat;
assign last_bucket = sel;
assign lat_min = first ? '0 : mn;
assign lat_max = mx;
assign n_samples = n;
assign lat_sum = sm;
assign any_bucket_saturated = bsat;
assign sum_saturated = ssat;
// Every reported figure is trustworthy only while nothing clamped.
assign counts_valid = !bsat && !ssat && (n != '0);
assign err_sample_while_reset = sample_valid && !rst_n;
endmoduleThe mn register initialising to all ones is the detail worth copying. A minimum initialised to zero is pinned at zero forever, reports a minimum latency of zero on every workload, and looks entirely plausible in a register dump. The first flag exists so that lat_min reads zero before any sample rather than reading all-ones, which would be equally misleading in the other direction.
The percentile query completes §9's table, and it answers with a bucket. pctl_target_256 is a fraction over 256 — so 253 is roughly the 99th percentile — and the scan returns the lowest bucket whose cumulative count reaches that share. The comparison is a cross-multiplication, cum × 256 ≥ n × target, rather than a division: dividing would truncate and bias the answer one bucket low on almost every query, which for logarithmic buckets means reporting half the true value.
And lat_sum is published without a divide on purpose. §9 argues the mean is the wrong summary; a block that computed one would be endorsing it. Publishing the sum forces the consumer to state its denominator, which is 12.4's discipline about naming denominators applied to latency.
13. The Path, in Sequence
The two self-messages on the DRAM lifeline are the irreducible components of §7, and the self-message on the queue is the unbounded one of §8. That is the whole chapter in one picture: the device's contributions are bounded and known, and the only interval whose length is nobody's published constant is the one the controller controls.
14. What the Assertions Prove
// Bind unit note: P1-P6 reference latency_decomposer's internal state
// and are written as though bound into it, so clk and rst_n are
// visible directly. P7-P10 reference latency_distribution likewise.
// P1 -- THE property of this chapter. The components sum to the total,
// on every cycle. If this holds, the breakdown is a decomposition; if
// it does not, it is six unrelated timers.
property p_components_sum_to_total;
@(posedge clk) disable iff (!rst_n)
sum_ok;
endproperty
assert property (p_components_sum_to_total);
// P2 -- exactly one component charges per cycle while busy. §3's
// disjointness, and the guard against a cycle being counted twice.
property p_one_component_per_cycle;
@(posedge clk) disable iff (!rst_n)
(busy && !err_saturated)
|=> ($countones({c_queue != $past(c_queue, 1),
c_arb != $past(c_arb, 1),
c_rowwork != $past(c_rowwork, 1),
c_cas != $past(c_cas, 1),
c_burst != $past(c_burst, 1),
c_return != $past(c_return, 1)}) <= 1);
endproperty
assert property (p_one_component_per_cycle);
// P3 -- the total only ever grows while a request is tracked, and only
// by one. A total that could jump would mean a stage was skipped.
property p_total_monotonic_by_one;
@(posedge clk) disable iff (!rst_n)
(busy && !err_saturated && $past(busy, 1))
|-> (total == $past(total, 1) + 1);
endproperty
assert property (p_total_monotonic_by_one);
// P4 -- a fresh request starts from zero. The stale-accumulator defect
// of §10, forbidden: without this, a second request reports the sum of
// two and still satisfies P1.
property p_fresh_request_starts_clean;
@(posedge clk) disable iff (!rst_n)
(ev_accept && !busy) |=> (total == LAT_W'(1));
endproperty
assert property (p_fresh_request_starts_clean);
// P5 -- stages advance in the fixed order of §3. An out-of-order event
// is reported and never silently mis-attributed.
property p_stage_order;
@(posedge clk) disable iff (!rst_n)
(stage != $past(stage, 1)) && $past(busy, 1) && busy
|-> (stage == $past(stage, 1) + 3'd1);
endproperty
assert property (p_stage_order);
// P6 -- the saturation flag and the total's ceiling agree, so a
// clamped measurement is never reported as a real one.
property p_saturation_is_flagged;
@(posedge clk) disable iff (!rst_n)
(total == LAT_W'(MAX_LAT)) && busy |=> err_saturated;
endproperty
assert property (p_saturation_is_flagged);
// P7 -- the maximum never falls. A high-water mark that could decrease
// loses the event it exists to record (§9).
property p_max_monotonic;
@(posedge clk) disable iff (!rst_n)
(lat_max >= $past(lat_max, 1));
endproperty
assert property (p_max_monotonic);
// P8 -- the minimum never rises once established.
property p_min_monotonic;
@(posedge clk) disable iff (!rst_n)
((n_samples != '0) && $past(n_samples, 1) != '0)
|-> (lat_min <= $past(lat_min, 1));
endproperty
assert property (p_min_monotonic);
// P9 -- min bounds max. Trivial-looking, and it catches the
// all-ones-initialisation bug of §12 in both directions.
property p_min_le_max;
@(posedge clk) disable iff (!rst_n)
(n_samples != '0) |-> (lat_min <= lat_max);
endproperty
assert property (p_min_le_max);
// P10 -- counts_valid is only asserted when nothing has clamped, so no
// consumer can compute a ratio from a saturated count.
property p_valid_requires_no_saturation;
@(posedge clk) disable iff (!rst_n)
counts_valid |-> (!any_bucket_saturated && !sum_saturated);
endproperty
assert property (p_valid_requires_no_saturation);
// P11 -- the percentile bucket is monotonic in the target. A higher
// target can never select a LOWER bucket, which is the one structural
// property a percentile must have and the one a truncating divide
// breaks.
property p_percentile_monotonic;
@(posedge clk) disable iff (!rst_n)
(pctl_valid && $past(pctl_valid, 1)
&& (pctl_target_256 >= $past(pctl_target_256, 1))
&& (n_samples == $past(n_samples, 1)))
|-> (pctl_bucket >= $past(pctl_bucket, 1));
endproperty
assert property (p_percentile_monotonic);
// P12 -- a percentile is never reported from a clamped histogram, where
// the cumulative sum would understate and bias the answer low.
property p_percentile_needs_clean_counts;
@(posedge clk) disable iff (!rst_n)
pctl_valid |-> !any_bucket_saturated;
endproperty
assert property (p_percentile_needs_clean_counts);
// ── Cover.
cover property (@(posedge clk) disable iff (!rst_n) done);
// A high percentile landing in the open-ended top bucket: the answer
// is "at least this much" and §9's callout says so.
cover property (@(posedge clk) disable iff (!rst_n)
pctl_valid && (pctl_bucket == BKT_W'(BUCKETS - 1)));
// A row HIT: row work charged zero cycles. The decomposition must
// handle a component of length zero, which is the case a naive
// implementation with a minimum-one-cycle stage gets wrong.
cover property (@(posedge clk) disable iff (!rst_n)
done && (c_rowwork == '0));
// A CONFLICT-shaped request: row work at least twice the CAS
// component, which at §2's parameters means a PRE preceded the ACT.
cover property (@(posedge clk) disable iff (!rst_n)
done && (c_rowwork >= (c_cas << 1)) && (c_cas != '0));
// Queue wait dominant -- §8's case, and the one that says the device
// is not the problem.
cover property (@(posedge clk) disable iff (!rst_n)
done && (dominant == 3'd0));
// Row work dominant -- the locality-bound case of 23.3.
cover property (@(posedge clk) disable iff (!rst_n)
done && (dominant == 3'd2));
cover property (@(posedge clk) disable iff (!rst_n) err_out_of_order);
cover property (@(posedge clk) disable iff (!rst_n) err_saturated);
// A sample landing in the open-ended top bucket: §9's resolution limit
// actually exercised.
cover property (@(posedge clk) disable iff (!rst_n)
sample_valid && (last_bucket == BKT_W'(BUCKETS - 1)));P1 is the property worth keeping if only one survives. It is the definition of a decomposition, and it fails immediately for the most natural wrong implementation — six independently started timers, which produce six plausible numbers that do not add up to anything.
P4 is the one most likely to be thought redundant. It is not: a stale-accumulator implementation satisfies P1 perfectly, because the components and the total are both the sum of two requests. Only P4 distinguishes them.
15. Corner Cases
| Case | Behaviour | Why |
|---|---|---|
| Row hit — zero row work | c_rowwork == 0, sum still holds | A zero-length component is legal; §14's second cover requires it |
| Request completing in the minimum 15 cycles | Valid; c_queue and c_arb both zero | §6's floor — CL + burst only |
Latency exceeding MAX_LAT | Total clamps, err_saturated sticky | A wrapped latency reads as small — the flattering direction |
| Saturation mid-request | tot and acc[chg] clamp together | The invariant survives saturation rather than breaking at it |
ev_accept while busy | err_restart_while_busy | Two overlapping requests need two instances |
| Out-of-order stage event | err_out_of_order; no mis-attribution | The stages are ordered by §3's construction |
| Request that never completes | Accumulators hold; next ev_accept clears them | Clearing at admission rather than completion — §10 |
| First sample into the distribution | lat_min replaced from all-ones | A zero-initialised minimum is pinned at zero forever |
| No samples yet | lat_min reads 0, counts_valid low | All-ones would be as misleading as zero |
| A bucket counter clamping | any_bucket_saturated, counts_valid low | Every ratio from the set is then wrong |
BUCKETS − 1 > LAT_W | $fatal at elaboration | The top bucket would swallow the data and the histogram would say nothing |
| All samples in one bucket | Legal; the distribution is degenerate | Not an error, and a strong signal the bucketing is wrong for this workload |
Row four is the one worth dwelling on. Saturating the total without saturating the component being charged breaks P1 exactly at the boundary where a long request most needs measuring — so the property would fail only on the interesting requests, which is the worst possible place for a measurement instrument to become unreliable.
16. DV — An Independent Timeline
The checker must not re-run the state machine, or it agrees with the DUT's bugs. What it should do is build a timeline from the events alone and compare the resulting intervals.
// Independent timeline reconstruction. Records the cycle at which each
// event occurred, then computes the components by SUBTRACTION -- a
// different method from the DUT's per-cycle accumulation, so agreement
// is evidence rather than tautology.
// SIMULATION-ONLY.
class latency_timeline;
int unsigned t_accept, t_selected, t_committed;
int unsigned t_col, t_first, t_last, t_delivered;
function int unsigned comp_queue(); return t_selected - t_accept; endfunction
function int unsigned comp_arb(); return t_committed - t_selected; endfunction
function int unsigned comp_rowwork(); return t_col - t_committed; endfunction
function int unsigned comp_cas(); return t_first - t_col; endfunction
function int unsigned comp_burst(); return t_last - t_first; endfunction
function int unsigned comp_return(); return t_delivered - t_last; endfunction
function int unsigned total(); return t_delivered - t_accept; endfunction
// The reference's own sanity check: subtraction must be exhaustive.
function bit intervals_partition();
return (comp_queue() + comp_arb() + comp_rowwork() + comp_cas()
+ comp_burst() + comp_return()) == total();
endfunction
endclass| Check | What it establishes |
|---|---|
| Every component matches the DUT's accumulator | Accumulation agrees with subtraction — two methods, one answer |
intervals_partition() holds for every request | The reference is itself exhaustive before being used as one |
Drive §4's configuration: expect 11 / 11 / 4 for row work, CAS, burst | The verified parameters reproduced from the RTL |
Drive §5's conflict: expect row work 22, total +11 over the miss | The tRP difference, independently computed |
Drive §6's hit: expect row work 0, total 22 | The zero-length component |
| Zero-length queue wait (selected on the accept cycle) | The other zero-length case |
Latency beyond MAX_LAT: expect clamp and sticky flag, and P1 still holding | Saturation does not break the invariant |
| Two back-to-back requests: expect the second's total to match its own timeline | The stale-accumulator defect would double it |
Feed a known latency multiset; check lat_max against the true maximum | The statistic §8 cares about |
| Feed the same multiset in a different order; expect identical statistics | Order independence of min, max, sum and buckets |
The eighth check is the one that catches the defect P4 exists for:
STALE-ACCUMULATOR DEFECT — TWO REQUESTS, ONE BREAKDOWN
MAX_LAT = 1023. Two identical row-miss reads, back to back.
§4's configuration: queue 6, arb 1, rowwork 11, cas 11, burst 4.
request A : timeline gives 6 / 1 / 11 / 11 / 4 total 33
request B : timeline gives 6 / 1 / 11 / 11 / 4 total 33
DUT with accumulators cleared at DONE (the natural choice):
request A reports 6 / 1 / 11 / 11 / 4 total 33 CORRECT
request B reports 6 / 1 / 11 / 11 / 4 total 33 CORRECT
DUT with accumulators cleared NOWHERE (the defect):
request A reports 6 / 1 / 11 / 11 / 4 total 33 CORRECT
request B reports 12 / 2 / 22 / 22 / 8 total 66 WRONG
and note what still passes on request B:
sum_ok : 12+2+22+22+8 = 66 == total. P1 HOLDS.
P2 : still one component per cycle. HOLDS.
P3 : total still grew by one per cycle. HOLDS.
P5 : stages still advanced in order. HOLDS.
dominant : still reports rowwork. PLAUSIBLE.
diagnosis : every arithmetic property is satisfied because the
breakdown is internally consistent -- it is the
breakdown of a REQUEST THAT DOES NOT EXIST, namely
A and B concatenated.
caught by : P4 (total == 1 on the cycle after a fresh accept) and
by this check comparing against a per-request timeline.
why it matters : the reported latency is exactly DOUBLE, which on
a real system looks like a genuine performance problem
and sends the investigation to the arbiter.That report is the most useful thing this testbench produces. A sound-looking decomposition that satisfies four of five properties and describes a request that never happened is a harder bug to find than one that produces obvious nonsense — and it would be reported as a latency regression rather than as a measurement defect.
17. Debugging
| Symptom | Likely cause | How to confirm |
|---|---|---|
| Reported latency is roughly double expectation | Stale accumulators — §16 | P4; compare two consecutive requests against a timeline |
| Minimum latency reads 0 on every workload | mn initialised to zero — §12 | P9; a minimum below CL + burst is impossible (§6) |
| Mean looks fine, users report stalls | The tail — §8's callout | Read lat_max, not the mean |
lat_max far above the mean | Queue wait dominant — §8 | dominant == 0; the device is not the problem |
| Row work dominant across many requests | Locality-bound — 23.3 | Compare against 9.6's class counts |
| Components do not sum | Independent timers, not a decomposition | P1; this is the defining defect |
| Latency improved but throughput did not | Latency and bandwidth are different questions | 23.2 — a reordering can cut latency and lose slots |
err_out_of_order firing | Event wiring, or overlapping requests | The stages are ordered; one instance tracks one request |
| A histogram with everything in one bucket | Bucketing wrong for this workload | Not an error; rescale BUCKETS or LAT_W |
counts_valid low | Something clamped — §12 | Ratios from a saturated set are wrong in a flattering direction |
| Latency rises with no code change | Another requester's queueing — §8 | Queue wait is a shared resource; the tail moves first |
| Device components vary between requests | Should be impossible at fixed configuration | CL and burst are constants; a varying c_cas is a wiring fault |
Row twelve is the one that indicates a real bug rather than a performance question. CL and the burst length are configuration constants, so c_cas and c_burst must be identical on every request in a fixed configuration. A varying c_cas means the event marking the first beat is not what the block thinks it is.
Row seven is the most common conceptual error in memory performance work, and 23.2 opens on it: reducing latency and increasing throughput are different objectives and sometimes conflict.
18. Misconceptions
“Memory latency is CL.” §4. CL is 11 of 33 cycles on a row miss under §2's configuration — a third. On a conflict it is 11 of 44, barely a quarter.
“A timing checker tells you where the time goes.” §1. 10.5 reports a verdict and is silent when everything is legal. A decomposition always has an answer.
“The components are approximate.” §3. They abut exactly by construction, so they sum exactly — P1 asserts it, and a breakdown that only approximately sums is six timers.
“A conflict costs about double a miss.” §5. It costs exactly one tRP more — 11 cycles at this bin. The doubling in §6 is an artefact of tRP = tRCD = CL at this grade, not a general truth.
“Most of a request's latency is the controller's fault.” §4. Under §2's configuration, 26 of 33 cycles on a row miss are device timing. Eliminating all queueing improves that request by 21%.
“Row work is irreducible.” §7. Irreducible per access; how often it is paid is a policy outcome, which is why 23.5 exists.
“Latency has a worst case you can look up.” §8. Queue wait has no upper bound in any specification. A bound must be designed into the arbitration policy.
“The mean summarises the distribution.” §9. The distribution is right-skewed by construction — a constant plus something bounded below and unbounded above — so the mean describes neither the mode nor the tail.
“A histogram gives you the maximum.” §9's callout. The top bucket is open-ended, which is why §12 keeps a separate register for it.
“A percentile from a histogram is a number.” §9's callout. It is the bucket containing the percentile, and for logarithmic buckets that can be a wide interval.
“If the components sum, the measurement is right.” §16. A stale-accumulator implementation sums perfectly and describes a request that never existed.
“Lower latency means higher throughput.” §17's row seven, and 23.2's opening. They are different objectives and can conflict.
19. Interview Reasoning
Decompose a row-miss read at DDR4-1600K. Queue wait plus arbitration, then tRCD of 11 cycles, CL of 11, and a BL8 burst of 4 — 26 cycles of device time plus whatever the controller added. With 6 cycles of queueing and 1 of arbitration, 33 cycles total, or 41.25 ns.
What does a conflict add, exactly? One tRP. The row must be closed before the new one opens, and the two are serialised — so 11 more cycles at this bin, making 44. Not approximately: exactly tRP.
Which component has no upper bound? Queue wait. Every other component is a published constant or a fixed pipeline depth. That is why the worst-case latency of a memory system is a property of its arbitration policy rather than of the DRAM.
So where does all the variance in a latency distribution come from? Queue wait, entirely. The device components are constant at a fixed configuration, so a latency histogram is a queue-wait histogram shifted by a constant.
Why is the mean a poor summary here? Because the distribution is a constant plus a quantity bounded below by zero and unbounded above — right-skewed by construction. The mean sits between the mode and the tail and describes neither.
What is the cheapest high-value statistic to add? The maximum. One register and one comparison per request. It cannot be diluted by fast requests, and it answers the worst-case question the mean hides.
Why keep a maximum separately if you already have a histogram? Because the top bucket is open-ended, so the histogram cannot recover the worst case. Reporting only buckets loses the statistic that matters most.
Your breakdown's components do not add up to the total. What have you built? Six independent timers, not a decomposition. The likely cause is starting a device-timing timer at request arrival rather than at the command, which double-counts the queue wait into the device's column.
The breakdown sums perfectly and every latency is exactly double expectation. What is wrong? The accumulators are not cleared per request, so each breakdown is the sum of that request and the previous one. It is internally consistent, satisfies the sum property, and describes a request that never existed.
Given the decomposition, where would you look first to improve latency? At which component dominates. If queue wait dominates, the arbitration policy is the lever. If row work dominates, locality and page policy are. If neither does, the latency is near the configuration's floor of CL + burst and the answer is that this workload is device-bound.
20. Exercises
-
Recompute §4's, §5's and §6's totals for DDR4-1866M —
CL-nRCD-nRP = 13-13-13,tCK = 1.071 ns,tRAS = 34 ns. Report each total in cycles and nanoseconds, and state which of the three classes improved most in nanoseconds. Was the improvement uniform? -
§6 notes that a conflict is exactly twice a hit at DDR4-1600K, and calls it an artefact. Find a real DDR4 speed bin where it is not twice, and state the general expression for the ratio in terms of
tRP,tRCD,CLand burst length. -
Remove the
firstflag from §12's block and describe whatlat_minreports before the first sample and after it. Which of P8 and P9 catches the result, and which does not? -
§10's accumulators clear on
ev_accept. Change them to clear ondoneand construct the request sequence that produces a wrong breakdown anyway. What does its existence say about where idempotence should be enforced? -
sum_wideis three bits wider thanLAT_W. Prove three is sufficient for six accumulators, and state the minimum extra width for a decomposition withNcomponents. -
Write the property that would bound queue wait, given an additional input carrying a starvation limit. Then explain from §8 why this chapter cannot assert it and 23.4 can.
-
§12 publishes
lat_sumwithout dividing. Argue both sides, then state what a consumer must report alongside a mean for the figure to be meaningful, referring to 12.4 §2's denominators. -
Using §2's verified parameters only, compute the fraction of a row-miss read's latency that is irreducible under §7's strictest definition —
CLplus burst alone. Then compute it for a hit. Explain why the two fractions differ and what that implies about which workloads benefit from a better scheduler.
21. Where This Goes
A request's latency is now a decomposition rather than a number: six disjoint, exhaustive components that sum exactly, with the device's contributions known constants and exactly one component unbounded.
Three results carry forward. Device time dominates a single access — 26 of 33 cycles on a row miss under §2's configuration, so a perfect scheduler improves that request by a fifth. Row work is the only component that varies across access classes, and its variation is exactly tRCD and tRP, which means locality's cost is computable from class counts without measuring latency at all. And queue wait is unbounded, so the worst case is a policy property that has to be designed.
What this chapter deliberately did not ask is whether any of it affects throughput. A request's latency and the interface's utilisation are different quantities, and §17's row seven flagged that they can move in opposite directions: a scheduler that reorders requests to fill more data slots makes some individual requests wait longer.
Chapter 23.2 takes that up. It starts from the peak figure §2 derived — 12.8 GB/s, 16 bytes per cycle — and asks where the difference goes, by building the accounting 12.4 named and did not construct: every cycle on the data bus charged to exactly one named cause, with the categories provably exhaustive. That is measure D, and it is the only one of 12.4's four measures that can see refresh, turnaround and row work at once.
Continue learning
Related tutorials
- Related topic
Latency Decomposition
Five terms with five owners: four are constants computable before a frame is sent, and queueing is the one that depends on load rather than speed — the only term that diverges, and the reason a mean and a tail are different questions.
- Related topic
The Memory Wall Problem
Why increasing compute capability eventually stops translating into proportional system performance. Latency against bandwidth, an RTL latency model that makes waiting visible, memory-level parallelism, queueing under saturation, and why DRAM is slow is the wrong way to say it.
- Related topic
The Refresh Requirement
Leakage produces a rule about the passage of time rather than about any operation. What the maintenance operation actually does, why it costs device availability, and how a digital design tracks a deadline, arbitrates it against traffic, and proves it never silently drops the obligation.
- Related topic
Restore Operations
Sensing consumed the stored state, so something must put it back. What restoration drives, why it covers a whole row, why a restored row is then cheap to access again, and an educational control model that cannot skip a prerequisite the array is unable to enforce.
Standards & specifications
- Governing standard
- JEDEC JESD79 (DDR SDRAM)(opens JEDEC Solid State Technology Association in a new tab)
Defines the DDR SDRAM device itself — signals, command encoding, mode registers, timing parameters and the initialisation sequence — one document per generation. Memory-controller microarchitecture, address-mapping policy, PHY training algorithms and board-level design are not specified by it.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the DDR curriculum.
