Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutContactPrivacyTerms
Skip to lesson

Course contents

  • Start Here6
  • SystemVerilog Language Foundations28
  • Object-Oriented SystemVerilog for Verification30
  • Constraint Randomization — Interview-Critical Deep Dive36
  • SystemVerilog Assertions — Interview-Critical Deep Dive40
  • Functional Coverage in SystemVerilog22
  • Testbench Architecture in SystemVerilog23
  • Advanced Language & Integration25
    • DPI-C: Calling C from SystemVerilog
    • bind: Attaching Verification to RTL
    • Macros, Defines & Compilation
    • Plusargs, Files & Runtime Configuration
    • Simulation Performance & TB Memory
      • Where Simulation Time Goes
      • Testbench Hotspots
      • TB Memory Discipline
      • Visibility Cost and Debug Strategy
    • RTL/TB Boundaries, Races & Timing
  • Senior & Interview Preparation21
  • Principal-Level Verification28
Loading lesson

‹Simulation Performance & TB Memory
  1. Topics
  2. SystemVerilog
  3. Advanced Language & Integration
  4. Simulation Performance & TB Memory
  5. TB Memory Discipline

Part 7 · Advanced & Integration · Intermediate

TB Memory Discipline

Bounded queues, retained handles, sparse arrays, watermarks and a leak-hunt walkthrough.

