State Pattern in UVM: Behavior That Changes With State, Without the Case Explosion
The Template Method post fixed the flow in one place and let each test override only the steps. Now invert it: the methods stay fixed, and what changes is what each call does — because the answer depends on where the object sits in its lifecycle. The pattern that hands each state its own class — so each state's behavior lives in one place instead of being scattered across walls of branching — is State. The question it answers comes straight from a MESI scoreboard: a snoop read arrives for an address your reference model tracks. If the line is Modified, it must supply data and downgrade; if it's Invalid, it must do nothing at all. Same event, opposite behavior — and that's one event out of four, two cells of a sixteen-cell matrix. How many case arms before the branching owns you?
- The Problem: The MESI Matrix in a Case Statement
- Gang of Four: The State Pattern
- UVM Implementation: State Machines Everywhere, State Pattern Nowhere
- Build Your Own: A MESI Cache Line That Knows Its State
- Scaling Up: From One Line to a Coherent Reference Model
- Advanced: Transient States
- Quick Reference
The Problem: The MESI Matrix in a Case Statement
Your scoreboard's reference model tracks the MESI state of every cache line the test has touched: core reads and writes arrive from the CPU-side monitor, snoops arrive from the bus monitor, and on each event the model must predict the line's next state and whatever bus traffic the transition owes. Four states, four event kinds — the protocol is a sixteen-cell matrix, and the spec draws the legal transitions as a single diagram:
stateDiagram-v2
[*] --> I
I --> S : core read (fill)
I --> M : core write (RFO)
S --> M : core write (upgrade)
E --> M : core write (silent)
E --> S : snoop read
M --> S : snoop read (supply data)
M --> I : snoop invalidate (writeback)
S --> I : snoop invalidate
E --> I : snoop invalidate
(One simplification, then we move on: a read miss fills to Shared in this model; real protocols fill to Exclusive when no other cache holds the line.)
The natural first implementation maps each event to a function and each function to a case over the state. Here are two of the four — including the snoop-read handler that answers the question from the top of the post, one arm supplying data and downgrading, another doing nothing at all:
// Inside the reference model — one of FOUR functions shaped exactly like this:
function void on_core_write(cache_line_t line);
case (line.state)
INVALID: begin expect_rfo(line); line.state = MODIFIED; end
SHARED: begin expect_upgrade(line); line.state = MODIFIED; end
EXCLUSIVE: line.state = MODIFIED; // silent upgrade — no bus traffic
MODIFIED: ; // already ours
endcase
endfunction
function void on_snoop_read(cache_line_t line);
case (line.state)
MODIFIED: begin expect_supply(line); line.state = SHARED; end
EXCLUSIVE: line.state = SHARED;
SHARED: ; // clean copy elsewhere — memory answers
INVALID: ; // not ours... but is silence checked, or assumed?
default: `uvm_error("MESI", "unreachable") // this handler has a default. The one above doesn't.
endcase
endfunction
// ... on_core_read and on_snoop_invalidate: two MORE case (line.state) blocks ...
Sixteen cells of protocol, sliced event-first into four functions. It compiles, it passes the smoke test, and three problems are already loaded and waiting:
- MESI → MOESI is a shotgun edit across every event handler — and it misses one. The project moves to a protocol with the Owned state, so
OWNEDneeds an arm in all four functions: what an Owned line does on a core write, on a core read, on each snoop — and it doesn't only add arms, it changes an existing one, becauseon_snoop_read'sMODIFIEDarm now goes M → O, supplying data with no memory writeback, instead of M → S. You open the handlers and add the arm. You add it to three. The fourth — sayon_snoop_invalidate, the one you skipped because "invalidate is invalidate" — has noOWNEDarm and nodefault, so an Owned line sails through the case untouched, stays Owned in the model after the bus said otherwise, and surfaces as a scoreboard mismatch forty thousand cycles downstream with no arrow pointing back at the arm you never wrote. - Illegal-transition handling is inconsistent, because the policy exists nowhere in particular. Read the two handlers again:
on_snoop_readcarries adefaultarm that fires auvm_error;on_core_writecarries none, so anything unexpected falls through silently. Neither author was careless — there was simply no single place where "what happens on an impossible transition" got decided, so each handler decided alone. The same goes for theINVALIDarm of the snoop handler: whether the model checks that a line it doesn't own stays off the bus, or merely assumes it, depends on nothing but the habits of whoever wrote that arm — the question was never decided, so the code answers it differently wherever it comes up. - You cannot read the protocol back out of the code. The spec writes per-state bullet lists — "a Modified line supplies data on a snoop read, writes back on an invalidate, absorbs core writes silently." To review the code against that one bullet list you collect one arm from each of four case statements, scattered across the file. The spec's unit of meaning is the state; the code's unit of organization is the event — and every review, every debug session, every new hire reads against that grain.
What you want is each state owning its own behavior — its arm from every one of those four case statements gathered into one class, so the code reads in the spec's units. That's the State pattern.
Gang of Four: The State Pattern
Before the cache line gets its classes, take the Gang of Four version on its own, so the structure stands clear of any MESI detail.
"Allow an object to alter its behavior when its internal state changes. The object will appear to change its class."
— Design Patterns: Elements of Reusable Object-Oriented Software (Gamma et al., 1994)
The second sentence is the strange one, and the whole pattern hides inside it. An object cannot change its class — but it can hold a handle to another object that can be swapped, and route all of its behavior through that handle. Swap the object behind the handle and, from the outside, the context appears to have become something else.
classDiagram
class Context {
-state: State
+request()
}
class State {
<<abstract>>
+handle(ctx)* State
}
class ConcreteStateA {
+handle(ctx) State
}
class ConcreteStateB {
+handle(ctx) State
}
Context o--> State : delegates to
State <|-- ConcreteStateA
State <|-- ConcreteStateB
note for Context "request() {
state = state.handle(this);
}"
Read the note on Context — that one line is the entire mechanism. The context owns a state handle and does no deciding of its own: every event that arrives, it delegates to whatever state it currently holds. Each concrete state implements handle() with the behavior that state owes and returns the successor state — the return value is the transition. (GoF's own sample plumbs this differently — the state reaches back and calls setState() on the context — but returning the successor is the same decision with the mutation moved into one line, and it's the form this post uses throughout.) The context never consults a transition table, never branches on an enum; it stores whatever came back and the next event lands on the new object.
Successor selection lives inside the states themselves: ConcreteStateA knows which state follows it, because that knowledge is the protocol — exactly the per-state bullet list the spec writes and the case statements of §1 shredded across four functions.
Now put that diagram next to Strategy's — the most-confused sibling pair in the catalog — and they are nearly identical: a context, an abstract interface, concrete subclasses behind it, behavior swapped by swapping an object. The one tell in our diagram — handle() returning a State — is precisely the difference the table below makes explicit: who drives the swap.
| Aspect | State | Strategy |
|---|---|---|
| Who selects the object | The current state, by returning its successor | The client, once, at injection |
| Objects aware of each other | Yes — states name their successors | No — strategies are independent |
| Changes during run | Constantly, driven by events | Rarely or never |
| Intent | Behavior follows a lifecycle | Interchangeable algorithms |
Rule of thumb: if the object picks its own successor, it's State; if the testbench picks it once at config time, it's Strategy.
UVM Implementation: State Machines Everywhere, State Pattern Nowhere
Every post in this series anchors the pattern in a place where UVM itself ships it. This one arrives with a twist: UVM is full of state machines — and not one uses the State pattern. That is not a gap in the library; it is the boundary of the pattern, and reading why the framework stayed on the enum side teaches when you should cross over. Two sightings make the case.
The first is the largest state machine in the framework: phasing. Every uvm_phase node carries its own lifecycle, walked through UVM_PHASE_DORMANT → SCHEDULED → … → EXECUTING → … → DONE — nine states along the normal walk, with SYNCING, STARTED, READY_TO_END, ENDED, and CLEANUP filling the gaps. Multiply that by every phase node in every domain and an ordinary simulation runs dozens of live state machines. And the API for all of them? get_state() returns the enum, wait_for_state() blocks until it matches. (One trap the glimpse below sidesteps: call them on the schedule node the domain's find() resolves — the uvm_main_phase::get() imp singleton never changes state.)
The second sighting is one you have called yourself, perhaps without filing it as a state machine: uvm_sequence_base tracks every sequence through its own enum lifecycle — CREATED → PRE_BODY → BODY → FINISHED (or STOPPED), with more waypoints than this sketch shows. Again the public surface is a query and a wait: get_sequence_state() and wait_for_sequence_state().
// UVM's state machines are everywhere — and they're all enums:
uvm_phase main_ph = uvm_domain::get_uvm_domain().find(uvm_main_phase::get());
main_ph.wait_for_state(UVM_PHASE_EXECUTING); // phase lifecycle
my_seq.wait_for_sequence_state(UVM_FINISHED); // sequence lifecycle
// Consumers only QUERY and WAIT — never branch behavior per state.
// That's exactly when an enum is the right tool — and exactly what
// stops being true inside your MESI reference model.
Now the honest part. Both are plain enums, and that is the correct choice, not a missed refactor. What consumers do with these states: ask which one is current, and block until a particular one arrives. Nothing more. The framework's own traversal machinery does branch on these states internally — but that branching is a fixed walk over a frozen state set, not a roster of event handlers where each state answers differently, not new states arriving release over release, not a sixteen-cell matrix. When every use is a comparison, a case never gets the chance to explode. The boundary in one sentence: enum + case when the state is only ever read — compared, waited on, stamped into a field; State pattern when behavior varies per state. UVM's lifecycles sit on the first side; the §1 reference model — where a snoop read means "supply data and downgrade" in one state and "do nothing" in another — sits on the second.
That sentence unpacks into a checklist for any state machine you meet (it returns as a table in §7):
- Stay with enum + case when the arms are one-liners — assignments, comparisons, waits — and the state set is frozen. A
caseover four states that only stamps a field is not debt; it is the simplest correct tool. - Reach for State when the arms grow behavior — calls, side effects, expected-traffic bookkeeping; when the state×event matrix grows in both dimensions (MESI → MOESI adds a row, a new snoop type a column, every cell an edit site); or when illegal-transition policy must be uniform and auditable instead of re-decided per handler, the way §1's two case statements decided it two different ways.
The verdict on UVM is clean — query-and-wait consumers, frozen state sets — so this section's GoF mapping cannot point at the framework. It points at what §4 builds; the framework supplies only the base classes and the object discipline, the roles yours to fill:
| GoF Role | Your Class (built in §4) |
|---|---|
| Context | cache_line (per-line model object inside the scoreboard) |
| State (abstract) | mesi_state virtual base class |
| handle() event methods | handle_core_read/core_write/snoop_read/snoop_invalidate(cache_line line) |
| ConcreteState | modified_state, exclusive_state, shared_state, invalid_state |
| Transition | each handler returns the next mesi_state |
UVM's state machines never needed the State pattern. Your reference model is about to.
Build Your Own: A MESI Cache Line That Knows Its State
Time to fill §3's table. The plan is §2's mechanics applied to §1's matrix: a mesi_state virtual base class declaring one handler per event, four concrete states each collecting its arm from each of the four case statements, and a cache_line context that holds the current state handle and delegates everything. Each handler returns the successor — the return value is the transition — and the sixteen-cell matrix re-slices itself state-first, into the spec's per-state bullet lists.
The Abstract State: mesi_state
Start with the base class, because it is where §1's second pain point goes to die. Four virtual handlers, one per event kind, each taking the cache_line context and returning the next state — and every base implementation fires a uvm_error, so the question §1's two handlers answered two different ways ("what happens on an impossible transition?") is now decided in exactly one place. A concrete state that doesn't override a handler has declared that event illegal, and the inherited error is uniform, auditable, and impossible to forget: the missing-default arm and the silent fall-through can no longer be written, because not writing anything now fires it.
typedef class cache_line; // states and context name each other — forward-declare
virtual class mesi_state extends uvm_object;
// ... constructor ...
// Every handler returns the NEXT state. The base class IS the
// illegal-transition policy: one place, uniform, auditable.
virtual function mesi_state handle_core_read(cache_line line);
`uvm_error("MESI", $sformatf("illegal core read in %s", get_name()))
return this;
endfunction
virtual function mesi_state handle_core_write(cache_line line);
`uvm_error("MESI", $sformatf("illegal core write in %s", get_name()))
return this;
endfunction
virtual function mesi_state handle_snoop_read(cache_line line);
`uvm_error("MESI", $sformatf("illegal snoop read in %s", get_name()))
return this;
endfunction
virtual function mesi_state handle_snoop_invalidate(cache_line line);
`uvm_error("MESI", $sformatf("illegal snoop invalidate in %s", get_name()))
return this;
endfunction
endclass
One consequence worth pausing on: this base class also forces the question §1's INVALID snoop arm left to authorial habit. An invalid_state that wants a snoop read to be a legal no-op must say so — override the handler, return this — turning silence-as-policy into a line of code you can point at in review.
The Concrete States: the Spec's Bullet Lists, as Classes
Now the states themselves. Here are Invalid and Shared in full — read either top to bottom and you are reading the spec's bullet list for that state, all four events in one place. (The ::get() calls are singleton accessors — one shared instance per state instead of a new() per transition, §5's business; for now read shared_state::get() as "the Shared state object.")
typedef class shared_state; // states name their successors — mutual references
typedef class modified_state; // need forward declarations within one file
class invalid_state extends mesi_state;
// ... constructor, ::get() singleton — see Scaling Up ...
virtual function mesi_state handle_core_read(cache_line line);
line.expect_fill(); // bookkeeping lives in the context
return shared_state::get(); // the return value IS the transition
endfunction
virtual function mesi_state handle_core_write(cache_line line);
line.expect_rfo(); // write miss — read-for-ownership
return modified_state::get();
endfunction
virtual function mesi_state handle_snoop_read(cache_line line);
return this; // not ours — an EXPLICIT legal no-op
endfunction
virtual function mesi_state handle_snoop_invalidate(cache_line line);
return this;
endfunction
endclass
class shared_state extends mesi_state;
// ... constructor, ::get() singleton ...
virtual function mesi_state handle_core_read(cache_line line);
return this; // read hit — stay Shared
endfunction
virtual function mesi_state handle_core_write(cache_line line);
line.expect_upgrade(); // must invalidate other sharers
return modified_state::get();
endfunction
virtual function mesi_state handle_snoop_read(cache_line line);
return this; // someone else supplies; memory answers
endfunction
virtual function mesi_state handle_snoop_invalidate(cache_line line);
return invalid_state::get();
endfunction
endclass
// exclusive_state: read hit stays E; write silently upgrades to M;
// snoop read downgrades to S; snoop invalidate to I.
// modified_state: hits stay M; snoop read supplies data, downgrades to S;
// snoop invalidate writes back, goes to I.
The question from the top of the post is now two adjacent overrides of the same handler: modified_state supplies data and returns shared_state::get(); invalid_state returns this. Same event, opposite behavior — one method name, two classes.
(The typedef class lines are not decoration. Invalid names Shared and Modified as successors, Shared names Invalid back — states knowing their successors is the pattern, per §2 — and mutual references in one file require forward declarations or the compile fails on the first shared_state::get().)
Note what invalid_state does not contain: a single branch. Neither does shared_state. The case statements are gone — not moved, gone — because an object that exists only while the line is Invalid never needs to ask what state the line is in. And note the division of labor, §2's diagram made concrete: the state selects the successor and what traffic the transition owes; the context — line.expect_fill(), line.expect_rfo(), line.expect_upgrade() — does the bookkeeping. (Per §1's simplification, expect_rfo is the write-miss path, expect_fill the read-miss path.)
The Context: cache_line and the Scoreboard That Stops Deciding
The context is deliberately boring. It owns the per-line data — tag, data, expected-traffic bookkeeping — plus one protected handle to the current state, an accessor, and a mutator:
class cache_line extends uvm_object;
bit [TAG_W-1:0] tag;
bit [63:0] data;
protected mesi_state m_state;
// ... constructor: m_state = invalid_state::get(); ...
function mesi_state state(); return m_state; endfunction
function void set_state(mesi_state next); // ONE chokepoint — §5 instruments it
m_state = next;
endfunction
// expect_fill / expect_rfo / expect_upgrade / expect_supply / expect_writeback:
// bookkeeping the scoreboard checks against observed bus traffic
endclass
// The scoreboard's analysis export — the case MATRIX is gone;
// what's left is one-level event decode:
function void write_snoop(snoop_txn t);
cache_line line = get_line(t.addr); // addr → line map — grows in §5
case (t.kind)
SNOOP_READ: line.set_state(line.state().handle_snoop_read(line));
SNOOP_INV: line.set_state(line.state().handle_snoop_invalidate(line));
endcase
endfunction
Look at what survived in the scoreboard's write(): one case — over the event kind, not the state. That branching is irreducible — something has to map a transaction field to a method call — but it is one level deep, never grows a state dimension, and contains no behavior. The state×event matrix — sixteen cells across four functions, headed for twenty-five once MOESI adds the row and the next snoop type the column — is not in this function. Each line is §2's note on Context, flattened: the scoreboard's write plays request(), composing delegate-and-store through the public accessor pair, keeping set_state the one visible chokepoint §5 will instrument. The line "appears to change its class" with every set_state, and the next snoop lands on whatever object the last transition chose.
Now collect the payoff against §1's three pain points, in order. MESI → MOESI stops being a shotgun edit. owned_state is a new class — one file, one bullet list, written next to the spec's O-state paragraph — plus a one-line change in modified_state::handle_snoop_read to return it. No fourth handler to forget, because there are no handlers to visit: a transition you fail to write isn't a silent fall-through forty thousand cycles from a mismatch, it's the base class's uvm_error on the first Owned-line event you didn't think about. Existing states closed to modification, the state set open to extension — Open/Closed, the same inversion every post in this series keeps arriving at. Illegal-transition policy lives in one place. Decided once, in mesi_state, inherited uniformly, with legal no-ops spelled out as explicit return this overrides you can audit. And you can finally read the protocol back out of the code. The spec's unit of meaning is the state; now it is the code's too — reviewing modified_state against the spec's Modified bullet list is a side-by-side read, not a scavenger hunt through four case statements.
Pitfalls
Three traps, each quietly rebuilding the problem you just removed:
- State objects accumulating per-line data. The first time a handler needs the line's tag, the temptation is to give the state a
tagfield. Do it and the design is broken: there are thousands of cache lines and (if §5's singletons are doing their job) exactly oneshared_state, so any per-line fact stored in a state is shared by every line in that state — a corruption bug that only fires when two lines occupy the state at once. States must stay stateless; everything per-line lives in the context, which is why every handler takescache_line lineas an argument. new()-ing a state per transition.shared_state nxt = new("s"); return nxt;works — and allocates an object per event, at analysis-port rates, for objects that carry no data and never differ: millions of snoops, millions of identical throwaway allocations. The::get()singletons elided above are the fix, and building them is where §5 picks up.- Transition side effects scattered into the states. When
invalid_stateneeds a fill expected, it callsline.expect_fill()— it does not reach into the scoreboard's queues itself. Hold that line: selection belongs to the states (which successor, which traffic is owed), mutation belongs to the context (do the bookkeeping, own the data, swap the handle). Blur it — states pushing into scoreboard queues, or worse, callingset_stateon the side while also returning a successor — and transitions happen in two places, the chokepoint stops being a chokepoint, and the transition logging §5 hangs on it sees only half the story.
Two states in full, two elided, one boring context — and a scoreboard that no longer decides anything. What's left is what this section deferred: those ::get() calls, and what one chokepoint and one shared instance per state buy when the model grows from one line to a coherent reference model.
Scaling Up: From One Line to a Coherent Reference Model
A real coherence test does not touch one line. The addr → line map that §4's scoreboard waved past fills with every address the test visits — a hundred thousand cache_line objects is an ordinary long run — and events arrive at analysis-port rates across all of them. That is the scale at which §4's three IOUs come due: that stateless states make a shared instance safe, that ::get() retires the per-transition new(), and that a single set_state chokepoint would earn its keep. Cash them in.
Four Objects for a Hundred Thousand Lines
Here is the ::get() every handler in §4 was already calling — the standard lazy-init Singleton: a local static handle, constructed on first request, returned ever after.
class shared_state extends mesi_state;
local static shared_state m_inst;
static function shared_state get();
if (m_inst == null) m_inst = new("shared_state");
return m_inst; // 100k lines, ONE Shared object
endfunction
virtual function mesi_kind_e kind(); return MESI_S; endfunction
// ... handlers as in Build Your Own ...
endclass
(That kind() override is new — hold it; it belongs to the chokepoint, not the singleton.)
Now pay off both §4 pitfalls at once. Why it's safe: states stay stateless — every per-line fact (tag, data, expected traffic) lives in the cache_line context, arriving as a handler argument. A shared_state carries nothing that distinguishes one line from another, so a hundred thousand lines pointing m_state at the same object cannot interfere — there is nothing to interfere with. Why it's worth it: new()-per-transition would mean millions of identical throwaway allocations; with ::get(), the reference model holds exactly four state objects for the whole simulation, and a transition is a handle copy — no allocation, no garbage, no churn.
One tease before moving on: "many contexts sharing a few stateless instances" is not just a Singleton trick — it has its own name in the Gang of Four catalog, Flyweight, and its own post. Here it rides in on Singleton mechanics, but the intent is Flyweight's.
The Transition Chokepoint
§4's third pitfall insisted that every transition flow through set_state — states return successors, never swapping the handle themselves. Here is what that buys: because exactly one line of code changes a cache line's state, instrumenting it instruments every transition in the run:
// set_state is now declared extern in cache_line — this out-of-class
// body replaces §4's inline one. Two new members support it:
// cur_event: mesi_event_e, stamped by the scoreboard (line.cur_event = SNOOP_READ;)
// before delegating — forget the stamp and every log line below
// misreports its event;
// trans_cg: covergroup with function sample(mesi_kind_e s, mesi_event_e e,
// mesi_kind_e n) — (state,event)→next_state cross, new()'d in
// the constructor.
function void cache_line::set_state(mesi_state next);
if (next != m_state) begin
`uvm_info("MESI/TRANS",
$sformatf("line=%0h %s -> %s on %s",
tag, m_state.get_name(), next.get_name(), cur_event.name()),
UVM_HIGH)
trans_cg.sample(m_state.kind(), cur_event, next.kind());
end
m_state = next;
endfunction
Two payloads, one guard. The uvm_info is a structured log line — fixed ID, fixed old → new on event shape — so grep 'MESI/TRANS' over a failing run replays the model's entire decision history, and "every line that left Modified on a snoop" is one filter, not a debug session. The trans_cg.sample call gives you transition coverage for free: a (state, event) → next_state cross sampled on every real transition, with no per-test, per-sequence, or per-state sampling code anywhere — there is nowhere else a transition can happen. And the next != m_state guard keeps the return this no-ops out of both — log and coverage: self-loops are legal but not transitions, and at scoreboard rates mostly noise — a comparison only meaningful because return this and ::get() hand back the same object every time, so the singletons quietly underwrite the guard. Whether they belong in the coverage is a separate decision the guard is making for you — a read hit in Modified is a cell of §1's matrix too — so if your plan wants the diagonal covered, sample before the guard, or cover (state, event) occupancy in a second, unguarded group.
One bridge made that sample call compile. Covergroups sample integral values, not class handles — so the mesi_state base gains pure virtual function mesi_kind_e kind(); (legal in a virtual class, and unlike §4's handlers there is no sensible default, so pure forces every concrete state to answer), and a four-value mesi_kind_e enum — MESI_M, MESI_E, MESI_S, MESI_I — reappears in the package. Yes: the enum §1 deleted is back. But read what it's for — kind() is a reporting detail, an integral shadow for sampling and logging, and nothing branches on it. The day a case (line.state().kind()) shows up, §1 has been rebuilt with extra steps. NEVER case on kind().
Reset: The Fifth Handler
§4's base class declared exactly four handlers, one per event the bus could deliver. Reset grows the roster for the first time, and it breaks the base-class rule the other four established: reset is the one event no steady state needs to customize — whatever you were, clear the line and become Invalid.
// In the mesi_state BASE — the one transition every state shares.
// The only base handler that is not an illegal-transition error:
virtual function mesi_state handle_reset(cache_line line);
line.clear(); // (returns invalid_state::get() —
return invalid_state::get(); // same forward-declaration dance as §4)
endfunction
(This model treats reset as invalidate-without-writeback — line.clear() drops Modified's dirty data on the floor, what most hardware does. A DUT that flushes on reset is one override away: modified_state alone reclaims handle_reset, calls expect_writeback(), and falls through to the base — the default-in-the-base shape was built for exactly this.)
The shape is the payoff inverted. The four event handlers put the error in the base because legality varies per state; handle_reset puts the behavior in the base because it doesn't — M, E, S, and I answer identically, so it is written once and no concrete state in THIS model mentions reset. And because the scoreboard routes reset like any other event — line.set_state(line.state().handle_reset(line)) — every reset lands in the chokepoint: the M → I on RESET lines show up in the log and the reset column fills in the covergroup, zero reset-specific instrumentation. (One caveat: a reset on an already-Invalid line is an I→I self-loop the guard clips, so the (I, RESET) cell fills only if you sample the diagonal as above.)
Worth noticing what just happened to the base class, because it will happen again: §4's handler set was four, reset makes it five, and §6 adds a sixth when transient states need an event the steady states never see. The abstract state's method list is the protocol's event vocabulary, and it grows when the vocabulary does — one declaration in the base per new event, against §1's one new arm in every case statement.
Four shared objects, one instrumented chokepoint, one universal event written once: the model now scales to a hundred thousand lines and tells you what it did. What it still cannot say is that a line is between states — a fill issued but not yet answered, an eviction in flight. Giving the in-between a class of its own is where the pattern gets interesting — and it's next.
Advanced: Transient States
The model the last five sections built carries one comfortable lie: that MESI has four states and a line always sits in exactly one. Between a core write to an I line and the fill data arriving from memory, the line is neither Invalid nor Modified — it is in flight, an "I→M pending fill," obligated to a transaction that has not completed. Add the acks a real protocol collects before it may upgrade and there are more still. This is why a production coherence model carries not four states but dozens, most of them these transients — the brief, obligated moments between the stable four. The pattern's promised payoff is that absorbing one costs almost nothing. Time to make good on the promise, and to be honest about the word "almost."
One New State, Counted Honestly
Here is "I→M pending fill" as a state object. The line has issued its read-for-ownership and is waiting; when the fill lands it commits the deferred write and becomes Modified; a snoop arriving meanwhile cannot be answered — the data has not arrived — so it stalls.
// New protocol reality: the fill takes time. One NEW class:
class im_pending_fill_state extends mesi_state;
// ... constructor, ::get() singleton ...
virtual function mesi_kind_e kind(); return MESI_IM; endfunction
virtual function mesi_state handle_fill(cache_line line);
line.commit_pending_write();
return modified_state::get();
endfunction
virtual function mesi_state handle_snoop_read(cache_line line);
line.stall_snoop(); // can't supply what we don't have yet
return this;
endfunction
// Everything else inherits the base illegal-transition policy — free.
endclass
// One new event in the BASE (illegal everywhere by default):
virtual function mesi_state handle_fill(cache_line line);
`uvm_error("MESI", $sformatf("illegal fill in %s", get_name()))
return this;
endfunction
// And ONE changed return value — the transition that enters the new state:
class invalid_state extends mesi_state;
virtual function mesi_state handle_core_write(cache_line line);
line.expect_rfo();
return im_pending_fill_state::get(); // was: modified_state::get()
endfunction
// ... rest unchanged ...
endclass
Now count the cost honestly, because the temptation is to call this "zero edits" and it is not. The transient state costs three behavioral edits and one bookkeeping touch — and naming that fourth out loud is the whole point, because "zero edits" is the story that hides it. One, a new class — im_pending_fill_state, written next to the spec's pending-fill paragraph, overriding only the two events it has an opinion about, inheriting illegal-by-default for the rest. Two, one new handler in the base: handle_fill, which mesi_state declares illegal everywhere, so every other state — M, E, S, I — gets "a fill here is a protocol violation" for free, the illegal-by-default discipline §4 established. Three — the one the "zero edits" story drops — one changed return value: invalid_state::handle_core_write now returns im_pending_fill_state::get() instead of modified_state::get(), because a write miss now parks in the pending state until the fill commits it. One line, one state's logic, not a character of M, E, or S. Four — and §5's pure-virtual kind() already forced this — the new class returns a fresh MESI_IM, so the coverage-only mesi_kind_e enum grows by one. Still nothing cases on it. That kind() override lives inside the one new class of item one, but the enum bump is a genuine touch in another file, so it earns its own number. Not zero — four touches, every one local, named, and pointable in review.
Set that against §1's case matrix. A new state there is a new enum value and therefore a new arm in every event handler — not "add an arm" but audit one: you reopen all four (now five, six) case statements and decide, for each, what a pending-fill line does, including the handlers where the answer is "illegal" and the only way to say so is a default you might skip. The pattern turns "audit every arm of every case statement" into "write one class and redirect one line" — localized extension versus distributed audit, the whole reason the pattern earns its keep, sharpest precisely here where states arrive by the dozen.
This is the sixth handler. §4 declared four — one per event the bus delivers. §5's reset made five. handle_fill is the sixth, the kind §5 foreshadowed: an event the steady states never see — meaningless to Invalid, Modified, or Shared, since only a line caught mid-transition is waiting for one. So the base declares it illegal everywhere and exactly one transient state overrides it, the inverse of handle_reset: reset belongs to every state and lives in the base; fill belongs to almost none and lives in the one state that owns the in-between.
The Hazard: Class Explosion, and the Honest Exit Sign
Every post in this series ends on the pattern's signature failure mode, and State's is written on the wall the moment you say "dozens of transient states." §1's case matrix obscured the protocol horizontally — one state's behavior shredded across four functions. A model with dozens of transient states reintroduces the same disease rotated ninety degrees: the protocol is now obscured vertically — dozens of small classes, most three lines long, each a single obligated moment, the shape lost in the file list. You can read any one state perfectly and still not see the machine. The cure for horizontal scatter became vertical scatter.
Three things keep it readable, and the third matters most because it tells you when to stop.
- Name the two tiers apart. Stable and transient states are different animals — one is where a line rests, the other where a line is briefly obligated. A convention like
mesi_*for the stable four andmesi_t_*for every transient (mesi_t_im_pending_fill) makes the file list itself legible: the four states that hold the protocol's shape stand apart from the dozens that thread between them. - Keep the map in one place. The stable-state diagram and the full transient list belong in a single doc block at the top of the package, not discovered class by class. The classes hold the behavior; one block holds the shape, so a reviewer sees the whole machine before reading any one cell — the vertical answer to §1's horizontal scavenger hunt.
- And the exit sign, stated plainly. Watch what the transient handlers become as they multiply. The interesting ones —
im_pending_fill_state, with its stall and deferred commit — carry real behavior and belong in classes. But the more you add, the more handlers shrink to a single line:return next_state::get(), no side effect, no stall, no bookkeeping — pure transition. When most of your states do nothing but name a successor, the per-state class has stopped paying for itself: you are spending a class, a singleton, and akind()override to encode one arrow. That is the honest exit sign. A protocol whose transients are almost all pure plumbing is asking for a table-driven FSM — the transition relation as data, a literal(state, event) → next_statetable the engine walks — not a wall of three-line classes encoding the same table one method at a time. The pattern is not the destination; it is the right tool while behavior varies per state, and the discipline to leave it when behavior drains out is the same judgment §3 used to keep UVM's lifecycles on the enum side. Reach for State when states act; reach for the table when they merely point.
Seventeen patterns. Seventeen orthogonal concerns. Factory builds, Adapter translates, Bridge decouples, Decorator adds, Facade simplifies, Proxy mediates, Composite treats a leaf and a subtree the same way, Observer broadcasts, Strategy swaps the algorithm, Chain of Responsibility delegates until claimed, Command encapsulates, Template Method fixes the flow — and State lets behavior follow the lifecycle, one class per state. None replaces another.
Quick Reference
Everything in one place — the §3 mapping finalized with the transient additions, the §3 checklist delivered as the table it promised, and the traps to avoid:
GoF Role → UVM Mapping
| GoF Role | Your Class / Mechanism |
|---|---|
| Context | cache_line (per-line model object inside the scoreboard) |
| State (abstract) | mesi_state virtual base class |
| handle() event methods | handle_core_read/core_write/snoop_read/snoop_invalidate + handle_reset (§5) + handle_fill (§6) |
| ConcreteState | modified_state, exclusive_state, shared_state, invalid_state, im_pending_fill_state |
| Transition | each handler returns the next mesi_state |
Every concrete state also implements kind() — not a GoF role, but the mandatory coverage-only integral shadow from §5, the fourth touch every new state owes.
Enum + case vs State Pattern: The Decision Checklist
| Signal | Verdict |
|---|---|
| Case arms are one-liners (assign, compare, wait) | enum + case |
| State set is frozen | enum + case |
Consumers only query and wait (get_state/wait_for_state) | enum + case |
| Arms grow behavior (calls, side effects, bookkeeping) | State pattern |
| The state×event matrix grows in both dimensions | State pattern |
| Illegal-transition policy must be uniform and auditable | State pattern |
Common Mistakes
| Mistake | Fix |
|---|---|
| State objects holding per-line data | Keep states stateless; pass the context into every handler |
new() per transition | Shared (singleton) state instances — all mutable data in the context |
Illegal transitions silently hit default | Base-class handlers uvm_error by default; legal rows override |
| Transition side effects scattered across handlers | One set_state() chokepoint — logging, coverage, mutation in one place |
| Confusing State with Strategy | States pick their own successors at runtime; a Strategy is injected once |
| Rebuilding the case matrix inside one state class | One virtual method per event — never case (event) inside a state |
Previous: Template Method Pattern — Define the flow once, override only the steps
Next: Coming soon
Comments (0)
Leave a Comment