DDR · Module 17
The Command Scheduler
A DDR command scheduler does not schedule requests. It re-derives each outstanding request's next required command every cycle, and advances architectural state only at the commit point where a command is genuinely issued.
Sixteen modules have built the device's side of the contract: what a bank is, what a command means, what timing permits, what refresh obliges. This module builds the other side — the digital block that has to satisfy all of it, every cycle, for many outstanding requests at once.
It opens with the question the whole module turns on, and the answer is not the obvious one:
A DDR command scheduler does not schedule requests. It schedules commands — and the commands do not exist until the scheduler derives them, every cycle, from request state that keeps changing underneath it.
That distinction sounds pedantic until you try to build one. Then it decides your entire architecture.
1. Five Requests, One Issue Slot
Start where a scheduler actually starts: with more work than it can do, and no obvious ordering.
Five requests are outstanding. Bank state is what it is — nobody arranged it conveniently.
BANK STATE
bank 0 OPEN, row 12
bank 1 OPEN, row 40
bank 2 CLOSED
bank 3 OPEN, row 7
bank 4 PRECHARGING
OUTSTANDING REQUESTS
R0 READ bank 0, row 12 arrived cycle 100
R1 READ bank 1, row 55 arrived cycle 102
R2 WRITE bank 2, row 19 arrived cycle 103
R3 READ bank 3, row 7 arrived cycle 111
R4 WRITE bank 4, row 2 arrived cycle 112Before asking which one to serve, ask what each one needs next — because they do not all need the same kind of thing. Chapter 9.3's classification gives the answer:
| Req | Bank state | Row | Class | Next command needed |
|---|---|---|---|---|
| R0 | open, row 12 | match | row hit | column RD |
| R1 | open, row 40 | differs | row conflict | PRE |
| R2 | closed | — | closed bank | ACT |
| R3 | open, row 7 | match | row hit | column RD |
| R4 | precharging | — | bank busy | nothing yet |
Four different next commands across five requests — and one of them wants nothing at all.
2. Eight Questions per Request
The table in §1 answered only the first question. A scheduler must answer eight, and they are not interchangeable — each can independently veto.
Add the timing state §1 left out, because it is where two of the five requests actually die:
TIMING STATE at cycle 120
bank 0 last column command cycle 118 (tCCD_L not yet met)
bank 1 row 40 activated cycle 95 (tRAS met — PRE allowed)
bank 2 last ACT anywhere cycle 119 (tRRD not yet met)
bank 3 row 7 activated cycle 119 (tRCD NOT met)
bank 4 PRE issued cycle 117 (tRP not yet met)
REFRESH pending, not urgent — normal issue still allowedNow every question, for every request. EDUCATIONAL TIMING — NOT JEDEC VALUES.
| Question | R0 | R1 | R2 | R3 | R4 |
|---|---|---|---|---|---|
| 1. Request valid? | yes | yes | yes | yes | yes |
| 2. Next command needed? | RD | PRE | ACT | RD | none |
| 3. State legal? | yes | yes | yes | yes | no — bank busy |
| 4. Timing legal? | no — tCCD | yes | no — tRRD | no — tRCD | n/a |
| 5. Resource legal? | yes | yes | yes | yes | n/a |
| 6. Refresh permits? | yes | yes | yes | yes | n/a |
| 7. Candidate legal? | no | YES | no | no | no |
| 8. Policy preference | — | only candidate | — | — | — |
| Issued? | no | YES — PRE | no | no | no |
The three failure modes in that table are genuinely different, and a controller that cannot tell them apart cannot be debugged.
- R4 fails at question 3: state. Nothing it does will help; it needs the device to finish precharging. Waiting is the only action.
- R0, R2, R3 fail at question 4: timing. Each will become legal at a computable future cycle — this is Chapter 13.1 §6's distinction between a refusal that needs work and one that needs only patience.
- Nothing fails at questions 5 or 6 this cycle. Both matter enormously when they do fire, and both are invisible in a controller that folds all six questions into one
readybit.
Now change one thing. Make refresh urgent:
| Question | R0 | R1 | R2 | R3 | R4 | REF |
|---|---|---|---|---|---|---|
| 2. Next command | RD | PRE | ACT | RD | none | REF |
| 4. Timing legal? | no | yes | no | no | n/a | — |
| 6. Refresh permits? | no | no | no | no | n/a | yes |
| Candidate legal? | no | no | no | no | no | YES |
| Issued? | no | no | no | no | no | YES — REF |
R1 was the only legal candidate and is now blocked — not by anything about R1, but because question 6 turned off for every normal command at once. This is the refresh gate of Chapter 17.3, and it is the one veto that applies globally rather than per request.
Note what does not happen: refresh does not win an arbitration against R1. It is not a competitor with higher priority. R1 stops being a legal candidate at all, and REF becomes the only one. Legality changed; policy was never consulted. Modelling refresh as a high-priority request in the arbiter is §16's misconception and produces a controller that occasionally issues normal traffic into a refresh window.
3. One Request Is Many Commands
R1 is a single transaction. Serving it takes a sequence:
R1 (one READ request, bank 1, row 55)
PRE bank 1 close row 40
↓ wait tRP
ACT bank 1, row 55 open the wanted row
↓ wait tRCD
RD bank 1, col c launch the column access
↓ wait CL
data burst returns
↓
completionThree commands, three waiting periods, then a data phase — for one request. Meanwhile R0 needs exactly one command, and R4 needs none.
This is where the first genuinely tempting architecture appears, and it is worth taking seriously because it is not stupid.
This is why Chapter 17.2 is titled the command queue but is really about request entries: the storage holds intent, and the command is recomputed.
4. The Pipeline
Here is the spine. Every remaining chapter of Module 17 owns one box of it.
Two features of this drawing are the whole architecture.
It is a loop, not a line. State updates feed back into derivation. That is why §3's pre-expansion fails: the derivation stage must see the current loop state, not a snapshot taken at admission.
Legality and policy are different boxes, and their order is fixed. Chapter 16.2 §3 established why at vector scale: an arbiter that also decides legality cannot report why nothing issued, and makes its own input depend on its own previous behaviour. Module 17 inherits that discipline rather than re-arguing it.
5. What This Chapter Does Not Build
An honest map, because a new module is not permission to grow a second copy of the architecture.
| Pipeline stage | Already owned by | Block | Module 17 does |
|---|---|---|---|
| Row classification | 9.3 | row_request_classifier | consume |
| Bank state | 5.2 | ddr_bank_state_table | consume |
| Three legality layers | 13.1 | command_legality_layers | consume |
| Per-bank legal vector | 16.2 | bank_candidate_mask | consume |
| Timing deadlines | 13.3 | deadline_scoreboard | consume |
| Issued-stream checking | 13.4 | issue_timing_checker | reuse as a checker |
| Refresh obligation accounting | 15.3 | refresh_credit_ledger | wrap in 17.3 |
So what is left? Exactly one thing in this chapter, and it is the thing none of those blocks can do: decide when the whole machine is allowed to change its mind about reality.
6. Request Progress Is a State Machine
Each entry in the pool carries a progress state. The derivation stage reads it together with bank state; the commit stage advances it.
Read the transition labels carefully. Not “PRE granted”. Not “PRE is a candidate”. “PRE commits” — and §7 is about why those three are not the same event.
Note also that WAITING is not a queue position. It is a classification pending: the entry is resident, and its class is recomputed against bank state until it resolves. An entry can be re-evaluated many times before generating its first command.
7. The Commit Point
Here is the central discipline of the module.
Three things happen in sequence, and each is weaker than the next:
CANDIDATE "this entry's next command is ACT bank 2"
← speculative. Many entries produce one each cycle.
GRANT "policy prefers entry 2's ACT this cycle"
← intent. At most one. Still has not happened.
COMMIT "the ACT was issued and the device interface took it"
← fact. THE ONLY EVENT PERMITTED TO CHANGE STATE.Only the third may update anything. Not bank state, not timing history, not refresh accounting, not entry progress.
The commit condition itself is small:
commit = grant_valid && phy_cmd_ready && normal_issue_allowedThree terms, three owners. grant_valid from 17.4. phy_cmd_ready from the interface (Modules 19–21 own what is behind it). normal_issue_allowed from 17.3. The scheduler owns none of the three inputs and all of the consequence.
8. Who Owns Which State
Duplicated ownership is the most expensive class of controller bug, because both copies look right in isolation and the divergence only appears under a sequence nobody wrote a test for. So name the owner of every piece of architectural state once, and make every other block a reader.
| State | Owner | Updated on | Read by | Must not be updated on |
|---|---|---|---|---|
| Open row per bank | 5.2 bank state | commit of ACT / PRE | derivation, legality | candidate generation; grant |
| Bank busy / transitioning | 5.2 bank state | commit + elapsed time | derivation, legality | grant |
| Timing history per obligation | 13.3 deadline ledger | commit of the constrained command | legality filter | any non-committed candidate |
| Legal-candidate vector | 16.2 | recomputed combinationally | arbitration | never stored across cycles |
| Entry valid / metadata | 17.2 pool | ingress accept; completion | derivation, arbitration | grant |
| Entry progress | 17.2 pool | commit via advance_onehot | derivation | grant; candidacy |
| Refresh debt and urgency | 15.3 ledger, wrapped by 17.3 | commit of REF; interval tick | refresh gate, arbitration | grant of REF |
| Refresh busy / gate | 17.3 manager | commit of REF; occupancy expiry | commit stage, arbitration | candidacy |
| Arbitration pointer | 17.4 arbiter | commit — see below | arbitration | grant alone |
| Commit counters | this chapter | commit / dropped grant | debug only | — |
The table also settles a question that comes up constantly in review: may the derivation stage cache anything? No. Every entry in the right-hand column of that table is a cached derivation someone was tempted by. The legal-candidate vector in particular is explicitly marked never stored across cycles — §3's staleness argument applies to it with full force, and it is recomputed rather than registered for exactly that reason.
9. The Commit Block
One responsibility, deliberately. It does not arbitrate, does not check legality, does not store entries. It converts intent into fact and publishes exactly who is permitted to advance.
// ─────────────────────────────────────────────────────────────────────
// command_issue_commit
//
// CLASSIFICATION
// Synthesizable educational RTL. One architectural responsibility:
// turning an arbitration GRANT into a COMMIT, and publishing the
// one-hot state-advance permission that — and only that — follows.
//
// WHAT IT DOES NOT MODEL
// - No arbitration policy. grant_* is an input (Chapter 17.4).
// - No legality. A grant is assumed already legal (13.1, 16.2).
// - No entry storage or progress state. That is Chapter 17.2's pool;
// this block only says WHO MAY advance, never advances anything.
// - No refresh accounting. normal_issue_allowed is an input (17.3).
// - No PHY. phy_cmd_ready is an abstract digital contract (19-21) —
// no DQS, no training, no delay lines.
// - No DDR command encoding. Commands stay symbolic; Module 7 owns
// the physical encoding.
// ─────────────────────────────────────────────────────────────────────
module command_issue_commit #(
parameter int NUM_ENTRIES = 8,
// Index into the entry pool. Guarded: $clog2(1) is 0, and a
// zero-width signal is not legal.
parameter int EN_W = (NUM_ENTRIES <= 1) ? 1 : $clog2(NUM_ENTRIES),
// Observability counters. Width is a REPORTING choice: nothing
// compares against them, so a wrap can only make a printed number
// ambiguous — it cannot produce a functional bug.
parameter int CNT_W = 16
) (
input logic clk,
input logic rst_n,
// ── Arbitration's answer. INTENT. Chapter 17.4 produces it from a
// legal-candidate mask; by contract it is already legal, and this
// block does not re-check that (§11's P4 checks the contract).
input logic grant_valid,
input logic [EN_W-1:0] grant_entry,
// 0 = PRE, 1 = ACT, 2 = column command, 3 = REF.
input logic [1:0] grant_cmd,
// ── The downstream half of the handshake. The command goes out only
// if the interface takes it this cycle.
input logic phy_cmd_ready,
// ── Chapter 17.3's gate. Low while a refresh occupies the device.
input logic normal_issue_allowed,
// ── The issued command. Combinational: it leaves the same cycle the
// commit happens, because the commit IS the issue.
output logic issue_valid,
output logic [EN_W-1:0] issue_entry,
output logic [1:0] issue_cmd,
// ── THE COMMIT POINT. Identical to issue_valid by construction, and
// published separately because every consumer of "may I change
// state?" must name this signal rather than grant_valid. §7.
output logic commit,
// ── One-hot advance permission. Exactly one bit set on a commit to a
// normal command, zero bits otherwise. Chapter 17.2's pool
// consumes this; nothing else may advance an entry.
output logic [NUM_ENTRIES-1:0] advance_onehot,
// ── Observability. A grant that did not commit is not an error — it
// is ordinary backpressure — but it must be VISIBLE, because a
// high dropped rate is the signature of several real bugs (§15).
output logic grant_dropped,
output logic [CNT_W-1:0] commit_count,
output logic [CNT_W-1:0] dropped_count,
// ── Set if a grant names an entry outside the pool. A design error,
// surfaced rather than silently masked by the index width.
output logic grant_index_error
);
// ── Parameter legality, resolved at elaboration. A zero-entry pool
// has no meaning and would make advance_onehot zero-width.
if (NUM_ENTRIES < 1)
$fatal(1, "command_issue_commit: NUM_ENTRIES must be >= 1");
if (CNT_W < 1)
$fatal(1, "command_issue_commit: CNT_W must be >= 1");
localparam logic [1:0] CMD_REF = 2'd3;
// ── A REF is not owned by an entry. Chapter 17.3 requests it on
// behalf of the device, not on behalf of a request, so it commits
// without advancing anything in the pool.
logic grant_is_ref;
assign grant_is_ref = (grant_cmd == CMD_REF);
// ── The three terms of §7. Note what is NOT here: no legality
// re-check, no policy, no queue inspection.
// A REF is exempt from normal_issue_allowed for the obvious
// reason — that gate exists to hold NORMAL traffic off while
// refresh proceeds, and gating REF with it would deadlock.
assign commit = grant_valid
&& phy_cmd_ready
&& (normal_issue_allowed || grant_is_ref);
assign issue_valid = commit;
assign issue_entry = grant_entry;
assign issue_cmd = grant_cmd;
assign grant_dropped = grant_valid && !commit;
// ── Index sanity. Only meaningful when the pool is not a power of
// two; when it is, EN_W cannot represent an out-of-range value and
// this is constant-false, which is correct rather than dead.
assign grant_index_error =
grant_valid && !grant_is_ref && (int'(grant_entry) >= NUM_ENTRIES);
// ── The one-hot advance permission. Built by comparison rather than
// by a shift so that an out-of-range index yields ZERO bits set
// rather than an aliased bit — failing safe, and visible through
// grant_index_error above.
always_comb begin
advance_onehot = '0;
if (commit && !grant_is_ref) begin
for (int i = 0; i < NUM_ENTRIES; i++)
if (int'(grant_entry) == i) advance_onehot[i] = 1'b1;
end
end
// ── Observability counters. Saturating, so a long run cannot wrap a
// reported figure back through zero and suggest the rate fell.
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
commit_count <= '0;
dropped_count <= '0;
end else begin
if (commit && !(&commit_count))
commit_count <= commit_count + CNT_W'(1);
if (grant_dropped && !(&dropped_count))
dropped_count <= dropped_count + CNT_W'(1);
end
end
endmoduleWhat the block deliberately lacks. There is no always_ff holding a grant across a stall. A dropped grant is simply not committed, and arbitration re-presents it next cycle from candidates re-derived against current state. Latching it would recreate §3's staleness bug at a smaller scale: a held grant is a frozen decision, and by the time the interface accepts it the state it was computed against may be gone.
10. Cycle-by-Cycle
EDUCATIONAL TIMING — NOT JEDEC VALUES. The magnitudes are chosen to make the mechanism visible in ten cycles; real spacings come from Module 14.
Entry 1 holds R1 from §1 — bank 1 currently open on row 40, wanted row 55, so a row conflict needing PRE then ACT then RD. The interface stalls in the middle, and refresh intervenes at the end.
cyc grant entry cmd phy_rdy norm_ok commit advance progress after
─── ───── ───── ─── ─────── ─────── ────── ─────── ──────────────
200 1 1 PRE 1 1 YES 0b0010 NEEDS_ACT
201 0 - - 1 1 no 0b0000 NEEDS_ACT
202 0 - - 1 1 no 0b0000 NEEDS_ACT (tRP)
203 1 1 ACT 0 1 no 0b0000 NEEDS_ACT ←
204 1 1 ACT 1 1 YES 0b0010 NEEDS_COL
205 0 - - 1 1 no 0b0000 NEEDS_COL
206 1 1 RD 1 0 no 0b0000 NEEDS_COL ←
207 1 1 RD 1 0 no 0b0000 NEEDS_COL
208 1 1 RD 1 1 YES 0b0010 DATA
209 0 - - 1 1 no 0b0000 DATATwo cycles carry the entire lesson, and they fail for different reasons:
Cycle 203 — the interface refused. grant_valid is high, the command is legal, policy chose it. phy_cmd_ready is low, so no command left the controller. Progress stayed at NEEDS_ACT. Had it advanced on the grant, the controller would believe bank 1 holds row 55 and would issue RD at cycle 204 to a bank that is still closed — the ghost activate of §7, and the resulting protocol violation would appear to originate at the RD.
Cycles 206–207 — refresh held the gate. Everything about the RD is fine; normal_issue_allowed is low because 17.3's manager has the device occupied. Same non-commit, entirely different cause — and grant_dropped counts both identically, which is exactly why §15's debugging procedure needs a second signal to separate them.
dropped_count reaches 3 over the trace; commit_count reaches 3. A healthy controller has a nonzero dropped count — it means arbitration is optimistic and the interface is applying real backpressure. A dropped count of zero under load is the suspicious reading, not a high one.
11. What the Assertions Prove
Where these live. The properties below are written for the block's own clk and rst_n, which command_issue_commit has, so they may sit inside the module or in a bind unit. The DV model of §12 is separate from all of them.
// ── P1. The commit condition is exactly §7's three terms. This looks
// tautological against the RTL, and it is — deliberately. Its value
// is as a REGRESSION anchor: it is the property that fails the day
// somebody adds a fourth term, or latches a grant across a stall.
property p_commit_definition;
@(posedge clk) disable iff (!rst_n)
commit == (grant_valid && phy_cmd_ready
&& (normal_issue_allowed || grant_cmd == 2'd3));
endproperty
a_commit_definition: assert property (p_commit_definition);
// ── P2. THE central property of the module. No entry may be given
// advance permission without a commit. This is the one that catches
// every §7 bug at its source rather than three cycles downstream.
property p_no_advance_without_commit;
@(posedge clk) disable iff (!rst_n)
(advance_onehot != '0) |-> commit;
endproperty
a_no_advance_without_commit: assert property (p_no_advance_without_commit);
// ── P3. At most one entry advances per commit. The command interface
// carries one command; two entries progressing from one commit means
// two requests are sharing a single command's effect.
property p_advance_onehot;
@(posedge clk) disable iff (!rst_n)
$countones(advance_onehot) <= 1;
endproperty
a_advance_onehot: assert property (p_advance_onehot);
// ── P4. The contract with Chapter 17.4, stated as an assumption this
// block relies on but does not enforce: a grant names a real entry.
// Written as an assertion on the OUTPUT that detects the violation,
// so a broken arbiter is caught here rather than corrupting the pool.
property p_grant_index_in_range;
@(posedge clk) disable iff (!rst_n)
!grant_index_error;
endproperty
a_grant_index_in_range: assert property (p_grant_index_in_range);
// ── P5. A dropped grant is not lost work: it must be RE-PRESENTED once
// the blockage clears, not silently abandoned.
// NOTE ON THE SHAPE. The blocking condition must be sampled on the
// cycle AFTER the drop, never alongside it. Conjoining grant_dropped
// with phy_cmd_ready and normal_issue_allowed in one cycle yields a
// CONTRADICTION -- those three together imply commit, and commit
// implies !grant_dropped -- so such a property is vacuous and can
// never fail. Written as a two-cycle sequence it is non-vacuous:
// if the stall never lifts, the antecedent never matches and nothing
// is claimed, which is the intended weakness.
property p_dropped_grant_returns;
@(posedge clk) disable iff (!rst_n)
(grant_dropped ##1 (phy_cmd_ready && normal_issue_allowed))
|-> grant_valid;
endproperty
a_dropped_grant_returns: assert property (p_dropped_grant_returns);
// ── Covers. An assertion that never fires on a stimulus that never
// reaches the interesting state proves nothing at all.
c_commit_then_stall: cover property
(@(posedge clk) disable iff (!rst_n) commit ##1 grant_dropped);
c_refresh_blocked: cover property
(@(posedge clk) disable iff (!rst_n)
grant_valid && phy_cmd_ready && !normal_issue_allowed);
c_ref_during_block: cover property
(@(posedge clk) disable iff (!rst_n)
commit && grant_cmd == 2'd3 && !normal_issue_allowed);What they do not prove. Nothing here establishes that the granted command was legal — that is 13.1's and 16.2's contract, verified by 13.4's issue_timing_checker watching the issued stream. Nothing establishes that the right entry was chosen — that is policy, and 17.4 owns it. Nothing establishes progress toward completion: P5 is bounded re-presentation, not liveness. A controller can satisfy every property here and still starve a request forever.
12. The Independent Model
The DV model must not mirror the RTL. If the checker computes commit the same way the design does, it agrees with the bug.
Build it from the observed stream instead. Maintain, independently:
- a per-bank reference state — open or closed, and which row — updated only on an observed issued command;
- a per-entry expected progress state, advanced only on an observed issue naming that entry;
- a timing ledger of last-issue cycles per obligation, reusing 13.3's deadline representation;
- a refresh obligation ledger from 15.3.
Then check the RTL's claims against reconstruction rather than against recomputation. The model never reads grant_valid, commit, or advance_onehot — it reads only what left the controller, which is the same thing the device sees.
A diagnostic that earns its place:
GHOST STATE ADVANCE
cycle : 203
entry : 1 (request R1, bank 1, row 55)
RTL progress before : NEEDS_ACT
RTL progress after : NEEDS_COL
observed issue : none
grant_valid : 1
phy_cmd_ready : 0
commit : 0
model progress : NEEDS_ACT (unchanged — no command observed)
diagnosis : entry advanced on GRANT, not COMMIT
predicted failure : column command at cycle 204 to a CLOSED bank 1
first visible symptom: protocol violation reported at cycle 204The last two lines are what make it worth building. The model names the cycle the corruption began and the cycle the symptom will appear — two cycles apart, which is precisely the gap that makes this bug class expensive to find by inspection.
13. Corner Cases
| Situation | Correct behaviour | Failure if mishandled |
|---|---|---|
grant_valid with phy_cmd_ready low | no commit, no advance, grant re-presented | ghost advance |
normal_issue_allowed low, normal command | no commit | refresh window violated |
normal_issue_allowed low, REF granted | commits — the gate exempts REF | deadlock: refresh can never issue |
NUM_ENTRIES = 1 | EN_W guarded to 1; one-hot is one bit | zero-width signal, elaboration failure |
grant_entry out of range | advance_onehot all zero, error flagged | aliased bit advances the wrong request |
grant and phy_cmd_ready both rise same cycle | commits that cycle | a needless lost slot |
| commit on consecutive cycles | permitted; each advances its own entry | throttling that looks like a timing bug |
| reset during a granted stall | counters clear, nothing commits | phantom commit at reset release |
REF commit | no entry advances; advance_onehot zero | a request advances on somebody else's command |
The REF exemption deserves the emphasis it gets twice. It is the kind of detail that makes a controller hang in a way that looks like a refresh-manager bug when the actual error is one missing term in the commit condition.
14. Synthesis, Cost and What Is Missing
What this block costs. command_issue_commit is a three-input AND, a comparator tree of NUM_ENTRIES equality checks against grant_entry, and two saturating counters. The comparator tree is the only part that grows with the pool, and it grows linearly in area and logarithmically in depth. Nothing here is a timing concern.
Where the real timing pressure is — and it is not here. The critical path in this architecture runs from bank state, through next-command derivation, through the legality filter, through arbitration, to grant_valid, and then through this block to issue_valid. That is a long combinational chain, and it must close in one cycle because the candidate set is only valid for the cycle it was computed in.
Production controllers attack this by pipelining the chain — deriving candidates in one stage and arbitrating in the next. That is a legitimate design, and it buys back frequency, but it reintroduces §3's problem in miniature: the arbiter is now choosing among candidates computed against last cycle's state. The resolution is to re-validate the winner against current state at the commit stage, which is a second, stricter reason the commit point exists. This chapter's block is deliberately unpipelined so the mechanism is visible; a design running at real DDR frequencies would not be.
Reset. The counters reset asynchronously and the commit path is purely combinational, so nothing can commit while rst_n is low — grant_valid will itself be low, because the arbiter is reset too. The block has no state that could survive a reset and produce a phantom first commit.
What a production controller has that this does not. Stating these plainly matters more than the RTL does, because the gap is where the “I built a DDR controller” claim usually breaks down:
- Write data path coupling. A column write command may issue only if the write data is available to launch at the required offset (Chapter 12.2). This block's commit condition has no data-availability term.
- Read data return tracking. Nothing here reserves the return slot or tracks which entry owns the burst; 17.5 opens this and Modules 19–21 own the capture.
- Rank and channel dimensions. One command bus is assumed. A multi-rank controller has rank-to-rank turnaround and chip-select fan-out that change both legality and arbitration (5.4).
- Read/write turnaround as a first-class cost. Direction changes on the shared data bus are expensive, and a real scheduler batches to amortise them — a policy dimension 17.4 opens and Module 23 quantifies.
- Power state management. Power-down and self-refresh entry and exit gate everything above, and are not modelled anywhere in Module 17.
- Error handling. ECC, parity on the command bus, and retry are absent.
None of these change the commit discipline. All of them add terms to the commit condition — which is itself the argument for keeping that condition in one named place rather than scattering it across the design.
15. Debugging
Symptom: the controller issues nothing, but requests are resident and the queue is filling.
Work the pipeline backwards, because each stage can produce the same silence:
is grant_valid ever high?
├─ NO → arbitration has no legal candidates
│ ├─ is the legal mask empty?
│ │ ├─ YES → is it empty because of timing, state, or the
│ │ │ refresh gate? 16.2's bank_candidate_mask
│ │ │ publishes the three filters SEPARATELY
│ │ │ precisely so this question has an answer.
│ │ └─ NO → arbitration bug: legal candidates ignored
│ └─ are entries valid at all? → 17.2's pool
└─ YES → grants exist but nothing commits
├─ phy_cmd_ready stuck low → interface / Modules 19-21
├─ normal_issue_allowed stuck low → 17.3: refresh manager
│ never left its busy state
└─ both high but commit low → the commit condition has
grown a fourth termdropped_count climbing while commit_count stays flat localises this in one read: grants are being produced and universally refused, so the fault is at or below the commit stage, not in candidate generation.
Symptom: one request is served twice. The device receives two ACTs for one row opening. Check whether progress advances on commit; then check whether it advances once per commit. A for loop that sets more than one bit of advance_onehot, or a pool that advances on grant_valid in one path and commit in another, produces exactly this. P3 catches the first; P2 catches the second.
Symptom: works at low load, fails at saturation. The commit stage is a strong suspect precisely because at low load phy_cmd_ready is almost always high and grant_dropped almost never asserts — so a commit-versus-grant confusion is unexercised until backpressure appears. This is why c_commit_then_stall exists as a cover: if that cover is unhit, the test suite has never exercised the bug's precondition, and a passing regression means nothing.
16. Misconceptions
“The scheduler schedules memory requests.” It schedules commands. A request is not an object the DDR interface accepts. Consequence: the architecture stores the wrong thing and cannot express a row conflict's three-command expansion. Replacement: requests are resident state; commands are derived and re-derived. Clue: a design where a request maps one-to-one onto an issue slot.
“A request maps to one DDR command.” A row hit does; a closed bank needs two; a conflict needs three, with waits between. Clue: a queue whose depth is sized as if each entry issues once.
“A command queue is a FIFO of future commands.” §3. Consequence: commands computed against state that has since changed. Replacement: store intent, re-derive. Clue: a PRE issued to a bank that is already closed.
“Queue order must equal issue order.” Allocation order, scheduling order and completion order are three different orders (17.2 §5). Clue: a controller whose bank parallelism collapses to one bank at a time.
“If a request is valid, its next command is legal.” Validity is about the request; legality is about the device's state and timing right now. Clue: an ACT issued inside a tRP window.
“If a command is legal, it should issue.” Several may be legal; one issue slot (16.1 §2). Legality admits; policy chooses. Clue: an arbiter with no defined behaviour when two candidates are equally legal.
“A candidate changes controller state.” Candidate generation is speculative and happens for every entry every cycle. Consequence: state churns on commands that were never even preferred. Clue: bank state changing on a cycle when nothing was issued.
“A granted command changes state.” The whole of §7. Clue: a controller that is correct at low load.
“A READ completes when the READ command issues.” It completes when the burst returns and is collected (17.5 §8). Freeing the entry at column-command issue loses the data's destination. Clue: read data returning with no owner.
“One FSM can represent a DDR controller.” There are many concurrent state machines — one progress FSM per entry, one per bank, a refresh manager, an arbiter pointer — and collapsing them into one serialises a device built for concurrency. Clue: a controller that never has two banks active.
17. Interview Reasoning
“What does a DDR command scheduler actually schedule?” Legal next-command candidates derived from outstanding transaction state — not requests. The follow-up that separates memorisation from understanding: where do those candidates come from, and how often are they computed? Every cycle, from request state and current bank state.
“Why not pre-expand every request into a command FIFO?” §3. A strong answer names both failure directions — the stale PRE and the missed row hit — and observes that the expansion is cheap while the staleness is not.
“What is the commit point, and what happens if state advances on grant?” The single event grant && phy_ready && gate. Advancing on grant produces ghost activates, phantom deadlines and lost refresh credits — and the symptom always appears downstream of the cause.
“Why is legality evaluated before arbitration rather than after?” So the arbiter's input does not depend on the arbiter's own history, so an empty candidate set is a reportable fact about the machine, and so a state refusal and a timing refusal stay distinguishable (16.2 §3).
“Your controller is correct in simulation and fails on hardware under load. Where do you look first?” At everything that is unexercised when backpressure is absent — commit-versus-grant, simultaneous allocate and free, refresh arriving during a stall. Then check whether the corresponding covers were ever hit.
“Which state belongs per bank and which per request?” Open row and timing history are per bank, because they describe the device. Progress, target and identity are per request, because they describe the transaction. Duplicating either into the other is the ownership error Chapter 17.2 §4 catalogues.
18. Exercises
1. For §1's five requests, write the candidate set if bank 1 were instead open on row 55. Which request changes class, and which command disappears from the set?
2. Construct a cycle sequence in which entry 3 is granted on four consecutive cycles and commits on none. Give a different legitimate cause for each cycle.
3. advance_onehot is built by comparison rather than by 1 << grant_entry. Give a value of NUM_ENTRIES and grant_entry where the shift silently corrupts and the comparison fails safe.
4. The commit condition exempts REF from normal_issue_allowed. Remove the exemption and trace what happens once the manager asserts its gate. At which cycle does the controller become permanently stuck, and why does the symptom look like a refresh bug?
5. Write an SVA property asserting that bank state changes only on a commit naming that bank. Which module must it be bound into, and why can it not live in command_issue_commit?
6. Rewrite P5 as a single-cycle conjunction — (grant_dropped && phy_cmd_ready && normal_issue_allowed) |=> grant_valid. Prove from the commit condition that its antecedent can never be true, and name the verification result that would nonetheless report it as passing.
7. A colleague proposes latching a dropped grant so it re-issues automatically when the interface frees. Name the bug class this reintroduces, and the specific cycle in §10's trace where the latched grant would be wrong.
19. Where This Goes
The spine is in place, and four of its boxes are still stubs.
Chapter 17.2 builds the entry pool this chapter consumed as given — what an entry must retain, and why allocation order is not issue order. 17.3 builds the manager behind normal_issue_allowed, turning Module 15's obligation into scheduler control. 17.4 builds the policy that produces grant_valid, and shows a row-hit-first scheduler starving a bank. 17.5 closes the loop at the top, where backpressure that began at this commit stage finally reaches the upstream master.
One sentence survives all four: nothing changes until a command commits.
Continue learning
Related tutorials
- Related topic
Controller-Scheduling Question
Whiteboard a scheduler. The failure is collapsing request, candidate, grant and committed command into one thing — and the interviewer's next three additions expose it. Includes a fairness fix that issues illegal commands.
- Related topic
Why Timing Parameters Exist
A DDR command can be perfectly meaningful and target a bank in exactly the right state and still be illegal right now. That third refusal is what timing parameters are, and it is a different question from the first two.
- Related topic
Bank-Aware Scheduling
A bank-aware controller evaluates every bank's candidate at once and produces a legal-candidate vector. The discipline that makes it correct is legality before policy — and the bug that defeats it is replacing timing legality with a bank comparison.
- Related topic
The Command Queue
The structure every controller calls a command queue does not hold commands, is usually not per bank, and is not a queue. Allocation order, scheduling order and completion order are three different orders.
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.
