feat(sim): add CAM performance sweep test infrastructure

- hw/sim/tests/test_cam_perf.py: new cocotb perf test with bounded wait helpers
- scripts/sweep_cam_perf.py: sweep data model, matrix, and make command builders  
- tests/test_sweep_cam_perf.py: unit tests for sweep helpers
- tests/conftest.py: pytest path configuration for scripts package
- hw/sim/Makefile: deterministic noise params override for perf test compatibility
This commit is contained in:
2026-05-15 18:43:25 +08:00
parent 24b8750f0f
commit 2e0e36eea5
5 changed files with 1396 additions and 0 deletions

View File

@@ -0,0 +1,306 @@
from __future__ import annotations
import cocotb
from cocotb.clock import Clock
from cocotb.triggers import RisingEdge, ReadOnly, Timer
# ── Helpers (local reimplementation — no imports from test_cam_basic) ─────────
def _get_param(dut, name, default=None):
"""Read a Verilator-exposed parameter from the DUT."""
try:
val = getattr(dut, name, None)
if val is not None:
return int(val.value)
except Exception:
pass
return default
def dut_num_rows(dut):
val = _get_param(dut, "NUM_ROWS", None)
if val is not None:
return val
return 1 << len(dut.wr_addr)
def dut_hash_bits(dut):
val = _get_param(dut, "HASH_BITS", None)
if val is not None:
return val
return len(dut.write_hash)
def dut_lanes(dut):
val = _get_param(dut, "LANES", None)
if val is not None:
return val
return len(dut.rd_resp_row_ids) // len(dut.wr_addr)
def _pipeline_depth(num_rows, lanes):
"""Number of match-engine pipeline stages (rows processed per query)."""
return num_rows // lanes
# ---------------------------------------------------------------------------
# Bounded wait helper (RisingEdge only — keep the caller writeable)
# ---------------------------------------------------------------------------
async def _wait_bounded(dut, condition_fn, max_cycles, label):
"""Wait for *condition_fn* to become ``True``, sampling at each RisingEdge.
Raises ``AssertionError`` with *label* if *max_cycles* is exceeded.
The caller is left in a writeable phase after return.
"""
for _ in range(max_cycles):
await RisingEdge(dut.clk)
if condition_fn():
return
raise AssertionError(
f"{label}: timed out after {max_cycles} clock cycles"
)
def _condition_signal_high(signal):
"""Return a nullary callable that checks *signal* is high (integer 1)."""
def _check():
return int(signal.value) == 1
return _check
# ---------------------------------------------------------------------------
# DUT helpers
# ---------------------------------------------------------------------------
async def reset_dut(dut):
"""Reset the DUT with new handshake interface.
``result_ready`` is held at 0 during measurement; the performance test
pulses it high for exactly one cycle after capturing ``result_valid``.
"""
dut.rst_n.value = 0
dut.wr_valid.value = 0
dut.wr_addr.value = 0
dut.write_hash.value = 0
dut.query_valid.value = 0
dut.query_hash.value = 0
dut.result_ready.value = 0
for _ in range(5):
await RisingEdge(dut.clk)
dut.rst_n.value = 1
for _ in range(2):
await RisingEdge(dut.clk)
async def wait_idle(dut, max_cycles=10):
"""Wait until both wr_ready=1 and query_ready=1 (system fully idle)."""
await _wait_bounded(
dut,
lambda: int(dut.wr_ready.value) and int(dut.query_ready.value),
max_cycles,
"wait_idle",
)
async def write_row(dut, addr, value, max_cycles=10):
"""Write a single row using wr_valid/wr_ready handshake."""
await wait_idle(dut)
dut.wr_addr.value = addr
dut.write_hash.value = int(value)
dut.wr_valid.value = 1
await _wait_bounded(
dut,
_condition_signal_high(dut.wr_ready),
max_cycles,
f"write_row(addr={addr}) handshake",
)
dut.wr_valid.value = 0
await wait_idle(dut)
async def write_rows(dut, rows):
"""Write all rows sequentially."""
for idx, value in enumerate(rows):
await write_row(dut, idx, value)
async def query_once_with_latency(dut, query, max_result_cycles):
"""Issue a query and return (top1_index, top1_score, latency_cycles, total_cycles).
Parameters
----------
max_result_cycles : int
Hard bound on cycles from query acceptance to *result_valid*.
Derived from ``NUM_ROWS / LANES + pipeline_slack`` by the caller.
Cycle counting
--------------
``query_ready`` is a combinational signal that goes low *immediately* after
the RisingEdge at which the query is accepted (the state machine
transitions from S_IDLE to S_SCAN). We therefore sample ``query_ready``
**before** ``ReadOnly`` (i.e. at the RisingEdge time-point itself) to
capture the handshake.
``result_valid`` is a registered output that stays high until consumed, so
we sample it in the **settled phase** after ``ReadOnly``.
``latency_cycles`` is the number of RisingEdge events between the cycle
where the query is accepted and the cycle where ``result_valid`` is
observed.
"""
await wait_idle(dut)
edge_count = 0
dut.query_hash.value = int(query)
dut.query_valid.value = 1
# ── Phase 1: Accept handshake ───────────────────────────────────────
# query_ready is combinational — sample before ReadOnly.
accept_edge = None
for _ in range(10):
await RisingEdge(dut.clk)
edge_count += 1
q_ready = int(dut.query_ready.value) # sample before state transition
await ReadOnly()
await Timer(1, units="step") # exit ReadOnly for driving
if q_ready:
accept_edge = edge_count
break
assert accept_edge is not None, (
"Query accept handshake timed out after 10 cycles"
)
dut.query_valid.value = 0
# ── Phase 2: Wait for result_valid (measurement window) ─────────────
# result_valid is registered — sample after ReadOnly is fine.
result_edge = None
for _ in range(max_result_cycles):
await RisingEdge(dut.clk)
edge_count += 1
await ReadOnly()
if int(dut.result_valid.value):
result_edge = edge_count
break
assert result_edge is not None, (
f"Query result_valid timed out after {max_result_cycles} cycles "
f"(accepted at edge {accept_edge})"
)
await Timer(1, units="step")
latency_cycles = result_edge - accept_edge
top1_index = int(dut.top1_index.value)
top1_score = int(dut.top1_score.value)
# ── Phase 3: Consume result (pulse result_ready for one cycle) ──────
dut.result_ready.value = 1
await RisingEdge(dut.clk)
edge_count += 1
await ReadOnly()
await Timer(1, units="step")
dut.result_ready.value = 0
return top1_index, top1_score, latency_cycles, edge_count
def deterministic_rows(num_rows, hash_bits, query_hash):
"""Create deterministic rows where only row 0 stores *query_hash*."""
mask = (1 << hash_bits) - 1
rows = [0] * num_rows
rows[0] = query_hash
for i in range(1, num_rows):
# Deterministic non-matching value; golden-ratio-like spread
val = ((i + 1) * 0x9E3779B97F4A7C15) & mask
if val == query_hash or val == 0:
val = ((val ^ query_hash) ^ 0xA5A5A5A5A5A5A5A5) & mask
if val == query_hash or val == 0:
val = (val ^ 1) & mask
rows[i] = val
return rows
# ── Performance Test ──────────────────────────────────────────────────────────
@cocotb.test()
async def cam_perf_benchmark(dut):
"""Performance benchmark: measure query latency in cycles."""
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_dut(dut)
num_rows = dut_num_rows(dut)
hash_bits = dut_hash_bits(dut)
lanes = dut_lanes(dut)
write_noise_en = _get_param(dut, "WRITE_NOISE_EN", 1)
read_noise_en = _get_param(dut, "READ_NOISE_EN", 0)
# ── Deterministic query ─────────────────────────────────────────────
query_hash = 0xAA55_AA55_AA55_AA55_AA55_AA55_AA55_AA55
query_hash &= (1 << hash_bits) - 1
if query_hash == 0:
query_hash = 1
rows = deterministic_rows(num_rows, hash_bits, query_hash)
await write_rows(dut, rows)
# Bound: pipeline depth plus generous slack for read + drain stages
pipeline = _pipeline_depth(num_rows, lanes)
max_result_cycles = pipeline + 30
top1_index, top1_score, latency_cycles, total_cycles = (
await query_once_with_latency(dut, query_hash, max_result_cycles)
)
# ── Performance assertions ──────────────────────────────────────────
assert latency_cycles > 0, (
f"latency_cycles must be positive, got {latency_cycles}"
)
assert total_cycles > 0, (
f"total_cycles must be positive, got {total_cycles}"
)
# ── Correctness assertions (conditional on noise state) ─────────────
if not write_noise_en and not read_noise_en:
# Without noise: stored hash at row 0 == query_hash → exact match.
assert top1_index == 0, (
f"Noise disabled: expected top1_index=0 (exact match), got "
f"{top1_index}"
)
assert top1_score == hash_bits, (
f"Noise disabled: expected top1_score={hash_bits}, got "
f"{top1_score}"
)
else:
# With noise: write/read flip masks may corrupt stored values, so
# we cannot reliably assert the exact match. Instead, confirm a
# valid non-zero score was produced (the match engine ran).
assert top1_score > 0, (
f"Noise enabled: expected top1_score > 0, got {top1_score}. "
"Match engine returned invalid result."
)
dut._log.info(
"Noise enabled (WRITE_NOISE_EN=%s, READ_NOISE_EN=%s) — "
"skipping exact top1_index/top1_score assertion. "
"top1_index=%d top1_score=%d",
write_noise_en, read_noise_en, top1_index, top1_score,
)
# ── Machine-readable performance marker ─────────────────────────────
queries_per_cycle = 1.0 / total_cycles
dut._log.info(
"PERF_RESULT latency_cycles=%d total_cycles=%d "
"accepted_queries=1 completed_queries=1 "
"queries_per_cycle=%.6f status=pass",
latency_cycles, total_cycles, queries_per_cycle,
)