feat(hw): add XNOR-popcount CAM design with cocotb verification

Implement a multi-lane Content Addressable Memory (CAM) that scores
rows by XNOR popcount against a query hash and returns the top-1 match.

RTL modules:
- popcount: parallel group-based population count
- argmax_update: iterative best-match tracking with tie-break
- cam_core: parameterized scanning engine (NUM_ROWS/HASH_BITS/LANES)
  with optional SIM_NOISE and SIM_DEBUG ifdef guards
- cam_top: thin wrapper exposing cam_core ports

Verification:
- Python reference model (ref_model.py) for score-level golden comparison
- cocotb testbench (test_cam_basic.py) covering write/query/reset and
  external noise mask scenarios with score debug verification
- Noise sweep script (sweep_noise.py) measuring top-1 stability under
  configurable bit-flip rates
- Verilator-oriented Makefile with parameterizable compile options
This commit is contained in:
2026-05-02 17:49:22 +08:00
parent ad45123022
commit f71bf06484
8 changed files with 769 additions and 0 deletions

30
hw/sim/Makefile Normal file
View File

@@ -0,0 +1,30 @@
# Minimal cocotb Makefile.
# Examples:
# make TESTCASE=basic_write_query_no_noise
# make TESTCASE=external_noise_mask EXTRA_DEFINES="+define+SIM_NOISE +define+SIM_DEBUG"
#
# Verilator is preferred. Icarus may not support all SystemVerilog constructs used here.
SIM ?= verilator
TOPLEVEL_LANG ?= verilog
TOPLEVEL := cam_top
MODULE ?= tests.test_cam_basic
NUM_ROWS ?= 512
HASH_BITS ?= 512
LANES ?= 16
EXTRA_ARGS += -DNUM_ROWS=$(NUM_ROWS) -DHASH_BITS=$(HASH_BITS) -DLANES=$(LANES)
# cocotb passes PLUSARGS/EXTRA_ARGS differently across simulators. Keep
# SystemVerilog parameters explicit through COMPILE_ARGS for Verilator.
COMPILE_ARGS += -Wall -Wno-fatal
COMPILE_ARGS += +define+SIM_DEBUG
COMPILE_ARGS += $(EXTRA_DEFINES)
VERILOG_SOURCES += $(PWD)/../rtl/popcount.sv
VERILOG_SOURCES += $(PWD)/../rtl/argmax_update.sv
VERILOG_SOURCES += $(PWD)/../rtl/cam_core.sv
VERILOG_SOURCES += $(PWD)/../rtl/cam_top.sv
include $(shell cocotb-config --makefiles)/Makefile.sim

127
hw/sim/model/ref_model.py Normal file
View File

@@ -0,0 +1,127 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, Sequence
import numpy as np
@dataclass(frozen=True)
class MatchResult:
top1_index: int
top1_score: int
scores: np.ndarray
def popcount_int(x: int) -> int:
return int(x.bit_count())
def mask_width(width: int) -> int:
return (1 << width) - 1
def xnor_popcount_score(query: int, stored: int, width: int = 512) -> int:
same_bits = ~(query ^ stored) & mask_width(width)
return popcount_int(same_bits)
def apply_noise(stored: int, noise_mask: int) -> int:
return stored ^ noise_mask
def match_top1(
query: int,
rows: Sequence[int],
*,
width: int = 512,
noise_masks: Sequence[int] | None = None,
) -> MatchResult:
scores = np.zeros(len(rows), dtype=np.int32)
best_index = 0
best_score = -1
for idx, row in enumerate(rows):
effective = row if noise_masks is None else apply_noise(row, int(noise_masks[idx]))
score = xnor_popcount_score(int(query), int(effective), width)
scores[idx] = score
# Tie-break: choose the smallest row index.
if score > best_score:
best_score = score
best_index = idx
return MatchResult(
top1_index=int(best_index),
top1_score=int(best_score),
scores=scores,
)
def random_hashes(
rng: np.random.Generator,
n: int,
*,
width: int = 512,
) -> list[int]:
words = (width + 63) // 64
out: list[int] = []
for _ in range(n):
value = 0
for w in range(words):
value |= int(rng.integers(0, 1 << 64, dtype=np.uint64)) << (64 * w)
out.append(value & mask_width(width))
return out
def random_noise_masks(
rng: np.random.Generator,
n: int,
*,
width: int = 512,
bit_flip_rate: float = 0.0,
) -> list[int]:
if not (0.0 <= bit_flip_rate <= 1.0):
raise ValueError("bit_flip_rate must be in [0, 1]")
masks: list[int] = []
for _ in range(n):
bits = rng.random(width) < bit_flip_rate
value = 0
for i, bit in enumerate(bits):
if bool(bit):
value |= 1 << i
masks.append(value)
return masks
def pack_lanes_flat(masks: Sequence[int], *, width: int = 512) -> int:
flat = 0
lane_mask = mask_width(width)
for lane, mask in enumerate(masks):
flat |= (int(mask) & lane_mask) << (lane * width)
return flat
def unpack_score_debug_flat(flat: int, num_rows: int, score_bits: int) -> np.ndarray:
mask = (1 << score_bits) - 1
return np.array(
[(int(flat) >> (row * score_bits)) & mask for row in range(num_rows)],
dtype=np.int32,
)
def split_hash_to_words_le(value: int, *, width: int = 512, word_bits: int = 32) -> list[int]:
n_words = width // word_bits
word_mask = (1 << word_bits) - 1
return [(int(value) >> (word_bits * i)) & word_mask for i in range(n_words)]
def join_hash_words_le(words: Sequence[int], *, word_bits: int = 32) -> int:
value = 0
word_mask = (1 << word_bits) - 1
for i, word in enumerate(words):
value |= (int(word) & word_mask) << (word_bits * i)
return value

