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
145 lines
5.0 KiB
Python
145 lines
5.0 KiB
Python
"""
|
|
Deterministic FPGA-style test vectors + golden-vector JSON export
|
|
(B8/B9). Every generator below returns a TestVector whose `expected`
|
|
field is computed by this SAME package's own golden model
|
|
(layer.FCLayer / numerics.neuron_reference) -- i.e. the vector is
|
|
self-consistent and re-verifiable by construction (see
|
|
tests/test_vectors.py's own round-trip test), not hand-typed.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field, asdict
|
|
from typing import List, Optional
|
|
|
|
import numpy as np
|
|
|
|
from .numerics import ACT_RELU, DEFAULT_P_IN
|
|
from .layer import FCLayer
|
|
|
|
|
|
@dataclass
|
|
class TestVector:
|
|
name: str
|
|
inputs: List[int]
|
|
weights: List[List[int]] # shape (n_neurons, n_inputs)
|
|
biases: List[int] # shape (n_neurons,)
|
|
activation: str
|
|
p_in: int
|
|
expected: List[int] # shape (n_neurons,)
|
|
numeric_format: str = "int8 in / int8 weight / int32 accumulate (wraparound) / int8 out (saturate)"
|
|
seed: Optional[int] = None
|
|
|
|
@property
|
|
def n_neurons(self) -> int:
|
|
return len(self.expected)
|
|
|
|
@property
|
|
def n_inputs(self) -> int:
|
|
return len(self.inputs)
|
|
|
|
|
|
def _make_vector(name: str, inputs, weights, biases=None, activation: str = ACT_RELU,
|
|
p_in: int = DEFAULT_P_IN, seed: Optional[int] = None) -> TestVector:
|
|
layer = FCLayer(weights, biases=biases, activation=activation, p_in=p_in)
|
|
expected = layer.forward(inputs).tolist()
|
|
biases_list = layer.biases.tolist()
|
|
return TestVector(
|
|
name=name, inputs=list(int(v) for v in inputs),
|
|
weights=[[int(w) for w in row] for row in layer.weights.tolist()],
|
|
biases=biases_list, activation=activation, p_in=p_in,
|
|
expected=[int(e) for e in expected], seed=seed,
|
|
)
|
|
|
|
|
|
# ---- Test 1: simple positive ----
|
|
def gen_simple_positive(p_in: int = DEFAULT_P_IN) -> TestVector:
|
|
"""Small positive inputs/weights, manually predictable:
|
|
inputs=[1..p_in], weights=all 1s -> sum = p_in*(p_in+1)/2."""
|
|
inputs = list(range(1, p_in + 1))
|
|
weights = [[1] * p_in]
|
|
return _make_vector("simple_positive", inputs, weights)
|
|
|
|
|
|
# ---- Test 2: signed values ----
|
|
def gen_signed_mix(p_in: int = DEFAULT_P_IN) -> TestVector:
|
|
"""Alternating positive/negative INT8 inputs and weights."""
|
|
inputs = [((-1) ** i) * (10 + i) for i in range(p_in)]
|
|
weights = [[((-1) ** (i + 1)) * (5 + i) for i in range(p_in)]]
|
|
return _make_vector("signed_mix", inputs, weights)
|
|
|
|
|
|
# ---- Test 3: extremes ----
|
|
def gen_extremes(p_in: int = DEFAULT_P_IN) -> TestVector:
|
|
"""-128/127 combinations designed to stress multiplication (the one
|
|
genuinely asymmetric INT8xINT8 case, -128*-128=16384, is
|
|
deliberately included) and accumulation across p_in terms."""
|
|
inputs = [(-128 if i % 2 == 0 else 127) for i in range(p_in)]
|
|
weights = [[(-128 if i % 2 == 1 else 127) for i in range(p_in)]]
|
|
return _make_vector("extremes", inputs, weights)
|
|
|
|
|
|
# ---- Test 4: zero ----
|
|
def gen_zero(p_in: int = DEFAULT_P_IN) -> TestVector:
|
|
inputs = [0] * p_in
|
|
weights = [[0] * p_in]
|
|
return _make_vector("zero", inputs, weights)
|
|
|
|
|
|
# ---- Test 5: random (fixed seed) ----
|
|
def gen_random(seed: int = 1234, n_inputs: int = 64, n_neurons: int = 4,
|
|
p_in: int = DEFAULT_P_IN) -> TestVector:
|
|
rng = np.random.default_rng(seed)
|
|
inputs = rng.integers(-128, 128, size=n_inputs, dtype=np.int64)
|
|
weights = rng.integers(-128, 128, size=(n_neurons, n_inputs), dtype=np.int64)
|
|
return _make_vector(f"random_seed{seed}", inputs, weights, p_in=p_in, seed=seed)
|
|
|
|
|
|
# ---- Test 6: D-Stress (256 neurons x 128 inputs, reproducing the
|
|
# existing RTL D-Stress benchmark's own dimensions) ----
|
|
def gen_dstress(seed: int = 42, n_neurons: int = 256, n_inputs: int = 128,
|
|
p_in: int = DEFAULT_P_IN) -> TestVector:
|
|
rng = np.random.default_rng(seed)
|
|
inputs = rng.integers(-128, 128, size=n_inputs, dtype=np.int64)
|
|
weights = rng.integers(-128, 128, size=(n_neurons, n_inputs), dtype=np.int64)
|
|
return _make_vector("d_stress", inputs, weights, p_in=p_in, seed=seed)
|
|
|
|
|
|
ALL_GENERATORS = {
|
|
"simple_positive": gen_simple_positive,
|
|
"signed_mix": gen_signed_mix,
|
|
"extremes": gen_extremes,
|
|
"zero": gen_zero,
|
|
"random": gen_random,
|
|
"d_stress": gen_dstress,
|
|
}
|
|
|
|
|
|
def generate_all() -> dict:
|
|
return {name: fn() for name, fn in ALL_GENERATORS.items()}
|
|
|
|
|
|
# ---- golden-vector export/import (machine-readable, JSON) ----
|
|
def to_dict(vector: TestVector) -> dict:
|
|
return asdict(vector)
|
|
|
|
|
|
def export_json(vector: TestVector, path: str) -> None:
|
|
with open(path, "w") as f:
|
|
json.dump(to_dict(vector), f, indent=2)
|
|
|
|
|
|
def load_json(path: str) -> TestVector:
|
|
with open(path) as f:
|
|
d = json.load(f)
|
|
return TestVector(**d)
|
|
|
|
|
|
def export_all_json(path: str) -> None:
|
|
"""Export every named generator's vector into one JSON file, keyed
|
|
by name -- convenient for a future RTL testbench harness to load
|
|
once and iterate."""
|
|
vectors = generate_all()
|
|
with open(path, "w") as f:
|
|
json.dump({name: to_dict(v) for name, v in vectors.items()}, f, indent=2)
|