Skip to content

UVM

Memory Usage

Finding and bounding memory growth in a large UVM environment — why memory is a hard ceiling (an unbounded structure OOM-kills the test) unlike runtime which only slows it, why the dominant problem is unbounded accumulation of retained objects (queues that never drain, coverage that grows, histories and copies), why it is time-dependent and hides in short tests, how to find the growing structure by profiling heap over time rather than guessing, and how to bound retention so the working set stays fixed and large environments fit.

Simulation Performance Optimization · Module 28 · Page 28.2

The Engineering Problem

The previous chapter made the environment fast. This one keeps it alive, because memory is a different kind of problem from runtime. Runtime makes a test slow — a slow test still finishes. Memory, when it grows without bound, makes a test die — the compute farm OOM-kills it the moment it crosses the memory ceiling. And the most dangerous memory problem is time-dependent: a structure that grows with simulation time runs fine for a while, then crosses the ceiling and dies — so the test passes in short runs and OOMs after hours in the long nightly regression, masking in development and striking in production. The trap is reading the OOM as an infrastructure or functional failure ("why did it die at hour four?") when it's a memory leak — and guessing at the cause instead of measuring what grew. In a garbage-collected language like SystemVerilog, a "leak" isn't a missing free — it's retaining a reference to an object you no longer need, so the garbage collector can't reclaim it, and the set of retained objects grows without bound: a scoreboard queue that never drains, a coverage database that grows, a history that's never pruned, unnecessary copies. The problem this chapter solves is memory usage: why memory is a hard ceiling and unbounded growth is fatal, why it hides in short tests, how to find the growing structure by profiling heap over time, and how to bound retention so the working set stays fixed and large environments fit.

Memory usage optimization is finding and bounding the structures that grow without bound, so the testbench retains a fixed-size working set regardless of how long it runs. The crucial difference from runtime: memory is a hard ceilingunbounded growth doesn't slow the test, it kills it (OOM) — and it's time-dependent (fails only on long-enough runs, masking in short tests). The dominant cause is unbounded accumulation: retaining objects you no longer need (in a GC language, holding a reference prevents reclamation), so the retained set grows with simulation time. The common accumulators: transaction queues that never drain (a scoreboard or FIFO that pushes faster than it pops, or never pops unmatched entries — the canonical leak); coverage databases growing unbounded (fine-grained per-transaction bins); histories and logs retained "for debug" and never pruned; associative arrays keyed by something unbounded (every address, every ID); and unnecessary copies (cloning large transactions when a reference would do). The fix is almost always to bound the retention: free, pop, match-and-discard, or prune as you go, so the working set is the outstanding set (small and fixed), not the cumulative history (unbounded). The cardinal discipline: make memory bounded — a fixed working set that does not grow with simulation time — and find the leak by profiling heap over time (which structure grows monotonically?), not by guessing. This chapter is memory usage: the hard ceiling, unbounded accumulation, the time-dependence, heap profiling, and bounding retention.

Why is memory a hard ceiling that OOM-kills a test rather than just slowing it, why does unbounded accumulation hide in short tests and strike only on long runs, how do you find the growing structure by profiling heap over time, and how do you bound retention so the working set stays fixed?

Motivation — why memory is a hard ceiling and unbounded growth is fatal

