Files
Mini-Nav/hw/sim/tests/modules/result_serializer/test_result_serializer.py
SikongJueluo e4cbb5e30d 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
2026-05-19 22:43:21 +08:00

213 lines
8.2 KiB
Python

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"