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

26
hw/rtl/argmax_update.sv Normal file
View File

@@ -0,0 +1,26 @@
`timescale 1ns/1ps
module argmax_update #(
parameter int ROW_BITS = 9,
parameter int SCORE_BITS = 10
) (
input logic [ROW_BITS-1:0] best_index_i,
input logic [SCORE_BITS-1:0] best_score_i,
input logic [ROW_BITS-1:0] cand_index_i,
input logic [SCORE_BITS-1:0] cand_score_i,
output logic [ROW_BITS-1:0] best_index_o,
output logic [SCORE_BITS-1:0] best_score_o
);
always_comb begin
best_index_o = best_index_i;
best_score_o = best_score_i;
if ((cand_score_i > best_score_i) ||
((cand_score_i == best_score_i) && (cand_index_i < best_index_i))) begin
best_index_o = cand_index_i;
best_score_o = cand_score_i;
end
end
endmodule

214
hw/rtl/cam_core.sv Normal file
View File

@@ -0,0 +1,214 @@
`timescale 1ns / 1ps
module cam_core #(
parameter int NUM_ROWS = 512,
parameter int HASH_BITS = 512,
parameter int LANES = 16,
parameter int ROW_BITS = $clog2(NUM_ROWS),
parameter int SCORE_BITS = $clog2(HASH_BITS + 1)
) (
input logic clk,
input logic rst_n,
// Static load interface. In the first prototype, writes are expected
// before online queries begin.
input logic [ ROW_BITS-1:0] wr_row,
input logic [HASH_BITS-1:0] wr_hash,
input logic wr_en,
// Single-request blocking query interface.
input logic query_valid,
output logic query_ready,
input logic [HASH_BITS-1:0] query_hash,
output logic result_valid,
input logic result_ready,
output logic [ ROW_BITS-1:0] top1_index,
output logic [SCORE_BITS-1:0] top1_score,
`ifdef SIM_NOISE
// Flattened for easier cocotb/Verilator handling:
// lane k mask = noise_mask_lanes_flat[k*HASH_BITS +: HASH_BITS]
input logic [LANES*HASH_BITS-1:0] noise_mask_lanes_flat,
`endif
`ifdef SIM_DEBUG
// Flattened for easier cocotb/Verilator handling:
// score[row] = score_debug_flat[row*SCORE_BITS +: SCORE_BITS]
output logic [NUM_ROWS*SCORE_BITS-1:0] score_debug_flat,
`endif
output logic busy
);
localparam int NUM_BATCHES = (NUM_ROWS + LANES - 1) / LANES;
typedef enum logic [1:0] {
S_IDLE,
S_SCAN,
S_DONE
} state_t;
state_t state_q, state_d;
logic [HASH_BITS-1:0] cam_mem[NUM_ROWS];
logic [HASH_BITS-1: 0] query_q;
logic [ROW_BITS-1 : 0] base_row_q;
logic [ROW_BITS-1 : 0] base_row_d;
logic [ROW_BITS-1:0] best_index_q, best_index_d;
logic [SCORE_BITS-1:0] best_score_q, best_score_d;
logic [ ROW_BITS-1:0] lane_best_index;
logic [SCORE_BITS-1:0] lane_best_score;
logic [ ROW_BITS-1:0] lane_best_index_next[LANES+1];
logic [SCORE_BITS-1:0] lane_best_score_next[LANES+1];
logic [SCORE_BITS-1:0] lane_score [ LANES];
logic [ ROW_BITS-1:0] lane_row [ LANES];
logic lane_valid [ LANES];
assign query_ready = (state_q == S_IDLE);
assign result_valid = (state_q == S_DONE);
assign top1_index = best_index_q;
assign top1_score = best_score_q;
assign busy = (state_q == S_SCAN);
// Memory write path.
always_ff @(posedge clk) begin
if (wr_en) begin
cam_mem[wr_row] <= wr_hash;
end
end
`ifdef SIM_DEBUG
// Clear by default. Individual rows are overwritten during scan.
initial begin
score_debug_flat = '0;
end
`endif
genvar lane;
generate
for (lane = 0; lane < LANES; lane++) begin : gen_lanes
logic [HASH_BITS-1:0] row_hash;
logic [HASH_BITS-1:0] effective_hash;
logic [HASH_BITS-1:0] match_bits;
assign lane_row[lane] = base_row_q + lane[ROW_BITS-1:0];
assign lane_valid[lane] = (lane_row[lane] < NUM_ROWS[ROW_BITS-1:0]);
// This read is modeled behaviorally. Later this can be replaced
// by banked BRAM/URAM without changing the compare path.
assign row_hash = lane_valid[lane] ? cam_mem[lane_row[lane]] : '0;
`ifdef SIM_NOISE
logic [HASH_BITS-1:0] lane_noise_mask;
assign lane_noise_mask = noise_mask_lanes_flat[lane*HASH_BITS+:HASH_BITS];
assign effective_hash = row_hash ^ lane_noise_mask;
`else
assign effective_hash = row_hash;
`endif
assign match_bits = ~(query_q ^ effective_hash);
popcount #(
.WIDTH(HASH_BITS),
.GROUP(8),
.OUT_WIDTH(SCORE_BITS)
) u_popcount (
.bits_i (match_bits),
.count_o(lane_score[lane])
);
argmax_update #(
.ROW_BITS (ROW_BITS),
.SCORE_BITS(SCORE_BITS)
) u_argmax_lane (
.best_index_i(lane_best_index_next[lane]),
.best_score_i(lane_best_score_next[lane]),
.cand_index_i(lane_row[lane]),
.cand_score_i(lane_valid[lane] ? lane_score[lane] : '0),
.best_index_o(lane_best_index_next[lane+1]),
.best_score_o(lane_best_score_next[lane+1])
);
end
endgenerate
assign lane_best_index_next[0] = best_index_q;
assign lane_best_score_next[0] = best_score_q;
assign lane_best_index = lane_best_index_next[LANES];
assign lane_best_score = lane_best_score_next[LANES];
always_comb begin
state_d = state_q;
base_row_d = base_row_q;
best_index_d = best_index_q;
best_score_d = best_score_q;
unique case (state_q)
S_IDLE: begin
if (query_valid) begin
state_d = S_SCAN;
base_row_d = '0;
best_index_d = {ROW_BITS{1'b1}};
best_score_d = '0;
end
end
S_SCAN: begin
best_index_d = lane_best_index;
best_score_d = lane_best_score;
if (base_row_q + LANES >= NUM_ROWS) begin
state_d = S_DONE;
end else begin
base_row_d = base_row_q + LANES[ROW_BITS-1:0];
end
end
S_DONE: begin
if (result_ready) begin
state_d = S_IDLE;
end
end
default: begin
state_d = S_IDLE;
end
endcase
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state_q <= S_IDLE;
query_q <= '0;
base_row_q <= '0;
best_index_q <= '0;
best_score_q <= '0;
end else begin
state_q <= state_d;
base_row_q <= base_row_d;
best_index_q <= best_index_d;
best_score_q <= best_score_d;
if ((state_q == S_IDLE) && query_valid) begin
query_q <= query_hash;
end
`ifdef SIM_DEBUG
if (state_q == S_IDLE && query_valid) begin
score_debug_flat <= '0;
end else if (state_q == S_SCAN) begin
for (int l = 0; l < LANES; l++) begin
if (lane_valid[l]) begin
score_debug_flat[lane_row[l]*SCORE_BITS+:SCORE_BITS] <= lane_score[l];
end
end
end
`endif
end
end
endmodule

64
hw/rtl/cam_top.sv Normal file
View File

@@ -0,0 +1,64 @@
`timescale 1ns/1ps
module cam_top #(
parameter int NUM_ROWS = 512,
parameter int HASH_BITS = 512,
parameter int LANES = 16,
parameter int ROW_BITS = $clog2(NUM_ROWS),
parameter int SCORE_BITS = $clog2(HASH_BITS + 1)
) (
input logic clk,
input logic rst_n,
input logic wr_en,
input logic [ROW_BITS-1:0] wr_row,
input logic [HASH_BITS-1:0] wr_hash,
input logic query_valid,
output logic query_ready,
input logic [HASH_BITS-1:0] query_hash,
output logic result_valid,
input logic result_ready,
output logic [ROW_BITS-1:0] top1_index,
output logic [SCORE_BITS-1:0] top1_score,
`ifdef SIM_NOISE
input logic [LANES*HASH_BITS-1:0] noise_mask_lanes_flat,
`endif
`ifdef SIM_DEBUG
output logic [NUM_ROWS*SCORE_BITS-1:0] score_debug_flat,
`endif
output logic busy
);
cam_core #(
.NUM_ROWS(NUM_ROWS),
.HASH_BITS(HASH_BITS),
.LANES(LANES),
.ROW_BITS(ROW_BITS),
.SCORE_BITS(SCORE_BITS)
) u_core (
.clk(clk),
.rst_n(rst_n),
.wr_row(wr_row),
.wr_hash(wr_hash),
.wr_en(wr_en),
.query_valid(query_valid),
.query_ready(query_ready),
.query_hash(query_hash),
.result_valid(result_valid),
.result_ready(result_ready),
.top1_index(top1_index),
.top1_score(top1_score),
`ifdef SIM_NOISE
.noise_mask_lanes_flat(noise_mask_lanes_flat),
`endif
`ifdef SIM_DEBUG
.score_debug_flat(score_debug_flat),
`endif
.busy(busy)
);
endmodule

