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,184 @@
|
|||||||
|
# neural_sim — FPGA-Neural V2 golden functional reference
|
||||||
|
|
||||||
|
Python simulator = golden functional reference
|
||||||
|
RTL / P&R = hardware implementation
|
||||||
|
|
||||||
|
This package is **not** a cycle-accurate FPGA simulator. It does not
|
||||||
|
model clock cycles, SDRAM controller timing, or SPI transaction
|
||||||
|
timing. Given weights, activations, and a network topology, it
|
||||||
|
computes the mathematically correct result that the real V2 hardware
|
||||||
|
(`hardware/v2/rtl/neural_processor.v`) must reproduce bit-for-bit. Its
|
||||||
|
job is to be the reference everything else — RTL simulation, and
|
||||||
|
eventually real hardware — is checked against.
|
||||||
|
|
||||||
|
## 1. Numeric model
|
||||||
|
|
||||||
|
INT8 in / INT8 weight / INT32 accumulate / INT8 out, matching
|
||||||
|
`neural_processor.v` exactly (re-derived by reading that file, not
|
||||||
|
assumed — see `numerics.py`'s own module docstring for the full,
|
||||||
|
line-by-line derivation):
|
||||||
|
|
||||||
|
| Stage | Width | Overflow behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| Multiply (INT8 × INT8) | 16-bit signed product | Cannot overflow for INT8 operands |
|
||||||
|
| Per-tile adder tree (P_IN=8 products) | 32-bit signed | Cannot overflow for P_IN=8 |
|
||||||
|
| Cross-tile accumulator | 32-bit signed | **Wraparound** (true two's-complement, matches a Verilog `reg signed [31:0]`'s silent overflow) |
|
||||||
|
| Bias add | 32-bit signed | **Wraparound** |
|
||||||
|
| Final activation/output | 8-bit signed | **Saturating** (the only saturating stage) |
|
||||||
|
|
||||||
|
`numerics.wrap_acc()` implements genuine 32-bit wraparound (not
|
||||||
|
Python's arbitrary-precision integers hiding the boundary) —
|
||||||
|
`tests/test_numerics.py::test_wrap_acc_true_32bit_wraparound` proves
|
||||||
|
`2**31` wraps to `-(2**31)`, exactly like the RTL.
|
||||||
|
|
||||||
|
This package reuses `tools/validation/mac_oracle.py`'s own
|
||||||
|
independently-derived two's-complement primitives (`to_signed`,
|
||||||
|
`mac8_tree`) rather than duplicating them — that file already is this
|
||||||
|
project's own hand-verified oracle for the identical wraparound-add
|
||||||
|
semantics used by `rtl/mac_unit.v`/`rtl/mac8.v`, which
|
||||||
|
`neural_processor.v`'s own header states is the SAME accumulation
|
||||||
|
lineage.
|
||||||
|
|
||||||
|
## 2. Neuron equation
|
||||||
|
|
||||||
|
```
|
||||||
|
y = activate(bias + sum(x[i] * w[i] for i in 0..N_INPUTS-1))
|
||||||
|
```
|
||||||
|
|
||||||
|
computed tile-by-tile in groups of `P_IN` (default 8, matching the
|
||||||
|
frozen hardware reference), with the cross-tile accumulator carrying
|
||||||
|
state (with wraparound) between tiles — exactly how
|
||||||
|
`neural_processor.v`'s own pipeline works (one tile enters per cycle,
|
||||||
|
`acc_reg` only clears at job load, activation is applied once at the
|
||||||
|
end after `tile_last`).
|
||||||
|
|
||||||
|
Two independent implementations are provided and cross-tested
|
||||||
|
(`tests/test_neuron.py`):
|
||||||
|
- `neuron.neuron_scalar` — plain Python, easiest to audit line-by-line
|
||||||
|
against the RTL.
|
||||||
|
- `neuron.neuron_vectorized` — NumPy-based (int64 intra-tile dot
|
||||||
|
products, since that stage can never overflow; explicit Python
|
||||||
|
wraparound arithmetic across tiles, so NumPy's own dtype-wraparound
|
||||||
|
behaviour is never silently relied on).
|
||||||
|
|
||||||
|
## 3. Layer equation
|
||||||
|
|
||||||
|
```
|
||||||
|
output[n] = activate(bias[n] + sum(input[i] * weight[n][i] for i in 0..N_INPUTS-1))
|
||||||
|
```
|
||||||
|
|
||||||
|
for `n` in `0..N_NEURONS-1` — `layer.FCLayer`, matching how a real job
|
||||||
|
batch is submitted (one `WRITE_JOB` per neuron, same `x_base`
|
||||||
|
activation tile, each with its own `w_base`/`result_addr`).
|
||||||
|
|
||||||
|
## 4. Network model (deliberately small scope)
|
||||||
|
|
||||||
|
`network.Network` is an ordered chain of `FCLayer`s. This is a real,
|
||||||
|
deliberate scope limit, not a hardware limit: the real
|
||||||
|
`dependency_manager.v` schedules an arbitrary DAG of neuron jobs via
|
||||||
|
`producer_ids`/`required` fields, not just linear layer chains. A
|
||||||
|
linear chain is what this first phase implements and verifies; see
|
||||||
|
"Optional future extension" below for what's deferred.
|
||||||
|
|
||||||
|
## 5. Memory model
|
||||||
|
|
||||||
|
`memory.MemoryModel` reproduces the real, official V2 unified-SDRAM
|
||||||
|
memory map's logical byte contents and addresses (weights@0x010000,
|
||||||
|
activations@0x200000, results@0x300000, all within the single 8MB
|
||||||
|
0x000000–0x7FFFFF space) — **not** SDRAM cycle timing (see
|
||||||
|
`hardware/v2/nms/rtl/sdram_controller.v` / `sdram_model.v` for that).
|
||||||
|
`write_weight`/`read_weight`/`write_activation`/`read_activation`/
|
||||||
|
`write_result`/`read_result` are bounds-checked against each region's
|
||||||
|
own real base address.
|
||||||
|
|
||||||
|
## 6. Quantization / activation behaviour
|
||||||
|
|
||||||
|
Exactly two activation encodings exist in `neural_processor.v`:
|
||||||
|
|
||||||
|
- **`relu`** (the RTL's own `default` case — i.e. any activation code
|
||||||
|
other than exactly `ACT_NONE` also produces ReLU): `acc <= 0 -> 0`;
|
||||||
|
`0 < acc <= 127 -> acc` exactly; `acc > 127 -> 127`. **This is the
|
||||||
|
only activation the real, currently-exposed V2 SPI job protocol
|
||||||
|
applies** (`spi_host_bridge.v`'s `WRITE_JOB` opcode has no
|
||||||
|
activation-selection field) — matches the V2 LaTeX datasheet's own
|
||||||
|
"Activation: ReLU + INT8 saturate, fixed".
|
||||||
|
- **`none`**: a two-sided saturating clamp to the full signed INT8
|
||||||
|
range `[-128, 127]` — implemented in the module and modeled here for
|
||||||
|
completeness, but not reachable via the real, currently-exposed
|
||||||
|
protocol.
|
||||||
|
|
||||||
|
No other quantization/scaling/shift stage exists in the real RTL, and
|
||||||
|
none is invented here.
|
||||||
|
|
||||||
|
## 7. FPGA correspondence
|
||||||
|
|
||||||
|
| This simulator | Real hardware |
|
||||||
|
|---|---|
|
||||||
|
| `numerics.py` | `hardware/v2/rtl/neural_processor.v` (bit-exact, line-referenced) |
|
||||||
|
| `memory.py` | `hardware/v2/nms/rtl/sdram_controller.v`'s logical address space (not its timing) |
|
||||||
|
| `network.py` (linear chains only) | `hardware/v2/rtl/dependency_manager.v` (arbitrary DAG — a superset, not yet modeled here) |
|
||||||
|
| — (not modeled) | `spi_host_bridge.v` transaction timing, SDRAM refresh/burst timing, tile-scheduling latency |
|
||||||
|
|
||||||
|
## 8. CLI usage
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m tools.neural_sim random-network --n-inputs 8 --n-neurons 4 --seed 1
|
||||||
|
python -m tools.neural_sim run --example 8to4 --seed 7
|
||||||
|
python -m tools.neural_sim vectors --gen d_stress --out vec.json
|
||||||
|
python -m tools.neural_sim vectors --all --out all_vectors.json
|
||||||
|
python -m tools.neural_sim compare --expected vec.json --actual fpga_results.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Built-in examples (`--example`): `8to1`, `8to4`, `8to16`, `8_8_1` (an
|
||||||
|
8→8→1 two-layer network — the hidden width is 8, not some other
|
||||||
|
number, specifically because every layer boundary must stay a
|
||||||
|
multiple of `P_IN=8`, the same tiling constraint the real hardware
|
||||||
|
has).
|
||||||
|
|
||||||
|
## 9. Test-vector generation and export
|
||||||
|
|
||||||
|
`vectors.py` provides six deterministic generators (`gen_simple_positive`,
|
||||||
|
`gen_signed_mix`, `gen_extremes`, `gen_zero`, `gen_random`, `gen_dstress`
|
||||||
|
— the last reproducing the existing RTL benchmark's own 256-neuron ×
|
||||||
|
128-input dimensions). Every vector's `expected` field is computed by
|
||||||
|
this package's own golden model, not hand-typed, and stores `inputs`,
|
||||||
|
`weights`, `biases`, `activation`, `p_in`, `numeric_format`, and `seed`
|
||||||
|
(when applicable) — everything a future RTL testbench needs to load a
|
||||||
|
vector and check its own result, without re-deriving anything by hand:
|
||||||
|
|
||||||
|
```
|
||||||
|
Python (vectors.py) -> JSON golden vectors -> (future) Verilog testbench -> FPGA
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. FPGA-result comparison
|
||||||
|
|
||||||
|
`compare.compare_results(expected, actual)` returns a `ComparisonReport`
|
||||||
|
with `exact_match`, `num_mismatches`, `first_mismatch_index`,
|
||||||
|
`first_mismatch_expected`/`_actual`, and `max_abs_diff`. **0 mismatches
|
||||||
|
is the only passing criterion** — this package never hides a real
|
||||||
|
numeric difference behind a tolerance. `compare.load_fpga_results(path)`
|
||||||
|
accepts either a JSON list of ints or a plain whitespace/line-separated
|
||||||
|
results file (a common shape for an RTL testbench's own dump).
|
||||||
|
|
||||||
|
## Running the tests
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 -m pytest tools/neural_sim/tests/ -q
|
||||||
|
```
|
||||||
|
|
||||||
|
96 tests, all passing as of this writing: signed-arithmetic edge
|
||||||
|
cases (including a direct 32-bit wraparound check), neuron
|
||||||
|
scalar-vs-vectorized cross-checks (parametrized across tile counts,
|
||||||
|
activations, and seeds), layer tests, memory bounds/adjacency tests,
|
||||||
|
golden-vector determinism and JSON round-trip tests, and comparison-
|
||||||
|
utility tests.
|
||||||
|
|
||||||
|
## Optional future extension (not implemented — by design)
|
||||||
|
|
||||||
|
This first phase deliberately stops at "the mathematical golden model
|
||||||
|
is unquestionably correct." A future phase could add, without
|
||||||
|
changing anything above: a cycle-accurate scheduler model,
|
||||||
|
`N_PROCESSORS` ∈ {1,2,4} scheduling, tile scheduling, SDRAM traffic
|
||||||
|
estimation, SPI transaction modeling, and latency prediction. None of
|
||||||
|
that exists yet, and this package does not claim to be
|
||||||
|
"cycle-accurate" anywhere.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
neural_sim -- the Python GOLDEN FUNCTIONAL REFERENCE for FPGA-Neural V2.
|
||||||
|
|
||||||
|
Given weights, activations, and a network topology, this package
|
||||||
|
computes the mathematically correct result that the real V2 hardware
|
||||||
|
(hardware/v2/rtl/neural_processor.v, unmodified since before the
|
||||||
|
single-SDRAM freeze) must reproduce bit-for-bit.
|
||||||
|
|
||||||
|
This is NOT a cycle-accurate FPGA simulator: it models the numeric
|
||||||
|
result only, not clock cycles, memory-controller timing, or SPI
|
||||||
|
transaction timing. See README.md for the full scope statement and
|
||||||
|
tools/neural_sim/network.py / tools/neural_sim/memory.py for what IS
|
||||||
|
and is NOT modeled.
|
||||||
|
|
||||||
|
Python simulator = golden functional reference
|
||||||
|
RTL / P&R = hardware implementation
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
CLI for the neural_sim golden simulator.
|
||||||
|
|
||||||
|
python -m tools.neural_sim random-network --n-inputs 8 --n-neurons 4 --seed 1
|
||||||
|
python -m tools.neural_sim run --example 8to4 --seed 7
|
||||||
|
python -m tools.neural_sim vectors --gen d_stress --out vec.json
|
||||||
|
python -m tools.neural_sim vectors --all --out all_vectors.json
|
||||||
|
python -m tools.neural_sim compare --expected golden.json --actual fpga_results.json
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .layer import FCLayer
|
||||||
|
from .network import Network
|
||||||
|
from . import examples as ex
|
||||||
|
from . import vectors as vec
|
||||||
|
from .compare import compare_results, load_fpga_results
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_random_network(args):
|
||||||
|
rng = np.random.default_rng(args.seed)
|
||||||
|
weights = rng.integers(-args.magnitude, args.magnitude + 1,
|
||||||
|
size=(args.n_neurons, args.n_inputs), dtype=np.int64)
|
||||||
|
layer = FCLayer(weights, activation=args.activation)
|
||||||
|
inputs = rng.integers(-128, 128, size=args.n_inputs, dtype=np.int64)
|
||||||
|
outputs = layer.forward(inputs)
|
||||||
|
print(f"n_inputs={layer.n_inputs} n_neurons={layer.n_neurons} activation={layer.activation}")
|
||||||
|
print(f"inputs: {inputs.tolist()}")
|
||||||
|
print(f"outputs: {outputs.tolist()}")
|
||||||
|
if args.save_weights:
|
||||||
|
with open(args.save_weights, "w") as f:
|
||||||
|
json.dump({"weights": weights.tolist(), "inputs": inputs.tolist(),
|
||||||
|
"outputs": outputs.tolist()}, f, indent=2)
|
||||||
|
print(f"saved to {args.save_weights}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_run(args):
|
||||||
|
if args.example not in ex.ALL_EXAMPLES:
|
||||||
|
print(f"unknown example {args.example!r}; choices: {list(ex.ALL_EXAMPLES)}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
net = ex.ALL_EXAMPLES[args.example]()
|
||||||
|
rng = np.random.default_rng(args.seed)
|
||||||
|
inputs = rng.integers(-128, 128, size=net.n_inputs, dtype=np.int64)
|
||||||
|
outputs = net.forward(inputs)
|
||||||
|
print(f"example={args.example} n_inputs={net.n_inputs} n_outputs={net.n_outputs}")
|
||||||
|
print(f"inputs: {inputs.tolist()}")
|
||||||
|
print(f"outputs: {outputs.tolist()}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_vectors(args):
|
||||||
|
if args.all:
|
||||||
|
vec.export_all_json(args.out)
|
||||||
|
print(f"exported all {len(vec.ALL_GENERATORS)} named vectors to {args.out}")
|
||||||
|
return
|
||||||
|
if args.gen not in vec.ALL_GENERATORS:
|
||||||
|
print(f"unknown generator {args.gen!r}; choices: {list(vec.ALL_GENERATORS)}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
v = vec.ALL_GENERATORS[args.gen]()
|
||||||
|
vec.export_json(v, args.out)
|
||||||
|
print(f"generated {v.name!r}: n_inputs={v.n_inputs} n_neurons={v.n_neurons} -> {args.out}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_compare(args):
|
||||||
|
expected = vec.load_json(args.expected).expected if args.expected.endswith(".json") and _is_vector_file(args.expected) \
|
||||||
|
else load_fpga_results(args.expected)
|
||||||
|
actual = load_fpga_results(args.actual)
|
||||||
|
report = compare_results(expected, actual)
|
||||||
|
print(report.summary())
|
||||||
|
sys.exit(0 if report.exact_match else 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_vector_file(path: str) -> bool:
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
d = json.load(f)
|
||||||
|
return isinstance(d, dict) and "expected" in d and "weights" in d
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(prog="python -m tools.neural_sim",
|
||||||
|
description="FPGA-Neural V2 golden functional reference simulator")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
p_rand = sub.add_parser("random-network", help="generate and run a random network")
|
||||||
|
p_rand.add_argument("--n-inputs", type=int, default=8)
|
||||||
|
p_rand.add_argument("--n-neurons", type=int, default=4)
|
||||||
|
p_rand.add_argument("--seed", type=int, default=0)
|
||||||
|
p_rand.add_argument("--magnitude", type=int, default=20, help="max abs weight magnitude")
|
||||||
|
p_rand.add_argument("--activation", choices=["relu", "none"], default="relu")
|
||||||
|
p_rand.add_argument("--save-weights", metavar="PATH", default=None)
|
||||||
|
p_rand.set_defaults(func=cmd_random_network)
|
||||||
|
|
||||||
|
p_run = sub.add_parser("run", help="run one of the built-in example networks")
|
||||||
|
p_run.add_argument("--example", choices=list(ex.ALL_EXAMPLES), default="8to4")
|
||||||
|
p_run.add_argument("--seed", type=int, default=0, help="seed for the random input vector")
|
||||||
|
p_run.set_defaults(func=cmd_run)
|
||||||
|
|
||||||
|
p_vec = sub.add_parser("vectors", help="generate golden test vectors")
|
||||||
|
p_vec.add_argument("--gen", choices=list(vec.ALL_GENERATORS), default=None)
|
||||||
|
p_vec.add_argument("--all", action="store_true", help="export every named generator")
|
||||||
|
p_vec.add_argument("--out", required=True, metavar="PATH")
|
||||||
|
p_vec.set_defaults(func=cmd_vectors)
|
||||||
|
|
||||||
|
p_cmp = sub.add_parser("compare", help="compare FPGA results against Python golden results")
|
||||||
|
p_cmp.add_argument("--expected", required=True, metavar="PATH",
|
||||||
|
help="a vectors-format JSON file (uses its 'expected' field) or a plain results file")
|
||||||
|
p_cmp.add_argument("--actual", required=True, metavar="PATH", help="FPGA-generated results file")
|
||||||
|
p_cmp.set_defaults(func=cmd_compare)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
FPGA-vs-Python comparison utility (B10). Loads FPGA-generated results
|
||||||
|
and compares them against this package's own Python golden results.
|
||||||
|
The primary pass/fail criterion is 0 mismatches -- exact bit-exact
|
||||||
|
match, never a tolerance-based "close enough" comparison (per this
|
||||||
|
project's own explicit "for the true bit-exact model, do not hide
|
||||||
|
numerical differences behind tolerances" instruction).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ComparisonReport:
|
||||||
|
exact_match: bool
|
||||||
|
total: int
|
||||||
|
num_mismatches: int
|
||||||
|
first_mismatch_index: Optional[int]
|
||||||
|
first_mismatch_expected: Optional[int]
|
||||||
|
first_mismatch_actual: Optional[int]
|
||||||
|
max_abs_diff: int
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
if self.exact_match:
|
||||||
|
return f"EXACT MATCH: {self.total}/{self.total} outputs bit-exact, 0 mismatches"
|
||||||
|
return (
|
||||||
|
f"MISMATCH: {self.num_mismatches}/{self.total} outputs differ "
|
||||||
|
f"(first at index {self.first_mismatch_index}: "
|
||||||
|
f"expected={self.first_mismatch_expected} actual={self.first_mismatch_actual}, "
|
||||||
|
f"max_abs_diff={self.max_abs_diff})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compare_results(expected: List[int], actual: List[int]) -> ComparisonReport:
|
||||||
|
if len(expected) != len(actual):
|
||||||
|
raise ValueError(
|
||||||
|
f"length mismatch: expected has {len(expected)} outputs, "
|
||||||
|
f"actual has {len(actual)} -- cannot compare index-by-index"
|
||||||
|
)
|
||||||
|
|
||||||
|
total = len(expected)
|
||||||
|
num_mismatches = 0
|
||||||
|
first_idx = None
|
||||||
|
first_exp = None
|
||||||
|
first_act = None
|
||||||
|
max_abs_diff = 0
|
||||||
|
|
||||||
|
for i, (e, a) in enumerate(zip(expected, actual)):
|
||||||
|
if e != a:
|
||||||
|
num_mismatches += 1
|
||||||
|
if first_idx is None:
|
||||||
|
first_idx, first_exp, first_act = i, e, a
|
||||||
|
max_abs_diff = max(max_abs_diff, abs(e - a))
|
||||||
|
|
||||||
|
return ComparisonReport(
|
||||||
|
exact_match=(num_mismatches == 0),
|
||||||
|
total=total,
|
||||||
|
num_mismatches=num_mismatches,
|
||||||
|
first_mismatch_index=first_idx,
|
||||||
|
first_mismatch_expected=first_exp,
|
||||||
|
first_mismatch_actual=first_act,
|
||||||
|
max_abs_diff=max_abs_diff,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_fpga_results(path: str) -> List[int]:
|
||||||
|
"""Loads FPGA-generated results from either a JSON list of ints, or
|
||||||
|
a plain text file with one (whitespace-separated) integer per
|
||||||
|
line -- a common shape for an RTL testbench's own $display/
|
||||||
|
$writememh-style dump."""
|
||||||
|
with open(path) as f:
|
||||||
|
text = f.read()
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
if isinstance(data, dict) and "results" in data:
|
||||||
|
data = data["results"]
|
||||||
|
return [int(v) for v in data]
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return [int(tok) for tok in text.split()]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Small example networks for experimentation (B12). Every example uses
|
||||||
|
operations the real V2 accelerator actually implements (INT8 in/INT8
|
||||||
|
weight/INT32-wraparound-accumulate/ReLU-saturate-out, P_IN=8 tiles) --
|
||||||
|
none invent unsupported functionality. The 2-layer example is
|
||||||
|
FPGA-compatible at the computational level via the real dependency-
|
||||||
|
graph mechanism (each layer-2 neuron's producer_ids/required fields
|
||||||
|
would gate it on all 4 layer-1 neurons completing first), but this
|
||||||
|
simulator does not yet model the SPI job-construction/address-wiring
|
||||||
|
needed to actually run it end-to-end on hardware (see network.py's
|
||||||
|
own scope note).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .layer import FCLayer
|
||||||
|
from .network import Network
|
||||||
|
|
||||||
|
|
||||||
|
def example_8_to_1() -> Network:
|
||||||
|
"""8 inputs -> 1 neuron, ReLU. Hand-picked, easy-to-verify weights."""
|
||||||
|
weights = [[1, 2, 3, 4, -1, -2, -3, -4]]
|
||||||
|
return Network([FCLayer(weights, activation="relu")])
|
||||||
|
|
||||||
|
|
||||||
|
def example_8_to_4() -> Network:
|
||||||
|
"""8 inputs -> 4 neurons, ReLU. Fixed-seed weights for reproducibility."""
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
weights = rng.integers(-20, 21, size=(4, 8), dtype=np.int64)
|
||||||
|
return Network([FCLayer(weights, activation="relu")])
|
||||||
|
|
||||||
|
|
||||||
|
def example_8_to_16() -> Network:
|
||||||
|
"""8 inputs -> 16 neurons, ReLU. Fixed-seed weights."""
|
||||||
|
rng = np.random.default_rng(2)
|
||||||
|
weights = rng.integers(-20, 21, size=(16, 8), dtype=np.int64)
|
||||||
|
return Network([FCLayer(weights, activation="relu")])
|
||||||
|
|
||||||
|
|
||||||
|
def example_8_8_1() -> Network:
|
||||||
|
"""8 -> 8 -> 1, both layers ReLU. Fixed-seed weights. The hidden
|
||||||
|
layer is 8 wide (not, say, 4) because every layer boundary must
|
||||||
|
stay a multiple of P_IN=8 -- the real hardware always tiles in
|
||||||
|
groups of 8, so a hidden width that doesn't divide evenly would not
|
||||||
|
be a layer this simulator's own FCLayer (or the real accelerator)
|
||||||
|
can actually tile. See module docstring for the FPGA-compatibility
|
||||||
|
scope note re: multi-layer chaining."""
|
||||||
|
rng = np.random.default_rng(3)
|
||||||
|
w1 = rng.integers(-15, 16, size=(8, 8), dtype=np.int64)
|
||||||
|
w2 = rng.integers(-15, 16, size=(1, 8), dtype=np.int64)
|
||||||
|
return Network([FCLayer(w1, activation="relu"), FCLayer(w2, activation="relu")])
|
||||||
|
|
||||||
|
|
||||||
|
ALL_EXAMPLES = {
|
||||||
|
"8to1": example_8_to_1,
|
||||||
|
"8to4": example_8_to_4,
|
||||||
|
"8to16": example_8_to_16,
|
||||||
|
"8_8_1": example_8_8_1,
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Fully-connected layer: N_INPUTS -> N_NEURONS, each neuron with its own
|
||||||
|
weight vector (and, optionally, its own bias), all sharing one
|
||||||
|
activation setting -- matching how a real V2 job batch is submitted
|
||||||
|
(one WRITE_JOB per neuron, all against the same x_base activation
|
||||||
|
tile, each with its own w_base/result_addr).
|
||||||
|
|
||||||
|
output[n] = activation(bias[n] + sum(input[i]*weight[n][i] for i in
|
||||||
|
0..N_INPUTS-1)), computed with the EXACT same tile-wise, wraparound-
|
||||||
|
accumulate-then-saturate semantics as numerics.neuron_reference.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .numerics import ACT_RELU, DEFAULT_P_IN, check_int8
|
||||||
|
from .neuron import neuron_vectorized
|
||||||
|
|
||||||
|
|
||||||
|
class FCLayer:
|
||||||
|
"""
|
||||||
|
weights: array-like, shape (n_neurons, n_inputs), signed INT8.
|
||||||
|
biases: array-like, shape (n_neurons,), signed INT8 (default: all
|
||||||
|
zero, matching the real V2 protocol's own lack of a bias
|
||||||
|
field -- see numerics.py's own module docstring).
|
||||||
|
activation: 'relu' (default, matches the real, currently-exposed
|
||||||
|
V2 system) or 'none'.
|
||||||
|
p_in: tile width (default 8, matches the frozen P_IN=8 reference).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, weights, biases=None, activation: str = ACT_RELU,
|
||||||
|
p_in: int = DEFAULT_P_IN):
|
||||||
|
self.weights = np.asarray(weights, dtype=np.int64)
|
||||||
|
if self.weights.ndim != 2:
|
||||||
|
raise ValueError("weights must be 2D: (n_neurons, n_inputs)")
|
||||||
|
self.n_neurons, self.n_inputs = self.weights.shape
|
||||||
|
if self.n_inputs % p_in != 0:
|
||||||
|
raise ValueError(f"n_inputs={self.n_inputs} must be a multiple of p_in={p_in}")
|
||||||
|
for row in self.weights.tolist():
|
||||||
|
for w in row:
|
||||||
|
check_int8(w, "weight")
|
||||||
|
|
||||||
|
if biases is None:
|
||||||
|
self.biases = np.zeros(self.n_neurons, dtype=np.int64)
|
||||||
|
else:
|
||||||
|
self.biases = np.asarray(biases, dtype=np.int64)
|
||||||
|
if self.biases.shape != (self.n_neurons,):
|
||||||
|
raise ValueError("biases must have shape (n_neurons,)")
|
||||||
|
for b in self.biases.tolist():
|
||||||
|
check_int8(b, "bias")
|
||||||
|
|
||||||
|
self.activation = activation
|
||||||
|
self.p_in = p_in
|
||||||
|
|
||||||
|
def forward(self, inputs) -> np.ndarray:
|
||||||
|
"""inputs: array-like, shape (n_inputs,), signed INT8. Returns
|
||||||
|
an int64 numpy array of shape (n_neurons,) -- each element is
|
||||||
|
an exact signed INT8 value (kept as int64 purely for easy
|
||||||
|
downstream composition; every value is guaranteed in
|
||||||
|
[-128, 127])."""
|
||||||
|
inputs = np.asarray(inputs, dtype=np.int64)
|
||||||
|
if inputs.shape != (self.n_inputs,):
|
||||||
|
raise ValueError(f"inputs must have shape ({self.n_inputs},), got {inputs.shape}")
|
||||||
|
for v in inputs.tolist():
|
||||||
|
check_int8(v, "input")
|
||||||
|
|
||||||
|
outputs = np.empty(self.n_neurons, dtype=np.int64)
|
||||||
|
for n in range(self.n_neurons):
|
||||||
|
outputs[n] = neuron_vectorized(
|
||||||
|
inputs, self.weights[n], bias=int(self.biases[n]),
|
||||||
|
activation=self.activation, p_in=self.p_in,
|
||||||
|
)
|
||||||
|
return outputs
|
||||||
@@ -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)]
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Network model: an ordered chain of FCLayer instances, each layer's
|
||||||
|
INT8 output feeding the next layer's INT8 input.
|
||||||
|
|
||||||
|
Scope, deliberately kept small (per this project's own explicit "do
|
||||||
|
not invent unsupported FPGA functionality" instruction): this models a
|
||||||
|
linear chain of fully-connected+activation layers. The real V2
|
||||||
|
hardware's dependency_manager.v is actually more general -- it
|
||||||
|
schedules an arbitrary DAG of neuron "jobs" via producer_ids/required
|
||||||
|
fields, so a layer boundary is not a hardware limitation, only a
|
||||||
|
simulator scope limit for this first phase. A linear chain is exactly
|
||||||
|
what a linear chain of dependency-graph layers computes, so this is
|
||||||
|
faithful for the topologies it supports; it does not yet model
|
||||||
|
arbitrary-DAG job graphs, SPI job submission, or scheduling -- see
|
||||||
|
README.md "Optional future extension" and PRE_PCB_VERIFICATION.md's
|
||||||
|
own dependency-graph description for what the real hardware supports
|
||||||
|
beyond what this simulator currently models.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .layer import FCLayer
|
||||||
|
|
||||||
|
|
||||||
|
class Network:
|
||||||
|
def __init__(self, layers: list[FCLayer]):
|
||||||
|
if not layers:
|
||||||
|
raise ValueError("a Network needs at least one layer")
|
||||||
|
for i in range(1, len(layers)):
|
||||||
|
if layers[i].n_inputs != layers[i - 1].n_neurons:
|
||||||
|
raise ValueError(
|
||||||
|
f"layer {i}'s n_inputs={layers[i].n_inputs} does not match "
|
||||||
|
f"layer {i-1}'s n_neurons={layers[i-1].n_neurons}"
|
||||||
|
)
|
||||||
|
self.layers = layers
|
||||||
|
|
||||||
|
@property
|
||||||
|
def n_inputs(self) -> int:
|
||||||
|
return self.layers[0].n_inputs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def n_outputs(self) -> int:
|
||||||
|
return self.layers[-1].n_neurons
|
||||||
|
|
||||||
|
def forward(self, inputs) -> np.ndarray:
|
||||||
|
"""Runs `inputs` through every layer in order, returning the
|
||||||
|
final layer's INT8 output vector. Also available as
|
||||||
|
`forward_all` if every intermediate tensor is needed."""
|
||||||
|
return self.forward_all(inputs)[-1]
|
||||||
|
|
||||||
|
def forward_all(self, inputs) -> list[np.ndarray]:
|
||||||
|
"""Returns [layer0_output, layer1_output, ..., layerN_output]
|
||||||
|
-- every intermediate tensor, not just the final one (useful
|
||||||
|
for debugging / per-layer golden-vector generation)."""
|
||||||
|
x = np.asarray(inputs, dtype=np.int64)
|
||||||
|
outputs = []
|
||||||
|
for layer in self.layers:
|
||||||
|
x = layer.forward(x)
|
||||||
|
outputs.append(x)
|
||||||
|
return outputs
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Neuron model: y = activation(bias + sum(x[i]*w[i] for i in 0..N_INPUTS-1)),
|
||||||
|
matching neural_processor.v (P_IN=8 baseline, parameterizable). Two
|
||||||
|
independent implementations are provided -- a plain-Python scalar
|
||||||
|
reference (numerics.neuron_reference) and a NumPy-vectorized one below
|
||||||
|
-- and tests/test_neuron.py proves they always agree, bit-exact,
|
||||||
|
across every test-vector category.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .numerics import (
|
||||||
|
DEFAULT_ACC_WIDTH, DEFAULT_DATA_WIDTH, DEFAULT_P_IN,
|
||||||
|
ACT_RELU, accumulate_tile, add_bias, activate_and_saturate,
|
||||||
|
check_int8, neuron_reference, wrap_acc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def neuron_scalar(inputs, weights, bias: int = 0, activation: str = ACT_RELU,
|
||||||
|
p_in: int = DEFAULT_P_IN) -> int:
|
||||||
|
"""Plain-Python scalar reference -- a thin, explicit re-export of
|
||||||
|
numerics.neuron_reference (kept as its own name so neuron.py has a
|
||||||
|
clearly-named "scalar" counterpart to neuron_vectorized below)."""
|
||||||
|
return neuron_reference(inputs, weights, bias=bias, activation=activation, p_in=p_in)
|
||||||
|
|
||||||
|
|
||||||
|
def neuron_vectorized(inputs, weights, bias: int = 0, activation: str = ACT_RELU,
|
||||||
|
p_in: int = DEFAULT_P_IN,
|
||||||
|
acc_width: int = DEFAULT_ACC_WIDTH,
|
||||||
|
data_width: int = DEFAULT_DATA_WIDTH) -> int:
|
||||||
|
"""
|
||||||
|
NumPy-vectorized equivalent of neuron_scalar. Uses int64 numpy
|
||||||
|
accumulation WITHIN one tile only (exact -- a single P_IN=8 tile's
|
||||||
|
product-sum can never exceed a few hundred thousand in magnitude,
|
||||||
|
nowhere near int64's range, so no numpy overflow risk there), then
|
||||||
|
applies the SAME explicit 32-bit-wraparound Python arithmetic as the
|
||||||
|
scalar path across tiles -- deliberately NOT letting NumPy's own
|
||||||
|
int32 dtype silently wrap with platform-dependent behaviour, per
|
||||||
|
this project's own "do not allow implicit integer promotion to hide
|
||||||
|
overflow" requirement.
|
||||||
|
"""
|
||||||
|
x = np.asarray(inputs, dtype=np.int64)
|
||||||
|
w = np.asarray(weights, dtype=np.int64)
|
||||||
|
if x.shape != w.shape:
|
||||||
|
raise ValueError("inputs and weights must have the same shape")
|
||||||
|
if x.size == 0 or x.size % p_in != 0:
|
||||||
|
raise ValueError(f"len(inputs)={x.size} must be a nonzero multiple of p_in={p_in}")
|
||||||
|
for v in x.tolist():
|
||||||
|
check_int8(v, "input")
|
||||||
|
for v in w.tolist():
|
||||||
|
check_int8(v, "weight")
|
||||||
|
|
||||||
|
n_tiles = x.size // p_in
|
||||||
|
x_tiles = x.reshape(n_tiles, p_in)
|
||||||
|
w_tiles = w.reshape(n_tiles, p_in)
|
||||||
|
# exact per-tile dot products (int64, no overflow risk)
|
||||||
|
tile_sums = np.einsum("ij,ij->i", x_tiles, w_tiles)
|
||||||
|
|
||||||
|
acc = 0
|
||||||
|
for tile_sum in tile_sums.tolist():
|
||||||
|
acc = accumulate_tile(acc, int(tile_sum), acc_width=acc_width)
|
||||||
|
|
||||||
|
final_acc = add_bias(acc, bias, acc_width=acc_width, data_width=data_width)
|
||||||
|
return activate_and_saturate(final_acc, activation=activation, data_width=data_width)
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""
|
||||||
|
Bit-exact numeric semantics of hardware/v2/rtl/neural_processor.v.
|
||||||
|
|
||||||
|
The RTL is authoritative. Every function here was derived by reading
|
||||||
|
neural_processor.v directly (not assumed), specifically:
|
||||||
|
|
||||||
|
Stage 1 (line ~139): product_comb[gm] = x0[gm] * w0[gm]
|
||||||
|
INT8 x INT8 signed multiply. PROD_WIDTH = 2*DATA_WIDTH = 16 bits
|
||||||
|
is always sufficient (min product -128*127=-16256, max
|
||||||
|
-128*-128=16384, both fit in signed 16 bits) -- the multiply
|
||||||
|
itself can NEVER overflow for DATA_WIDTH=8. Each product is then
|
||||||
|
sign-extended to ACC_WIDTH=32 bits (line ~151).
|
||||||
|
|
||||||
|
Stages 2..(1+TREE_LEVELS) (line ~166-206): a balanced binary adder
|
||||||
|
tree reduces the P_IN products to one `tile_sum`, entirely in
|
||||||
|
ACC_WIDTH=32-bit signed arithmetic. For P_IN=8 this can never
|
||||||
|
overflow either (max magnitude 8*16384=131072 << 2^31).
|
||||||
|
|
||||||
|
Stage (2+TREE_LEVELS) (line ~212-229): `acc_reg <= acc_reg +
|
||||||
|
tile_sum` -- a RUNNING accumulator across ALL TILES of one job
|
||||||
|
(cleared only at NP_LOAD_JOB), using plain Verilog `+` on a
|
||||||
|
32-bit signed reg. This is WRAPAROUND (modulo 2^32) arithmetic,
|
||||||
|
NOT saturating -- Verilog silently wraps a fixed-width `+`.
|
||||||
|
Practically never triggered for realistic tile counts (a job
|
||||||
|
would need on the order of 2^17 tiles for the running sum to
|
||||||
|
approach the 32-bit signed range), but modeled as true wraparound
|
||||||
|
here anyway, per this project's own explicit "do not let overflow
|
||||||
|
hide behind Python's arbitrary precision" requirement, and
|
||||||
|
because an adversarial/stress test vector may deliberately probe
|
||||||
|
this boundary.
|
||||||
|
|
||||||
|
Stage (3+TREE_LEVELS) (line ~235-260): `final_acc_reg <= acc_reg +
|
||||||
|
bias_ext` -- bias (sign-extended from an INT8 job_bias field) is
|
||||||
|
ALSO added with 32-bit wraparound semantics. NOTE: the real,
|
||||||
|
currently-exposed V2 SPI job protocol (spi_host_bridge.v's own
|
||||||
|
WRITE_JOB opcode) has no bias field at all -- bias is a
|
||||||
|
neural_processor.v MODULE-LEVEL capability, not something the
|
||||||
|
real V2 host can currently set. This model defaults bias=0 to
|
||||||
|
match the real, currently-exposed system behaviour, while still
|
||||||
|
implementing nonzero bias faithfully for anyone driving
|
||||||
|
neural_processor.v directly.
|
||||||
|
|
||||||
|
Stage (4+TREE_LEVELS) (line ~262-288): the ONLY saturating stage.
|
||||||
|
Two activation encodings exist in the RTL: ACT_NONE (a two-sided
|
||||||
|
saturating clamp to the full signed INT8 range [-128, 127]) and
|
||||||
|
ACT_RELU (the Verilog `case` statement's `default` branch, so
|
||||||
|
ANY activation code other than exactly ACT_NONE=0 also produces
|
||||||
|
ReLU behaviour). The real, currently-exposed V2 SPI job protocol
|
||||||
|
has no activation-selection field either -- ReLU is the only
|
||||||
|
activation the real system currently applies (matches the V2
|
||||||
|
LaTeX datasheet's own "Activation: ReLU + INT8 saturate, fixed").
|
||||||
|
|
||||||
|
Overflow/signedness summary (as explicitly requested):
|
||||||
|
- multiplication: exact, cannot overflow for INT8 operands
|
||||||
|
- per-tile adder tree: exact for P_IN<=8, WRAPAROUND semantics modeled
|
||||||
|
- cross-tile accumulator: WRAPAROUND (32-bit, two's complement)
|
||||||
|
- bias add: WRAPAROUND (32-bit, two's complement)
|
||||||
|
- final activation/output: SATURATING (to INT8, either two-sided for
|
||||||
|
ACT_NONE or ReLU-then-saturate for ACT_RELU)
|
||||||
|
|
||||||
|
Reuses tools/validation/mac_oracle.py's own independently-derived
|
||||||
|
two's-complement primitives (`to_signed`, `mac8_tree`) rather than
|
||||||
|
duplicating them -- that file is this project's own pre-existing,
|
||||||
|
already-hand-verified oracle for the identical wraparound-add
|
||||||
|
semantics (rtl/mac_unit.v / rtl/mac8.v), and neural_processor.v's own
|
||||||
|
header states its accumulation is "same sign-extended INT32-style
|
||||||
|
accumulation" as that same MAC lineage.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tools.validation.mac_oracle import to_signed, to_unsigned, mac8_tree
|
||||||
|
|
||||||
|
INT8_MIN = -128
|
||||||
|
INT8_MAX = 127
|
||||||
|
DEFAULT_DATA_WIDTH = 8
|
||||||
|
DEFAULT_ACC_WIDTH = 32
|
||||||
|
DEFAULT_P_IN = 8
|
||||||
|
|
||||||
|
ACT_NONE = "none"
|
||||||
|
ACT_RELU = "relu"
|
||||||
|
|
||||||
|
|
||||||
|
def check_int8(val: int, name: str = "value") -> int:
|
||||||
|
"""Assert `val` is a valid signed INT8 and return it unchanged."""
|
||||||
|
if not (INT8_MIN <= val <= INT8_MAX):
|
||||||
|
raise ValueError(f"{name}={val} out of signed INT8 range [{INT8_MIN}, {INT8_MAX}]")
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_acc(val: int, acc_width: int = DEFAULT_ACC_WIDTH) -> int:
|
||||||
|
"""Wrap a Python int to signed acc_width-bit two's complement -- the
|
||||||
|
exact semantics of a fixed-width Verilog `reg signed [acc_width-1:0]`
|
||||||
|
after a `+` that would otherwise overflow."""
|
||||||
|
return to_signed(val, acc_width)
|
||||||
|
|
||||||
|
|
||||||
|
def tile_product_sum(x_tile, w_tile, acc_width: int = DEFAULT_ACC_WIDTH,
|
||||||
|
data_width: int = DEFAULT_DATA_WIDTH) -> int:
|
||||||
|
"""
|
||||||
|
One P_IN-wide tile: P_IN independent INT8xINT8 products, reduced by
|
||||||
|
the balanced adder tree (mac8_tree, acc_in=0). Matches
|
||||||
|
neural_processor.v's `tile_sum` (stages 1..1+TREE_LEVELS) exactly.
|
||||||
|
"""
|
||||||
|
if len(x_tile) != len(w_tile):
|
||||||
|
raise ValueError("x_tile and w_tile must have the same length (P_IN)")
|
||||||
|
n = len(x_tile)
|
||||||
|
if n == 0 or (n & (n - 1)) != 0:
|
||||||
|
raise ValueError(f"tile length {n} must be a power of two (P_IN), matching the RTL's tree")
|
||||||
|
products = []
|
||||||
|
for x, w in zip(x_tile, w_tile):
|
||||||
|
check_int8(x, "input")
|
||||||
|
check_int8(w, "weight")
|
||||||
|
products.append(x * w) # exact, INT8xINT8 never overflows PROD_WIDTH=16
|
||||||
|
return mac8_tree(products, acc_in=0, acc_width=acc_width)
|
||||||
|
|
||||||
|
|
||||||
|
def accumulate_tile(acc_reg: int, tile_sum: int, acc_width: int = DEFAULT_ACC_WIDTH) -> int:
|
||||||
|
"""`acc_reg <= acc_reg + tile_sum` -- one running-accumulator update
|
||||||
|
across tiles of the SAME job. WRAPAROUND, matching the RTL's plain
|
||||||
|
fixed-width `+` exactly (not saturating)."""
|
||||||
|
return wrap_acc(acc_reg + tile_sum, acc_width)
|
||||||
|
|
||||||
|
|
||||||
|
def add_bias(acc_reg: int, bias: int, acc_width: int = DEFAULT_ACC_WIDTH,
|
||||||
|
data_width: int = DEFAULT_DATA_WIDTH) -> int:
|
||||||
|
"""`final_acc_reg <= acc_reg + bias_ext` -- bias is sign-extended from
|
||||||
|
an INT8 value, then added with WRAPAROUND semantics (same as
|
||||||
|
accumulate_tile)."""
|
||||||
|
check_int8(bias, "bias")
|
||||||
|
return wrap_acc(acc_reg + bias, acc_width)
|
||||||
|
|
||||||
|
|
||||||
|
def activate_and_saturate(final_acc: int, activation: str = ACT_RELU,
|
||||||
|
data_width: int = DEFAULT_DATA_WIDTH) -> int:
|
||||||
|
"""
|
||||||
|
The ONE saturating stage in the whole datapath -- neural_processor.v
|
||||||
|
lines ~256-288, reproduced exactly (not approximated):
|
||||||
|
|
||||||
|
ACT_NONE: two-sided saturating clamp to [INT8_MIN, INT8_MAX] -- if
|
||||||
|
final_acc fits in signed data_width bits, pass its exact truncated
|
||||||
|
value through; otherwise clamp to INT8_MIN (if negative) or
|
||||||
|
INT8_MAX (if positive).
|
||||||
|
|
||||||
|
ACT_RELU (default, and the RTL's own `case` default for ANY
|
||||||
|
activation code other than exactly ACT_NONE): final_acc<=0 -> 0;
|
||||||
|
0 < final_acc <= INT8_MAX -> final_acc exactly; final_acc >
|
||||||
|
INT8_MAX -> saturate to INT8_MAX. There is no negative saturation
|
||||||
|
branch for ReLU since negative values are already zeroed.
|
||||||
|
"""
|
||||||
|
lo, hi = -(1 << (data_width - 1)), (1 << (data_width - 1)) - 1
|
||||||
|
|
||||||
|
if activation == ACT_NONE:
|
||||||
|
if lo <= final_acc <= hi:
|
||||||
|
return final_acc
|
||||||
|
return lo if final_acc < 0 else hi
|
||||||
|
|
||||||
|
# ACT_RELU (and, matching the RTL's `default:` case branch, any
|
||||||
|
# activation value that isn't exactly ACT_NONE)
|
||||||
|
if final_acc <= 0:
|
||||||
|
return 0
|
||||||
|
if final_acc > hi:
|
||||||
|
return hi
|
||||||
|
return final_acc
|
||||||
|
|
||||||
|
|
||||||
|
def neuron_reference(inputs, weights, bias: int = 0, activation: str = ACT_RELU,
|
||||||
|
p_in: int = DEFAULT_P_IN, acc_width: int = DEFAULT_ACC_WIDTH,
|
||||||
|
data_width: int = DEFAULT_DATA_WIDTH) -> int:
|
||||||
|
"""
|
||||||
|
Full, tile-by-tile, bit-exact reference for one neuron_processor.v
|
||||||
|
job: y = activation(bias + sum(x[i]*w[i] for i in 0..N_INPUTS-1)),
|
||||||
|
computed the SAME WAY the RTL computes it -- P_IN-wide tiles, each
|
||||||
|
reduced by the adder tree, accumulated across tiles with 32-bit
|
||||||
|
wraparound, THEN bias-added (also wraparound), THEN activated/
|
||||||
|
saturated exactly once at the end (matching tile_last/NP_FINISH).
|
||||||
|
|
||||||
|
len(inputs) must be a multiple of p_in (one real tile per group of
|
||||||
|
p_in inputs -- matches n_tiles*P_IN in the real WRITE_JOB protocol).
|
||||||
|
"""
|
||||||
|
if len(inputs) != len(weights):
|
||||||
|
raise ValueError("inputs and weights must have the same length")
|
||||||
|
if len(inputs) == 0 or len(inputs) % p_in != 0:
|
||||||
|
raise ValueError(f"len(inputs)={len(inputs)} must be a nonzero multiple of p_in={p_in}")
|
||||||
|
|
||||||
|
acc = 0
|
||||||
|
for t in range(0, len(inputs), p_in):
|
||||||
|
x_tile = inputs[t:t + p_in]
|
||||||
|
w_tile = weights[t:t + p_in]
|
||||||
|
tile_sum = tile_product_sum(x_tile, w_tile, acc_width=acc_width, data_width=data_width)
|
||||||
|
acc = accumulate_tile(acc, tile_sum, acc_width=acc_width)
|
||||||
|
|
||||||
|
final_acc = add_bias(acc, bias, acc_width=acc_width, data_width=data_width)
|
||||||
|
return activate_and_saturate(final_acc, activation=activation, data_width=data_width)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user