57
hw/sim/sweep_noise.py Normal file
View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import argparse
import numpy as np
from model.ref_model import match_top1, random_hashes, random_noise_masks
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--rows", type=int, default=512)
parser.add_argument("--queries", type=int, default=128)
parser.add_argument("--width", type=int, default=512)
parser.add_argument("--seed", type=int, default=1234)
parser.add_argument(
"--rates",
type=float,
nargs="+",
default=[0.0, 0.001, 0.005, 0.01, 0.02, 0.05],
)
args = parser.parse_args()
rng = np.random.default_rng(args.seed)
rows = random_hashes(rng, args.rows, width=args.width)
# Construct simple positive queries by selecting existing rows.
query_indices = rng.integers(0, args.rows, size=args.queries)
queries = [rows[int(i)] for i in query_indices]
clean_results = [match_top1(q, rows, width=args.width) for q in queries]
print("rate,top1_stability,avg_clean_margin")
for rate in args.rates:
stable = 0
margins = []
for q, clean in zip(queries, clean_results):
noise_masks = random_noise_masks(
rng,
args.rows,
width=args.width,
bit_flip_rate=rate,
)
noisy = match_top1(q, rows, width=args.width, noise_masks=noise_masks)
if noisy.top1_index == clean.top1_index:
stable += 1
sorted_scores = np.sort(clean.scores)
margin = int(sorted_scores[-1] - sorted_scores[-2])
margins.append(margin)
print(f"{rate},{stable / args.queries:.6f},{np.mean(margins):.3f}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,210 @@
from __future__ import annotations
import cocotb
import numpy as np
from cocotb.clock import Clock
from cocotb.triggers import RisingEdge
from model.ref_model import ( # noqa: E402
match_top1,
pack_lanes_flat,
random_hashes,
unpack_score_debug_flat,
)
NUM_ROWS = 512
HASH_BITS = 512
LANES = 16
SCORE_BITS = 10
async def reset_dut(dut):
dut.rst_n.value = 0
dut.wr_en.value = 0
dut.wr_row.value = 0
dut.wr_hash.value = 0
dut.query_valid.value = 0
dut.query_hash.value = 0
dut.result_ready.value = 1
if hasattr(dut, "noise_mask_lanes_flat"):
dut.noise_mask_lanes_flat.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 write_rows(dut, rows):
for idx, value in enumerate(rows):
dut.wr_row.value = idx
dut.wr_hash.value = int(value)
dut.wr_en.value = 1
await RisingEdge(dut.clk)
dut.wr_en.value = 0
await RisingEdge(dut.clk)
async def query_once(dut, query, noise_masks=None):
dut.query_hash.value = int(query)
dut.query_valid.value = 1
await RisingEdge(dut.clk)
dut.query_valid.value = 0
# Feed lane noise masks batch by batch while DUT is scanning.
# For no-noise builds this signal is absent and ignored.
base = 0
while int(dut.result_valid.value) == 0:
if hasattr(dut, "noise_mask_lanes_flat") and noise_masks is not None:
lane_masks = []
for lane in range(LANES):
row = base + lane
lane_masks.append(noise_masks[row] if row < NUM_ROWS else 0)
dut.noise_mask_lanes_flat.value = pack_lanes_flat(
lane_masks, width=HASH_BITS
)
base += LANES
await RisingEdge(dut.clk)
top1_index = int(dut.top1_index.value)
top1_score = int(dut.top1_score.value)
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,
)
await RisingEdge(dut.clk)
return top1_index, top1_score, score_debug
@cocotb.test()
async def basic_write_query_no_noise(dut):
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_dut(dut)
rng = np.random.default_rng(1)
rows = random_hashes(rng, NUM_ROWS, width=HASH_BITS)
query_index = 123
query = rows[query_index]
await write_rows(dut, rows)
top1_index, top1_score, score_debug = await query_once(dut, query)
expected = match_top1(query, rows, width=HASH_BITS)
assert top1_index == expected.top1_index
assert top1_score == expected.top1_score
assert top1_index == query_index
assert top1_score == HASH_BITS
if score_debug is not None:
assert np.array_equal(score_debug, expected.scores)
@cocotb.test()
async def all_zero_all_one_boundary(dut):
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_dut(dut)
rows = [0] * NUM_ROWS
rows[0] = 0
rows[1] = (1 << HASH_BITS) - 1
query = 0
await write_rows(dut, rows)
top1_index, top1_score, score_debug = await query_once(dut, query)
assert top1_score == HASH_BITS
assert top1_index == 0
if score_debug is not None:
assert int(score_debug[0]) == HASH_BITS
assert int(score_debug[1]) == 0
@cocotb.test()
async def known_hamming_distance(dut):
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_dut(dut)
query = 0
rows = [0] * NUM_ROWS
rows[10] = (1 << 7) - 1 # Hamming distance = 7
rows[11] = (1 << 31) - 1 # Hamming distance = 31
rows[12] = (1 << 128) - 1 # Hamming distance = 128
await write_rows(dut, rows)
top1_index, top1_score, score_debug = await query_once(dut, query)
# Many rows are identical to query; tie-break must select row 0.
assert top1_index == 0
assert top1_score == HASH_BITS
if score_debug is not None:
assert int(score_debug[10]) == HASH_BITS - 7
assert int(score_debug[11]) == HASH_BITS - 31
assert int(score_debug[12]) == HASH_BITS - 128
@cocotb.test()
async def tie_break_policy(dut):
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_dut(dut)
rng = np.random.default_rng(2)
rows = random_hashes(rng, NUM_ROWS, width=HASH_BITS)
query = rows[200]
rows[10] = query
rows[20] = query
rows[200] = query
await write_rows(dut, rows)
top1_index, top1_score, _ = await query_once(dut, query)
assert top1_index == 10
assert top1_score == HASH_BITS
@cocotb.test()
async def external_noise_mask(dut):
# This test is meaningful only when compiled with SIM_NOISE and SIM_DEBUG.
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_dut(dut)
if not hasattr(dut, "noise_mask_lanes_flat"):
dut._log.warning("SIM_NOISE not enabled; skipping exact noise-mask behavior.")
return
rng = np.random.default_rng(3)
rows = random_hashes(rng, NUM_ROWS, width=HASH_BITS)
query_index = 42
query = rows[query_index]
noise_masks = [0] * NUM_ROWS
noise_masks[query_index] = (1 << 13) - 1 # flip exactly 13 bits
await write_rows(dut, rows)
top1_index, top1_score, score_debug = await query_once(
dut,
query,
noise_masks=noise_masks,
)
expected = match_top1(query, rows, width=HASH_BITS, noise_masks=noise_masks)
assert top1_index == expected.top1_index
assert top1_score == expected.top1_score
if score_debug is not None:
assert int(score_debug[query_index]) == HASH_BITS - 13
assert np.array_equal(score_debug, expected.scores)