From e5d13917b21052c64f4785cf632e0c64d325989f Mon Sep 17 00:00:00 2001 From: SikongJueluo Date: Tue, 26 May 2026 15:45:33 +0800 Subject: [PATCH] feat(cam): add Bernoulli noise mask module and cocotb tests - Add noise_mask_bernoulli.sv RTL module for probabilistic masking - Add cocotb test suite with reset, threshold, determinism, and distribution checks - Update rtl-sources.mk to include new module - Fix PYTHONPATH in devenv.nix shell hook --- devenv.nix | 1 + hw/rtl/noise/noise_mask_bernoulli.sv | 126 +++++++++ hw/sim/mk/rtl-sources.mk | 1 + .../modules/noise_mask_bernoulli/Makefile | 11 + .../modules/noise_mask_bernoulli/__init__.py | 1 + .../test_noise_mask_bernoulli.py | 242 ++++++++++++++++++ 6 files changed, 382 insertions(+) create mode 100644 hw/rtl/noise/noise_mask_bernoulli.sv create mode 100644 hw/sim/tests/modules/noise_mask_bernoulli/Makefile create mode 100644 hw/sim/tests/modules/noise_mask_bernoulli/__init__.py create mode 100644 hw/sim/tests/modules/noise_mask_bernoulli/test_noise_mask_bernoulli.py diff --git a/devenv.nix b/devenv.nix index 4af7147..7b7f27b 100644 --- a/devenv.nix +++ b/devenv.nix @@ -28,6 +28,7 @@ in enterShell = '' export UV_PROJECT_ENVIRONMENT=$MAMBA_ROOT_PREFIX/envs/mini-nav + unset PYTHONPATH eval "$(micromamba shell hook --shell bash)" micromamba activate mini-nav diff --git a/hw/rtl/noise/noise_mask_bernoulli.sv b/hw/rtl/noise/noise_mask_bernoulli.sv new file mode 100644 index 0000000..f800387 --- /dev/null +++ b/hw/rtl/noise/noise_mask_bernoulli.sv @@ -0,0 +1,126 @@ +`timescale 1ns / 1ps +`include "cam_params.svh" + +module noise_mask_bernoulli #( + parameter int HASH_BITS = `HASH_BITS, + parameter int PROB_BITS = 8, + parameter int PRNG_WORDS = 2, + parameter int BITS_PER_CYCLE = 32, + parameter logic [63:0] SEED = 64'hD1B5_4A32_9E37_79B9 +) ( + input logic clk, + input logic rst_n, + input logic start_i, + input logic [PROB_BITS-1:0] threshold_i, + output logic busy_o, + output logic done_o, + output logic [HASH_BITS-1:0] mask_o +); + + localparam int RANDOM_BITS = PRNG_WORDS * 128; + localparam int BLOCKS = HASH_BITS / BITS_PER_CYCLE; + localparam int BLOCK_INDEX_BITS = (BLOCKS <= 1) ? 1 : $clog2(BLOCKS); + localparam logic [BLOCK_INDEX_BITS-1:0] LAST_BLOCK_IDX = BLOCK_INDEX_BITS'(BLOCKS - 1); + + typedef enum logic [1:0] { + STATE_IDLE, + STATE_PRIME, + STATE_RUN + } state_t; + + state_t state_q; + logic [BLOCK_INDEX_BITS-1:0] block_idx_q; + logic [PROB_BITS-1:0] threshold_q; + logic [HASH_BITS-1:0] mask_q; + logic prng_en; + logic [RANDOM_BITS-1:0] random_bits; + logic [BITS_PER_CYCLE-1:0] mask_slice; + + assign busy_o = (state_q != STATE_IDLE); + assign mask_o = mask_q; + assign prng_en = (state_q == STATE_PRIME) || + ((state_q == STATE_RUN) && (block_idx_q != LAST_BLOCK_IDX)); + + `ifndef SYNTHESIS + initial begin + if (HASH_BITS <= 0) $fatal(1, "HASH_BITS must be > 0"); + if (PROB_BITS <= 0) $fatal(1, "PROB_BITS must be > 0"); + if (PRNG_WORDS <= 0) $fatal(1, "PRNG_WORDS must be > 0"); + if (BITS_PER_CYCLE <= 0) $fatal(1, "BITS_PER_CYCLE must be > 0"); + if (HASH_BITS % BITS_PER_CYCLE != 0) $fatal(1, "HASH_BITS must be divisible by BITS_PER_CYCLE"); + if (RANDOM_BITS != BITS_PER_CYCLE * PROB_BITS) $fatal(1, "PRNG_WORDS*128 must equal BITS_PER_CYCLE*PROB_BITS"); + if (SEED == 64'h0) $fatal(1, "SEED must be non-zero"); + for (int w = 0; w < PRNG_WORDS; w++) begin + if ({SEED, SEED ^ (64'(w+1))} == 128'h0) $fatal(1, "PRNG seed must be non-zero"); + for (int w2 = w+1; w2 < PRNG_WORDS; w2++) begin + if ({SEED, SEED ^ (64'(w+1))} == {SEED, SEED ^ (64'(w2+1))}) + $fatal(1, "PRNG seeds must differ"); + end + end + end + `endif + + always_ff @(posedge clk or negedge rst_n) begin + if (!rst_n) begin + state_q <= STATE_IDLE; + block_idx_q <= '0; + threshold_q <= '0; + mask_q <= '0; + done_o <= 1'b0; + end else begin + done_o <= 1'b0; + + unique case (state_q) + STATE_IDLE: begin + block_idx_q <= '0; + if (start_i) begin + threshold_q <= threshold_i; + mask_q <= '0; + state_q <= STATE_PRIME; + end + end + + STATE_PRIME: begin + state_q <= STATE_RUN; + end + + STATE_RUN: begin + mask_q[block_idx_q * BITS_PER_CYCLE +: BITS_PER_CYCLE] <= mask_slice; + + if (block_idx_q == LAST_BLOCK_IDX) begin + state_q <= STATE_IDLE; + block_idx_q <= '0; + done_o <= 1'b1; + end else begin + block_idx_q <= block_idx_q + 1'b1; + end + end + + default: begin + state_q <= STATE_IDLE; + block_idx_q <= '0; + end + endcase + end + end + + generate + for (genvar w = 0; w < PRNG_WORDS; w++) begin : gen_prng + localparam logic [127:0] PRNG_SEED = {SEED, SEED ^ (64'(w+1))}; + random128 prng_inst ( + .clk (clk), + .rst_n (rst_n), + .enable (prng_en), + .seed (PRNG_SEED), + .out (random_bits[w*128 +: 128]) + ); + end + endgenerate + + generate + for (genvar i = 0; i < BITS_PER_CYCLE; i++) begin : gen_bernoulli + assign mask_slice[i] = (random_bits[i*PROB_BITS +: PROB_BITS] < threshold_q); + end + endgenerate + +endmodule diff --git a/hw/sim/mk/rtl-sources.mk b/hw/sim/mk/rtl-sources.mk index 37211f1..6c1ae54 100644 --- a/hw/sim/mk/rtl-sources.mk +++ b/hw/sim/mk/rtl-sources.mk @@ -5,6 +5,7 @@ endif RTL_RANDOM := $(RTL_ROOT)/random/random128.sv RTL_NOISE_MASK := $(RTL_ROOT)/noise/noise_mask_grouped.sv +RTL_BERNOULLI_NOISE_MASK := $(RTL_ROOT)/noise/noise_mask_bernoulli.sv $(RTL_RANDOM) RTL_WRITE_NOISE := $(RTL_NOISE_MASK) $(RTL_RANDOM) $(RTL_ROOT)/noise/cam_write_noise.sv RTL_READ_NOISE := $(RTL_NOISE_MASK) $(RTL_RANDOM) $(RTL_ROOT)/noise/cam_read_noise.sv diff --git a/hw/sim/tests/modules/noise_mask_bernoulli/Makefile b/hw/sim/tests/modules/noise_mask_bernoulli/Makefile new file mode 100644 index 0000000..9466fd1 --- /dev/null +++ b/hw/sim/tests/modules/noise_mask_bernoulli/Makefile @@ -0,0 +1,11 @@ +SIM_ROOT := $(abspath ../../..) +RTL_ROOT := $(abspath $(SIM_ROOT)/../rtl) +include $(SIM_ROOT)/mk/rtl-sources.mk + +TOPLEVEL := noise_mask_bernoulli +COCOTB_TEST_MODULES := tests.modules.noise_mask_bernoulli.test_noise_mask_bernoulli +VERILOG_SOURCES := $(RTL_BERNOULLI_NOISE_MASK) + +HASH_BITS ?= 512 + +include $(SIM_ROOT)/mk/cocotb-common.mk diff --git a/hw/sim/tests/modules/noise_mask_bernoulli/__init__.py b/hw/sim/tests/modules/noise_mask_bernoulli/__init__.py new file mode 100644 index 0000000..3baab04 --- /dev/null +++ b/hw/sim/tests/modules/noise_mask_bernoulli/__init__.py @@ -0,0 +1 @@ +"""Cocotb tests for noise_mask_bernoulli.""" diff --git a/hw/sim/tests/modules/noise_mask_bernoulli/test_noise_mask_bernoulli.py b/hw/sim/tests/modules/noise_mask_bernoulli/test_noise_mask_bernoulli.py new file mode 100644 index 0000000..de9fdbb --- /dev/null +++ b/hw/sim/tests/modules/noise_mask_bernoulli/test_noise_mask_bernoulli.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import cocotb +from cocotb.clock import Clock +from cocotb.triggers import RisingEdge, Timer + +HASH_BITS = 512 +DEFAULT_BLOCKS = 16 +DEFAULT_PRIME_CYCLES = 1 +DEFAULT_START_TO_DONE_CYCLES = DEFAULT_PRIME_CYCLES + DEFAULT_BLOCKS + + +def popcount(value: int) -> int: + return int(value).bit_count() + + +async def reset_dut(dut): + """Reset the DUT and drive inactive defaults.""" + dut.rst_n.value = 0 + dut.start_i.value = 0 + dut.threshold_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 start_and_wait_done(dut, threshold: int) -> tuple[int, int]: + """Start one generation and return (mask, cycles from accepted start to done).""" + dut.threshold_i.value = threshold + dut.start_i.value = 1 + await Timer(1, unit="step") + await RisingEdge(dut.clk) + await Timer(1, unit="step") + dut.start_i.value = 0 + + cycles = 0 + while True: + assert cycles < 128, "timed out waiting for done_o" + if bool(dut.done_o.value): + break + assert bool(dut.busy_o.value), "busy_o should stay high until done_o" + await RisingEdge(dut.clk) + await Timer(1, unit="step") + cycles += 1 + + mask = int(dut.mask_o.value) + await RisingEdge(dut.clk) + await Timer(1, unit="step") + assert not bool(dut.done_o.value), "done_o should be a single-cycle pulse" + assert not bool(dut.busy_o.value), "busy_o should be low after done_o" + assert int(dut.mask_o.value) == mask, "mask_o should remain stable after done_o" + return mask, cycles + + +@cocotb.test() +async def reset_outputs_idle_and_clear_mask(dut): + cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start()) + await reset_dut(dut) + + assert not bool(dut.busy_o.value), "busy_o should be low after reset" + assert not bool(dut.done_o.value), "done_o should be low after reset" + assert int(dut.mask_o.value) == 0, "mask_o should reset to zero" + + +@cocotb.test() +async def threshold_zero_generates_empty_mask(dut): + cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start()) + await reset_dut(dut) + + mask, cycles = await start_and_wait_done(dut, threshold=0) + + assert mask == 0, "threshold=0 should generate all-zero mask" + assert popcount(mask) == 0, "threshold=0 popcount should be zero" + assert cycles == DEFAULT_START_TO_DONE_CYCLES, ( + f"default latency should be {DEFAULT_START_TO_DONE_CYCLES} cycles, got {cycles}" + ) + + +@cocotb.test() +async def test_deterministic_across_reset(dut): + """Same threshold after reset should produce identical mask sequence.""" + cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start()) + await reset_dut(dut) + + masks_a = [] + for _ in range(3): + mask, _ = await start_and_wait_done(dut, threshold=128) + masks_a.append(mask) + + await reset_dut(dut) + + masks_b = [] + for _ in range(3): + mask, _ = await start_and_wait_done(dut, threshold=128) + masks_b.append(mask) + + assert masks_a == masks_b, "Mask sequence should be deterministic across reset" + + +@cocotb.test() +async def test_threshold_monotonicity(dut): + """Higher threshold should yield higher average popcount.""" + cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start()) + await reset_dut(dut) + + N = 32 + masks_64 = [] + for _ in range(N): + mask, _ = await start_and_wait_done(dut, threshold=64) + masks_64.append(mask) + + await reset_dut(dut) + masks_192 = [] + for _ in range(N): + mask, _ = await start_and_wait_done(dut, threshold=192) + masks_192.append(mask) + + avg_64 = sum(popcount(m) for m in masks_64) / N + avg_192 = sum(popcount(m) for m in masks_192) / N + + # threshold=64: expected popcount ~512*64/256 = 128 + # threshold=192: expected popcount ~512*192/256 = 384 + assert 60 < avg_64 < 135, f"avg popcount at threshold=64 out of range: {avg_64}" + assert 365 < avg_192 < 405, f"avg popcount at threshold=192 out of range: {avg_192}" + assert avg_192 > avg_64 + 200, ( + f"threshold=192 avg ({avg_192}) should exceed threshold=64 avg ({avg_64}) by margin" + ) + + +@cocotb.test() +async def test_threshold_255_not_all_ones(dut): + """threshold=255 should produce dense masks but not force all ones.""" + cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start()) + await reset_dut(dut) + + masks = [] + for _ in range(8): + mask, _ = await start_and_wait_done(dut, threshold=255) + masks.append(mask) + + popcounts = [popcount(m) for m in masks] + avg = sum(popcounts) / len(popcounts) + + # At least one mask should have some zero bits + assert any(pc < HASH_BITS for pc in popcounts), ( + "threshold=255 should produce at least one mask with some zero bits" + ) + # Average density should be very high (p=255/256) + assert avg > HASH_BITS * 0.9, f"threshold=255 average popcount too low: {avg}" + + +@cocotb.test() +async def test_busy_start_ignored(dut): + """Pulsing start_i while busy_o is high must be ignored. + + Start one generation, then pulse start_i with a different threshold_i while + the DUT is busy, and prove the busy pulse is ignored (no second done_o, + same latency, same mask as an uninterrupted run). + """ + cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start()) + await reset_dut(dut) + + # --- Baseline: single uninterrupted generation with threshold=64 --- + mask_ref, cycles_ref = await start_and_wait_done(dut, threshold=64) + assert cycles_ref == DEFAULT_START_TO_DONE_CYCLES, ( + f"Baseline latency should be {DEFAULT_START_TO_DONE_CYCLES}, got {cycles_ref}" + ) + + # --- Test: pulse spurious start with threshold=255 while busy --- + await reset_dut(dut) + + # Start legitimate operation with threshold=64 + dut.threshold_i.value = 64 + dut.start_i.value = 1 + await Timer(1, unit="step") + await RisingEdge(dut.clk) # sampled: FSM transitions to PRIME + await Timer(1, unit="step") + dut.start_i.value = 0 + + assert bool(dut.busy_o.value), "busy_o should be high after start" + + # Let FSM reach RUN (PRIME→RUN at next posedge, then one RUN cycle) + await RisingEdge(dut.clk) # PRIME → RUN + await Timer(1, unit="step") + await RisingEdge(dut.clk) # RUN, first block processed + await Timer(1, unit="step") + + assert bool(dut.busy_o.value), "DUT must be busy during RUN" + + # Pulse start_i with threshold=255 while DUT is busy (must be ignored) + dut.threshold_i.value = 255 + dut.start_i.value = 1 + await Timer(1, unit="step") + await RisingEdge(dut.clk) # sampled but FSM in RUN → ignored + await Timer(1, unit="step") + dut.start_i.value = 0 + + # Count remaining cycles until done_o + # Cycles already consumed from the legitimate start: + # 1 for PRIME + 1 for RUN before spurious + 1 for spurious-start cycle = 3 + elapsed = 3 + cycles_from_spurious = 0 + while True: + if bool(dut.done_o.value): + break + assert elapsed + cycles_from_spurious < 128, "timed out waiting for done_o" + assert bool(dut.busy_o.value), "busy_o should stay high until done_o" + await RisingEdge(dut.clk) + await Timer(1, unit="step") + cycles_from_spurious += 1 + + total_cycles = elapsed + cycles_from_spurious + assert total_cycles == DEFAULT_START_TO_DONE_CYCLES, ( + f"Latency should be {DEFAULT_START_TO_DONE_CYCLES} despite spurious start, " + f"got {total_cycles} ({elapsed}+{cycles_from_spurious})" + ) + + mask_test = int(dut.mask_o.value) + await RisingEdge(dut.clk) + await Timer(1, unit="step") + + # Verify done_o single-cycle and busy_o falls low + assert not bool(dut.done_o.value), "done_o should be a single-cycle pulse" + assert not bool(dut.busy_o.value), "busy_o should be low after done_o" + assert int(dut.mask_o.value) == mask_test, ( + "mask_o should remain stable after done_o" + ) + + # Compare mask to baseline (same seed + same threshold → same mask) + assert mask_test == mask_ref, ( + "Spurious start must not corrupt in-flight mask generation" + ) + + # Verify no second done_o from the ignored start + for _ in range(32): + await RisingEdge(dut.clk) + await Timer(1, unit="step") + assert not bool(dut.done_o.value), ( + "Spurious start must not queue a second operation" + )