Memory differs from runtime in ways that make unbounded growth a distinct, fatal, and sneaky failure. The reasons:

  • Memory is a hard ceiling; runtime is a slope. A slow test is still correct and still finishes — runtime is a gradual cost. An out-of-memory test dies — memory has a hard limit (the farm's RAM), and crossing it is fatal, not gradual.
  • Unbounded growth is time-dependent, so it hides. A structure that grows with simulation time is small in a short test (passes) and huge in a long one (OOMs) — so it hides in development (short runs) and strikes in regression (long runs). The failure correlates with run length, not with what the test does.
  • The OOM looks like something else. A test that dies at hour four looks like an infrastructure problem or a functional hang — so the natural first guess is wrong, and you debug the wrong thing until you recognize it as a memory leak.
  • In a GC language, leaks are retained references, not missing frees. SystemVerilog garbage-collects — there's no manual free. So a leak is holding a reference (in a queue, an array, a history) to something you no longer need, which prevents reclamation. The cause is accumulation, not forgetting to free.
  • Bounded vs unbounded is a design property you can measure. A memory-safe testbench has a fixed working set regardless of run length; an unsafe one grows. The difference is measurable (does the heap grow monotonically?) — so you find it by profiling over time, not guessing.

The motivation, in one line: memory is a hard ceiling (OOM kills, doesn't slow), and unbounded accumulation is time-dependent (hides in short tests, strikes on long runs) and looks like an infrastructure failure — and in a GC language it's retained references, not missing frees — so you must recognize the time-correlated OOM as a leak, profile the heap over time to find what grows, and bound the retention to a fixed working set.

Mental Model

Hold memory usage as a warehouse that must ship goods as fast as it receives them — or inventory piles up to overflow:

A warehouse takes in shipments and sends them back out. As long as it ships goods out as fast as it takes them in, the amount of inventory sitting on the floor stays roughly constant — it's just the goods currently in transit, being processed and moved along. That's a healthy, bounded operation: no matter how many years it runs or how many millions of shipments pass through, the floor never holds more than the in-transit set at any moment, which is small relative to the total throughput. Now suppose something goes wrong with shipping out. Maybe a fraction of incoming shipments get misrouted to a corner and forgotten, or a paperwork mismatch means they can never be matched to an outgoing order, so they just sit there. Each one is small, and on any given day you'd barely notice — the floor looks fine. But they accumulate. Every day, a few more shipments arrive that will never leave, and the pile of stuck inventory grows, slowly and relentlessly, without any bound. For a while the warehouse has plenty of room and operates normally. Then, after enough time, the stuck inventory fills the floor to the rafters, there's no room to process new shipments, and the whole operation grinds to a halt. The tragedy is that a short inspection would never have caught it — early on there's loads of space — and the failure shows up only after the warehouse has been running long enough for the slow pile-up to reach the ceiling. The fix is never "buy a bigger warehouse," which only delays the halt; it's to make sure everything received is shipped back out, so inventory stays bounded at the in-transit set. A warehouse takes in shipments and sends them out. As long as it ships as fast as it receives, the inventory on the floor stays roughly constant — just the goods in transit. That's a healthy, bounded operation: no matter how many years it runs, the floor never holds more than the in-transit set, which is small relative to total throughput. Now suppose shipping out goes wrong: a fraction of incoming shipments get misrouted to a corner and forgotten, or a mismatch means they can never be matched to an outgoing order, so they just sit there. Each is small, and on any given day you'd barely notice — the floor looks fine. But they accumulate: every day, a few more arrive that will never leave, and the pile grows, slowly and relentlessly, without bound. For a while the warehouse has plenty of room. Then, after enough time, the stuck inventory fills the floor to the rafters, there's no room, and operations halt. The tragedy: a short inspection would never have caught it (early on there's loads of space), and the failure shows up only after running long enough for the slow pile-up to reach the ceiling. The fix is never "buy a bigger warehouse" (which only delays the halt); it's to ship everything received back out, so inventory stays bounded at the in-transit set.

So memory usage is a warehouse shipping as fast as it receives: transactions arriving are shipments received, matching-and-freeing them is shipping them out, and the heap is the inventory on the floor. A bounded testbench ships as fast as it receives — its working set is the in-transit (outstanding) set, small and fixed regardless of run length. An unbounded one receives without fully shipping — a fraction of transactions are retained and never freed (a queue that never drains, a history never pruned), so inventory piles up slowly and relentlessly until it fills to the rafters (crosses the memory ceiling) and operations halt (OOM). The time-dependence is the warehouse's: short runs have loads of room (the test passes), long runs fill to overflow (the test OOMs after hours). And the fix is the warehouse's: not a bigger warehouse (more RAM only delays the OOM) but ship everything received (bound the retention — free/match/prune as you go, so the working set stays the outstanding set). Diagnose memory like warehouse inventory: profile the heap over time to find what piles up (grows monotonically), and bound retention so everything received is shipped out — because a structure that grows with run length will eventually hit the ceiling and halt, and a bigger warehouse only delays it. Ship as fast as you receive, or the floor fills to the rafters.

Visual Explanation — bounded vs unbounded, and the common accumulators

The defining picture is the bounded-vs-unbounded distinction and the common accumulators that grow without bound.

Bounded vs unbounded memory and common accumulatorsBounded working set (the goal)holds only the outstanding/in-transit set — a fixed size regardless of run length, does not grow with timeholds only the outstanding/in-transit set — a fixed size regardless of run length, does not grow with timeUnbounded: queues that never draina scoreboard or FIFO that pushes faster than it pops, or never removes unmatched entries — the canonical leaka scoreboard or FIFO that pushes faster than it pops, or never removes unmatched entries — the canonical leakUnbounded: coverage + historiescoverage databases with fine-grained bins; histories and logs retained for debug and never prunedcoverage databases with fine-grained bins; histories and logs retained for debug and never prunedUnbounded: unbounded keys + copiesassociative arrays keyed by every address/ID; unnecessary copies of large transactions where a reference would doassociative arrays keyed by every address/ID; unnecessary copies of large transactions where a reference would do
Figure 1 — bounded versus unbounded memory, and the common accumulators. A bounded working set stays a fixed size regardless of run length — it holds only the outstanding set (the in-transit transactions), so it does not grow with simulation time. An unbounded structure grows monotonically with simulation time and eventually crosses the memory ceiling, OOM-killing the test. The common accumulators that grow without bound: a transaction queue that never drains (a scoreboard with unmatched entries never removed — the canonical leak), a coverage database growing with fine-grained bins, histories and logs retained for debug and never pruned, associative arrays keyed by something unbounded, and unnecessary copies of large transactions. The fix is to bound retention so each accumulator stays a fixed working set.

The figure shows bounded versus unbounded and the common accumulators. Bounded working set (the success-coloredthe goal): holds only the outstanding/in-transit set — a fixed size regardless of run length, does not grow with time. Unbounded: queues that never drain (the warning-colored — the canonical leak): a scoreboard or FIFO that pushes faster than it pops, or never removes unmatched entries. Unbounded: coverage + histories (brand-colored): coverage databases with fine-grained bins; histories and logs retained for debug and never pruned. Unbounded: unbounded keys + copies (default-colored): associative arrays keyed by every address/ID; unnecessary copies of large transactions. The crucial reading is the binary distinction between the success-colored bounded working set and the three unbounded categories: a bounded structure's size is a function of the outstanding work (which stays small in steady state), while an unbounded structure's size is a function of cumulative simulation time (which grows without limit). This is the property that determines whether a testbench survives a long run: bounded survives any run length; unbounded dies once the run is long enough to cross the ceiling. The common accumulators are all the same mistake in different formsretaining something proportional to total throughput rather than outstanding work: a queue that keeps every transaction instead of only the unmatched; coverage that keeps a bin per distinct value when the value space is huge; a history that keeps every transaction "for debug"; an array keyed by every ID ever seen; copies kept when references would do. The fix for all of them is the same: bound the retentionretain only the outstanding set, free/prune/cap the rest. The diagram is the memory-safety distinction: bounded (fixed working set, survives) versus unbounded (grows with time, OOMs), with the common accumulators all being forms of retaining cumulative rather than outstanding state. Memory is safe when bounded — a fixed working set independent of run length — and dies when unbounded; the common accumulators all retain cumulative throughput instead of outstanding work, and the fix is to bound retention.

RTL / Simulation Perspective — the unbounded queue and bounding it

In code, the canonical leak is the queue that never fully drains, and the fix is bounding the retention. The example shows the leak and the bound.

the unbounded scoreboard queue — and bounding the retention
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ✗ UNBOUNDED: expected transactions pushed, but unmatched ones are NEVER removed → grows with time
class leaky_scoreboard extends uvm_scoreboard;
  my_txn expected_q[$];                       // the queue — should hold only OUTSTANDING expected
  function void write_expected(my_txn t);
    expected_q.push_back(t);                   // push every expected transaction
  endfunction
  function void write_actual(my_txn a);
    foreach (expected_q[i]) if (expected_q[i].matches(a)) begin
      expected_q.delete(i); return;            // matched → removed (good)
    end
    // ✗ if NO match (a dropped/never-arriving response), the expected entry stays FOREVER
  endfunction                                   // → a few unmatched per million → queue grows unboundedly → OOM after hours
endclass
 
// ✓ BOUNDED: cap retention — age out stale unmatched entries (and flag them), keep the working set fixed
class bounded_scoreboard extends uvm_scoreboard;
  typedef struct { my_txn t; int unsigned age; } entry_t;
  entry_t expected_q[$];
  function void write_actual(my_txn a);
    foreach (expected_q[i]) if (expected_q[i].t.matches(a)) begin expected_q.delete(i); return; end
  endfunction
  function void age_and_prune();                // called periodically (e.g., per N transactions)
    foreach (expected_q[i]) expected_q[i].age++;
    for (int i = expected_q.size()-1; i >= 0; i--)
      if (expected_q[i].age > MAX_AGE) begin
        `uvm_error("SB", "expected transaction never matched (timed out)")  // flag the real issue
        expected_q.delete(i);                   // ✓ remove → the queue stays BOUNDED to the outstanding set
      end
  endfunction
endclass
 
// === MEASURE: profile heap over time to find WHICH structure grows monotonically ===
//   take heap snapshots at t1 and t2 (e.g., hour 1 and hour 3) → diff → the structure that GREW is the leak
//   the simulator's memory profiler / +mem reports rank objects by count — the growing one is the accumulator

The code shows the unbounded queue and the bound. Unbounded (leaky_scoreboard): expected_q pushes every expected transaction, and write_actual removes a matched one — but if no match (a dropped or never-arriving response), the expected entry stays forever. So a few unmatched per million make the queue grow unboundedlyOOM after hours. Bounded (bounded_scoreboard): cap the retention by aging out stale unmatched entriesage_and_prune (called periodically) increments each entry's age and removes (and flags with uvm_error) any older than MAX_AGE — so the queue stays bounded to the outstanding set, and the never-matched entries are both freed and reported (the prune surfaces the real issue instead of silently leaking). Measure: profile heap over timetake snapshots at two times (hour 1, hour 3), diff them, and the structure that grew is the leak; the simulator's memory profiler ranks objects by count, and the growing one is the accumulator. The shape to carry: the canonical leak is a queue whose unmatched entries are never removed, growing unboundedly with time; the fix is to bound retentioncap, age out, or prune so the working set is the outstanding set — and the pruning also flags the real problem (the never-matched transaction) rather than hiding it as a slow leak. Bound retention so queues hold only the outstanding set — age out or prune stale entries (flagging them) — and find the leak by profiling the heap over time to see which structure grows.

Verification Perspective — the leak hides in short tests

The defining danger is the time-dependence: an unbounded leak passes in short tests and OOMs only on long runs, so it escapes development and strikes regression. Seeing why clarifies why you test at scale and profile over time.

A leak hides in short tests and strikes long runsshort run: grows a littleshort run:grows a…under the ceiling → passesunder theceiling →…long run: growslargecrosses the ceiling → OOMcrosses theceiling →…Unbounded structuregrows linearly with timeShort testdevelopment, smokePasses (leak invisible)well under the ceilingLong regressionhours, millions of txnsOOM-killedcrosses the ceiling12
Figure 2 — the leak hides in short tests and strikes on long runs. An unbounded structure grows linearly with simulation time. In a short test — development, a quick smoke run — it grows only a little, stays well under the memory ceiling, and the test passes; the leak is invisible. In the long nightly regression — millions of transactions over hours — it grows large, crosses the memory ceiling, and OOM-kills the test. The same leak is harmless at short run lengths and fatal at long ones, so it escapes development (where runs are short) and strikes production (where runs are long). This is why memory safety can't be confirmed by short tests, and why you profile heap growth over time rather than checking peak memory once.

The figure shows the leak hiding in short tests and striking on long runs. An unbounded structure grows linearly with simulation time. In a short test (development, a quick smoke run), it grows only a little, stays well under the memory ceiling, and the test passes — the leak is invisible. In the long nightly regression (millions of transactions over hours), it grows large, crosses the memory ceiling, and OOM-kills the test. The verification insight is that the same leak is harmless at short run lengths and fatal at long ones — so the failure correlates with run length, not with what the test does. This inverts the usual debugging signal: normally a failure points at the test that triggers it, but a memory leak is triggered by duration, so it appears in whatever test runs longest (often a soak or regression test), regardless of which test contains the leaky code — the leaky scoreboard might be exercised by every test, but only the long one OOMs. The warning leak → default short → success pass path and the warning leak → default long → warning OOM path show the same structure producing opposite outcomes based purely on run length. This is why memory safety cannot be confirmed by short tests: a passing smoke test proves nothing about memory — the leak is there, just not yet large enough to kill. And it's why you profile heap growth over time (does it grow?) rather than checking peak memory once (is it under the limit right now?): a one-time peak check on a short run passes a leak that will OOM on a long run, while a growth-over-time check catches it (the slope is non-zero). The crucial discipline is to test memory at scale (long soak runs) and measure growth (not just peak), because the leak's defining property — growth with time — is only visible over time. The diagram is the time-dependence: a leak passes short and OOMs long, so confirm memory safety with long runs and growth measurement, not short tests and peak checks. A leak is harmless at short run lengths and fatal at long ones, so it hides in development and strikes regression — confirm memory safety by measuring heap growth over long runs, not peak memory once on a short test.

Runtime / Execution Flow — finding and bounding the leak

At run time, finding a leak follows a measure-then-bound discipline: recognize the time-correlated OOM, profile heap over time, find the growing structure, bound its retention. The flow shows it.

Finding and bounding a memory leaksymptom: OOM correlated with run length (leak, not infra) → profile heap over time (snapshots at t1, t2 → diff) → find the structure that grew monotonically → bound its retention (free/pop/prune to the outstanding set) → re-profile a long run: heap flat over timesymptom: OOM correlated with run length (leak, not infra) → profile heap over time (snapshots at t1, t2 → diff) → find the structure that grew monotonically → bound its retention (free/pop/prune to the outstanding set) → re-profile a long run: heap flat over time1Symptom: OOM correlated with run lengthdies on long runs, passes on short — a leak, not an infrastructureor functional bug.2Profile the heap over timesnapshots at two points, or the memory profiler ranking objects bycount — diff them.3Find the structure that grew monotonicallythe accumulator retains cumulative state (every transaction/bin)instead of outstanding state.4Bound retention → re-profilefree/pop/prune to the outstanding set; re-profile a long run toconfirm the heap is flat over time.
Figure 3 — finding and bounding a memory leak. Recognize the symptom: an OOM that correlates with run length — the test dies only on long runs, passes on short ones — which says leak, not infrastructure or functional bug. Profile the heap over time: take snapshots at two points in the run and diff them, or use the simulator's memory profiler to rank objects by count. Find the structure that grew monotonically between the snapshots — that is the accumulator. Identify why it grows: it retains cumulative state (every transaction, every bin) instead of outstanding state. Bound the retention: free, pop, match-and-discard, cap, or prune so the working set is the outstanding set. Re-profile a long run to confirm the heap is now flat over time.

The flow shows finding and bounding a leak. Symptom (step 1): an OOM that correlates with run lengthdies on long runs, passes on shorta leak, not an infrastructure or functional bug. Profile (step 2): snapshots at two points, or the memory profiler ranking objects by countdiff them. Find (step 3): the structure that grew monotonically — the accumulator retains cumulative state (every transaction/bin) instead of outstanding state. Bound → re-profile (step 4): free/pop/prune to the outstanding set; re-profile a long run to confirm the heap is flat over time. The runtime insight is that the diff between two heap snapshots is the key technique: a single snapshot tells you what's in memory now (which might be fine), but the diff between two times tells you what grew, and what grew is the leak. This is the memory analogue of profiling for runtime (28.1): don't guess which structure leaksmeasure which one grows. The finding step (3) often reveals the cause directly: the growing structure's contents show what it's retaining (a queue full of old unmatched transactions, a coverage model with millions of bins), which points at the retention bug. The bounding step (4) is the fixmake the retention proportional to outstanding work, not cumulative throughput — and the re-profile is the confirmation: a bounded fix makes the heap flat over time (the slope goes to zero), which a long re-profile verifies. The crucial point is that the confirmation must be over time on a long run: a fixed leak and an unfixed leak both look fine in a short run, so only a long run with growth measurement proves the fix worked (the heap stopped growing). The brand (symptom) → success (profile/find) → warning (bound/confirm) flow shows measurement driving the fix and confirming it. The flow is the leak diagnostic: time-correlated OOM → profile heap over time → find what grew → bound retention → re-profile flat. Find a leak by recognizing the run-length-correlated OOM, profiling the heap over time to see which structure grew, bounding its retention to the outstanding set, and re-profiling a long run to confirm the heap is flat.

Waveform Perspective — the queue that only grows

The unbounded growth is visible as a retained count that only ratchets upnever returning to a baseline, unlike a bounded queue that oscillates around a steady level. The waveform shows the monotonic growth.

The retained queue depth only ratchets up — never draining back — the signature of an unbounded leak

12 cycles
The retained queue depth only ratchets up — never draining back — the signature of an unbounded leaktransactions arrive continuously (txn_in) — the inflowtransactions arrive co…most are matched and freed (freed) — the outflow, which should keep the queue boundedmost are matched and f…but a fraction are never matched → queue_depth ratchets up, never draining backbut a fraction are nev…queue_depth climbs monotonically (01→05...) → over a long run it crosses the ceiling → OOMqueue_depth climbs mon…clktxn_infreedqueue_depth010102020203030404040505t0t1t2t3t4t5t6t7t8t9t10t11
Figure 4 — the queue that only grows. Transactions arrive (txn_in) and most are matched and freed (freed pulses), which would keep a healthy queue bounded. But a fraction are never matched, so each time, the retained queue depth ratchets up by one and never comes back down — queue_depth climbs monotonically (1, 2, 3, 4, 5) rather than oscillating around a steady level. A bounded queue would rise and fall as transactions are matched and freed, hovering around the outstanding count; this one only rises, because the unmatched entries are never removed. Over a long run that monotonic climb crosses the memory ceiling and OOM-kills the test. The signature is a retained count that only ever increases, never returning toward a baseline.

The waveform shows the queue that only grows. Transactions arrive (txn_in) and most are matched and freed (freed pulses), which would keep a healthy queue bounded. But a fraction are never matched, so each time, the retained queue depth ratchets up by one and never comes back downqueue_depth climbs monotonically (01, 02, 03, 04, 05) rather than oscillating around a steady level. The crucial reading is the shape of queue_depth: it only risesnever falls back — and that monotonic, ratcheting climb is the signature of unbounded accumulation. A bounded queue would rise and fall as transactions are matched and freed, hovering around the outstanding count (the in-transit set); this one only rises, because the unmatched entries are never removed. The picture to carry is the contrast between oscillating and ratcheting: a healthy working set oscillates (fills and drains, steady on average) — its depth is bounded; a leaking one ratchets (fills faster than it drains, or never drains some entries) — its depth grows without bound. Reading the queue depth over timedoes it return toward a baseline, or only climb? — is the diagnostic: oscillating around steady → bounded → safe; climbing monotonically → unbounded → will OOM on a long enough run. Over the short window shown, the climb is small (01 to 05) and harmless — but extrapolated over millions of transactions and hours, that same slope crosses the memory ceiling and OOM-kills the test. This is why the signature is the slope, not the level: the level (5) is fine now, but the non-zero slope (always rising) guarantees a future OOM. A retained count that only ratchets up, never draining back toward a baseline, is the signature of an unbounded leak — a bounded queue oscillates around a steady level, while a leaking one climbs monotonically and will OOM on a long enough run; watch the slope, not the level.

DebugLab — the scoreboard queue that OOM-killed the regression after four hours

A nightly regression test that ran fine for hours then OOM-died, from a scoreboard queue that never removed unmatched entries

Symptom

A team's nightly regression included a long soak testmillions of transactions over several hours. It had passed for months. Then, after the DUT grew and the soak test was extended to run longer, the test began dyingOOM-killed by the compute farm after about four hours, every night, never reaching the end. The failure was baffling: the test ran correctly for four hoursno functional errors, no mismatches, the scoreboard comparing cleanly — and then just died. The team first suspected infrastructure ("the farm node ran out of memory — a farm problem?"), then a functional hang ("did it get stuck at hour four?"). But the test hadn't hung — it had been making progress right up to the OOM — and other tests on the same nodes were fine. The failure correlated with one thing: how long the test ran. Shorter versions of the same test passed; only the full-length soak OOMed, and it OOMed later if given more memorydelaying, not fixing the death.

Root cause

The scoreboard's expected-transaction queue grew without bound: a small fraction of expected transactions were never matched (a corner-case response that occasionally never arrived), and the scoreboard only removed matched entries — so the unmatched ones accumulated forever, growing the queue linearly with simulation time until it crossed the memory ceiling after four hours:

why a scoreboard queue grew unboundedly and OOMed only on long runs
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
✗ UNBOUNDED — unmatched expected entries are NEVER removed:
  function void write_actual(my_txn a);
    foreach (expected_q[i]) if (expected_q[i].matches(a)) begin expected_q.delete(i); return; end
    // if NO match (a corner-case response that never arrives), the entry stays in expected_q FOREVER
  endfunction
  // a few unmatched per million transactions → expected_q grows ~linearly with run time
  // short test: queue small → PASSES.  4-hour soak: queue huge → crosses ceiling → OOM (DebugLab)
  // more memory just delays the OOM (bigger warehouse, same pile-up)
 
✓ BOUNDED — age out + flag stale unmatched entries → working set stays the OUTSTANDING set:
  function void age_and_prune();   // periodically
    for (int i = expected_q.size()-1; i >= 0; i--)
      if (++expected_q[i].age > MAX_AGE) begin
        `uvm_error("SB", "expected transaction never matched — the REAL bug, now surfaced")
        expected_q.delete(i);      // remove → queue bounded; AND the dropped-response bug is now reported
      end
  endfunction
 
  // FOUND BY: heap snapshots at hour 1 vs hour 3 → diff → expected_q grew monotonically → the leak

This is the unbounded-queue leak — the canonical memory failure, and a textbook time-dependent OOM. The scoreboard pushed every expected transaction into expected_q and removed it when a matching actual arrivedcorrect for matched transactions. But a small fraction of expected transactions were never matched — a corner-case response that occasionally never arrived (itself a real but rare issue) — and the scoreboard had no path to remove an unmatched entry, so those entries stayed in the queue forever. A few unmatched per million transactions made expected_q grow roughly linearly with simulation time. The time-dependence explains everything baffling: short tests passed (the queue stayed small); the four-hour soak OOMed (the queue grew huge); more memory delayed but didn't fix it (a bigger warehouse, same pile-up); and it looked like infrastructure (the OOM is a farm-level event) rather than a testbench bug. The team debugged the wrong thingsinfrastructure, a hang — until someone recognized the run-length correlation as the signature of a leak and profiled the heap: snapshots at hour one and hour three, diffed, showed expected_q grew monotonicallythe accumulator. The fix is to bound the retention: an age_and_prune that ages each entry and, past MAX_AGE, removes it AND flags it with a uvm_error — so the queue stays bounded to the outstanding set and the never-matched transactions (the real underlying bug — the dropped response) are surfaced as errors instead of silently leaking. So the bound both fixes the memory death and exposes the functional issue the leak was hiding. The general lesson, and the chapter's thesis: a structure that retains cumulative state (every transaction) instead of outstanding state (the unmatched/in-transit set) grows without bound and OOM-kills the test on a long enough runthe failure correlates with run length, hides in short tests, and looks like infrastructure; recognize the run-length-correlated OOM as a leak, profile the heap over time to find the structure that grew, and bound the retention (free/prune to the outstanding set, flagging what you prune), because a leak passes every short test and dies only on the long run, and more memory only delays the death — the fix is to ship everything you receive, not to buy a bigger warehouse. An OOM that correlates with run length is a leak, not infrastructure — profile the heap over time, find what grows, and bound its retention.

Diagnosis

The tell is an OOM that correlates with run length — long runs die, short ones pass. Diagnose a leak:

  1. Correlate the OOM with duration, not the test. If the failure depends on how long the test ran rather than what it did, and more memory only delays it, it's an unbounded leak.
  2. Profile the heap over time. Take snapshots at two points in the run and diff them, or use the memory profiler; the structure that grew monotonically is the accumulator.
  3. Inspect the growing structure's contents. A queue full of old unmatched entries, or a coverage model with millions of bins, shows what is being retained and why.
  4. Distinguish retaining cumulative from outstanding state. The leak retains something proportional to total throughput (every transaction) instead of outstanding work (the in-transit set).
Prevention

Bound retention so memory stays fixed:

  1. Retain only the outstanding set. Free, pop, or match-and-discard as you go, so a queue holds the in-transit transactions, not the cumulative history.
  2. Cap or age out anything that could accumulate. Add a maximum age or size to queues and histories, removing and flagging stale entries rather than letting them pile up.
  3. Profile memory growth on long runs. Confirm memory safety by measuring heap growth over a long soak, not peak memory once on a short test.
  4. Prune to surface, not hide, the cause. When you remove a stale unmatched entry, flag it as an error, so the bound also exposes the underlying functional issue.

The one-sentence lesson: a structure that retains cumulative state instead of outstanding state grows without bound and OOM-kills the test on a long enough run while passing every short test, so recognize a run-length-correlated OOM as a leak (not infrastructure), profile the heap over time to find the structure that grew monotonically, and bound its retention to the outstanding set — freeing or aging-out and flagging what you prune — because more memory only delays the death.

Common Mistakes

  • Retaining cumulative state instead of outstanding state. A queue or history that keeps every transaction grows without bound; retain only the outstanding/in-transit set.
  • Never removing unmatched entries. A scoreboard that only removes matched entries leaks the unmatched ones forever; age out and flag stale entries.
  • Confirming memory safety with short tests. A leak passes every short run; confirm with long soak runs and heap-growth measurement, not peak memory once.
  • Reading a time-correlated OOM as infrastructure. An OOM that depends on run length is a leak; don't blame the farm or chase a hang.
  • Adding memory instead of bounding retention. More RAM only delays the OOM; the fix is to make the working set bounded, not the warehouse bigger.
  • Unbounded keys and unnecessary copies. Associative arrays keyed by every ID, or copies kept where references would do, accumulate; bound the keys and prefer references.

Senior Design Review Notes

Interview Insights

Memory is a hard ceiling because there's a fixed amount of it — the compute node's RAM — and crossing it is fatal: the test is OOM-killed, dead, not finished. Runtime is fundamentally different: it's a slope, not a ceiling. A slow test is still correct and still completes; runtime is a gradual cost you pay in wall-clock time, and you can always wait longer or throw more cores at a regression. Memory has no such graceful degradation. When an unbounded structure grows past the available RAM, the operating system or the farm scheduler kills the process immediately, and the test produces no result. So the two resources fail in opposite ways: runtime degrades, memory dies. The second crucial difference is time-dependence. Runtime cost is roughly proportional to work, so a test that's slow is slow from the start and slow throughout — you notice it early. An unbounded memory structure grows with simulation time, so the failure is correlated with run length, not with what the test does. It's small and harmless in a short run, and only crosses the ceiling after the test has run long enough. That makes it sneaky in a way runtime problems aren't: it passes every short test and dies only on the long nightly regression or soak. A third difference is that more of the resource doesn't fix it. Adding cores genuinely speeds up a slow regression. Adding memory does not fix a leak — it only delays the OOM, because an unbounded structure will eventually exhaust any finite amount; it's a bigger warehouse with the same pile-up. So the fix for memory is qualitatively different from runtime: for runtime you make the hot path faster, but for memory you make the working set bounded — you ensure it doesn't grow with time at all. And the diagnostic is different: for runtime you profile where cycles go and rank by cost; for memory you profile heap over time and look for what grows monotonically. The unifying theme with runtime is measure-don't-guess, but the failure mode, the time-dependence, the futility of adding resource, and the bounded-working-set fix are all specific to memory being a hard ceiling rather than a slope.

Unbounded accumulation is when a structure retains objects proportional to cumulative throughput rather than outstanding work, so it grows without bound as the simulation runs — and in a garbage-collected language like SystemVerilog, that's what a leak is, because the leak isn't a missing free, it's a retained reference. In languages with manual memory management, a leak is allocating memory and forgetting to free it. SystemVerilog garbage-collects: objects are reclaimed automatically when nothing references them, so there's no free to forget. But the garbage collector can only reclaim an object that's unreachable — if anything still holds a reference to it, it stays in memory. So a leak in this world is holding a reference to something you no longer need. Concretely, you push transactions into a queue, or store them in an associative array, or append them to a history, and you never remove them. The objects are still referenced by that container, so the GC can't reclaim them, and the container grows without bound. The distinction that matters is bounded versus unbounded retention. A bounded structure retains only the outstanding set — the in-transit work at any moment — which stays small in steady state regardless of how long you run; for example a scoreboard that holds only the expected transactions not yet matched. An unbounded structure retains cumulative state — every transaction ever seen, every coverage bin ever hit, every entry ever added — which grows with total throughput, that is, with time. The common forms are all the same mistake: a queue that pushes but doesn't fully pop, a history kept for debug and never pruned, an associative array keyed by every address or ID ever seen, coverage with a bin per distinct value in a huge space, or copies retained where references would do. All of them retain something proportional to total throughput instead of outstanding work. The fix is always to bound the retention: free, pop, match-and-discard, cap, or prune so that what's retained is the outstanding set, not the cumulative history. The mental check is: does this structure's size depend on how long I run, or only on how much work is in flight right now? If it depends on run length, it's unbounded, and it will eventually OOM.

A memory leak hides in short tests and strikes on long runs because an unbounded structure grows with simulation time, so its size — and whether it crosses the memory ceiling — depends on how long the test runs, not on what the test does. Picture the growth as a line rising with time. In a short test — a developer's quick run, a smoke test — the line rises only a little, stays far below the memory ceiling, and the test passes; the leak is present but invisible, because it hasn't had time to grow large. In the long nightly regression or a multi-hour soak, the same line rises far higher, crosses the ceiling, and the test is OOM-killed. The identical leak, in the identical code, is harmless at short run lengths and fatal at long ones. This produces several confusing consequences. First, the failure correlates with duration, not with the test's content, so it tends to appear in whatever test runs longest, regardless of which code contains the leak — the leaky scoreboard might be exercised by every test, but only the long soak OOMs. Second, it escapes development, where runs are short, and strikes production regression, where runs are long, so it gets caught late, by the regression rather than the author. Third, it looks like an infrastructure problem, because the OOM is a system-level event at hour four, not a clear functional error. And fourth, giving it more memory makes it die later, which feels like progress but is just delay. The key methodological consequence is that you cannot confirm memory safety with short tests, and you cannot confirm it by checking peak memory once. A short run passes a leak that will OOM on a long run, and a one-time peak check tells you the level now, not whether it's growing. The defining property of a leak — growth with time — is only visible over time. So you confirm memory safety by running long soak tests and measuring heap growth, looking at the slope: a bounded testbench has a flat heap over time, a leaking one has a rising heap. That's why memory testing specifically requires duration and growth measurement, not just a peak check on a quick run.

You find which structure is leaking by profiling the heap over time and looking for the structure that grows monotonically — specifically by taking heap snapshots at two points in the run and diffing them, because what grew between them is the leak. The principle is the same measure-don't-guess discipline as runtime profiling, applied to memory, but with a crucial twist: for memory, a single snapshot isn't enough. One snapshot tells you what's in memory right now, which might look perfectly reasonable — lots of transactions, some queues, the coverage model. It doesn't tell you what's growing. The diagnostic signal of a leak is growth, so you need two snapshots separated in time — say at hour one and hour three of a long run — and you diff them. The structures that are the same size in both are bounded and fine. The structure that's much larger in the later snapshot is the accumulator, the leak. The simulator's memory profiler typically helps here by ranking objects by count or by type, so you can see, for instance, that the count of expected-transaction objects went from a few thousand to a few hundred thousand between the snapshots — that's your queue growing. Once you've identified the growing structure, you inspect its contents to understand why it grows. A queue full of old, never-matched transactions tells you the scoreboard isn't removing unmatched entries. A coverage model with millions of bins tells you the bins are too fine-grained for the value space. An associative array with an entry per unique ID tells you the key space is unbounded. The contents reveal the retention bug directly. Then you bound the retention and, importantly, re-profile a long run to confirm the fix: the heap should now be flat over time, the growing structure should have stopped growing. That confirmation has to be over time on a long run, because a fixed leak and an unfixed leak both look fine in a short run — only the slope over a long run distinguishes them. So the method is: recognize the run-length-correlated OOM, take two heap snapshots and diff, find the monotonically growing structure, inspect its contents for the retention bug, bound the retention, and re-profile a long run to confirm the heap is now flat. Never guess which structure leaks — measure which one grows.

You bound retention by making what a structure holds proportional to outstanding work rather than cumulative throughput — freeing, popping, matching-and-discarding, capping, or aging-out entries as you go — so the working set stays a fixed size regardless of run length. The general principle is that every accumulating structure needs a removal path that keeps pace with its addition path. A scoreboard queue that pushes expected transactions needs to remove them — not just the matched ones, but a way to remove entries that will never match, otherwise the unmatched ones accumulate forever. A history kept for debug needs a maximum size or age, dropping the oldest. An associative array keyed by ID needs the keys bounded or entries evicted. Coverage with huge bin counts needs coarser bins. The most robust technique for queues is aging: each entry carries an age that increments over time, and periodically you prune entries older than a maximum age, so nothing can sit in the queue indefinitely — the queue is bounded to roughly the entries added within the age window, which is the outstanding set. Now, why bounding also surfaces the underlying bug: in the canonical case, the reason entries accumulate is that some of them never match — and that not-matching is itself a real functional issue, like a response that occasionally never arrives or a dropped transaction. An unbounded queue hides that issue by silently retaining the unmatched entries; the symptom is only a slow memory leak, with no functional error reported, so the dropped-response bug goes unnoticed. When you bound retention by aging out stale entries, you have a choice about what to do at the moment of pruning, and the right choice is to flag it: raise an error reporting that an expected transaction was never matched and is being timed out. Now the prune does two things at once — it removes the entry, keeping memory bounded, and it reports the never-matched transaction as an error, surfacing the functional bug that was causing the accumulation. So bounding retention with a flagging prune converts a silent memory leak into both a bounded working set and an explicit functional error, fixing the OOM and exposing the real cause the leak was masking. That's why the discipline is to prune to surface, not to hide: aging out and flagging is strictly better than aging out silently, because the thing accumulating is usually a symptom of a real problem worth reporting.

Exercises

  1. Bounded or unbounded. For each — a scoreboard holding unmatched expected transactions, a queue of in-flight requests drained on response, a per-address associative array, a transaction history capped at 1000 entries — say whether memory is bounded or unbounded and why.
  2. Diagnose the OOM. Given a soak test that OOMs at hour four but passes when shortened, and dies later with more memory, explain why this is a leak and how you'd find it.
  3. Bound the queue. Given a scoreboard that only removes matched entries, add an aging-and-pruning mechanism and explain how it bounds memory and surfaces the dropped-response bug.
  4. Confirm the fix. Describe the measurement that confirms a memory fix worked, and why it must be over time on a long run.

Summary

  • Memory usage optimization is finding and bounding the structures that grow without bound, so the testbench retains a fixed-size working set regardless of run length.
  • Memory is a hard ceiling, unlike runtime: an unbounded structure OOM-kills the test (doesn't slow it), and it's time-dependent — it passes in short tests and strikes only on long runs, hiding in development and looking like infrastructure.
  • The dominant cause is unbounded accumulation: in a GC language, a leak is a retained reference (the GC can't reclaim a referenced object), so retaining cumulative state (every transaction, every bin) grows with time — the canonical leak is a scoreboard queue that never removes unmatched entries.
  • Find the leak by profiling the heap over time (diff two snapshots; the structure that grew monotonically is the accumulator), not by guessing; the signature is the slope (growth), not the level.
  • The durable rule of thumb: make memory bounded — a fixed working set that retains only the outstanding (in-transit) set, not the cumulative history — because an unbounded structure that retains cumulative state grows with simulation time and OOM-kills the test on a long enough run while passing every short test, and more memory only delays the death; recognize a run-length-correlated OOM as a leak, profile the heap over time to find the structure that grew monotonically, and bound its retention (free, pop, cap, or age-out and flag what you prune, which also surfaces the underlying bug) — ship everything you receive, don't buy a bigger warehouse.

Next — Logging Optimization: logging sits at the intersection of runtime and memory — it costs CPU to format and write, and it accumulates on disk and in memory. The next chapter focuses on the reporting system as a performance concern: why verbose logging dominates both runtime and storage, how to control verbosity and message construction so logs cost nothing when disabled, how to log enough to debug without drowning the simulation, and how to keep the reporting system from becoming the bottleneck it so often is.