mirror of
https://github.com/SikongJueluo/Mini-Nav.git
synced 2026-07-12 20:15:31 +08:00
feat(hw/rtl): implement full Top-K CAM search pipeline with serial result output
- add TOPK_K, FIFO_DEPTH, RESULT_SERIAL parameters to cam_params - add candidate_fifo: synchronous ready/valid FIFO for (row, score) candidates - add topk_tracker: tracks top-K candidates with clear/ready handshake - add result_serializer: serializes packed Top-K array into rank-ordered stream - refactor match_engine_pipeline from 4-state to 8-state Top-K pipeline - extend cam_top with serial Top-K interface (result_rank/row/score/last) - add backward-compatible top1_index/top1_score aliases from rank-0 beat - add comprehensive tests for all new modules
This commit is contained in:
32
hw/sim/tests/modules/candidate_fifo/Makefile
Normal file
32
hw/sim/tests/modules/candidate_fifo/Makefile
Normal file
@@ -0,0 +1,32 @@
|
||||
SIM_ROOT := $(abspath ../../..)
|
||||
RTL_ROOT := $(abspath $(SIM_ROOT)/../rtl)
|
||||
include $(SIM_ROOT)/mk/rtl-sources.mk
|
||||
|
||||
TOPLEVEL := candidate_fifo
|
||||
COCOTB_TEST_MODULES := tests.modules.candidate_fifo.test_candidate_fifo
|
||||
VERILOG_SOURCES := $(RTL_ROOT)/core/candidate_fifo.sv
|
||||
|
||||
# Allow override from command line (e.g. make FIFO_DEPTH=10)
|
||||
FIFO_DEPTH ?= 16
|
||||
EXTRA_ARGS += +define+FIFO_DEPTH=$(FIFO_DEPTH)
|
||||
|
||||
# Parameter-specific build directory so depth variants never collide.
|
||||
SIM_BUILD := sim_build/fifo_depth_$(FIFO_DEPTH)
|
||||
|
||||
include $(SIM_ROOT)/mk/cocotb-common.mk
|
||||
|
||||
# ── Depth-specific test targets ──────────────────────────────────────────
|
||||
# Recompile with a non-default DEPTH and filter to the relevant test(s).
|
||||
.PHONY: test-depth-10 test-depth-1
|
||||
|
||||
test-depth-10:
|
||||
$(MAKE) -B -f Makefile results.xml FIFO_DEPTH=10 \
|
||||
COCOTB_TEST_FILTER=non_power_of_two
|
||||
|
||||
test-depth-1:
|
||||
$(MAKE) -B -f Makefile results.xml FIFO_DEPTH=1 \
|
||||
COCOTB_TEST_FILTER=depth_one
|
||||
|
||||
# Also remove variant build directories on clean.
|
||||
clean::
|
||||
rm -rf sim_build/fifo_depth_10 sim_build/fifo_depth_1
|
||||
0
hw/sim/tests/modules/candidate_fifo/__init__.py
Normal file
0
hw/sim/tests/modules/candidate_fifo/__init__.py
Normal file
306
hw/sim/tests/modules/candidate_fifo/test_candidate_fifo.py
Normal file
306
hw/sim/tests/modules/candidate_fifo/test_candidate_fifo.py
Normal file
@@ -0,0 +1,306 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import cocotb
|
||||
from cocotb.clock import Clock
|
||||
from cocotb.triggers import RisingEdge
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def reset_fifo(dut):
|
||||
"""Assert reset for 5 cycles, release, then wait 2 cycles."""
|
||||
dut.rst_n.value = 0
|
||||
dut.wr_valid_i.value = 0
|
||||
dut.rd_ready_i.value = 0
|
||||
dut.wr_row_i.value = 0
|
||||
dut.wr_score_i.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 push(dut, row: int, score: int):
|
||||
"""Push a (row, score) entry, waiting until wr_ready_o is asserted."""
|
||||
while not dut.wr_ready_o.value:
|
||||
await RisingEdge(dut.clk)
|
||||
dut.wr_row_i.value = row
|
||||
dut.wr_score_i.value = score
|
||||
dut.wr_valid_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.wr_valid_i.value = 0
|
||||
|
||||
|
||||
async def pop(dut) -> tuple[int, int]:
|
||||
"""Pop an entry, waiting until rd_valid_o is asserted. Return (row, score)."""
|
||||
while not dut.rd_valid_o.value:
|
||||
await RisingEdge(dut.clk)
|
||||
# Sample data in the cycle rd_valid_o is high, before accepting
|
||||
row = int(dut.rd_row_o.value)
|
||||
score = int(dut.rd_score_o.value)
|
||||
dut.rd_ready_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.rd_ready_i.value = 0
|
||||
return (row, score)
|
||||
|
||||
|
||||
async def push_pop_simultaneous(dut, row: int, score: int) -> tuple[int, int]:
|
||||
"""Push and pop in the same cycle. Return the popped (row, score)."""
|
||||
# Capture read data before accepting (combinational read)
|
||||
row_out = int(dut.rd_row_o.value)
|
||||
score_out = int(dut.rd_score_o.value)
|
||||
# Assert both handshakes
|
||||
dut.wr_row_i.value = row
|
||||
dut.wr_score_i.value = score
|
||||
dut.wr_valid_i.value = 1
|
||||
dut.rd_ready_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.wr_valid_i.value = 0
|
||||
dut.rd_ready_i.value = 0
|
||||
return (row_out, score_out)
|
||||
|
||||
|
||||
# ── Test 1: Reset ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def reset_asserts_empty_and_defaults(dut):
|
||||
"""
|
||||
After reset:
|
||||
empty_o == 1
|
||||
full_o == 0
|
||||
rd_valid_o == 0
|
||||
wr_ready_o == 1
|
||||
"""
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_fifo(dut)
|
||||
|
||||
assert dut.empty_o.value == 1, "empty_o should be 1 after reset"
|
||||
assert dut.full_o.value == 0, "full_o should be 0 after reset"
|
||||
assert dut.rd_valid_o.value == 0, "rd_valid_o should be 0 after reset"
|
||||
assert dut.wr_ready_o.value == 1, "wr_ready_o should be 1 after reset"
|
||||
|
||||
|
||||
# ── Test 2: Order preservation ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def fifo_preserves_order(dut):
|
||||
"""
|
||||
Push [(3,44),(1,55),(7,22),(2,55)] then pop and verify each entry appears
|
||||
in the same order. After draining, empty_o == 1.
|
||||
"""
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_fifo(dut)
|
||||
|
||||
entries = [(3, 44), (1, 55), (7, 22), (2, 55)]
|
||||
|
||||
for row, score in entries:
|
||||
await push(dut, row, score)
|
||||
|
||||
for expected_row, expected_score in entries:
|
||||
row, score = await pop(dut)
|
||||
assert row == expected_row, (
|
||||
f"row mismatch: got {row}, expected {expected_row}"
|
||||
)
|
||||
assert score == expected_score, (
|
||||
f"score mismatch: got {score}, expected {expected_score}"
|
||||
)
|
||||
|
||||
assert dut.empty_o.value == 1, "fifo should be empty after draining all entries"
|
||||
|
||||
|
||||
# ── Test 3: Full / back-pressure / drain ────────────────────────────────────
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def fifo_full_backpressure_and_drain(dut):
|
||||
"""
|
||||
Fill the FIFO until full (discover depth), confirm backpressure,
|
||||
pop one, push a new item, drain all, verify final inserted item
|
||||
(99, 999) is preserved.
|
||||
"""
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_fifo(dut)
|
||||
|
||||
# ── Fill the FIFO until full ──
|
||||
depth = 0
|
||||
while not dut.full_o.value:
|
||||
await push(dut, depth, depth * 10)
|
||||
depth += 1
|
||||
|
||||
assert depth > 0, "should have pushed at least one item"
|
||||
assert dut.full_o.value == 1, "fifo should report full after DEPTH pushes"
|
||||
assert dut.wr_ready_o.value == 0, "wr_ready should be 0 when full"
|
||||
|
||||
# ── Pop one ──
|
||||
row, score = await pop(dut)
|
||||
assert row == 0 and score == 0, (
|
||||
f"first popped entry should be (0,0), got ({row},{score})"
|
||||
)
|
||||
|
||||
assert dut.full_o.value == 0, "fifo should no longer be full after one pop"
|
||||
assert dut.wr_ready_o.value == 1, "wr_ready should be 1 after one pop"
|
||||
|
||||
# ── Push a new item ──
|
||||
await push(dut, 99, 999)
|
||||
|
||||
# ── Drain all entries ──
|
||||
# Expected order: (1,10), (2,20), … (depth-1, (depth-1)*10), (99,999)
|
||||
for i in range(1, depth):
|
||||
row, score = await pop(dut)
|
||||
assert row == i, (
|
||||
f"row mismatch during drain: got {row}, expected {i}"
|
||||
)
|
||||
assert score == i * 10, (
|
||||
f"score mismatch during drain: got {score}, expected {i * 10}"
|
||||
)
|
||||
|
||||
row, score = await pop(dut)
|
||||
assert row == 99 and score == 999, (
|
||||
f"final inserted item wrong: got ({row},{score}), expected (99,999)"
|
||||
)
|
||||
|
||||
assert dut.empty_o.value == 1, "fifo should be empty after draining everything"
|
||||
|
||||
|
||||
# ── Test 4: Simultaneous push+pop at full ───────────────────────────────────
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def test_simultaneous_push_pop_when_full(dut):
|
||||
"""
|
||||
When the FIFO is full, a simultaneous read should allow a write to pass
|
||||
through in the same cycle (wr_ready_o goes high combinationally because
|
||||
of the read handshake). Verify the pop returns the oldest item, the
|
||||
push inserts a new item, and the fill level stays at depth.
|
||||
"""
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_fifo(dut)
|
||||
|
||||
# ── Fill to full ──
|
||||
depth = 0
|
||||
while not dut.full_o.value:
|
||||
await push(dut, depth, depth * 10)
|
||||
depth += 1
|
||||
|
||||
assert dut.full_o.value == 1
|
||||
assert dut.wr_ready_o.value == 0 # backpressure active
|
||||
|
||||
# ── Simultaneous push+pop ──
|
||||
# wr_ready_o should be 1 this cycle because rd_valid_o && rd_ready_i
|
||||
old_row, old_score = await push_pop_simultaneous(dut, 99, 999)
|
||||
assert old_row == 0 and old_score == 0, (
|
||||
f"simultaneous pop should return the oldest item, got ({old_row},{old_score})"
|
||||
)
|
||||
|
||||
# Fill level unchanged (pop frees a slot, push immediately refills it)
|
||||
assert dut.full_o.value == 1, "should remain full after simultaneous push+pop"
|
||||
|
||||
# ── Drain — expected: (1,10) … (depth-1,…), (99,999) ──
|
||||
for i in range(1, depth):
|
||||
row, score = await pop(dut)
|
||||
assert row == i, (
|
||||
f"row mismatch: got {row}, expected {i}"
|
||||
)
|
||||
|
||||
row, score = await pop(dut)
|
||||
assert row == 99 and score == 999, (
|
||||
f"final item wrong: got ({row},{score})"
|
||||
)
|
||||
|
||||
assert dut.empty_o.value == 1
|
||||
|
||||
|
||||
# ── Test 5: Non-power-of-two depth ──────────────────────────────────────────
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def test_non_power_of_two_depth(dut):
|
||||
"""
|
||||
Validate pointer wrap at a non-power-of-two DEPTH boundary (e.g. 10).
|
||||
Fill the FIFO, then perform multiple pop+push cycles to force the write
|
||||
pointer past a binary-wrap boundary, then drain and verify data integrity.
|
||||
"""
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_fifo(dut)
|
||||
|
||||
# ── Fill to full ──
|
||||
depth = 0
|
||||
while not dut.full_o.value:
|
||||
await push(dut, depth, depth)
|
||||
depth += 1
|
||||
|
||||
# ── Force several pointer wraps by pop+push ──
|
||||
# For a broken RTL that wraps at 2**clog2(DEPTH) instead of DEPTH,
|
||||
# enough iterations will hit out-of-bounds storage or corrupt data.
|
||||
wraps = depth * 3 # well past the binary wrap point
|
||||
for i in range(wraps):
|
||||
row, score = await pop(dut)
|
||||
await push(dut, 500 + i, 600 + i)
|
||||
|
||||
# ── Drain and verify count ──
|
||||
items = []
|
||||
while dut.rd_valid_o.value:
|
||||
row, score = await pop(dut)
|
||||
items.append((row, score))
|
||||
|
||||
assert len(items) == depth, (
|
||||
f"expected {depth} items after {wraps} push/pop cycles, got {len(items)}"
|
||||
)
|
||||
|
||||
# All items should be from the latest wrap cycle (order preserved)
|
||||
# Final batch started at iteration wraps - depth:
|
||||
for idx, (row, _score) in enumerate(items):
|
||||
expected_row = 500 + (wraps - depth + idx)
|
||||
assert row == expected_row, (
|
||||
f"item {idx}: expected row {expected_row}, got {row}"
|
||||
)
|
||||
|
||||
assert dut.empty_o.value == 1
|
||||
|
||||
|
||||
# ── Test 6: Depth = 1 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def test_depth_one(dut):
|
||||
"""
|
||||
Validate DEPTH=1 FIFO: push one item → full, pop → empty,
|
||||
push another → full, pop → empty.
|
||||
|
||||
Gated: pushes one item; if the FIFO does *not* become full, we are
|
||||
compiled with DEPTH > 1 and the test silently passes (skips).
|
||||
(Use ``make test-depth-1`` to run with FIFO_DEPTH=1.)
|
||||
"""
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_fifo(dut)
|
||||
|
||||
# ── Probe: is DEPTH == 1? ────────────────────────────────────────────
|
||||
await push(dut, 42, 100)
|
||||
if not dut.full_o.value:
|
||||
# Not compiled at DEPTH=1; pop the item back and return.
|
||||
await pop(dut)
|
||||
return
|
||||
|
||||
# ── Full DEPTH=1 logic follows ───────────────────────────────────────
|
||||
assert dut.wr_ready_o.value == 0, "wr_ready should be 0 when full"
|
||||
|
||||
row, score = await pop(dut)
|
||||
assert row == 42 and score == 100, (
|
||||
f"popped ({row},{score}), expected (42,100)"
|
||||
)
|
||||
assert dut.empty_o.value == 1, "depth-1 FIFO should be empty after one pop"
|
||||
assert dut.full_o.value == 0
|
||||
|
||||
await push(dut, 7, 55)
|
||||
assert dut.full_o.value == 1
|
||||
assert dut.empty_o.value == 0
|
||||
|
||||
row, score = await pop(dut)
|
||||
assert row == 7 and score == 55
|
||||
|
||||
assert dut.empty_o.value == 1
|
||||
assert dut.full_o.value == 0
|
||||
@@ -6,4 +6,24 @@ TOPLEVEL := match_engine_pipeline
|
||||
COCOTB_TEST_MODULES := tests.modules.match_engine_pipeline.test_match_engine_pipeline
|
||||
VERILOG_SOURCES := $(RTL_MATCH_ENGINE)
|
||||
|
||||
# Allow override from command line (e.g. make TOPK_K=1)
|
||||
TOPK_K ?= 4
|
||||
EXTRA_ARGS += +define+TOPK_K=$(TOPK_K)
|
||||
export TOPK_K
|
||||
|
||||
# Parameter-specific build directory so K variants never collide.
|
||||
SIM_BUILD := sim_build/k_$(TOPK_K)
|
||||
|
||||
include $(SIM_ROOT)/mk/cocotb-common.mk
|
||||
|
||||
# ── K-specific test targets ──────────────────────────────────────────────
|
||||
# Recompile with K=1 and filter to the K=1 test.
|
||||
.PHONY: test-k1
|
||||
|
||||
test-k1:
|
||||
$(MAKE) -B -f Makefile results.xml TOPK_K=1 \
|
||||
COCOTB_TEST_FILTER=match_engine_topk_k1
|
||||
|
||||
# Also remove all module-local build artifacts, including legacy and variant dirs.
|
||||
clean::
|
||||
rm -rf sim_build
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import cocotb
|
||||
from cocotb.clock import Clock
|
||||
from cocotb.triggers import RisingEdge
|
||||
from cocotb.triggers import RisingEdge, Timer
|
||||
|
||||
# Read TOPK_K from environment (set by Makefile); default 4 for K=4-only tests.
|
||||
TOPK_K = int(os.environ.get("TOPK_K", "4"))
|
||||
|
||||
|
||||
async def reset_match(dut):
|
||||
@@ -21,8 +26,99 @@ async def reset_match(dut):
|
||||
await RisingEdge(dut.clk)
|
||||
|
||||
|
||||
def score_for(query: int, value: int, width: int) -> int:
|
||||
"""Compute popcount of ~(query ^ value), i.e. number of matching bits."""
|
||||
return width - ((query ^ value) & ((1 << width) - 1)).bit_count()
|
||||
|
||||
|
||||
def golden_topk(
|
||||
rows_and_hashes: list[tuple[int, int]], query: int, width: int, k: int
|
||||
) -> list[tuple[int, int]]:
|
||||
"""Return the Top-K (row, score) pairs sorted by score desc, row asc."""
|
||||
scored = [(row, score_for(query, value, width))
|
||||
for row, value in rows_and_hashes]
|
||||
return sorted(scored, key=lambda item: (-item[1], item[0]))[:k]
|
||||
|
||||
|
||||
async def collect_serial_results(
|
||||
dut, k: int, timeout_cycles: int = 200
|
||||
) -> list[tuple[int, int, int, int]]:
|
||||
"""Collect serialised Top-K results from the pipeline.
|
||||
|
||||
Returns list of (rank, row, score, last) tuples.
|
||||
Asserts timeout if the stream does not complete.
|
||||
"""
|
||||
dut.result_ready.value = 1
|
||||
observed: list[tuple[int, int, int, int]] = []
|
||||
for _ in range(timeout_cycles):
|
||||
if int(dut.result_valid.value):
|
||||
rank = int(dut.result_rank.value) if hasattr(
|
||||
dut, "result_rank") else len(observed)
|
||||
row = int(dut.result_row.value)
|
||||
score = int(dut.result_score.value)
|
||||
last = int(dut.result_last.value) if hasattr(
|
||||
dut, "result_last") else int(len(observed) == k - 1)
|
||||
observed.append((rank, row, score, last))
|
||||
if last:
|
||||
return observed
|
||||
await RisingEdge(dut.clk)
|
||||
raise AssertionError(
|
||||
f"result stream did not complete after {timeout_cycles} cycles"
|
||||
)
|
||||
|
||||
|
||||
async def feed_all_batches(
|
||||
dut,
|
||||
rows_and_hashes: list[tuple[int, int]] | None = None,
|
||||
lane_mask: int | None = None,
|
||||
):
|
||||
"""Feed all batches to the pipeline.
|
||||
|
||||
If *rows_and_hashes* is provided, the test uses those values for each row
|
||||
(row_id -> hash) instead of the default hash=query (with special rows).
|
||||
If *lane_mask* is provided, only those lanes are marked valid in each batch.
|
||||
"""
|
||||
LANES = len(dut.rd_lane_valid_i)
|
||||
ROW_BITS = len(dut.rd_row_ids_i) // LANES
|
||||
HASH_BITS = len(dut.rd_hashes_i) // LANES
|
||||
NUM_ROWS = 1 << len(dut.rd_base_row_o)
|
||||
|
||||
for base in range(0, NUM_ROWS, LANES):
|
||||
while int(dut.rd_valid_o.value) == 0:
|
||||
await RisingEdge(dut.clk)
|
||||
assert int(dut.rd_base_row_o.value) == base
|
||||
rows = 0
|
||||
hashes = 0
|
||||
for lane in range(LANES):
|
||||
row_id = base + lane
|
||||
rows |= row_id << (lane * ROW_BITS)
|
||||
if rows_and_hashes is not None and row_id in rows_and_hashes:
|
||||
row_hash = rows_and_hashes[row_id]
|
||||
else:
|
||||
row_hash = 0
|
||||
hashes |= row_hash << (lane * HASH_BITS)
|
||||
dut.rd_row_ids_i.value = rows
|
||||
dut.rd_hashes_i.value = hashes
|
||||
if lane_mask is not None:
|
||||
dut.rd_lane_valid_i.value = lane_mask
|
||||
else:
|
||||
dut.rd_lane_valid_i.value = (1 << LANES) - 1
|
||||
dut.rd_valid_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.rd_valid_i.value = 0
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Test: Top-1 smoke
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def match_engine_returns_top1_after_pipeline_drain(dut):
|
||||
"""Top-1 smoke test — verify basic result_valid/result_row/result_score.
|
||||
|
||||
Works with both old Top-1 and new Top-K serial interfaces.
|
||||
"""
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_match(dut)
|
||||
|
||||
@@ -38,23 +134,8 @@ async def match_engine_returns_top1_after_pipeline_drain(dut):
|
||||
await RisingEdge(dut.clk)
|
||||
dut.query_valid.value = 0
|
||||
|
||||
for base in range(0, NUM_ROWS, LANES):
|
||||
while int(dut.rd_valid_o.value) == 0:
|
||||
await RisingEdge(dut.clk)
|
||||
assert int(dut.rd_base_row_o.value) == base
|
||||
rows = 0
|
||||
hashes = 0
|
||||
for lane in range(LANES):
|
||||
row_id = base + lane
|
||||
rows |= row_id << (lane * ROW_BITS)
|
||||
row_hash = query if row_id == TARGET_ROW else 0
|
||||
hashes |= row_hash << (lane * HASH_BITS)
|
||||
dut.rd_row_ids_i.value = rows
|
||||
dut.rd_hashes_i.value = hashes
|
||||
dut.rd_lane_valid_i.value = (1 << LANES) - 1
|
||||
dut.rd_valid_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.rd_valid_i.value = 0
|
||||
rows_and_hashes: dict[int, int] = {9: query}
|
||||
await feed_all_batches(dut, rows_and_hashes)
|
||||
|
||||
for _ in range(20):
|
||||
if int(dut.result_valid.value):
|
||||
@@ -64,3 +145,417 @@ async def match_engine_returns_top1_after_pipeline_drain(dut):
|
||||
assert int(dut.result_valid.value) == 1
|
||||
assert int(dut.result_row.value) == TARGET_ROW
|
||||
assert int(dut.result_score.value) == HASH_BITS
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Test: Top-K all-valid
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def match_engine_keeps_multiple_topk_candidates_from_same_batch(dut):
|
||||
"""Verify Top-K pipeline returns K best matches in rank order.
|
||||
|
||||
Feed all rows with known hash values so the golden Top-K is predictable.
|
||||
"""
|
||||
if TOPK_K != 4:
|
||||
dut._log.info(f"Skipping: hardcoded K=4 test (TOPK_K={TOPK_K})")
|
||||
return
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_match(dut)
|
||||
|
||||
LANES = len(dut.rd_lane_valid_i)
|
||||
ROW_BITS = len(dut.rd_row_ids_i) // LANES
|
||||
HASH_BITS = len(dut.rd_hashes_i) // LANES
|
||||
NUM_ROWS = 1 << len(dut.rd_base_row_o)
|
||||
K = 4
|
||||
|
||||
query = (1 << HASH_BITS) - 1
|
||||
special = {0: query, 1: query ^ 1, 2: query ^ 3, 3: query ^ 7}
|
||||
|
||||
rows_and_hashes: list[tuple[int, int]] = []
|
||||
dut.query_hash.value = query
|
||||
dut.query_valid.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.query_valid.value = 0
|
||||
|
||||
for base in range(0, NUM_ROWS, LANES):
|
||||
while int(dut.rd_valid_o.value) == 0:
|
||||
await RisingEdge(dut.clk)
|
||||
rows = 0
|
||||
hashes = 0
|
||||
for lane in range(LANES):
|
||||
row_id = base + lane
|
||||
row_hash = special.get(row_id, 0)
|
||||
rows_and_hashes.append((row_id, row_hash))
|
||||
rows |= row_id << (lane * ROW_BITS)
|
||||
hashes |= row_hash << (lane * HASH_BITS)
|
||||
dut.rd_row_ids_i.value = rows
|
||||
dut.rd_hashes_i.value = hashes
|
||||
dut.rd_lane_valid_i.value = (1 << LANES) - 1
|
||||
dut.rd_valid_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.rd_valid_i.value = 0
|
||||
|
||||
expected = golden_topk(rows_and_hashes, query, HASH_BITS, K)
|
||||
observed = await collect_serial_results(dut, K, timeout_cycles=800)
|
||||
|
||||
assert [(row, score) for _, row, score, _ in observed] == expected, (
|
||||
f"observed={[(r, s) for _, r, s, _ in observed]} != expected={expected}"
|
||||
)
|
||||
assert [rank for rank, _, _, _ in observed] == list(range(K)), (
|
||||
"ranks are not sequential"
|
||||
)
|
||||
assert observed[-1][3] == 1, "last flag not set on final result"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Test: Invalid-mask coverage — lane 0 invalid, all-invalid batch
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def match_engine_handles_invalid_lane_masks(dut):
|
||||
"""Pipeline completes correctly with invalid lanes and all-invalid batches.
|
||||
|
||||
Coverage:
|
||||
- Batch 0 is driven with rd_lane_valid_i=0 (all-invalid)
|
||||
→ batch_scores_done vacuously true, zero candidates pushed.
|
||||
- All other batches use lane_mask = 0b0110 (lanes 1 and 2 only).
|
||||
- Invalid lanes (0, 3-7) carry high-score trap hashes that WOULD enter
|
||||
the Top-K if the pipeline wrongly pushed them.
|
||||
- Golden Top-K is computed from valid-lane rows only and compared
|
||||
against the full serialised output — not just a subset check.
|
||||
"""
|
||||
if TOPK_K != 4:
|
||||
dut._log.info(f"Skipping: hardcoded K=4 test (TOPK_K={TOPK_K})")
|
||||
return
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_match(dut)
|
||||
|
||||
LANES = len(dut.rd_lane_valid_i)
|
||||
ROW_BITS = len(dut.rd_row_ids_i) // LANES
|
||||
HASH_BITS = len(dut.rd_hashes_i) // LANES
|
||||
NUM_ROWS = 1 << len(dut.rd_base_row_o)
|
||||
K = 4
|
||||
|
||||
query = (1 << HASH_BITS) - 1
|
||||
trap_hash = query # perfect score; would be Top-1 if wrongly pushed
|
||||
|
||||
# Only rows on valid lanes (1, 2) from batches AFTER the all-invalid one
|
||||
# contribute candidates. Use distinct scores so the golden ordering
|
||||
# is unambiguous.
|
||||
valid_rows: dict[int, int] = {9: query, 10: query ^ 1}
|
||||
|
||||
# Track hash values passed per row (including trap rows on invalid lanes)
|
||||
# for golden-topk computation.
|
||||
valid_rows_and_hashes: list[tuple[int, int]] = []
|
||||
|
||||
dut.query_hash.value = query
|
||||
dut.query_valid.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.query_valid.value = 0
|
||||
|
||||
for base in range(0, NUM_ROWS, LANES):
|
||||
while int(dut.rd_valid_o.value) == 0:
|
||||
await RisingEdge(dut.clk)
|
||||
# Batch 0 is all-invalid; all others use lanes 1,2 only.
|
||||
cur_mask = 0x00 if base == 0 else 0b0110
|
||||
|
||||
rows = 0
|
||||
hashes = 0
|
||||
for lane in range(LANES):
|
||||
row_id = base + lane
|
||||
rows |= row_id << (lane * ROW_BITS)
|
||||
# On invalid lanes drive a trap hash (perfect score).
|
||||
# On valid lanes drive the designated hash (or 0 if not special).
|
||||
if cur_mask & (1 << lane):
|
||||
row_hash = valid_rows.get(row_id, 0)
|
||||
valid_rows_and_hashes.append((row_id, row_hash))
|
||||
else:
|
||||
row_hash = trap_hash # would be Top-1 if wrongly pushed
|
||||
hashes |= row_hash << (lane * HASH_BITS)
|
||||
dut.rd_row_ids_i.value = rows
|
||||
dut.rd_hashes_i.value = hashes
|
||||
dut.rd_lane_valid_i.value = cur_mask
|
||||
dut.rd_valid_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.rd_valid_i.value = 0
|
||||
|
||||
expected = golden_topk(valid_rows_and_hashes, query, HASH_BITS, K)
|
||||
observed = await collect_serial_results(dut, K, timeout_cycles=800)
|
||||
|
||||
assert [(row, score) for _, row, score, _ in observed] == expected, (
|
||||
f"observed={[(r, s) for _, r, s, _ in observed]} != expected={expected}"
|
||||
)
|
||||
assert [rank for rank, _, _, _ in observed] == list(range(K)), (
|
||||
"ranks are not sequential"
|
||||
)
|
||||
assert observed[-1][3] == 1, "last flag not set on final result"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Test: result_ready stall stability
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def match_engine_result_ready_stall_preserves_output(dut):
|
||||
"""When result_ready is deasserted while result_valid is high, the output
|
||||
signals (result_rank, result_row, result_score, result_last) must remain
|
||||
stable until result_ready is re-asserted.
|
||||
"""
|
||||
if TOPK_K != 4:
|
||||
dut._log.info(f"Skipping: hardcoded K=4 test (TOPK_K={TOPK_K})")
|
||||
return
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_match(dut)
|
||||
|
||||
LANES = len(dut.rd_lane_valid_i)
|
||||
ROW_BITS = len(dut.rd_row_ids_i) // LANES
|
||||
HASH_BITS = len(dut.rd_hashes_i) // LANES
|
||||
NUM_ROWS = 1 << len(dut.rd_base_row_o)
|
||||
K = 4
|
||||
|
||||
query = (1 << HASH_BITS) - 1
|
||||
special = {0: query, 1: query ^ 1, 2: query ^ 3, 3: query ^ 7}
|
||||
|
||||
rows_and_hashes: list[tuple[int, int]] = []
|
||||
dut.query_hash.value = query
|
||||
dut.query_valid.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.query_valid.value = 0
|
||||
|
||||
await feed_all_batches(dut)
|
||||
|
||||
# Wait for first result
|
||||
for _ in range(200):
|
||||
await RisingEdge(dut.clk)
|
||||
if int(dut.result_valid.value):
|
||||
break
|
||||
|
||||
assert int(dut.result_valid.value) == 1, "result_valid never went high"
|
||||
|
||||
# Capture the first-beat values
|
||||
rank0 = int(dut.result_rank.value)
|
||||
row0 = int(dut.result_row.value)
|
||||
score0 = int(dut.result_score.value)
|
||||
last0 = int(dut.result_last.value)
|
||||
|
||||
# Stall — deassert result_ready for several cycles
|
||||
dut.result_ready.value = 0
|
||||
for _ in range(5):
|
||||
await RisingEdge(dut.clk)
|
||||
assert int(dut.result_valid.value) == 1, (
|
||||
"result_valid dropped during stall"
|
||||
)
|
||||
assert int(dut.result_rank.value) == rank0
|
||||
assert int(dut.result_row.value) == row0
|
||||
assert int(dut.result_score.value) == score0
|
||||
assert int(dut.result_last.value) == last0
|
||||
|
||||
# Release stall — should advance to next rank
|
||||
dut.result_ready.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
|
||||
# Collect the rest
|
||||
observed: list[tuple[int, int, int, int]] = [(rank0, row0, score0, last0)]
|
||||
for _ in range(200):
|
||||
if int(dut.result_valid.value):
|
||||
rank = int(dut.result_rank.value)
|
||||
row = int(dut.result_row.value)
|
||||
score = int(dut.result_score.value)
|
||||
last = int(dut.result_last.value)
|
||||
observed.append((rank, row, score, last))
|
||||
if last:
|
||||
break
|
||||
await RisingEdge(dut.clk)
|
||||
|
||||
assert len(observed) == K, (
|
||||
f"expected {K} results, got {len(observed)}"
|
||||
)
|
||||
assert observed[-1][3] == 1, "last flag not set on final result"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Test: Toggled ready with golden comparison
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def match_engine_holds_serial_result_under_ready_stalls(dut):
|
||||
"""Toggle result_ready during serialisation and verify golden Top-K matches.
|
||||
|
||||
Uses query=(1<<HASH_BITS)-1 and row_hash=query^row_id for deterministic
|
||||
per-row scoring. result_ready is deasserted every 3rd cycle to exercise
|
||||
backpressure. Observed beats are captured only on valid+ready handshakes
|
||||
and compared against golden_topk.
|
||||
"""
|
||||
if TOPK_K != 4:
|
||||
dut._log.info(f"Skipping: hardcoded K=4 test (TOPK_K={TOPK_K})")
|
||||
return
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_match(dut)
|
||||
|
||||
LANES = len(dut.rd_lane_valid_i)
|
||||
ROW_BITS = len(dut.rd_row_ids_i) // LANES
|
||||
HASH_BITS = len(dut.rd_hashes_i) // LANES
|
||||
NUM_ROWS = 1 << len(dut.rd_base_row_o)
|
||||
K = TOPK_K
|
||||
|
||||
query = (1 << HASH_BITS) - 1
|
||||
dut.query_hash.value = query
|
||||
dut.query_valid.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.query_valid.value = 0
|
||||
|
||||
# Feed all rows with deterministic hash = query ^ row_id
|
||||
rows_and_hashes: list[tuple[int, int]] = []
|
||||
for base in range(0, NUM_ROWS, LANES):
|
||||
while int(dut.rd_valid_o.value) == 0:
|
||||
await RisingEdge(dut.clk)
|
||||
rows = 0
|
||||
hashes = 0
|
||||
for lane in range(LANES):
|
||||
row_id = base + lane
|
||||
row_hash = query ^ row_id
|
||||
rows_and_hashes.append((row_id, row_hash))
|
||||
rows |= row_id << (lane * ROW_BITS)
|
||||
hashes |= row_hash << (lane * HASH_BITS)
|
||||
dut.rd_row_ids_i.value = rows
|
||||
dut.rd_hashes_i.value = hashes
|
||||
dut.rd_lane_valid_i.value = (1 << LANES) - 1
|
||||
dut.rd_valid_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.rd_valid_i.value = 0
|
||||
|
||||
expected = golden_topk(rows_and_hashes, query, HASH_BITS, K)
|
||||
|
||||
# Collect with toggling result_ready: stall on every 3rd cycle (cycle % 3 == 1)
|
||||
observed: list[tuple[int, int]] = []
|
||||
for cycle in range(800):
|
||||
dut.result_ready.value = 0 if cycle % 3 == 1 else 1
|
||||
await RisingEdge(dut.clk)
|
||||
if int(dut.result_valid.value) and int(dut.result_ready.value):
|
||||
row = int(dut.result_row.value)
|
||||
score = int(dut.result_score.value)
|
||||
observed.append((row, score))
|
||||
if int(dut.result_last.value):
|
||||
break
|
||||
|
||||
assert observed == expected, (
|
||||
f"observed={observed} != expected={expected}"
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Test: consumed_candidates_debug (SIM_DEBUG only)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def match_engine_debug_consumed_candidates(dut):
|
||||
"""Under SIM_DEBUG, consumed_candidates_debug must equal NUM_ROWS after
|
||||
an all-lane-valid query completes (every row produces one candidate).
|
||||
"""
|
||||
if not hasattr(dut, "consumed_candidates_debug"):
|
||||
dut._log.info("Skipping: consumed_candidates_debug not present (no SIM_DEBUG)")
|
||||
return
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_match(dut)
|
||||
|
||||
LANES = len(dut.rd_lane_valid_i)
|
||||
ROW_BITS = len(dut.rd_row_ids_i) // LANES
|
||||
HASH_BITS = len(dut.rd_hashes_i) // LANES
|
||||
NUM_ROWS = 1 << len(dut.rd_base_row_o)
|
||||
K = TOPK_K
|
||||
|
||||
query = (1 << HASH_BITS) - 1
|
||||
dut.query_hash.value = query
|
||||
dut.query_valid.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.query_valid.value = 0
|
||||
|
||||
await feed_all_batches(dut)
|
||||
|
||||
# Wait for result completion
|
||||
await collect_serial_results(dut, K, timeout_cycles=800)
|
||||
|
||||
# After completion, consumed_candidates_debug should show all rows consumed
|
||||
consumed = int(dut.consumed_candidates_debug.value)
|
||||
assert consumed == NUM_ROWS, (
|
||||
f"consumed_candidates_debug={consumed} != NUM_ROWS={NUM_ROWS}"
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Test: K=1 regression
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def match_engine_topk_k1(dut):
|
||||
"""K=1: verify single beat with rank=0, last=1, row/score matches golden.
|
||||
|
||||
Feeds all rows all-lane-valid with a single high-score row (row 9).
|
||||
Only one result beat is expected; it must carry rank=0, last=1, and
|
||||
match the golden Top-1 (row, score).
|
||||
"""
|
||||
if TOPK_K != 1:
|
||||
dut._log.info(f"Skipping: K=1 test requires TOPK_K=1 (got {TOPK_K})")
|
||||
return
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_match(dut)
|
||||
|
||||
LANES = len(dut.rd_lane_valid_i)
|
||||
ROW_BITS = len(dut.rd_row_ids_i) // LANES
|
||||
HASH_BITS = len(dut.rd_hashes_i) // LANES
|
||||
NUM_ROWS = 1 << len(dut.rd_base_row_o)
|
||||
|
||||
query = (1 << HASH_BITS) - 1
|
||||
# Row 9 gets the best hash; all others get 0
|
||||
special: dict[int, int] = {9: query}
|
||||
|
||||
rows_and_hashes: list[tuple[int, int]] = []
|
||||
dut.query_hash.value = query
|
||||
dut.query_valid.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.query_valid.value = 0
|
||||
|
||||
for base in range(0, NUM_ROWS, LANES):
|
||||
while int(dut.rd_valid_o.value) == 0:
|
||||
await RisingEdge(dut.clk)
|
||||
rows = 0
|
||||
hashes = 0
|
||||
for lane in range(LANES):
|
||||
row_id = base + lane
|
||||
row_hash = special.get(row_id, 0)
|
||||
rows_and_hashes.append((row_id, row_hash))
|
||||
rows |= row_id << (lane * ROW_BITS)
|
||||
hashes |= row_hash << (lane * HASH_BITS)
|
||||
dut.rd_row_ids_i.value = rows
|
||||
dut.rd_hashes_i.value = hashes
|
||||
dut.rd_lane_valid_i.value = (1 << LANES) - 1
|
||||
dut.rd_valid_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.rd_valid_i.value = 0
|
||||
|
||||
expected = golden_topk(rows_and_hashes, query, HASH_BITS, 1)
|
||||
assert len(expected) == 1
|
||||
exp_row, exp_score = expected[0]
|
||||
|
||||
# Collect single result
|
||||
dut.result_ready.value = 1
|
||||
for _ in range(200):
|
||||
if int(dut.result_valid.value):
|
||||
rank = int(dut.result_rank.value)
|
||||
row = int(dut.result_row.value)
|
||||
score = int(dut.result_score.value)
|
||||
last = int(dut.result_last.value)
|
||||
assert rank == 0, f"rank={rank} != 0"
|
||||
assert last == 1, f"last={last} != 1"
|
||||
assert row == exp_row, f"row={row} != {exp_row}"
|
||||
assert score == exp_score, f"score={score} != {exp_score}"
|
||||
return
|
||||
await RisingEdge(dut.clk)
|
||||
raise AssertionError("result_valid never went high")
|
||||
|
||||
29
hw/sim/tests/modules/result_serializer/Makefile
Normal file
29
hw/sim/tests/modules/result_serializer/Makefile
Normal file
@@ -0,0 +1,29 @@
|
||||
SIM_ROOT := $(abspath ../../..)
|
||||
RTL_ROOT := $(abspath $(SIM_ROOT)/../rtl)
|
||||
include $(SIM_ROOT)/mk/rtl-sources.mk
|
||||
|
||||
TOPLEVEL := result_serializer
|
||||
COCOTB_TEST_MODULES := tests.modules.result_serializer.test_result_serializer
|
||||
VERILOG_SOURCES := $(RTL_ROOT)/core/result_serializer.sv
|
||||
|
||||
# Allow override from command line (e.g. make TOPK_K=1)
|
||||
TOPK_K ?= 4
|
||||
EXTRA_ARGS += +define+TOPK_K=$(TOPK_K)
|
||||
export TOPK_K
|
||||
|
||||
# Parameter-specific build directory so K variants never collide.
|
||||
SIM_BUILD := sim_build/k_$(TOPK_K)
|
||||
|
||||
include $(SIM_ROOT)/mk/cocotb-common.mk
|
||||
|
||||
# ── K-specific test targets ──────────────────────────────────────────────
|
||||
# Recompile with K=1 and filter to the K=1 test.
|
||||
.PHONY: test-k1
|
||||
|
||||
test-k1:
|
||||
$(MAKE) -B -f Makefile results.xml TOPK_K=1 \
|
||||
COCOTB_TEST_FILTER=serializer_handles_k1
|
||||
|
||||
# Also remove variant build directories on clean.
|
||||
clean::
|
||||
rm -rf sim_build/k_1
|
||||
212
hw/sim/tests/modules/result_serializer/test_result_serializer.py
Normal file
212
hw/sim/tests/modules/result_serializer/test_result_serializer.py
Normal file
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import cocotb
|
||||
from cocotb.clock import Clock
|
||||
from cocotb.triggers import RisingEdge
|
||||
|
||||
# ── Parameter defaults (matching cam_params.svh: NUM_ROWS=4096, HASH_BITS=512) ──
|
||||
# K is read from env so Makefile override (e.g. make TOPK_K=1) propagates to tests.
|
||||
K = int(os.environ.get('TOPK_K', '4'))
|
||||
ROW_BITS = 12
|
||||
SCORE_BITS = 10 # $clog2(512 + 1)
|
||||
# Mirror the RTL's RANK_BITS formula: (K <= 1) ? 1 : $clog2(K)
|
||||
RANK_BITS = 1 if K <= 1 else (K - 1).bit_length()
|
||||
|
||||
|
||||
def pack(values: list[int], width: int) -> int:
|
||||
"""Pack a list of values into a flattened integer (LSB = index 0)."""
|
||||
result = 0
|
||||
for idx, value in enumerate(values):
|
||||
result |= value << (idx * width)
|
||||
return result
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def reset_serializer(dut):
|
||||
"""Drive reset active-low for 5 cycles, then release; zero all inputs."""
|
||||
dut.rst_n.value = 0
|
||||
dut.start_i.value = 0
|
||||
dut.result_ready_i.value = 0
|
||||
dut.topk_rows_i.value = 0
|
||||
dut.topk_scores_i.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 assert_output_stable(dut, rank: int, row: int, score: int,
|
||||
last: bool, valid: bool):
|
||||
"""Assert output signals match expected values."""
|
||||
assert int(dut.result_rank_o.value) == rank, \
|
||||
f"rank: expected {rank}, got {int(dut.result_rank_o.value)}"
|
||||
assert int(dut.result_row_o.value) == row, \
|
||||
f"row: expected {row}, got {int(dut.result_row_o.value)}"
|
||||
assert int(dut.result_score_o.value) == score, \
|
||||
f"score: expected {score}, got {int(dut.result_score_o.value)}"
|
||||
assert bool(dut.result_last_o.value) == last, \
|
||||
f"last: expected {last}, got {bool(dut.result_last_o.value)}"
|
||||
assert bool(dut.result_valid_o.value) == valid, \
|
||||
f"valid: expected {valid}, got {bool(dut.result_valid_o.value)}"
|
||||
|
||||
|
||||
# ── Test 1: Serializer outputs rank order and last ─────────────────────────────
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def serializer_outputs_rank_order_and_last(dut):
|
||||
"""
|
||||
Start serialization with K=4 results. Clock through all 4 ranks, verifying
|
||||
row/score/last at each step. Assert done_o pulses after last rank.
|
||||
|
||||
Gated: requires TOPK_K=4 (the default). Use ``make test-k1`` for K=1.
|
||||
"""
|
||||
if K != 4:
|
||||
cocotb.log.info("Skipping (designed for TOPK_K=4, got %d)", K)
|
||||
return
|
||||
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_serializer(dut)
|
||||
|
||||
# Pre-defined top-4 results
|
||||
rows = [42, 17, 99, 5]
|
||||
scores = [10, 9, 8, 7]
|
||||
|
||||
# Drive packed inputs and assert start
|
||||
dut.topk_rows_i.value = pack(rows, ROW_BITS)
|
||||
dut.topk_scores_i.value = pack(scores, SCORE_BITS)
|
||||
dut.start_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.start_i.value = 0
|
||||
|
||||
# DUT should be busy, valid, showing rank 0
|
||||
assert bool(dut.busy_o.value), "busy_o should be high after start"
|
||||
await assert_output_stable(dut, rank=0, row=rows[0], score=scores[0],
|
||||
last=False, valid=True)
|
||||
|
||||
# Cycle through remaining ranks with ready held high
|
||||
for r in range(1, K):
|
||||
dut.result_ready_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
is_last = (r == K - 1)
|
||||
await assert_output_stable(dut, rank=r, row=rows[r], score=scores[r],
|
||||
last=is_last, valid=True)
|
||||
|
||||
# After last rank fire, next cycle should show done_o and deassert busy
|
||||
await RisingEdge(dut.clk)
|
||||
assert bool(dut.done_o.value), "done_o should be high after last rank fire"
|
||||
assert not bool(dut.busy_o.value), "busy_o should be low after serialization done"
|
||||
assert not bool(dut.result_valid_o.value), \
|
||||
"result_valid_o should be low after serialization done"
|
||||
|
||||
# done_o should be single-cycle pulse
|
||||
await RisingEdge(dut.clk)
|
||||
assert not bool(dut.done_o.value), "done_o should be single-cycle pulse"
|
||||
assert int(dut.result_rank_o.value) == 0, \
|
||||
"rank should reset to 0 after done"
|
||||
|
||||
|
||||
# ── Test 2: Holds output when not ready ────────────────────────────────────────
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def serializer_holds_output_when_not_ready(dut):
|
||||
"""
|
||||
After start, with result_ready_i=0, output remains stable.
|
||||
After ready is asserted, advances to next rank.
|
||||
|
||||
Gated: requires TOPK_K=4 (the default). Use ``make test-k1`` for K=1.
|
||||
"""
|
||||
if K != 4:
|
||||
cocotb.log.info("Skipping (designed for TOPK_K=4, got %d)", K)
|
||||
return
|
||||
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_serializer(dut)
|
||||
|
||||
rows = [10, 20, 30, 40]
|
||||
scores = [5, 4, 3, 2]
|
||||
|
||||
dut.topk_rows_i.value = pack(rows, ROW_BITS)
|
||||
dut.topk_scores_i.value = pack(scores, SCORE_BITS)
|
||||
dut.result_ready_i.value = 0 # not ready
|
||||
dut.start_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.start_i.value = 0
|
||||
|
||||
# Output should present rank 0 but hold because ready is low
|
||||
await assert_output_stable(dut, rank=0, row=rows[0], score=scores[0],
|
||||
last=False, valid=True)
|
||||
|
||||
# Stall for 3 cycles — mutating inputs to verify they don't leak through
|
||||
# (snapshot must hold original values captured at start)
|
||||
for cycle in range(3):
|
||||
# Corrupt the live inputs — a buggy combinational slice would change
|
||||
dut.topk_rows_i.value = pack([cycle + 100, 0, 0, 0], ROW_BITS)
|
||||
dut.topk_scores_i.value = pack([cycle + 50, 0, 0, 0], SCORE_BITS)
|
||||
await RisingEdge(dut.clk)
|
||||
await assert_output_stable(dut, rank=0, row=rows[0], score=scores[0],
|
||||
last=False, valid=True)
|
||||
|
||||
# Restore original inputs and assert ready for 1 cycle; rank advances to 1
|
||||
dut.topk_rows_i.value = pack(rows, ROW_BITS)
|
||||
dut.topk_scores_i.value = pack(scores, SCORE_BITS)
|
||||
dut.result_ready_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
await assert_output_stable(dut, rank=1, row=rows[1], score=scores[1],
|
||||
last=False, valid=True)
|
||||
|
||||
# Stall again at rank 1 for 2 cycles — mutate inputs again
|
||||
dut.result_ready_i.value = 0
|
||||
for cycle in range(2):
|
||||
dut.topk_rows_i.value = pack([cycle + 200, 0, 0, 0], ROW_BITS)
|
||||
dut.topk_scores_i.value = pack([cycle + 100, 0, 0, 0], SCORE_BITS)
|
||||
await RisingEdge(dut.clk)
|
||||
await assert_output_stable(dut, rank=1, row=rows[1], score=scores[1],
|
||||
last=False, valid=True)
|
||||
|
||||
|
||||
# ── Test 3: K=1 edge case ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def serializer_handles_k1(dut):
|
||||
"""
|
||||
When K=1 (overridden via generics), start and fire produce done_o and
|
||||
deassert busy on the next cycle. Run via ``make test-k1``.
|
||||
"""
|
||||
if K != 1:
|
||||
cocotb.log.info("Skipping (designed for TOPK_K=1, got %d)", K)
|
||||
return
|
||||
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_serializer(dut)
|
||||
|
||||
rows = [100]
|
||||
scores = [50]
|
||||
|
||||
dut.topk_rows_i.value = pack(rows, ROW_BITS)
|
||||
dut.topk_scores_i.value = pack(scores, SCORE_BITS)
|
||||
dut.start_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.start_i.value = 0
|
||||
|
||||
# K=1: rank 0 is also the last; valid asserted
|
||||
assert bool(dut.busy_o.value), "busy_o should be high after start"
|
||||
await assert_output_stable(dut, rank=0, row=rows[0], score=scores[0],
|
||||
last=True, valid=True)
|
||||
|
||||
# Fire the one and only result
|
||||
dut.result_ready_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
|
||||
# done_o should pulse, busy deasserted
|
||||
assert bool(dut.done_o.value), "done_o should pulse after K=1 fire"
|
||||
assert not bool(dut.busy_o.value), "busy_o should be low after done"
|
||||
assert not bool(dut.result_valid_o.value), "valid should be low after done"
|
||||
assert int(dut.result_rank_o.value) == 0, "rank should reset to 0"
|
||||
9
hw/sim/tests/modules/topk_tracker/Makefile
Normal file
9
hw/sim/tests/modules/topk_tracker/Makefile
Normal file
@@ -0,0 +1,9 @@
|
||||
SIM_ROOT := $(abspath ../../..)
|
||||
RTL_ROOT := $(abspath $(SIM_ROOT)/../rtl)
|
||||
include $(SIM_ROOT)/mk/rtl-sources.mk
|
||||
|
||||
TOPLEVEL := topk_tracker
|
||||
COCOTB_TEST_MODULES := tests.modules.topk_tracker.test_topk_tracker
|
||||
VERILOG_SOURCES := $(RTL_ROOT)/core/topk_tracker.sv
|
||||
|
||||
include $(SIM_ROOT)/mk/cocotb-common.mk
|
||||
133
hw/sim/tests/modules/topk_tracker/test_topk_tracker.py
Normal file
133
hw/sim/tests/modules/topk_tracker/test_topk_tracker.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import cocotb
|
||||
from cocotb.clock import Clock
|
||||
from cocotb.triggers import RisingEdge
|
||||
|
||||
# ── Parameter defaults (matching cam_params.svh with NUM_ROWS=4096, HASH_BITS=512) ──
|
||||
K = 4
|
||||
ROW_BITS = 12
|
||||
SCORE_BITS = 10 # $clog2(512 + 1)
|
||||
ROW_SENTINEL = (1 << ROW_BITS) - 1 # all-ones
|
||||
|
||||
|
||||
def golden(candidates, k=K):
|
||||
"""Return top-k candidates sorted by score desc, then row asc (higher score wins; lower row breaks ties)."""
|
||||
return sorted(candidates, key=lambda item: (-item[1], item[0]))[:k]
|
||||
|
||||
|
||||
def unpack(dut, k=K):
|
||||
"""Read packed topk_rows_o and topk_scores_o; return list of (row, score) tuples ordered by rank."""
|
||||
rows_packed = int(dut.topk_rows_o.value)
|
||||
scores_packed = int(dut.topk_scores_o.value)
|
||||
result = []
|
||||
for i in range(k):
|
||||
row = (rows_packed >> (i * ROW_BITS)) & ((1 << ROW_BITS) - 1)
|
||||
score = (scores_packed >> (i * SCORE_BITS)) & ((1 << SCORE_BITS) - 1)
|
||||
result.append((row, score))
|
||||
return result
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def reset_tracker(dut):
|
||||
"""Assert reset for 5 cycles, release, then wait 2 cycles."""
|
||||
dut.rst_n.value = 0
|
||||
dut.clear_i.value = 0
|
||||
dut.candidate_valid_i.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 push_candidate(dut, row: int, score: int):
|
||||
"""Drive one candidate handshake (candidate_ready_o is always 1)."""
|
||||
dut.candidate_valid_i.value = 1
|
||||
dut.candidate_row_i.value = row
|
||||
dut.candidate_score_i.value = score
|
||||
await RisingEdge(dut.clk)
|
||||
dut.candidate_valid_i.value = 0
|
||||
|
||||
|
||||
# ── Test 1: Basic ordering ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def tracker_orders_by_score_then_lower_row(dut):
|
||||
"""
|
||||
Push candidates [(5,10), (3,11), (2,11), (9,7), (1,11), (0,3)] and verify
|
||||
the tracker's Top-4 matches golden: [(1,11), (2,11), (3,11), (5,10)].
|
||||
"""
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_tracker(dut)
|
||||
|
||||
candidates = [(5, 10), (3, 11), (2, 11), (9, 7), (1, 11), (0, 3)]
|
||||
|
||||
for row, score in candidates:
|
||||
await push_candidate(dut, row, score)
|
||||
|
||||
result = unpack(dut)
|
||||
expected = golden(candidates)
|
||||
assert result == expected, f"Expected {expected}, got {result}"
|
||||
assert dut.update_pending_o.value == 0, "update_pending_o should be 0"
|
||||
|
||||
|
||||
# ── Test 2: Clear restarts query ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def tracker_clear_starts_new_query(dut):
|
||||
"""
|
||||
After candidate (7,60), pulse clear_i, send candidate (4,12).
|
||||
Expect rank 0 = (4,12), remaining slots = sentinel rows, score 0.
|
||||
"""
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_tracker(dut)
|
||||
|
||||
# Push (7, 60)
|
||||
await push_candidate(dut, 7, 60)
|
||||
|
||||
# Pulse clear
|
||||
dut.clear_i.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.clear_i.value = 0
|
||||
|
||||
# Push (4, 12)
|
||||
await push_candidate(dut, 4, 12)
|
||||
|
||||
result = unpack(dut)
|
||||
expected = [(4, 12)] + [(ROW_SENTINEL, 0)] * (K - 1)
|
||||
assert result == expected, f"Expected {expected}, got {result}"
|
||||
|
||||
|
||||
# ── Test 3: Random validation against golden ─────────────────────────────────────
|
||||
|
||||
|
||||
@cocotb.test()
|
||||
async def tracker_matches_random_python_golden(dut):
|
||||
"""
|
||||
Push 100 candidates (row in [0,63], score in [0,127]) seeded at 42.
|
||||
Verify final Top-4 matches Python golden model.
|
||||
"""
|
||||
import random
|
||||
random.seed(42)
|
||||
|
||||
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
|
||||
await reset_tracker(dut)
|
||||
|
||||
max_row = 64
|
||||
max_score = 128
|
||||
candidates = []
|
||||
|
||||
for _ in range(100):
|
||||
row = random.randint(0, max_row - 1)
|
||||
score = random.randint(0, max_score - 1)
|
||||
candidates.append((row, score))
|
||||
await push_candidate(dut, row, score)
|
||||
|
||||
result = unpack(dut)
|
||||
expected = golden(candidates)
|
||||
assert result == expected, f"Expected {expected}, got {result}"
|
||||
@@ -64,6 +64,63 @@ def dut_score_bits(dut):
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def collect_topk(dut, timeout_cycles: int = 2000):
|
||||
"""Collect all serial Top-K result beats with result_ready held high.
|
||||
|
||||
Returns list of (rank, row, score, last) tuples.
|
||||
Raises AssertionError if the stream does not complete within timeout.
|
||||
"""
|
||||
dut.result_ready.value = 1
|
||||
beats = []
|
||||
for _ in range(timeout_cycles):
|
||||
if int(dut.result_valid.value):
|
||||
rank = int(dut.result_rank.value)
|
||||
row = int(dut.result_row.value)
|
||||
score = int(dut.result_score.value)
|
||||
last = int(dut.result_last.value)
|
||||
beats.append((rank, row, score, last))
|
||||
if last:
|
||||
return beats
|
||||
await RisingEdge(dut.clk)
|
||||
raise AssertionError("Top-K result stream did not finish")
|
||||
|
||||
|
||||
async def query_topk_once(dut, query, timeout_cycles=2000):
|
||||
"""Issue a query, collect full serial Top-K stream, and return beats + Top-1 metadata.
|
||||
|
||||
Returns (beats, top1_index, top1_score, score_debug).
|
||||
After this call the full result stream has been consumed and the DUT is idle.
|
||||
"""
|
||||
await wait_idle(dut)
|
||||
|
||||
dut.query_hash.value = int(query)
|
||||
dut.query_valid.value = 1
|
||||
|
||||
# Wait for handshake
|
||||
while True:
|
||||
await RisingEdge(dut.clk)
|
||||
if int(dut.query_ready.value):
|
||||
break
|
||||
|
||||
dut.query_valid.value = 0
|
||||
|
||||
# Consume full serial result stream
|
||||
beats = await collect_topk(dut, timeout_cycles=timeout_cycles)
|
||||
|
||||
# score_debug is available after query completes
|
||||
num_rows = dut_num_rows(dut)
|
||||
score_bits = dut_score_bits(dut)
|
||||
score_debug = None
|
||||
if hasattr(dut, "score_debug_flat"):
|
||||
score_debug = unpack_score_debug_flat(
|
||||
int(dut.score_debug_flat.value),
|
||||
num_rows,
|
||||
score_bits,
|
||||
)
|
||||
|
||||
return beats, beats[0][1], beats[0][2], score_debug
|
||||
|
||||
|
||||
async def reset_dut(dut):
|
||||
"""Reset the DUT with new handshake interface."""
|
||||
dut.rst_n.value = 0
|
||||
@@ -116,41 +173,11 @@ async def write_rows(dut, rows):
|
||||
|
||||
|
||||
async def query_once(dut, query):
|
||||
"""Issue a query and return (top1_index, top1_score, score_debug)."""
|
||||
await wait_idle(dut)
|
||||
|
||||
dut.query_hash.value = int(query)
|
||||
dut.query_valid.value = 1
|
||||
|
||||
# Wait for handshake
|
||||
while True:
|
||||
await RisingEdge(dut.clk)
|
||||
if int(dut.query_ready.value):
|
||||
break
|
||||
|
||||
dut.query_valid.value = 0
|
||||
|
||||
# Wait for result
|
||||
while int(dut.result_valid.value) == 0:
|
||||
await RisingEdge(dut.clk)
|
||||
|
||||
top1_index = int(dut.top1_index.value)
|
||||
top1_score = int(dut.top1_score.value)
|
||||
|
||||
num_rows = dut_num_rows(dut)
|
||||
score_bits = dut_score_bits(dut)
|
||||
score_debug = None
|
||||
if hasattr(dut, "score_debug_flat"):
|
||||
score_debug = unpack_score_debug_flat(
|
||||
int(dut.score_debug_flat.value),
|
||||
num_rows,
|
||||
score_bits,
|
||||
)
|
||||
|
||||
dut.result_ready.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
dut.result_ready.value = 0
|
||||
"""Issue a query and return (top1_index, top1_score, score_debug).
|
||||
|
||||
Consumes the full serial Top-K stream and returns rank-0 data.
|
||||
"""
|
||||
_, top1_index, top1_score, score_debug = await query_topk_once(dut, query)
|
||||
return top1_index, top1_score, score_debug
|
||||
|
||||
|
||||
@@ -187,7 +214,7 @@ async def baseline_no_noise(dut):
|
||||
query = rows[query_index]
|
||||
|
||||
await write_rows(dut, rows)
|
||||
top1_index, top1_score, score_debug = await query_once(dut, query)
|
||||
beats, top1_index, top1_score, score_debug = await query_topk_once(dut, query)
|
||||
|
||||
expected = match_top1(query, rows, width=hash_bits)
|
||||
|
||||
@@ -196,6 +223,16 @@ async def baseline_no_noise(dut):
|
||||
assert top1_index == query_index
|
||||
assert top1_score == hash_bits
|
||||
|
||||
# Serial Top-K stream verification: beats from the single query above
|
||||
assert beats[0][0] == 0, "First beat must have rank 0"
|
||||
assert beats[-1][3] == 1, "Last beat must assert result_last"
|
||||
# Verify top1 aliases match first beat after stream fully consumed
|
||||
assert int(dut.top1_index.value) == beats[0][1]
|
||||
assert int(dut.top1_score.value) == beats[0][2]
|
||||
# Verify returned top1 matches first beat rank0
|
||||
assert top1_index == beats[0][1]
|
||||
assert top1_score == beats[0][2]
|
||||
|
||||
if score_debug is not None:
|
||||
assert np.array_equal(score_debug, expected.scores)
|
||||
|
||||
@@ -564,10 +601,10 @@ async def query_scan_blocks_writes_until_result_consumed(dut):
|
||||
assert int(dut.wr_ready.value) == 0
|
||||
dut.wr_valid.value = 0
|
||||
|
||||
while int(dut.result_valid.value) == 0:
|
||||
await RisingEdge(dut.clk)
|
||||
dut.result_ready.value = 1
|
||||
await RisingEdge(dut.clk)
|
||||
# Consume full serial stream so the DUT returns idle
|
||||
beats = await collect_topk(dut, timeout_cycles=2000)
|
||||
assert len(beats) > 0
|
||||
assert beats[-1][3] == 1 # last asserted
|
||||
|
||||
|
||||
# ── Test I: Read noise model match ──────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user