On this page

    Bound every queue

    SystemVerilog is garbage-collected, so a C-style leak is impossible. The equivalent failure is keeping references to objects that are never used again. A scoreboard that pushes expected transactions but pops only on a match leaves an entry behind on every mismatch, dropped response or filtered transaction.

    A long soak test then dies with the simulator out of memory. The fix is a bound: exceeding it is a loud report at the push site, not a slow death hours later.

    design.sv
    1
    2
    3
    4
    5
    6
    7
    // snippet
    localparam int MAX_OUTSTANDING = 256;
    task automatic write_expected(int id);
      expected_q.push_back(id);
      if (expected_q.size() > MAX_OUTSTANDING)
        $error("scoreboard queue exceeded %0d entries", MAX_OUTSTANDING);
    endtask
    • An object is collected only when no handle references it. One forgotten queue entry pins the transaction and everything it references.

    • Static arrays of handles, such as a debug log of every transaction ever seen, are deliberate leaks. Cap or disable them in long runs.

    • A mailbox with no consumer grows silently. Guard the put side when the consumer can be disabled.

    • Assigning null to one handle frees nothing while another copy of the handle survives elsewhere.

    Sparse storage and clean retire

    A memory model declared as a fixed array over the full address space allocates the whole range up front. An associative array allocates only the entries actually written, which is the standard tool for sparse address spaces and ID-keyed trackers.

    A read of an address never written needs a 4-state return. A 2-state byte converts X to 0, so the function below returns logic to keep the unknown.

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // snippet
    byte mem_sparse [bit [31:0]];   // sparse: only written addresses exist
    
    function void backdoor_write(bit [31:0] addr, byte data);
      mem_sparse[addr] = data;      // entry created on first write
    endfunction
    
    function logic [7:0] backdoor_read(bit [31:0] addr);
      if (!mem_sparse.exists(addr)) return 8'hxx;   // 4-state return keeps X
      return mem_sparse[addr];
    endfunction
    
    // Per-tag trackers retire the same way: delete the entry when done,
    // which frees the entry and drops the handle.

    Hunt the leak with watermarks

    Long tests should self-report container sizes on a period. When memory grows, the hunt is mechanical: find which container grows, then find why its entries are never removed.

    Diagram
    LEAK-HUNT WALKTHROUGH
    
      Symptom: 12-hour soak test; simulator RSS grows 1 GB per hour.
    
      Step 1  Add a periodic watermark report (every 1M cycles):
                scb.expected_q     : 14    14    15    13   stable, innocent
                scb.posted_wr_q    : 210   8k    61k   480k growing, suspect
                mem_model entries  : 4k    4k    4k    4k   stable
    
      Step 2  Why does posted_wr_q only grow?
                push site: write_expected on every posted write, as expected
                pop site:  on write RESPONSE, but the DUT sends
                           no responses for POSTED writes
    
      Step 3  Fix: posted writes check against the memory model
              directly and are never enqueued. Re-run: RSS flat.
    
      Moral: the leak was a protocol misunderstanding, found by
      watching WHICH container grows, not by staring at code.
    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    // snippet
    task automatic memory_watermark();
      forever begin
        repeat (1000000) @(posedge clk);
        $display("[MEMWATCH] t=%0t exp_q=%0d in_flight=%0d",
                 $time, expected_q.size(), in_flight.num());
      end
    endtask

    Container

    Grows when

    Fix

    Expected queue

    Entries never match or retire

    Bound with a loud report on overflow

    Debug history array

    Every transaction is logged forever

    Cap depth or disable in long runs

    ID-keyed tracker

    Retire forgets delete(id)

    Delete the entry on retire

    Sparse memory model

    Test writes a huge range

    Expected; check the range is intended

    Common mistakes

    Common pitfall

    A scoreboard queue that shrinks only on a perfect match leaks on every mismatch. Each unmatched entry stays forever. Bound the queue and drain or report stale entries, so a checking hole cannot become a memory death.

    Interview

    Likely question: “A soak test climbs 1 GB per hour. Walk through the triage.”

    • Watermarks first: periodic container sizes name the growing queue.

    • Push vs pop: list every push site and every pop site for that container.

    • Protocol check: the usual root cause is a pop on an event the DUT never sends.

    Check yourself A scoreboard pushes every expected transaction and deletes on a compare match. A dropped DUT response leaves its entry behind. What bounds the damage?

    A queue bound with a loud report on overflow. The bound turns silent growth into a report at the push site, close to the cause.

    Check yourself A 4 GB address space needs a backdoor memory model, but tests touch 4 KB of it. Fixed array or associative array, and why?

    Associative array. It allocates only touched addresses, while a fixed array over the full range allocates everything up front and may thrash the simulator.

    Testbench

    A plain testbench with an integer queue proves the bounded-queue pattern: matched entries retire, unmatched entries stay, and unchecked growth trips the bound.

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    module tb_bounded_queue;
      localparam int MAX_OUTSTANDING = 8;
      int expected_q[$];
      int over_limit = 0;
      int errors = 0;
    
      task automatic write_expected(int id);
        expected_q.push_back(id);
        if (expected_q.size() > MAX_OUTSTANDING) over_limit++;
      endtask
    
      task automatic write_actual(int id);
        int idx = -1;
        for (int i = 0; i < expected_q.size(); i++)
          if (idx == -1 && expected_q[i] == id) idx = i;
        if (idx != -1) expected_q.delete(idx);   // retire; unmatched entries stay
      endtask
    
      initial begin
        for (int k = 0; k < 8; k++) write_expected(k);   // fill to the bound
        for (int k = 0; k < 8; k += 2) write_actual(k);  // retire evens, 4 remain
        if (expected_q.size() != 4) errors++;
        for (int k = 100; k < 106; k++) write_actual(k); // 6 unmatched: queue kept
        if (expected_q.size() != 4) errors++;
        for (int k = 10; k < 20; k++) write_expected(k);  // 10 more, none retired
        if (over_limit == 0) errors++;                    // bound must have tripped
        $display("[MEMWATCH] exp_q=%0d over_limit=%0d", expected_q.size(), over_limit);
        if (errors == 0) $display("PASS: bounded queue held 4 retirees and flagged the overflow");
        else $fatal(1, "FAIL: exp_q=%0d over_limit=%0d", expected_q.size(), over_limit);
        $finish;
      end
    endmodule

    Related topics

    • Classes and handles — object costs and handle discipline

    Previous

    205 / 259

    Next: Visibility Cost and Debug Strategy

    Up next

    Up nextVisibility Cost and Debug Strategy

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Where Simulation Time Goes