DDR · Module 23
Row-Buffer Locality
Chapter 9.6 counts hits, misses and conflicts. This owns what produces them: a stride, a mapping, and a temporal structure that identical totals cannot distinguish.
Chapter 9.6 built row_class_counters and made an argument this chapter depends on entirely: a row-hit rate alone is not enough. Its RTL takes hit, miss and conflict as inputs — someone else classifies, it tallies — and its §11 is titled “When a High Hit Rate Means Poor Performance.”
So the counting is done. What nothing yet owns is the question underneath it: what produces those counts?
A request sequence does not have a hit rate. A request sequence plus an address mapping plus a bank state has one, and changing any of the three changes the counts without changing the requests. This chapter is about that dependency, and it has two results worth stating up front because both came out of simulation rather than reasoning.
Under one access pattern, two completely different mappings produce identical class counts and opposite temporal structure. Under another, the same two mappings differ by a factor of sixteen in row-work cycles.
The module's central law:
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.
Locality is where “decisions that can be improved” has the most leverage in the whole module, and 23.2 §20 showed why: model C loses nearly 90% of peak to row work, against refresh's unconditional 4.49%.
1. What Counting Cannot See
Chapter 9.6's block reports six numbers: total, hit, miss, conflict, busy, bad-bank, plus a transition count. They are the right numbers and they are not sufficient, for a reason that is structural rather than a shortcoming of the implementation.
| A class count tells you | A class count cannot tell you |
|---|---|
| How many accesses hit | Why they hit |
| The hit rate | Whether a different mapping would change it |
| The transition count | Whether the transitions were clustered or spread |
| That conflicts occurred | What access structure produced them |
The third row is the one §4 demonstrates, and it is the least obvious. Two workloads with identical hit, miss and conflict totals can have completely different temporal structure — and 16.3 already established that the temporal structure is what decides whether bank-level parallelism is available.
So this chapter adds two things to the counting: the cause (§3 through §11) and the structure (§4, §12).
2. The Cost Model
Locality's cost is computable rather than measurable, and 23.1 §5 established the arithmetic. Restating it as the weights this chapter uses:
| Class | Row work | Cycles | Category |
|---|---|---|---|
| Hit | None — the row is open | 0 | DERIVED from VERIFIED |
| Miss | One ACT | tRCD = 11 | DERIVED from VERIFIED |
| Conflict | A PRE then an ACT, serialised | tRP + tRCD = 22 | DERIVED from VERIFIED |
So row-work cycles for a sequence are 11 × misses + 22 × conflicts, and that is a closed-form cost from class counts alone — no latency measurement required.
This is why 9.6's counters are a performance instrument and not merely diagnostic telemetry, and it is the basis of every comparison below.
3. The Same Sequence Under Two Mappings
Here is the experiment. One request sequence. Two address mappings. Nothing else changed.
The configuration gives 1024 / 64 = 16 blocks per row, where a block is one BL8 burst of 64 bytes, across 16 banks. The two mappings differ only in which address bits select the bank:
| Mapping | Bank bits | Consecutive blocks | Behaviour |
|---|---|---|---|
| Column-then-bank | Above the column bits | Fill one row, then move to the next bank | A row is exhausted before the bank changes |
| Bank-then-column | Below the column bits | Rotate through all banks | The bank changes on every block |
These are 18.2's policy choices; that chapter owns why a designer picks one. This chapter measures what each does to the class counts.
Access pattern one: a sequential walk of 256 consecutive blocks.
| Mapping | Hits | Misses | Conflicts | Hit rate | Row work |
|---|---|---|---|---|---|
| Column-then-bank | 240 | 16 | 0 | 93.8% | 176 cycles |
| Bank-then-column | 240 | 16 | 0 | 93.8% | 176 cycles |
DERIVED by simulation. The two mappings are identical on every metric.
That result is worth sitting with, because the obvious expectation — and mine, before running it — was that one mapping would win. It does not. Both produce 16 misses and 240 hits, and 11 × 16 + 22 × 0 = 176 cycles of row work either way.
The reason is arithmetic: a sequential walk of 256 blocks touches 16 rows in both mappings, because 256 / 16 = 16 either way. Column-then-bank fills row 0 of bank 0, then row 0 of bank 1, and so on. Bank-then-column visits all 16 banks at column 0, then all 16 at column 1. Sixteen distinct rows opened, in both cases, and every other access hits.
4. Identical Counts, Opposite Structure
The totals are identical. The temporal structure is not, and the difference is total.
Here is the class of each of the first 40 accesses, DERIVED by simulation, with M for miss and h for hit:
column-then-bank M h h h h h h h h h h h h h h h M h h h h h h h h h h h h h h h M h h h h h h h
bank-then-column M M M M M M M M M M M M M M M M h h h h h h h h h h h h h h h h h h h h h h h hColumn-then-bank spreads its misses: one miss, then a run of fifteen hits, repeating. Bank-then-column clusters them: sixteen consecutive misses, then a long run of hits.
Identical totals. Opposite distribution. And 16.3 established exactly why that matters — it owns the concentration pathology, and this is its mirror image:
| Column-then-bank | Bank-then-column | |
|---|---|---|
| Misses over time | Spread — one per 16 accesses | Clustered — 16 then none |
| Row activity | One bank active at a time | All 16 banks activated up front |
tFAW exposure | Low — activates are 16 apart | High — 16 activates back to back |
| Bank-level parallelism | Low, and not needed | High, and essential |
The tFAW row is where identical class counts produce different bandwidth. Chapter 23.2 §3 established that tFAW admits four activates per 20 cycles. Sixteen back-to-back activates cannot proceed at full rate — they serialise against tFAW — while sixteen activates spread one per sixteen accesses never approach the limit.
So the same class counts, with the same computed row work of 176 cycles, produce different achieved bandwidth, and no class counter can see the difference. That is §1's third row, demonstrated.
5. The Strided Case, Where the Answer Is Not Close
Change the access pattern and nothing else. Stride of one page — 16 blocks — for 256 accesses.
| Mapping | Hits | Misses | Conflicts | Hit rate | Row work |
|---|---|---|---|---|---|
| Column-then-bank | 0 | 16 | 240 | 0.0% | 5456 cycles |
| Bank-then-column | 240 | 1 | 15 | 93.8% | 341 cycles |
DERIVED by simulation. The ratio is 5456 / 341 = 16.0.
A factor of sixteen in row-work cycles, from the same 256 requests. Not a tuning difference — an order of magnitude and then some, produced entirely by which address bits select the bank.
The mechanism is direct. A stride equal to the page size means every access lands in a different row of the same bank under column-then-bank, because the column bits are exhausted by exactly one stride. Every access is therefore a conflict: close the open row, open the new one, 22 cycles each. Under bank-then-column the low bits select the bank, so a stride of 16 blocks lands on 16 mod 16 = 0 — the same bank — no, the stride advances the column field instead, so accesses walk along a row and hit.
And the winner flips between §3's pattern and this one:
| Pattern | Column-then-bank | Bank-then-column | Winner |
|---|---|---|---|
| Sequential | 176 cycles | 176 cycles | Tie |
| Strided by one page | 5456 cycles | 341 cycles | Bank-then-column, by 16× |
That is the practical content of the chapter. No mapping is optimal for all patterns, which is 18.2's argument arriving with numbers, and it is the reason a mapping is a policy choice rather than a solved problem.
6. Hit Rate Is Not Enough, Quantified
Chapter 9.6 §4 argued that a hit rate alone is insufficient. §2's cost weights let that argument be quantified exactly.
Take the strided column-then-bank case: 0% hit rate, 16 misses, 240 conflicts. Now imagine a different workload, also at 0% hit rate, but where every non-hit is a miss rather than a conflict — 256 misses.
| Workload | Hit rate | Misses | Conflicts | Row work |
|---|---|---|---|---|
| Strided, column-then-bank | 0.0% | 16 | 240 | 5456 cycles |
| All-miss (idle banks) | 0.0% | 256 | 0 | 2816 cycles |
Both DERIVED. The all-conflict workload costs 5456 / 2816 = 1.94 times as much — approaching the exact 2× that §2's callout explains, and differing from it only because 16 of the 256 accesses in the first case are misses rather than conflicts.
The same hit rate, nearly double the cost. So “hit rate” is not a sufficient statistic for row-buffer behaviour, and the reason is that it collapses two classes with different costs into one bucket labelled not a hit.
What is sufficient, given §2's weights, is the pair (misses, conflicts) — from which the cost follows in closed form. A single ratio cannot carry it.
7. Bank Groups Change the Answer
§3 through §6 treated banks as interchangeable. They are not. Chapter 16.4 established that DDR4 organises banks into bank groups, and the timing parameters differ depending on whether two accesses fall in the same group or different ones.
The relevant verified values at this configuration:
| Parameter | Same bank group | Different bank group | Category |
|---|---|---|---|
Column-to-column (tCCD) | tCCD_L = 5 | tCCD_S = 4 | VERIFIED |
Activate-to-activate (tRRD, x8) | tRRD_L = 5 | tRRD_S = 4 | VERIFIED |
Both parameters are shorter across groups than within one, and that single fact is what 23.2 §4's 100% model rests on: tCCD_S is 4 cycles and a BL8 burst is 4 cycles, so consecutive column commands to different bank groups leave no gap at all. Staying inside one group makes tCCD_L = 5 the spacing and one cycle in five is lost.
So locality has a second dimension that class counts cannot see either:
| What it measures | What it misses | |
|---|---|---|
| Hit / miss / conflict | Row-state work | Which bank group each access lands in |
| Bank-group alternation | tCCD spacing | Row-state work |
A workload can have a perfect hit rate and still lose 20% of peak by keeping every access inside one bank group — which is precisely 23.2's model B, at 80%. Nothing in §2's cost model captures it, because row work is zero on every access and the loss is entirely in column spacing.
And this interacts with §5's mapping comparison. Bank-then-column, which won by 16× on the strided pattern, places the bank bits in the low address positions — so consecutive blocks rotate through banks and therefore through bank groups. It delivers tCCD_S spacing by construction. Column-then-bank fills a row before changing bank, so consecutive accesses stay in one group and pay tCCD_L.
DERIVED: on the sequential walk of §3, where both mappings gave identical 176-cycle row work, bank-then-column additionally gets tCCD_S spacing while column-then-bank gets tCCD_L. §3's tie was a tie on row work only. Including column spacing, the two mappings differ by 23.2 §3's 100% versus 80% — a 25% throughput difference invisible to every metric in §3's table.
8. Two Streams, Each With Perfect Locality
Everything so far has assumed one access stream. Real controllers serve several, and the interaction produces the largest single result in this chapter.
Take two streams, each walking sequentially along a row. Each has essentially perfect locality in isolation: one miss to open the row, then hits.
DERIVED by simulation, 64 accesses per stream:
| Case | Hits | Misses | Conflicts | Row work |
|---|---|---|---|---|
| Stream X alone | 63 | 1 | 0 | 11 cycles |
| Stream Y alone | 63 | 1 | 0 | 11 cycles |
| Sum of the two in isolation | — | — | — | 22 cycles |
Now interleave them — X, Y, X, Y — with both streams landing in the same bank on different rows:
| Case | Hits | Misses | Conflicts | Row work |
|---|---|---|---|---|
| X and Y interleaved, same bank | 0 | 1 | 127 | 2805 cycles |
Twenty-two cycles becomes 2805. The ratio is 2805 / 22 = 128. The class sequence shows what happened:
interleaved, same bank M C C C C C C C C C C C C C C C C C C C ...
interleaved, other banks M M h h h h h h h h h h h h h h h h h h ...Every access after the first closes the row the other stream just opened. Neither stream has poor locality. The interleaving destroys it — and the row buffer, which holds exactly one row per bank, is the reason: two streams need two open rows in one bank, and a bank has one.
Change nothing except the bank the second stream lands in:
| Case | Hits | Misses | Conflicts | Row work |
|---|---|---|---|---|
| X and Y interleaved, different banks | 126 | 2 | 0 | 22 cycles |
DERIVED, and identical to the isolated sum. The interleaving costs exactly nothing when the streams do not collide in a bank.
| Row work | Relative | |
|---|---|---|
| Same bank | 2805 cycles | 127.5× |
| Different banks | 22 cycles | 1× |
That is the largest ratio in the module, and it is produced by a one-bit change in where the second stream's addresses land.
The collapse is a cliff, not a gradient
The obvious follow-up is what happens with three streams, or eight. The answer is not what the two-stream result suggests, and it is worth having.
DERIVED by simulation, all streams colliding in one bank, 64 accesses each:
| Streams | Accesses | Hits | Conflicts | Row work | Per access |
|---|---|---|---|---|---|
| 1 | 64 | 63 | 0 | 11 | 0.17 cycles |
| 2 | 128 | 0 | 127 | 2805 | 21.91 cycles |
| 3 | 192 | 0 | 191 | 4213 | 21.94 cycles |
| 4 | 256 | 0 | 255 | 5621 | 21.96 cycles |
| 8 | 512 | 0 | 511 | 11253 | 21.98 cycles |
The entire collapse happens between one stream and two. Per-access row work rises by a factor of 129 at the second stream, and then by 0.1% for each stream after it — asymptoting at tRP + tRCD = 22 cycles, which is every access being a conflict.
The reason is that there is nothing left to lose. At two streams every access already closes a row the other just opened, so the hit rate is already zero. A third stream cannot make it worse than zero, and the per-access cost cannot exceed one conflict.
Two consequences, and the second is the useful one.
A two-requester system already has the full pathology. Testing with one stream measures nothing about it; testing with two measures essentially all of it. There is no gradual degradation to extrapolate from.
And the recovery is correspondingly steep. §9 shows batching recovering 99.6% of the two-stream damage — and since the damage does not grow with more streams, the same mechanism recovers proportionally more as streams are added. That asymmetry is why reordering is the single largest lever in this module.
9. What a Scheduler Can Recover
§8's result is a scheduling problem, and it has a scheduling answer. Instead of serving X, Y, X, Y, serve all of X and then all of Y — batching by row.
DERIVED by simulation, same two streams, same bank, 64 accesses each:
| Order | Hits | Misses | Conflicts | Row work |
|---|---|---|---|---|
| Interleaved | 0 | 1 | 127 | 2805 cycles |
| Batched — all X, then all Y | 126 | 1 | 1 | 33 cycles |
| Isolated sum (the floor) | — | — | — | 22 cycles |
Batching recovers 2772 of the 2783 lost cycles — 99.6% of the damage. The residual 11 cycles is exactly one conflict: the single row transition where the batch switches from X's row to Y's.
That single conflict is the irreducible cost of serving two rows in one bank, and it is tRP + tRCD = 22 cycles against the 11-cycle floor of two misses. So the batched result is 33 rather than 22, and the difference is one tRP — computable in advance from §2's weights rather than discovered by measurement.
Three things follow, and the third is the chapter's handoff.
Reordering is the lever, and it is enormous. A 128× penalty reduced to 1.5× by changing nothing but the order of service. No mapping change, no hardware change, no timing change.
The scheduler needs the row state to do it. Batching by row requires knowing which pending requests target the row currently open — which is exactly what §10's locality_monitor computes, and exactly what 17.2's queue must expose for a heuristic to act on.
And it is not free. Serving all of X before any of Y means every Y request waits for all 64 X requests. Chapter 23.1 §8 established that queue wait is the one latency component with no upper bound, and batching is precisely the mechanism that makes it large. Chapter 23.2 §1's table predicted this exact trade: reordering to group row hits raises throughput and raises the delayed request's latency.
So §8 and §9 together state the problem 23.4 exists to solve, with both numbers attached: 128× of throughput available, and an unbounded latency cost if taken naively.
10. What a Stride Does to a Row
§5's result generalises, and stating the general rule is more useful than the one data point.
A row holds R blocks — 16 in this configuration. Under a mapping where the column field occupies the low address bits, an access stream with stride s blocks behaves as:
| Stride | Accesses per row before leaving it | Class sequence |
|---|---|---|
s = 1 | R = 16 | 1 miss, 15 hits |
s = 2 | R/2 = 8 | 1 miss, 7 hits |
s = R = 16 | 1 | Every access a conflict |
s = 2R = 32 | 1 | Every access a conflict, skipping rows |
s coprime with R, large | 1 | Every access a conflict |
The pathological stride is exactly the page size, and any multiple of it. That is not an obscure corner: a stride of one page is what a program walking a 2-D array column-wise produces when the row length happens to match the page, and 18.3 owns why that access shape is common.
The general expression, and the one worth carrying: accesses per row is max(1, R / gcd(s, R)) when s < R, and 1 when s is a multiple of R. DERIVED, and §24's exercise asks for the verification.
The mitigation is what 18.2 calls a mapping change and what §5 demonstrated: move the bank bits below the column bits and a page-sized stride stops landing in the same bank. It does not eliminate the problem — it relocates the pathological stride to a different value, which §24's fifth exercise asks you to find.
11. Measuring Reuse Without Storing Addresses
To detect a stride, or to estimate reuse, the obvious approach is to keep a history of addresses and analyse it. In a memory controller that is not available: the storage would be large, the analysis would need multiple cycles, and the answer would arrive after the accesses it describes.
What is affordable is bounded state that estimates, and reports its own error. Two mechanisms, both in §14 and §16.
Per-bank open-row tracking is exact and cheap. One row-address register per bank plus a valid bit. Comparing an incoming access's row against the stored one classifies it as hit, miss or conflict in one cycle — and this is what §14's block does. It is not an estimate; it is the same state the controller already maintains to schedule correctly.
Stride detection is an estimate, and must say so. Keeping the previous address and the previous delta lets a block recognise a repeated delta. What it cannot do is recognise a stride interleaved with other traffic — two streams with different strides look like a stream with an alternating delta, and a single-delta detector reports no stride at all.
12. The Locality Architecture
The structural point is the two paths out of the address. One goes through the decoder and the open-row state to produce class counts — what 9.6 already owns. The other goes straight to the stride detector and the concentration tracker to produce structure, which §4 showed the counts cannot carry.
Both read the same address stream. They answer different questions, and a report containing only the first is the one §1's table describes as insufficient.
13. The Locality Monitor
// ---------------------------------------------------------------------
// locality_monitor -- computes the access class from an address and
// per-bank open-row state, then weights it with the verified cost model.
//
// CLASSIFICATION: educational, synthesisable.
//
// RELATION TO 9.6: row_class_counters takes hit/miss/conflict as
// INPUTS. This block COMPUTES them, which is the advance -- and it is
// the reason it needs per-bank state that 9.6's block does not.
//
// WHAT IT DOES NOT MODEL:
// - the device or its timing rules (Modules 13-14 own them)
// - the scheduler, or whether a conflict was avoidable (§1: a count
// is not a verdict)
// - absolute time. The cost weights are in CYCLES, from §2.
// ---------------------------------------------------------------------
module locality_monitor #(
parameter int NUM_BANKS = 16,
parameter int ROW_W = 16,
// §2's VERIFIED cost weights, in cycles, at DDR4-1600K. Parameters
// rather than constants because they are bin-specific: at another
// speed grade tRP and tRCD differ and the ratio changes (§2's callout).
parameter int COST_MISS = 11, // tRCD
parameter int COST_CONFLICT = 22, // tRP + tRCD
parameter int CNT_W = 24,
parameter int BK_W = (NUM_BANKS <= 1) ? 1 : $clog2(NUM_BANKS),
// A COUNT of banks up to NUM_BANKS needs clog2(NUM_BANKS+1).
parameter int BKC_W = $clog2(NUM_BANKS + 1),
parameter int COST_W = CNT_W + 6
) (
input logic clk,
input logic rst_n,
// ── One classified access. The bank and row come from the decoder,
// so the MAPPING is upstream -- which is what makes §3's and §5's
// comparison possible without changing this block.
input logic acc_valid,
input logic [BK_W-1:0] acc_bank,
input logic [ROW_W-1:0] acc_row,
// A precharge closing a row, from the scheduler. Without it the
// open-row state drifts from the device's and every class after the
// first precharge is wrong.
input logic pre_valid,
input logic [BK_W-1:0] pre_bank,
input logic pre_all,
// ── Class of THIS access, available the same cycle.
output logic is_hit,
output logic is_miss,
output logic is_conflict,
// ── Counts and the weighted cost of §2.
output logic [CNT_W-1:0] n_hit,
output logic [CNT_W-1:0] n_miss,
output logic [CNT_W-1:0] n_conflict,
output logic [CNT_W-1:0] n_total,
output logic [COST_W-1:0] row_work_cycles,
// ── Structure, which §4 showed the counts cannot carry.
output logic [BKC_W-1:0] banks_open,
// Consecutive non-hits. A run of these is §4's CLUSTERED pattern;
// isolated ones are the SPREAD pattern, and the totals are equal.
output logic [7:0] worst_nonhit_run,
output logic [7:0] cur_nonhit_run,
output logic any_saturated,
output logic counts_valid,
output logic err_pre_unopened
);
initial begin
if (NUM_BANKS < 1)
$fatal(1, "locality_monitor: NUM_BANKS must be at least 1");
if (COST_CONFLICT <= COST_MISS)
// A conflict does strictly more work than a miss (§2). A
// parameterisation claiming otherwise is not a cost model.
$fatal(1, "locality_monitor: COST_CONFLICT (%0d) must exceed COST_MISS (%0d)",
COST_CONFLICT, COST_MISS);
if (COST_W <= CNT_W)
$fatal(1, "locality_monitor: COST_W must exceed CNT_W to hold a weighted sum");
end
logic [ROW_W-1:0] open_row [NUM_BANKS];
logic row_valid [NUM_BANKS];
// Declared above the continuous assigns that read them.
logic [CNT_W-1:0] c_hit, c_miss, c_conf, c_tot;
logic [COST_W-1:0] c_cost;
logic [7:0] run_cur, run_worst;
logic sat;
logic bad_pre;
// ── THE CLASSIFICATION. Three mutually exclusive outcomes, decided
// by the bank's stored state. This is the whole of what 9.6's
// block receives ready-made.
logic hit_c, miss_c, conf_c;
always_comb begin
hit_c = 1'b0;
miss_c = 1'b0;
conf_c = 1'b0;
if (acc_valid) begin
if (!row_valid[acc_bank]) miss_c = 1'b1;
else if (open_row[acc_bank] == acc_row) hit_c = 1'b1;
else conf_c = 1'b1;
end
end
logic [BKC_W-1:0] n_open;
always_comb begin
n_open = '0;
for (int b = 0; b < NUM_BANKS; b++)
if (row_valid[b]) n_open = n_open + BKC_W'(1);
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int b = 0; b < NUM_BANKS; b++) begin
open_row[b] <= '0;
row_valid[b] <= 1'b0;
end
c_hit <= '0; c_miss <= '0; c_conf <= '0; c_tot <= '0;
c_cost <= '0;
run_cur <= '0; run_worst <= '0;
sat <= 1'b0; bad_pre <= 1'b0;
end else begin
bad_pre <= 1'b0;
// ── Precharge first, so an access in the same cycle sees the
// post-precharge state. Ordering these the other way makes a
// same-cycle access classify against a row already closed.
if (pre_valid) begin
if (pre_all) begin
for (int b = 0; b < NUM_BANKS; b++) row_valid[b] <= 1'b0;
end else begin
if (!row_valid[pre_bank]) bad_pre <= 1'b1;
row_valid[pre_bank] <= 1'b0;
end
end
if (acc_valid) begin
// ── State update. A miss or a conflict leaves the accessed
// row open; a hit changes nothing.
if (miss_c || conf_c) begin
open_row[acc_bank] <= acc_row;
row_valid[acc_bank] <= 1'b1;
end
// ── Counts, saturating.
if (c_tot == {CNT_W{1'b1}}) sat <= 1'b1;
else begin
c_tot <= c_tot + CNT_W'(1);
if (hit_c) c_hit <= c_hit + CNT_W'(1);
if (miss_c) c_miss <= c_miss + CNT_W'(1);
if (conf_c) c_conf <= c_conf + CNT_W'(1);
end
// ── §2's weighted cost, accumulated as the accesses arrive
// rather than computed at the end from the counts. Both
// routes must agree, and §18's P4 requires it.
if (miss_c) c_cost <= c_cost + COST_W'(COST_MISS);
if (conf_c) c_cost <= c_cost + COST_W'(COST_CONFLICT);
// ── §4's structure: the run length of consecutive non-hits.
if (hit_c) begin
run_cur <= '0;
end else begin
if (run_cur != 8'hFF) run_cur <= run_cur + 8'd1;
if ((run_cur + 8'd1) > run_worst) run_worst <= run_cur + 8'd1;
end
end
end
end
assign is_hit = hit_c;
assign is_miss = miss_c;
assign is_conflict = conf_c;
assign n_hit = c_hit;
assign n_miss = c_miss;
assign n_conflict = c_conf;
assign n_total = c_tot;
assign row_work_cycles = c_cost;
assign banks_open = n_open;
assign cur_nonhit_run = run_cur;
assign worst_nonhit_run = run_worst;
assign any_saturated = sat;
assign counts_valid = !sat && (c_tot != '0);
assign err_pre_unopened = bad_pre;
endmoduleThree points, and the second is the one that makes §4 measurable.
The precharge input is not optional. Without it the block's open-row state diverges from the device's the moment the scheduler closes a row — and every subsequent classification is wrong, in the flattering direction, because a closed row still registers as open and a genuine miss is reported as a hit.
worst_nonhit_run is §4's structure, in one number. The spread pattern of §4 produces a worst run of 1; the clustered pattern produces 16. Identical class totals, and this output tells them apart — which is precisely what 9.6's counters cannot do.
The cost accumulates per access rather than being computed at the end. Both routes must agree, which makes §18's P4 a genuine cross-check between an incremental accumulation and a closed-form weighting, rather than an identity checked against itself.
14. The Stride Detector
// ---------------------------------------------------------------------
// stride_detector -- recognises a constant address stride from bounded
// state, and reports its own confidence and error rate.
//
// CLASSIFICATION: educational, synthesisable.
//
// WHAT IT DOES NOT MODEL:
// - multiple interleaved streams. Two streams with different strides
// present an alternating delta; this block then reports NO stride
// rather than a wrong one (§11). That is a deliberate limitation,
// not an oversight.
// - reuse distance. A true reuse-distance computation needs an
// address history, which §11 establishes is not affordable here.
//
// AN ESTIMATOR THAT DOES NOT REPORT ITS OWN ERROR IS WORSE THAN NONE.
// ---------------------------------------------------------------------
module stride_detector #(
parameter int ADDR_W = 32,
// Consecutive confirmations before the stride is declared valid.
// §11's callout: one observation is a coincidence.
parameter int CONFIRM = 3,
parameter int CONF_W = (CONFIRM <= 1) ? 1 : $clog2(CONFIRM + 1),
parameter int CNT_W = 16
) (
input logic clk,
input logic rst_n,
input logic acc_valid,
input logic [ADDR_W-1:0] acc_addr,
// ── The estimate. Signed, because a descending walk is as common as
// an ascending one and an unsigned delta would report a huge
// positive stride for a backward scan.
output logic signed [ADDR_W-1:0] stride,
output logic stride_valid,
output logic [CONF_W-1:0] confidence,
// ── Its own error. A caller acting on stride_valid without reading
// these is acting on an unvalidated estimate.
output logic [CNT_W-1:0] n_confirm,
output logic [CNT_W-1:0] n_break,
// Sticky: the delta alternated rather than repeated, which is §11's
// interleaved-streams signature.
output logic alternating_seen,
output logic err_valid_without_confidence
);
initial begin
if (CONFIRM < 2)
// A single confirmation is one observation. §11's callout.
$fatal(1, "stride_detector: CONFIRM must be at least 2");
if (ADDR_W < 4)
$fatal(1, "stride_detector: ADDR_W too small");
end
logic [ADDR_W-1:0] prev_addr;
logic signed [ADDR_W-1:0] cur_delta, prev_delta;
logic have_prev, have_delta;
logic [CONF_W-1:0] conf;
logic [CNT_W-1:0] n_ok, n_bad;
logic alt;
logic signed [ADDR_W-1:0] delta_now;
assign delta_now = $signed(acc_addr) - $signed(prev_addr);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
prev_addr <= '0;
cur_delta <= '0;
prev_delta <= '0;
have_prev <= 1'b0;
have_delta <= 1'b0;
conf <= '0;
n_ok <= '0;
n_bad <= '0;
alt <= 1'b0;
end else if (acc_valid) begin
if (!have_prev) begin
have_prev <= 1'b1;
end else if (!have_delta) begin
cur_delta <= delta_now;
have_delta <= 1'b1;
conf <= '0;
end else begin
if (delta_now == cur_delta) begin
// Confirmation. Saturate at CONFIRM so confidence is a
// bounded, comparable measure rather than an unbounded count.
if (conf != CONF_W'(CONFIRM)) conf <= conf + CONF_W'(1);
if (n_ok != {CNT_W{1'b1}}) n_ok <= n_ok + CNT_W'(1);
end else begin
// Break. Adopt the new delta and restart confidence -- the
// stream may have changed stride rather than become random.
if (n_bad != {CNT_W{1'b1}}) n_bad <= n_bad + CNT_W'(1);
// An alternating delta -- back to the delta before last -- is
// §11's interleaved-streams signature, not a stride change.
if (delta_now == prev_delta) alt <= 1'b1;
prev_delta <= cur_delta;
cur_delta <= delta_now;
conf <= '0;
end
end
prev_addr <= acc_addr;
end
end
assign stride = cur_delta;
assign confidence = conf;
// Valid ONLY at full confidence. A partially confirmed stride is
// published as an estimate with its confidence, never as valid.
assign stride_valid = (conf == CONF_W'(CONFIRM));
assign n_confirm = n_ok;
assign n_break = n_bad;
assign alternating_seen = alt;
assign err_valid_without_confidence =
stride_valid && (conf != CONF_W'(CONFIRM));
endmoduleTwo design decisions carry §11's argument.
stride is signed. A descending scan is as common as an ascending one, and an unsigned delta reports a backward step of 64 bytes as a stride of four billion — which then matches nothing and reports a permanent break. The $signed casts are the fix and they are easy to omit.
alternating_seen distinguishes a stride change from interleaved streams. A break where the new delta equals the previous delta is the signature of two streams alternating, not of one stream changing pace. Without that flag, a caller sees a high n_break and cannot tell whether the pattern is irregular or simply mixed — and those call for different responses.
15. The Third Metric
§7 established that there are three measurements of an access stream, each blind to what the others see, and that §13's block publishes only the first two. The third — bank-group alternation — is a locality property and belongs here rather than with 23.2's attribution, because it is computed from the address stream rather than from bus cycles.
It is also the metric that decides whether a workload can reach 23.2 §4's 100%, so leaving it unbuilt would leave the chapter's most consequential claim unmeasured.
What it must report is not simply how many bank groups were touched. That is a count, and a workload can touch all four groups while never alternating between consecutive accesses — which is the property tCCD_S actually depends on. The distinction:
| Stream | Groups touched | Consecutive alternations | tCCD spacing |
|---|---|---|---|
0,0,0,0,1,1,1,1,2,2,2,2 | 3 | 2 of 11 | Mostly tCCD_L |
0,1,2,0,1,2,0,1,2,0,1,2 | 3 | 11 of 11 | Always tCCD_S |
Both touch three groups. One gets the short spacing on every transfer and the other almost never does. A group-count metric reports them identically, which is exactly the failure §7's callout warns about, one level down.
// ---------------------------------------------------------------------
// bank_group_tracker -- measures how often consecutive accesses change
// bank group, which is the property tCCD_S availability depends on.
//
// CLASSIFICATION: educational, synthesisable. Supplies §7's third
// metric, which class counts and run lengths are both blind to.
//
// WHAT IT DOES NOT MODEL:
// - the device, or tCCD enforcement (13.4 owns command legality)
// - bandwidth. It reports an alternation RATE as a numerator and a
// denominator; 23.2 owns turning spacing into throughput.
// - whether alternating is DESIRABLE. §7: it enables tCCD_S, and
// whether a workload should be reshaped to get it is 18.2's and
// 23.4's question.
//
// IT COUNTS ALTERNATIONS, NOT GROUPS. A stream touching every group in
// long runs alternates rarely; one rotating touches the same groups and
// alternates always. The two get identical group counts and opposite
// column spacing (§15's table).
// ---------------------------------------------------------------------
module bank_group_tracker #(
parameter int NUM_GROUPS = 4,
// VERIFIED at DDR4-1600: column spacing within and across groups.
parameter int TCCD_L = 5,
parameter int TCCD_S = 4,
parameter int CNT_W = 24,
parameter int BG_W = (NUM_GROUPS <= 1) ? 1 : $clog2(NUM_GROUPS),
// A COUNT of groups up to NUM_GROUPS needs clog2(NUM_GROUPS+1).
parameter int BGC_W = $clog2(NUM_GROUPS + 1),
// Accumulated spacing can reach CNT_W accesses times TCCD_L.
parameter int SPC_W = CNT_W + 4
) (
input logic clk,
input logic rst_n,
input logic acc_valid,
input logic [BG_W-1:0] acc_group,
// ── The alternation rate, as a PAIR. §13's block publishes counts
// and not ratios for the same reason: a rate without its
// denominator is not a quantity.
output logic [CNT_W-1:0] n_alternations,
output logic [CNT_W-1:0] n_transitions, // the denominator
// ── Groups touched, which is the metric this block exists NOT to be
// confused with. Published so the distinction is visible.
output logic [BGC_W-1:0] groups_touched,
// ── Accumulated column spacing the stream incurred, in cycles:
// TCCD_S per alternation, TCCD_L per same-group transition. This
// is the quantity that turns into bandwidth (23.2 §3).
output logic [SPC_W-1:0] spacing_cycles,
// The same stream's spacing if every transition had alternated --
// the floor, so a caller can see the gap without dividing.
output logic [SPC_W-1:0] spacing_floor,
// ── Longest run within one group: the §15 table's first row made
// measurable, and the reason a group COUNT is insufficient.
output logic [15:0] worst_same_group_run,
output logic any_saturated,
output logic counts_valid,
output logic err_group_out_of_range
);
initial begin
if (NUM_GROUPS < 1)
$fatal(1, "bank_group_tracker: NUM_GROUPS must be at least 1");
if (TCCD_S > TCCD_L)
// Across-group spacing is SHORTER (§7's verified table). A
// parameterisation claiming otherwise inverts the whole metric.
$fatal(1, "bank_group_tracker: TCCD_S (%0d) exceeds TCCD_L (%0d)",
TCCD_S, TCCD_L);
if (SPC_W <= CNT_W)
$fatal(1, "bank_group_tracker: SPC_W must exceed CNT_W to hold a product");
end
logic [BG_W-1:0] prev_group;
logic have_prev;
logic [NUM_GROUPS-1:0] seen_mask;
logic [CNT_W-1:0] n_alt, n_tr;
logic [SPC_W-1:0] spc;
logic [15:0] run_cur, run_worst;
logic sat, oor;
// Declared above the assigns that read it.
logic changed;
assign changed = have_prev && acc_valid && (acc_group != prev_group);
logic [BGC_W-1:0] n_seen;
always_comb begin
n_seen = '0;
for (int g = 0; g < NUM_GROUPS; g++)
if (seen_mask[g]) n_seen = n_seen + BGC_W'(1);
end
logic in_range;
assign in_range = ({1'b0, acc_group} < (BG_W+1)'(NUM_GROUPS));
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
prev_group <= '0;
have_prev <= 1'b0;
seen_mask <= '0;
n_alt <= '0; n_tr <= '0; spc <= '0;
run_cur <= '0; run_worst <= '0;
sat <= 1'b0; oor <= 1'b0;
end else begin
oor <= 1'b0;
if (acc_valid) begin
if (!in_range) begin
oor <= 1'b1;
end else begin
seen_mask[acc_group] <= 1'b1;
if (have_prev) begin
if (n_tr == {CNT_W{1'b1}}) begin
sat <= 1'b1;
end else begin
n_tr <= n_tr + CNT_W'(1);
if (acc_group != prev_group) begin
// An alternation: this transfer gets the SHORT spacing.
n_alt <= n_alt + CNT_W'(1);
spc <= spc + SPC_W'(TCCD_S);
run_cur <= '0;
end else begin
// Same group: the LONG spacing, and the run extends.
spc <= spc + SPC_W'(TCCD_L);
if (run_cur != 16'hFFFF) run_cur <= run_cur + 16'd1;
if ((run_cur + 16'd1) > run_worst) run_worst <= run_cur + 16'd1;
end
end
end
prev_group <= acc_group;
have_prev <= 1'b1;
end
end
end
end
assign n_alternations = n_alt;
assign n_transitions = n_tr;
assign groups_touched = n_seen;
assign spacing_cycles = spc;
// The floor: every transition alternating. Computed from the SAME
// transition count, so the two are directly comparable and the gap
// needs no division.
assign spacing_floor = {{(SPC_W-CNT_W){1'b0}}, n_tr} * SPC_W'(TCCD_S);
assign worst_same_group_run = run_worst;
assign any_saturated = sat;
assign counts_valid = !sat && (n_tr != '0);
assign err_group_out_of_range = oor;
endmoduleThree assertions belong with it:
// P11 -- alternations never exceed transitions. The numerator cannot
// exceed its own denominator, and a violation means one is counted on
// a cycle the other is not.
property p_alternations_bounded;
@(posedge clk) disable iff (!rst_n)
(n_alternations <= n_transitions);
endproperty
assert property (p_alternations_bounded);
// P12 -- accumulated spacing is bounded by the two extremes: every
// transition short, or every transition long. If it falls outside,
// the per-transition charge is wrong.
property p_spacing_within_bounds;
@(posedge clk) disable iff (!rst_n)
counts_valid
|-> ((spacing_cycles >= spacing_floor)
&& (spacing_cycles <= ({{(SPC_W-CNT_W){1'b0}}, n_transitions}
* SPC_W'(TCCD_L))));
endproperty
assert property (p_spacing_within_bounds);
// P13 -- full alternation means spacing equals the floor exactly. The
// boundary case, and the one that proves the accounting is tight
// rather than merely bounded.
property p_full_alternation_hits_floor;
@(posedge clk) disable iff (!rst_n)
(counts_valid && (n_alternations == n_transitions))
|-> (spacing_cycles == spacing_floor);
endproperty
assert property (p_full_alternation_hits_floor);
// ── Cover: a stream that alternates on every transition -- 23.2's
// model A precondition, and the case a suite must reach to have
// tested against the actual ceiling.
cover property (@(posedge clk) disable iff (!rst_n)
counts_valid && (n_alternations == n_transitions)
&& (n_transitions > CNT_W'(16)));
// A stream touching every group while alternating rarely -- §15's
// first table row, the case a group COUNT reports identically.
cover property (@(posedge clk) disable iff (!rst_n)
counts_valid && (groups_touched == BGC_W'(NUM_GROUPS))
&& (worst_same_group_run > 16'd4));spacing_floor exists so the gap needs no division. The block publishes what the stream actually cost in column spacing and what it would have cost with perfect alternation, both in cycles, from the same transition count. DERIVED for §7's two example streams over 11 transitions: the rotating stream costs 11 × 4 = 44 cycles and its floor is 44 — it is at the floor. The run-based stream costs 2 × 4 + 9 × 5 = 53 cycles against the same 44-cycle floor, so it pays 9 extra cycles — 20.5% more — from touching the same three groups in a different order. Both streams touch exactly three groups and their worst same-group runs are 0 and 3.
And P13 is the property that makes the accounting tight. Bounding spacing between the two extremes (P12) is necessary and weak; requiring it to equal the floor when alternation is complete is what proves the per-transition charge is right rather than merely plausible.
16. What the Assertions Prove
// Bind unit note: P1-P6 reference locality_monitor's internals and
// P7-P10 reference stride_detector's; both are written as though bound
// into their module, so clk and rst_n are visible directly.
// P1 -- the three classes are mutually exclusive and, on a valid
// access, exactly one holds. §12's comparator is the only classifier.
property p_exactly_one_class;
@(posedge clk) disable iff (!rst_n)
acc_valid |-> ($countones({is_hit, is_miss, is_conflict}) == 1);
endproperty
assert property (p_exactly_one_class);
// P2 -- no class is reported without a valid access.
property p_no_class_without_access;
@(posedge clk) disable iff (!rst_n)
!acc_valid |-> !(is_hit || is_miss || is_conflict);
endproperty
assert property (p_no_class_without_access);
// P3 -- the counts partition the total. The sum property, in this
// chapter's terms.
property p_counts_sum_to_total;
@(posedge clk) disable iff (!rst_n)
counts_valid |-> ((n_hit + n_miss + n_conflict) == n_total);
endproperty
assert property (p_counts_sum_to_total);
// P4 -- THE cross-check. The incrementally accumulated cost equals the
// closed-form weighting of the counts. Two routes to one number, so a
// disagreement is a real defect rather than a restated identity.
property p_cost_matches_closed_form;
@(posedge clk) disable iff (!rst_n)
counts_valid
|-> (row_work_cycles == (n_miss * COST_W'(COST_MISS)
+ n_conflict * COST_W'(COST_CONFLICT)));
endproperty
assert property (p_cost_matches_closed_form);
// P5 -- a hit requires the bank's row to have been open and matching.
// The state discipline: a hit against an invalid row is the flattering
// error §13 warns about.
property p_hit_requires_open_matching_row;
@(posedge clk) disable iff (!rst_n)
is_hit |-> (row_valid[acc_bank] && (open_row[acc_bank] == acc_row));
endproperty
assert property (p_hit_requires_open_matching_row);
// P6 -- the non-hit run resets on a hit. §4's structure measure: a run
// that survived a hit would merge two clusters and understate nothing
// while overstating the worst.
property p_run_resets_on_hit;
@(posedge clk) disable iff (!rst_n)
(acc_valid && is_hit) |=> (cur_nonhit_run == '0);
endproperty
assert property (p_run_resets_on_hit);
// P7 -- a stride is valid only at full confidence. §11's callout: one
// observation is not a measurement.
property p_valid_only_at_full_confidence;
@(posedge clk) disable iff (!rst_n)
stride_valid |-> (confidence == CONF_W'(CONFIRM));
endproperty
assert property (p_valid_only_at_full_confidence);
// P8 -- confidence resets on a break, so a stale confirmation cannot
// keep a stride valid after the pattern changed.
property p_confidence_resets_on_break;
@(posedge clk) disable iff (!rst_n)
(acc_valid && have_delta && (delta_now != cur_delta))
|=> (confidence == '0);
endproperty
assert property (p_confidence_resets_on_break);
// P9 -- confidence never exceeds CONFIRM, so it is a bounded and
// comparable measure.
property p_confidence_bounded;
@(posedge clk) disable iff (!rst_n)
(confidence <= CONF_W'(CONFIRM));
endproperty
assert property (p_confidence_bounded);
// P10 -- a confirmation and a break are mutually exclusive on one
// access, so n_confirm + n_break never exceeds the accesses seen.
property p_confirm_xor_break;
@(posedge clk) disable iff (!rst_n)
(acc_valid && have_delta)
|=> ((n_confirm != $past(n_confirm, 1)) != (n_break != $past(n_break, 1)));
endproperty
assert property (p_confirm_xor_break);
// ── Cover.
cover property (@(posedge clk) disable iff (!rst_n) is_hit);
cover property (@(posedge clk) disable iff (!rst_n) is_conflict);
// §4's SPREAD pattern: a worst non-hit run of exactly 1 with many
// accesses -- misses isolated between hits.
cover property (@(posedge clk) disable iff (!rst_n)
counts_valid && (worst_nonhit_run == 8'd1) && (n_total > 24'd32));
// §4's CLUSTERED pattern: a long run of consecutive non-hits.
cover property (@(posedge clk) disable iff (!rst_n)
worst_nonhit_run >= 8'd8);
// §5's pathological case: 0% hit rate, all conflicts.
cover property (@(posedge clk) disable iff (!rst_n)
counts_valid && (n_hit == '0) && (n_conflict > 24'd16));
// A precharge-all with several banks open -- the state-clearing path.
cover property (@(posedge clk) disable iff (!rst_n)
pre_valid && pre_all && (banks_open > BKC_W'(1)));
cover property (@(posedge clk) disable iff (!rst_n) stride_valid);
// §11's interleaved streams: the alternating signature.
cover property (@(posedge clk) disable iff (!rst_n) alternating_seen);P4 is the property worth keeping. It checks an incremental accumulation against a closed-form weighting of the same events — two genuinely different computations of one quantity, so agreement is evidence. A single-route implementation could be consistently wrong and have nothing to disagree with.
P5 is the one that catches the flattering failure. A hit reported against a bank whose row was closed inflates the hit rate and understates row work, and it is exactly what happens when the precharge input of §13 is left unconnected.
17. What to Report, and Why Three Numbers
This chapter has produced three measurements of one access stream, and §7's callout said each is blind to what the others see. It is worth closing with a concrete recommendation, because the practical failure in performance work is not a wrong measurement — it is a report containing one number where three are needed, and a reader who does not know which two are missing.
Report one: the class pair. Not the hit rate — the pair (misses, conflicts). §6 established why: a single ratio collapses two classes whose costs differ by 1 + tRP/tRCD, which at this bin is exactly 2×, and the same 0% hit rate can cost 2816 or 5456 cycles. From the pair, §2's closed form gives row-work cycles directly. From the ratio, nothing follows.
Report two: the non-hit run length. §4 showed two mappings producing identical class counts — 240/16/0 both — with opposite temporal structure, one spreading misses at one per sixteen accesses and the other clustering sixteen together. The pair cannot distinguish them. The worst run can, and the distinction decides tFAW exposure and therefore bandwidth from identical counts.
Report three: the alternation pair. §15 showed two streams touching the same three bank groups and paying 44 versus 53 cycles of column spacing. Neither the class pair nor the run length sees it, because row work is identical and the difference is entirely in tCCD spacing.
Together those five numbers — two counts, one run length, two more counts — are sufficient in a specific sense worth stating: from them, the row-work and column-spacing contributions to a window's cycles both follow in closed form, with no latency measurement and no division.
The diagnostic sequence that follows from all of it, and the reason the three metrics are worth carrying separately:
| If the report shows | Look at | Owned by |
|---|---|---|
| Conflicts dominating, one stream | Stride against gcd with blocks-per-row | §10, §18 |
| Conflicts dominating, several streams | Bank collision between streams | §8 |
| Good pair, long non-hit runs | tFAW exposure from clustered activation | §4, 16.3 |
| Good pair, short runs, poor bandwidth | Bank-group alternation | §15 |
| Good on all five, poor bandwidth | Not locality — turnaround or refresh | 23.2 §7, 23.2 §6 |
The last row is the one that saves the most time, and it is the reason a locality report needs to be complete enough to exonerate locality. A report that only counts classes can never say “this is not a locality problem” — it can only say the classes look acceptable, which is weaker and invites the investigation to continue in the wrong place.
18. Corner Cases
| Case | Behaviour | Why |
|---|---|---|
| First access to a bank | MISS — no row open | row_valid low out of reset |
| Access after a precharge to that bank | MISS, not a conflict | The row was closed, so nothing to conflict with |
| Precharge to an already-closed bank | err_pre_unopened | A redundant precharge, worth reporting |
| Precharge-all with 16 banks open | All cleared; the fifth cover fires | The pre_all path |
| Precharge and access in the same cycle | Precharge applied first | Otherwise the access classifies against a closed row |
| Precharge input unconnected | Hit rate inflates silently | P5 catches it; §13 says it is not optional |
| All accesses to one open row | 1 miss then all hits, worst_nonhit_run == 1 | §4's spread extreme |
| Stride equal to the page size | Every access a conflict — §5, §10 | The pathological stride |
COST_CONFLICT <= COST_MISS | $fatal at elaboration | A conflict does strictly more work; anything else is not a cost model |
| Single access, then a stride query | stride_valid low, confidence 0 | One observation is not a measurement |
| Two interleaved streams | stride_valid low, alternating_seen set | The limitation is reported, not guessed past |
| Backward scan | Negative stride, correctly confirmed | Unsigned arithmetic would report a break every access |
| Counter saturation | counts_valid low | Every ratio and the cost are then understated |
Row six deserves the emphasis it gets. An unconnected precharge input produces a monotonically rising hit rate on any workload, because rows are only ever opened and never closed in the model. It looks like excellent locality, it is entirely an artefact, and P5 is the only thing that catches it.
19. DV — Replay the Simulations
The checker's job is to reproduce §3's and §5's published results from the RTL, using an independent classifier that does not share the DUT's state machine.
// Independent classifier. Keeps its own open-row map and classifies by
// associative lookup rather than by indexed per-bank registers -- a
// different structure, so agreement is evidence.
// SIMULATION-ONLY.
class locality_ref;
int unsigned open_row [int]; // bank -> row, absent = closed
int unsigned n_hit, n_miss, n_conf;
int unsigned cost_miss, cost_conf;
int unsigned run_cur, run_worst;
function void access(int unsigned bank, int unsigned row);
if (!open_row.exists(bank)) begin n_miss++; open_row[bank]=row; bump(0); end
else if (open_row[bank] == row) begin n_hit++; run_cur = 0; end
else begin n_conf++; open_row[bank]=row; bump(0); end
endfunction
function void bump(int unused);
run_cur++;
if (run_cur > run_worst) run_worst = run_cur;
endfunction
function void precharge(int unsigned bank);
if (open_row.exists(bank)) open_row.delete(bank);
endfunction
function int unsigned row_work();
return n_miss*cost_miss + n_conf*cost_conf;
endfunction
endclass| Check | What it establishes |
|---|---|
| §3 sequential, column-then-bank: expect 240/16/0 and 176 cycles | The published result, from the RTL |
| §3 sequential, bank-then-column: expect the same 240/16/0 and 176 | The surprise of §3, confirmed |
§4: worst_nonhit_run is 1 for column-then-bank and 16 for bank-then-column | The structure the counts cannot carry |
| §5 strided, column-then-bank: expect 0/16/240 and 5456 cycles | The pathological case |
| §5 strided, bank-then-column: expect 240/1/15 and 341 cycles | The 16× ratio |
| Ratio of the two: expect exactly 16.0 | The headline result, recomputed |
| §6: 256 all-misses gives 2816; compare with 5456 | The miss-versus-conflict factor |
| Leave the precharge input unconnected; expect a rising hit rate and P5 to fire | §18's row six |
| Sweep stride from 1 to 32; record accesses-per-row against §10's formula | The general expression |
Interleave two strides; expect stride_valid low and alternating_seen set | §11's admitted limitation |
The ninth check produces the result that generalises the chapter:
ACCESSES PER ROW VERSUS STRIDE — column-then-bank, R = 16 blocks/row
formula from §10: max(1, R / gcd(s, R)) for s < R; 1 for s a
multiple of R.
stride gcd(s,16) predicted measured class sequence
------ --------- --------- -------- --------------------
1 1 16 16 1 miss, 15 hits
2 2 8 8 1 miss, 7 hits
3 1 16 16 1 miss, 15 hits
4 4 4 4 1 miss, 3 hits
8 8 2 2 1 miss, 1 hit
16 16 1 1 EVERY ACCESS A CONFLICT
32 16 1 1 EVERY ACCESS A CONFLICT
all seven agree.
the interesting row is stride 3: gcd(3,16) = 1, so a stride of
three visits all 16 columns of a row before leaving it -- SAME
locality as a stride of one, despite skipping two blocks in three.
a stride being large is not what hurts; being a MULTIPLE OF THE
PAGE is.
diagnosis of the common mistake : "large strides hurt locality" is
wrong as stated. Stride 3 is fine and stride 16 is catastrophic,
and 16 is the smaller relative to the row.
what actually matters : gcd(stride, blocks-per-row), which is a
number nobody's intuition computes.That table is the most useful output of this chapter's verification, and it corrects a genuinely widespread intuition. A stride of 3 has the same locality as a stride of 1, and a stride of 16 destroys it — so the property that matters is the greatest common divisor with the row length, not the magnitude.
20. Debugging
| Symptom | Likely cause | How to confirm |
|---|---|---|
| Hit rate rises monotonically to 100% | Precharge input unconnected — §18 | P5; rows are opened and never closed in the model |
| 0% hit rate, all conflicts | Stride is a multiple of the page — §5, §10 | The stride detector; check gcd(stride, blocks-per-row) |
| 0% hit rate, all misses | Banks are being closed between accesses | Not a locality problem; page policy — 23.5 |
| Hit rate good, bandwidth poor | Concentration — §4 | worst_nonhit_run; and 16.3 owns the pathology |
| Two mappings, identical counts | Expected on a sequential walk — §3 | Not a measurement fault; compare structure, not totals |
| Cost disagrees with counts | Incremental and closed-form diverge | P4; one of the two routes is wrong |
| Large strides assumed harmful | The intuition is wrong — §19 | gcd with the row length is what matters |
stride_valid never asserts | Interleaved streams, or a genuinely irregular pattern | alternating_seen distinguishes them |
| Stride reported as a huge positive number | Unsigned delta on a backward scan — §14 | The $signed casts |
err_pre_unopened frequent | Redundant precharges from the scheduler | Wasteful rather than wrong; 23.5 |
| Row work high, hit rate high | Conflicts are expensive — §6 | The (misses, conflicts) pair, not the ratio |
| Counts saturate quickly | CNT_W too narrow for the observation window | counts_valid low; every derived figure understates |
Row seven is the one worth internalising because it contradicts a widely held belief. “Large strides hurt locality” is false as stated — §19's table shows a stride of 3 behaving identically to a stride of 1, while a stride of 16 is catastrophic. The quantity that matters is the greatest common divisor with the blocks per row.
Row four routes to 16.3 deliberately: a good hit rate with poor bandwidth is the concentration pathology, that chapter owns it, and §4 supplies the structural measurement it needs.
21. Misconceptions
“A hit rate summarises row-buffer behaviour.” §6. The same 0% hit rate costs 2816 or 5456 cycles depending on the miss-versus-conflict split. The sufficient statistic is the pair.
“A sequence has a hit rate.” §3. A sequence plus a mapping plus a bank state has one. Change the mapping and the counts change without changing a single request.
“One mapping is better than another.” §5. The winner flips: identical on a sequential walk, and bank-then-column wins by 16× on a page-strided one.
“Identical class counts mean identical behaviour.” §4. Two mappings produce the same 240/16/0 with opposite temporal structure — one spreads misses, one clusters them — and they expose tFAW completely differently.
“Clustered misses are worse than spread ones.” §4's callout. Clustering activates 16 banks and enables 23.2's 100% model; spreading keeps one bank busy and avoids tFAW. Neither is universally better.
“Large strides hurt locality.” §19. A stride of 3 behaves exactly like a stride of 1. A stride of 16 — smaller relative to nothing in particular — destroys it. gcd(stride, blocks-per-row) is the quantity.
“A conflict is roughly a miss.” §2. It is exactly tRP more, which at this bin is precisely double. A cost model that treats them alike understates by up to 2×.
“A conflict costs twice a miss.” §2's callout. At this bin, because tRP = tRCD. The general ratio is 1 + tRP/tRCD.
“You can measure reuse in a controller.” §11. A true reuse distance needs an address history nobody can afford. What is affordable is per-bank state, which is exact, and a stride estimate, which is not.
“A stride detector reports the workload's stride.” §11 and §14. It reports a repeated delta. Two interleaved streams defeat it, and alternating_seen says so rather than guessing.
“A detected stride is actionable.” §11's callout. Not without its confidence. One observation is a coincidence, which is why stride_valid requires full confirmation.
“The monitor needs no precharge input.” §18's row six. Without it the hit rate rises to 100% on every workload, and it looks like excellent locality.
22. Interview Reasoning
Does a request sequence have a row-hit rate? No. A sequence plus an address mapping plus a bank state has one. The same requests under two mappings produce different counts, which is why mapping is a policy decision.
Compute row work from class counts. tRCD × misses + (tRP + tRCD) × conflicts. At DDR4-1600K that is 11 × misses + 22 × conflicts, and it needs no latency measurement — the counts are sufficient.
Why is a hit rate insufficient? It collapses misses and conflicts, which cost differently — at this bin, by exactly 2×. Two workloads at 0% hit rate can differ by nearly a factor of two in row work.
Two mappings give identical hit, miss and conflict counts. Are they equivalent? No. They can have opposite temporal structure — one spreading misses one per sixteen accesses, the other clustering sixteen together — which exposes tFAW completely differently and yields different bandwidth from identical counts.
Which of those two is better? It depends on what follows. Clustered activation opens many banks and enables consecutive accesses in different bank groups, which is the only pattern that reaches peak. Spread activation keeps one bank busy and never approaches tFAW. The scheduler and the page policy decide.
What stride is pathological? One that is a multiple of the page size, because it lands every access in a different row of the same bank — every access a conflict. Magnitude is not the issue; the greatest common divisor with the blocks per row is.
So is a stride of 3 bad? No. gcd(3, 16) = 1, so it visits all sixteen columns of a row before leaving — the same locality as a stride of 1, despite skipping two blocks in three.
How would you measure locality in a controller? Per-bank open-row registers and a comparator: exact, one cycle, and it is state the controller already keeps to schedule correctly. Reuse distance is not affordable; a stride estimate is, provided it publishes its confidence.
Your locality monitor reports a hit rate climbing to 100%. What is wrong? The precharge input is almost certainly unconnected, so rows are opened and never closed in the model. Every closed row still registers as open, and a genuine miss reports as a hit.
Why must a stride detector report confidence? Because one repeated delta is one observation. Acting on it is acting on a coincidence — and two interleaved streams present an alternating delta that a confidence-free detector would report as a stride change on every access.
23. Exercises
-
Recompute §3's and §5's four results for a 2KB page — 32 blocks per row — keeping 16 banks and 256 accesses. Which of the four changes, and does the 16× ratio of §5 grow or shrink? Explain from §10's formula.
-
§2's callout says a conflict costs exactly twice a miss only at this bin. Derive the general ratio, then compute it for DDR4-1866M where
tRCD = tRP = 13.92 ns. Is the doubling a coincidence of the bin or of DDR4's bin structure generally? -
Verify §19's formula
max(1, R / gcd(s, R))forR = 16and every stride from 1 to 20 by simulation. Identify every stride that is as good ass = 1, and state what they have in common. -
§5 claims bank-then-column relocates the pathological stride rather than eliminating it. Find the pathological stride for that mapping with
R = 16and 16 banks, and confirm it by simulation. -
Disconnect the precharge input in §13 and run §3's sequential walk. Report the hit rate after 256, 1024 and 4096 accesses. At what point would the artefact become obvious without P5, and what does that say about relying on plausibility?
-
§13's cost accumulates incrementally and P4 checks it against the closed form. Construct the implementation bug that breaks P4 while leaving P3 intact, and explain why two routes to one number is a stronger check than one.
-
Extend §14's detector to track two independent streams, each with its own previous address and delta. What state does it cost, and which of §11's limitations does it remove? Which remains?
-
Using §4's two class strings, compute each mapping's exposure to
tFAWover the first 40 accesses, given 23.2 §3's verifiedtFAW = 20cycles admitting four activates. State which mapping stalls and by how many cycles, and confirm that the class totals cannot predict it.
24. Where This Goes
Locality now has a cause and a structure. Class counts are a function of the sequence, the mapping and the bank state — not of the sequence alone. The cost follows in closed form from the (miss, conflict) pair, which makes 9.6's counters a performance instrument. And identical counts can hide opposite temporal structure, which is why worst_nonhit_run exists and why the counts alone cannot predict tFAW exposure.
Two results transfer directly. Mapping matters by 16× on the wrong pattern and not at all on the right one, so no mapping is optimal and 18.2's policy framing is correct. And the pathological property is gcd(stride, blocks-per-row), not stride magnitude — which corrects an intuition most engineers carry.
What this chapter has consistently deferred is the decision. It can say a conflict cost 22 cycles; it cannot say whether the scheduler should have serviced a different request first, or whether the row should have been left open at all. §16's closing callout was explicit: worst_nonhit_run measures clustering and does not say whether clustering is wanted.
Chapter 23.4 takes the first of those. Chapter 17.1 built the commit point and said outright that “legality admits; policy chooses” — and that a real scheduler batches to amortise turnarounds, calling it “a policy dimension 17.4 opens and Module 23 quantifies.” That is the quantification: which heuristic to choose, what it gains in the throughput of 23.2, what it costs in the latency of 23.1, and how to prove it does not leave a request waiting forever — because 23.1 §8 established that queue wait is the one component with no upper bound.
Continue learning
Related tutorials
- Related topic
Row-Buffer Question
Three outcomes, not two — and the cost of an access is a property of the stream rather than of the access. Includes the starvation case the optimisation itself produces, and a property that proves something adjacent to its claim.
- 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.
- Related topic
Sense Amplifiers
How a tiny analog disturbance becomes a reliable digital value, why the same operation simultaneously creates a temporary copy of the whole row, and why a selected row is not yet a usable row — the state distinction every memory controller is built around.
- Related topic
DDR4
DDR4 is the generation where prefetch depth stops changing. With granularity already at a cache line, the rate had to come from overlapping independent accesses instead — which is what bank groups are, and why peak bandwidth became conditional on the access pattern.
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.