41
hw/rtl/popcount.sv Normal file
View File

@@ -0,0 +1,41 @@
`timescale 1ns/1ps
module popcount #(
parameter int WIDTH = 512,
parameter int GROUP = 8,
parameter int OUT_WIDTH = $clog2(WIDTH + 1)
) (
input logic [WIDTH-1:0] bits_i,
output logic [OUT_WIDTH-1:0] count_o
);
localparam int NUM_GROUPS = (WIDTH + GROUP - 1) / GROUP;
localparam int GROUP_COUNT_WIDTH = $clog2(GROUP + 1);
logic [GROUP_COUNT_WIDTH-1:0] group_counts [NUM_GROUPS];
genvar g;
generate
for (g = 0; g < NUM_GROUPS; g++) begin : gen_group_popcount
always_comb begin
group_counts[g] = '0;
for (int b = 0; b < GROUP; b++) begin
int idx;
idx = g * GROUP + b;
if (idx < WIDTH) begin
group_counts[g] = group_counts[g] + bits_i[idx];
end
end
end
end
endgenerate
// Reduction over small group counts. This is intentionally simple and
// synthesis-friendly enough for the first prototype. If timing fails,
// replace this block with a fully staged adder tree.
always_comb begin
count_o = '0;
for (int i = 0; i < NUM_GROUPS; i++) begin
count_o = count_o + group_counts[i];
end
end
endmodule