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
111 lines
4.4 KiB
Python
111 lines
4.4 KiB
Python
"""
|
|
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)]
|