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:
2026-09-06 19:51:33 +02:00
co-authored by Claude Sonnet 5
parent c4763aab10
commit 9b5d1055b8
18 changed files with 1518 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
"""
Logical memory model of the real, official V2 unified-SDRAM memory map
(hardware/v2/docs/DatasheetLatex/chapters/02-architecture.tex's own
"Official V2 memory map" table, cross-checked against
hardware/v2/nms/sim/tb_sdram_boundary.v's own use of the same
addresses):
Region Base address Notes
Weights 0x010000 1MB-aligned
Activations 0x200000 1MB-aligned
Results 0x300000 1MB-aligned
all three non-overlapping within the single 8MB (0x000000-0x7FFFFF)
SDRAM address space. This model reproduces the LOGICAL byte contents
and addresses only -- it does NOT reproduce SDRAM cycle timing,
refresh, or burst behaviour (see hardware/v2/nms/sim/sdram_model.v /
sdram_controller.v for that; this is a plain flat byte array).
Region sizes are inferred from adjacency (each region's own end is the
next region's own base) -- the real hardware does not enforce region
size limits in the datapath itself (base addresses are host-
programmable per job), so this is this model's own deliberately
conservative bounds-checking convention, matching the same assumption
tb_sdram_boundary.v's own "weights-last(pre-act)"/"activations-
last(pre-res)" checks use.
"""
from __future__ import annotations
SDRAM_SIZE = 8 * 1024 * 1024 # 8MB, 0x000000-0x7FFFFF
WEIGHTS_BASE = 0x010000
ACTIVATIONS_BASE = 0x200000
RESULTS_BASE = 0x300000
WEIGHTS_END = ACTIVATIONS_BASE # exclusive
ACTIVATIONS_END = RESULTS_BASE # exclusive
RESULTS_END = SDRAM_SIZE # exclusive
def _to_unsigned8(v: int) -> int:
return v & 0xFF
def _to_signed8(v: int) -> int:
v &= 0xFF
return v - 256 if v >= 128 else v
class MemoryModel:
"""A flat 8MB byte array standing in for the real unified SDRAM,
with bounds-checked, region-aware, signed-INT8 read/write helpers."""
def __init__(self):
self._mem = bytearray(SDRAM_SIZE)
# ---- raw byte access (any address in the full 8MB space) ----
def read_byte(self, addr: int) -> int:
if not (0 <= addr < SDRAM_SIZE):
raise IndexError(f"address 0x{addr:06x} out of range [0, 0x{SDRAM_SIZE:06x})")
return _to_signed8(self._mem[addr])
def write_byte(self, addr: int, value: int) -> None:
if not (0 <= addr < SDRAM_SIZE):
raise IndexError(f"address 0x{addr:06x} out of range [0, 0x{SDRAM_SIZE:06x})")
if not (-128 <= value <= 127):
raise ValueError(f"value {value} out of signed INT8 range [-128, 127]")
self._mem[addr] = _to_unsigned8(value)
# ---- region-aware helpers (B7's own required helper names) ----
def _region_check(self, base: int, end: int, offset: int, label: str) -> int:
addr = base + offset
if not (base <= addr < end):
raise IndexError(
f"{label} offset {offset} (address 0x{addr:06x}) falls outside "
f"its own region [0x{base:06x}, 0x{end:06x})"
)
return addr
def write_weight(self, offset: int, value: int) -> None:
self.write_byte(self._region_check(WEIGHTS_BASE, WEIGHTS_END, offset, "weight"), value)
def read_weight(self, offset: int) -> int:
return self.read_byte(self._region_check(WEIGHTS_BASE, WEIGHTS_END, offset, "weight"))
def write_activation(self, offset: int, value: int) -> None:
self.write_byte(self._region_check(ACTIVATIONS_BASE, ACTIVATIONS_END, offset, "activation"), value)
def read_activation(self, offset: int) -> int:
return self.read_byte(self._region_check(ACTIVATIONS_BASE, ACTIVATIONS_END, offset, "activation"))
def write_result(self, offset: int, value: int) -> None:
self.write_byte(self._region_check(RESULTS_BASE, RESULTS_END, offset, "result"), value)
def read_result(self, offset: int) -> int:
return self.read_byte(self._region_check(RESULTS_BASE, RESULTS_END, offset, "result"))
# ---- bulk convenience (not a hardware concept, pure host-side sugar) ----
def write_weights(self, offset: int, values) -> None:
for i, v in enumerate(values):
self.write_weight(offset + i, int(v))
def read_weights(self, offset: int, count: int):
return [self.read_weight(offset + i) for i in range(count)]
def write_activations(self, offset: int, values) -> None:
for i, v in enumerate(values):
self.write_activation(offset + i, int(v))
def read_activations(self, offset: int, count: int):
return [self.read_activation(offset + i) for i in range(count)]