feat: neural_sim Python golden functional reference simulator
Adds tools/neural_sim/, a NumPy-based reference implementation of the FPGA-Neural V2 numeric model (INT8 in/weight, 32-bit wraparound accumulation, ReLU+saturate out), derived directly from hardware/v2/rtl/neural_processor.v (not assumed) and reusing tools/validation/mac_oracle.py's own pre-existing, hand-verified two's-complement primitives rather than duplicating them. Provides: neuron/layer/network models, a logical memory model of the real V2 SDRAM map (weights/activations/results), deterministic test-vector generators (simple/signed/extremes/zero/random/D-Stress 256x128) with JSON golden-vector export, an FPGA-vs-Python bit-exact comparison utility, four example networks, a CLI (`python -m tools.neural_sim ...`), and a 96-test pytest suite (all passing) covering signed-arithmetic edge cases (including a direct 32-bit wraparound proof), scalar-vs-vectorized neuron cross-checks, layer/memory/vector/comparison tests. This is a golden functional reference (bit-exact numeric result), explicitly NOT a cycle-accurate FPGA simulator -- see tools/neural_sim/README.md for the full scope statement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013xXuuRUWZScuo1DeYJxs3v
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.neural_sim.compare import compare_results, load_fpga_results
|
||||
|
||||
|
||||
def test_exact_match():
|
||||
report = compare_results([1, 2, 3], [1, 2, 3])
|
||||
assert report.exact_match
|
||||
assert report.num_mismatches == 0
|
||||
|
||||
|
||||
def test_single_mismatch_reported_precisely():
|
||||
report = compare_results([1, 2, 3], [1, 5, 3])
|
||||
assert not report.exact_match
|
||||
assert report.num_mismatches == 1
|
||||
assert report.first_mismatch_index == 1
|
||||
assert report.first_mismatch_expected == 2
|
||||
assert report.first_mismatch_actual == 5
|
||||
assert report.max_abs_diff == 3
|
||||
|
||||
|
||||
def test_multiple_mismatches_max_abs_diff():
|
||||
report = compare_results([0, 0, 0], [10, -5, 0])
|
||||
assert report.num_mismatches == 2
|
||||
assert report.max_abs_diff == 10
|
||||
|
||||
|
||||
def test_length_mismatch_raises():
|
||||
with pytest.raises(ValueError):
|
||||
compare_results([1, 2], [1, 2, 3])
|
||||
|
||||
|
||||
def test_load_fpga_results_json_list(tmp_path):
|
||||
path = tmp_path / "results.json"
|
||||
path.write_text(json.dumps([1, -2, 3]))
|
||||
assert load_fpga_results(str(path)) == [1, -2, 3]
|
||||
|
||||
|
||||
def test_load_fpga_results_json_dict_with_results_key(tmp_path):
|
||||
path = tmp_path / "results.json"
|
||||
path.write_text(json.dumps({"results": [4, 5, 6], "meta": "x"}))
|
||||
assert load_fpga_results(str(path)) == [4, 5, 6]
|
||||
|
||||
|
||||
def test_load_fpga_results_plain_text(tmp_path):
|
||||
path = tmp_path / "results.txt"
|
||||
path.write_text("1 2 -3\n4 5")
|
||||
assert load_fpga_results(str(path)) == [1, 2, -3, 4, 5]
|
||||
@@ -0,0 +1,44 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tools.neural_sim.layer import FCLayer
|
||||
from tools.neural_sim.neuron import neuron_scalar
|
||||
|
||||
|
||||
def test_layer_matches_per_neuron_scalar_reference():
|
||||
rng = np.random.default_rng(7)
|
||||
n_inputs, n_neurons = 32, 5
|
||||
weights = rng.integers(-128, 128, size=(n_neurons, n_inputs), dtype=np.int64)
|
||||
biases = rng.integers(-128, 128, size=n_neurons, dtype=np.int64)
|
||||
inputs = rng.integers(-128, 128, size=n_inputs, dtype=np.int64)
|
||||
|
||||
layer = FCLayer(weights, biases=biases, activation="relu")
|
||||
outputs = layer.forward(inputs)
|
||||
|
||||
for n in range(n_neurons):
|
||||
expected = neuron_scalar(inputs.tolist(), weights[n].tolist(), bias=int(biases[n]), activation="relu")
|
||||
assert outputs[n] == expected
|
||||
|
||||
|
||||
def test_layer_rejects_bad_input_shape():
|
||||
layer = FCLayer([[1] * 8])
|
||||
with pytest.raises(ValueError):
|
||||
layer.forward([1] * 7)
|
||||
|
||||
|
||||
def test_layer_rejects_non_multiple_of_p_in():
|
||||
with pytest.raises(ValueError):
|
||||
FCLayer([[1] * 7])
|
||||
|
||||
|
||||
def test_layer_default_bias_is_zero():
|
||||
layer = FCLayer([[1] * 8, [2] * 8])
|
||||
assert layer.biases.tolist() == [0, 0]
|
||||
|
||||
|
||||
def test_layer_all_outputs_in_int8_range():
|
||||
rng = np.random.default_rng(99)
|
||||
weights = rng.integers(-128, 128, size=(20, 64), dtype=np.int64)
|
||||
inputs = rng.integers(-128, 128, size=64, dtype=np.int64)
|
||||
outputs = FCLayer(weights).forward(inputs)
|
||||
assert all(-128 <= v <= 127 for v in outputs.tolist())
|
||||
@@ -0,0 +1,63 @@
|
||||
import pytest
|
||||
|
||||
from tools.neural_sim.memory import (
|
||||
MemoryModel, WEIGHTS_BASE, ACTIVATIONS_BASE, RESULTS_BASE, SDRAM_SIZE,
|
||||
)
|
||||
|
||||
|
||||
def test_read_after_write_each_region():
|
||||
mem = MemoryModel()
|
||||
mem.write_weight(0, -5)
|
||||
mem.write_activation(0, 42)
|
||||
mem.write_result(0, -128)
|
||||
assert mem.read_weight(0) == -5
|
||||
assert mem.read_activation(0) == 42
|
||||
assert mem.read_result(0) == -128
|
||||
|
||||
|
||||
def test_regions_are_at_the_real_v2_addresses():
|
||||
mem = MemoryModel()
|
||||
mem.write_weight(0, 1)
|
||||
mem.write_activation(0, 2)
|
||||
mem.write_result(0, 3)
|
||||
assert mem.read_byte(WEIGHTS_BASE) == 1
|
||||
assert mem.read_byte(ACTIVATIONS_BASE) == 2
|
||||
assert mem.read_byte(RESULTS_BASE) == 3
|
||||
|
||||
|
||||
def test_adjacent_regions_do_not_corrupt_each_other():
|
||||
mem = MemoryModel()
|
||||
mem.write_weight(0x0FFFFF - WEIGHTS_BASE, 0x11) # last word before activations
|
||||
mem.write_activation(0, 0x22) # first word of activations
|
||||
assert mem.read_weight(0x0FFFFF - WEIGHTS_BASE) == 0x11
|
||||
assert mem.read_activation(0) == 0x22
|
||||
|
||||
|
||||
def test_out_of_range_byte_raises():
|
||||
mem = MemoryModel()
|
||||
with pytest.raises(IndexError):
|
||||
mem.read_byte(-1)
|
||||
with pytest.raises(IndexError):
|
||||
mem.read_byte(SDRAM_SIZE)
|
||||
|
||||
|
||||
def test_value_out_of_int8_range_raises():
|
||||
mem = MemoryModel()
|
||||
with pytest.raises(ValueError):
|
||||
mem.write_byte(0, 128)
|
||||
with pytest.raises(ValueError):
|
||||
mem.write_byte(0, -129)
|
||||
|
||||
|
||||
def test_region_bounds_checking_rejects_spillover():
|
||||
mem = MemoryModel()
|
||||
weights_size = ACTIVATIONS_BASE - WEIGHTS_BASE
|
||||
with pytest.raises(IndexError):
|
||||
mem.write_weight(weights_size, 0) # one byte past the weights region
|
||||
|
||||
|
||||
def test_bulk_helpers_round_trip():
|
||||
mem = MemoryModel()
|
||||
values = list(range(-10, 10))
|
||||
mem.write_weights(0, values)
|
||||
assert mem.read_weights(0, len(values)) == values
|
||||
@@ -0,0 +1,36 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tools.neural_sim import examples as ex
|
||||
from tools.neural_sim.layer import FCLayer
|
||||
from tools.neural_sim.network import Network
|
||||
|
||||
|
||||
def test_network_rejects_empty():
|
||||
with pytest.raises(ValueError):
|
||||
Network([])
|
||||
|
||||
|
||||
def test_network_rejects_shape_mismatch():
|
||||
layer1 = FCLayer([[1] * 8]) # 8 -> 1
|
||||
layer2 = FCLayer([[1] * 8, [2] * 8]) # 8 -> 2, but layer1 outputs only 1
|
||||
with pytest.raises(ValueError):
|
||||
Network([layer1, layer2])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(ex.ALL_EXAMPLES.keys()))
|
||||
def test_all_examples_run_and_stay_in_int8_range(name):
|
||||
net = ex.ALL_EXAMPLES[name]()
|
||||
rng = np.random.default_rng(123)
|
||||
inputs = rng.integers(-128, 128, size=net.n_inputs, dtype=np.int64)
|
||||
outputs = net.forward(inputs)
|
||||
assert len(outputs) == net.n_outputs
|
||||
assert all(-128 <= v <= 127 for v in outputs.tolist())
|
||||
|
||||
|
||||
def test_network_forward_all_matches_forward_final():
|
||||
net = ex.example_8_8_1()
|
||||
inputs = np.array([1, 2, 3, 4, -1, -2, -3, -4], dtype=np.int64)
|
||||
all_outputs = net.forward_all(inputs)
|
||||
assert all_outputs[-1].tolist() == net.forward(inputs).tolist()
|
||||
assert len(all_outputs) == len(net.layers)
|
||||
@@ -0,0 +1,30 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tools.neural_sim.neuron import neuron_scalar, neuron_vectorized
|
||||
from tools.neural_sim.numerics import ACT_NONE, ACT_RELU
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n_tiles", [1, 2, 4, 16])
|
||||
@pytest.mark.parametrize("activation", [ACT_RELU, ACT_NONE])
|
||||
@pytest.mark.parametrize("seed", [0, 1, 2, 3, 4])
|
||||
def test_scalar_and_vectorized_agree(n_tiles, activation, seed):
|
||||
rng = np.random.default_rng(seed)
|
||||
n = n_tiles * 8
|
||||
inputs = rng.integers(-128, 128, size=n, dtype=np.int64).tolist()
|
||||
weights = rng.integers(-128, 128, size=n, dtype=np.int64).tolist()
|
||||
bias = int(rng.integers(-128, 128))
|
||||
|
||||
scalar = neuron_scalar(inputs, weights, bias=bias, activation=activation)
|
||||
vectorized = neuron_vectorized(inputs, weights, bias=bias, activation=activation)
|
||||
assert scalar == vectorized
|
||||
|
||||
|
||||
def test_scalar_and_vectorized_agree_on_extremes():
|
||||
inputs = [-128, 127] * 4
|
||||
weights = [127, -128] * 4
|
||||
assert neuron_scalar(inputs, weights) == neuron_vectorized(inputs, weights)
|
||||
|
||||
|
||||
def test_scalar_and_vectorized_agree_on_zero():
|
||||
assert neuron_scalar([0] * 8, [0] * 8) == neuron_vectorized([0] * 8, [0] * 8) == 0
|
||||
@@ -0,0 +1,116 @@
|
||||
import pytest
|
||||
|
||||
from tools.neural_sim.numerics import (
|
||||
check_int8, wrap_acc, tile_product_sum, accumulate_tile, add_bias,
|
||||
activate_and_saturate, neuron_reference, ACT_NONE, ACT_RELU,
|
||||
)
|
||||
|
||||
|
||||
def test_check_int8_accepts_range():
|
||||
assert check_int8(-128) == -128
|
||||
assert check_int8(127) == 127
|
||||
assert check_int8(0) == 0
|
||||
|
||||
|
||||
def test_check_int8_rejects_out_of_range():
|
||||
with pytest.raises(ValueError):
|
||||
check_int8(128)
|
||||
with pytest.raises(ValueError):
|
||||
check_int8(-129)
|
||||
|
||||
|
||||
def test_wrap_acc_no_overflow_is_identity():
|
||||
assert wrap_acc(1000, acc_width=32) == 1000
|
||||
assert wrap_acc(-1000, acc_width=32) == -1000
|
||||
|
||||
|
||||
def test_wrap_acc_true_32bit_wraparound():
|
||||
# 2**31 is one past the max positive signed 32-bit value (2**31 - 1)
|
||||
# -- must wrap to the most-negative value, exactly like a Verilog
|
||||
# `reg signed [31:0]` silently overflowing.
|
||||
assert wrap_acc(2**31, acc_width=32) == -(2**31)
|
||||
assert wrap_acc(2**31 - 1, acc_width=32) == 2**31 - 1 # exact boundary, no wrap
|
||||
assert wrap_acc(-(2**31) - 1, acc_width=32) == 2**31 - 1
|
||||
|
||||
|
||||
def test_tile_product_sum_exact_known_values():
|
||||
# 1*1 + 2*1 + ... + 8*1 = 36
|
||||
assert tile_product_sum(list(range(1, 9)), [1] * 8) == 36
|
||||
|
||||
|
||||
def test_tile_product_sum_extreme_product():
|
||||
# -128 * -128 = 16384, the one INT8xINT8 case that does not fit
|
||||
# symmetrically in magnitude terms
|
||||
assert tile_product_sum([-128], [-128], acc_width=32) == 16384
|
||||
|
||||
|
||||
def test_tile_product_sum_rejects_non_power_of_two():
|
||||
with pytest.raises(ValueError):
|
||||
tile_product_sum([1, 2, 3], [1, 1, 1])
|
||||
|
||||
|
||||
def test_tile_product_sum_rejects_out_of_range_input():
|
||||
with pytest.raises(ValueError):
|
||||
tile_product_sum([200], [1])
|
||||
|
||||
|
||||
def test_accumulate_tile_matches_wrap_acc():
|
||||
assert accumulate_tile(10, 20) == 30
|
||||
assert accumulate_tile(2**31 - 1, 1) == -(2**31)
|
||||
|
||||
|
||||
def test_add_bias_wraparound():
|
||||
assert add_bias(100, 27) == 127
|
||||
assert add_bias(2**31 - 1, 127) == wrap_acc(2**31 - 1 + 127)
|
||||
|
||||
|
||||
def test_activate_relu_zeroes_non_positive():
|
||||
assert activate_and_saturate(0, activation=ACT_RELU) == 0
|
||||
assert activate_and_saturate(-1, activation=ACT_RELU) == 0
|
||||
assert activate_and_saturate(-1000000, activation=ACT_RELU) == 0
|
||||
|
||||
|
||||
def test_activate_relu_passes_in_range():
|
||||
assert activate_and_saturate(1, activation=ACT_RELU) == 1
|
||||
assert activate_and_saturate(127, activation=ACT_RELU) == 127
|
||||
|
||||
|
||||
def test_activate_relu_saturates_positive():
|
||||
assert activate_and_saturate(128, activation=ACT_RELU) == 127
|
||||
assert activate_and_saturate(1000000, activation=ACT_RELU) == 127
|
||||
|
||||
|
||||
def test_activate_none_passes_full_signed_range():
|
||||
assert activate_and_saturate(-128, activation=ACT_NONE) == -128
|
||||
assert activate_and_saturate(127, activation=ACT_NONE) == 127
|
||||
assert activate_and_saturate(0, activation=ACT_NONE) == 0
|
||||
|
||||
|
||||
def test_activate_none_saturates_both_sides():
|
||||
assert activate_and_saturate(128, activation=ACT_NONE) == 127
|
||||
assert activate_and_saturate(-129, activation=ACT_NONE) == -128
|
||||
assert activate_and_saturate(1000000, activation=ACT_NONE) == 127
|
||||
assert activate_and_saturate(-1000000, activation=ACT_NONE) == -128
|
||||
|
||||
|
||||
def test_neuron_reference_simple_positive():
|
||||
y = neuron_reference(list(range(1, 9)), [1] * 8, activation=ACT_RELU)
|
||||
assert y == 36
|
||||
|
||||
|
||||
def test_neuron_reference_multi_tile_accumulates_across_tiles():
|
||||
# two tiles of 8, same weights -- accumulator must carry across tiles
|
||||
inputs = [1] * 8 + [1] * 8
|
||||
weights = [1] * 8 + [1] * 8
|
||||
assert neuron_reference(inputs, weights, activation=ACT_RELU) == 16
|
||||
|
||||
|
||||
def test_neuron_reference_rejects_length_not_multiple_of_p_in():
|
||||
with pytest.raises(ValueError):
|
||||
neuron_reference([1] * 5, [1] * 5)
|
||||
|
||||
|
||||
def test_neuron_reference_bias_default_zero_matches_no_bias():
|
||||
y_default = neuron_reference([1] * 8, [1] * 8)
|
||||
y_explicit = neuron_reference([1] * 8, [1] * 8, bias=0)
|
||||
assert y_default == y_explicit
|
||||
@@ -0,0 +1,66 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from tools.neural_sim import vectors as vec
|
||||
from tools.neural_sim.layer import FCLayer
|
||||
|
||||
|
||||
def test_simple_positive_manually_predictable():
|
||||
v = vec.gen_simple_positive()
|
||||
# inputs=[1..8], weights=all 1 -> sum = 36, ReLU(36)=36
|
||||
assert v.expected == [36]
|
||||
|
||||
|
||||
def test_zero_vector_is_all_zero_output():
|
||||
v = vec.gen_zero()
|
||||
assert v.expected == [0]
|
||||
|
||||
|
||||
def test_extremes_vector_is_self_consistent():
|
||||
v = vec.gen_extremes()
|
||||
layer = FCLayer(v.weights, biases=v.biases, activation=v.activation, p_in=v.p_in)
|
||||
assert layer.forward(v.inputs).tolist() == v.expected
|
||||
|
||||
|
||||
def test_random_vector_is_deterministic_across_calls():
|
||||
v1 = vec.gen_random(seed=555)
|
||||
v2 = vec.gen_random(seed=555)
|
||||
assert v1.inputs == v2.inputs
|
||||
assert v1.weights == v2.weights
|
||||
assert v1.expected == v2.expected
|
||||
|
||||
|
||||
def test_random_vector_different_seed_differs():
|
||||
v1 = vec.gen_random(seed=1)
|
||||
v2 = vec.gen_random(seed=2)
|
||||
assert v1.inputs != v2.inputs
|
||||
|
||||
|
||||
def test_d_stress_dimensions_match_the_real_rtl_benchmark():
|
||||
v = vec.gen_dstress()
|
||||
assert v.n_neurons == 256
|
||||
assert v.n_inputs == 128
|
||||
|
||||
|
||||
def test_d_stress_is_deterministic():
|
||||
v1 = vec.gen_dstress(seed=42)
|
||||
v2 = vec.gen_dstress(seed=42)
|
||||
assert v1.expected == v2.expected
|
||||
|
||||
|
||||
def test_export_then_import_round_trip(tmp_path):
|
||||
v = vec.gen_signed_mix()
|
||||
path = str(tmp_path / "vec.json")
|
||||
vec.export_json(v, path)
|
||||
loaded = vec.load_json(path)
|
||||
assert loaded == v
|
||||
|
||||
|
||||
def test_export_all_json_contains_every_generator(tmp_path):
|
||||
path = str(tmp_path / "all.json")
|
||||
vec.export_all_json(path)
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
assert set(data.keys()) == set(vec.ALL_GENERATORS.keys())
|
||||
for name, d in data.items():
|
||||
assert "expected" in d and "weights" in d and "inputs" in d
|
||||
Reference in New Issue
Block a user