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.
// 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);
endtaskAn 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.
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.
// 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.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.
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.// 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
endtaskContainer | 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 |
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.
Associative array. It allocates only touched addresses, while a fixed array over the full range allocates everything up front and may thrash the simulator.
A plain testbench with an integer queue proves the bounded-queue pattern: matched entries retire, unmatched entries stay, and unchecked growth trips the bound.
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