feat(v2): scaffold hardware/v1 frozen baseline + M1 Neural Processor

Begins the V2 Neural Multiprocessor / Dataflow architecture per
docs/v2-description.md, per explicit user request to freeze V1 and
start V2 development, copying from V1 what's needed.

Scaffold:
- hardware/v1/: byte-exact, read-only copy of the current V1 codebase
  (rtl, testbenches, tools, constraints, a representative subset of
  synthesis results, and reference docs) -- verified identical via
  diff/cmp against the live top-level tree before being made
  filesystem-read-only. The live top-level tree is untouched and
  remains the project's "production" V1 (see hardware/v1/README.md
  and hardware/v2/logs/decisions.log DEC-0001 for why copy-not-move).
- hardware/v2/: mandatory structure (rtl/sim/constraints/synthesis/
  reports/scripts/logs/docs) plus the full logging system required by
  the spec (development/architecture/simulation/synthesis/timing/
  benchmark/decisions/experiments/errors.log).

M1 -- Neural Processor (hardware/v2/rtl/neural_processor.v):
- 8-stage pipelined perceptron unit (P_IN=8): input align, 8
  multipliers, 3-level adder tree, accumulator, bias+activation, INT8
  saturation. Genuine 1-tile/cycle throughput, not just a wider
  combinational datapath.
- 7-state FSM (NP_IDLE..NP_ERROR per docs/v2-description.md §6, with
  4 baseline states merged into NP_WAIT_OPERANDS -- see
  decisions.log DEC-0002); valid/ready/data/last stream interfaces
  per §7.
- Bit-exact vs the frozen hardware/v1/rtl/neuron_parallel.v + mac8.v
  + mac_unit.v: 7/7 tests pass (hardware/v2/sim/tb_neural_processor.v),
  covering regular/mixed-sign/extreme-INT8 vectors, both activations,
  a zero-idle-gap back-to-back-tiles throughput check, and an 8-tile
  job -- verified with Verilator (see below for why).
- Real synthesis + place&route (Yosys + nextpnr-ecp5): 0 CHECK
  problems, Fmax 183.12 MHz at ACC_WIDTH=32 (PASS at 80MHz, ~3x V1's
  isolated PARALLEL=8 Fmax of 61.71 MHz) and 176.21 MHz at ACC_WIDTH=24
  (a user-requested comparison experiment, also bit-exact-verified;
  see experiments.log EXP-0001/EXP-0002 and benchmark.log).

Three real bugs found and resolved during M1 development (full
diagnostic record in errors.log):
- Two independent, reproducible Icarus Verilog v13.0 scheduling
  defects (ERR-0001, ERR-0002) that silently produced wrong simulation
  results for standard sequential Verilog -- confirmed via Verilator
  5.050 giving correct results on the same minimal repros. Verilator
  is now the trusted simulator for hardware/v2/ (decisions.log
  DEC-0004); Icarus's affected protocol-violation check was removed
  from the RTL and deferred architecturally to the Neural Director
  (DEC-0003) rather than chased further.
- One real RTL bug (ERR-0003): last0 wasn't gated like valid0,
  letting a "last tile" tag leak into the pipeline ahead of its
  actual valid tile on back-to-back jobs. Fixed and verified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xXuuRUWZScuo1DeYJxs3v
This commit is contained in:
2026-09-05 14:06:53 +02:00
co-authored by Claude Sonnet 5
parent 07a48e401f
commit dc0b331d3e
161 changed files with 1151210 additions and 0 deletions
@@ -0,0 +1,285 @@
import importlib.util
import os
import sys
import unittest
# Load this repo's tools/netasm package by explicit file path rather
# than via `import tools.netasm`: some environments put an unrelated
# `tools` namespace package earlier on PYTHONPATH (e.g. Project
# Trellis's own tools/ directory), which would otherwise shadow this
# repo's tools/ and break the dotted import regardless of sys.path
# ordering tricks.
_PKG_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_spec = importlib.util.spec_from_file_location(
"netasm_under_test", os.path.join(_PKG_DIR, "__init__.py"),
submodule_search_locations=[_PKG_DIR],
)
_netasm = importlib.util.module_from_spec(_spec)
sys.modules["netasm_under_test"] = _netasm
_spec.loader.exec_module(_netasm)
parse = _netasm.parse
NetasmSyntaxError = _netasm.NetasmSyntaxError
GraphNet = _netasm.GraphNet
DenseNet = _netasm.DenseNet
assemble_graph = _netasm.assemble_graph
assemble_dense = _netasm.assemble_dense
NetasmError = _netasm.NetasmError
F = importlib.import_module("netasm_under_test.frames")
# The worked example from spec §3 / already validated byte-exact in
# sim/graph_format_tb.v and end-to-end in sim/graph_engine_tb.v and
# sim/spi_neuron_top_graph_tb.v: 4 inputs, n4 = relu(x0*5+x1*(-3)+2),
# n5 = x2*7 + act[n4]*2, output = n5.
GRAPH_SRC = """
; worked example, spec §3
NET graph
INPUTS 4
NEURON n4 relu bias=2
CONN 0 w=5
CONN 1 w=-3
NEURON n5 none bias=0
CONN n4 w=2
CONN 2 w=7
OUTPUT n5
END
"""
class TestParser(unittest.TestCase):
def test_parses_graph(self):
net = parse(GRAPH_SRC)
self.assertIsInstance(net, GraphNet)
self.assertEqual(net.n_inputs, 4)
self.assertEqual([n.name for n in net.neurons], ["n4", "n5"])
self.assertEqual(net.outputs, ["n5"])
def test_parses_dense(self):
src = """
NET dense
INPUTS 256
LAYER 64 relu
LAYER 16 relu
LAYER 4 none
END
"""
net = parse(src)
self.assertIsInstance(net, DenseNet)
self.assertEqual(net.n_inputs, 256)
self.assertEqual([l.n_neurons for l in net.layers], [64, 16, 4])
self.assertEqual([l.activation for l in net.layers], ["relu", "relu", "none"])
def test_missing_net_directive(self):
with self.assertRaises(NetasmSyntaxError):
parse("INPUTS 4\nEND\n")
def test_missing_end(self):
with self.assertRaises(NetasmSyntaxError):
parse("NET graph\nINPUTS 4\nNEURON n0 relu bias=0\n CONN 0 w=1\nOUTPUT n0\n")
def test_conn_outside_neuron(self):
with self.assertRaises(NetasmSyntaxError):
parse("NET graph\nINPUTS 4\nCONN 0 w=1\nEND\n")
def test_bad_weight_range(self):
with self.assertRaises(NetasmSyntaxError):
parse(
"NET graph\nINPUTS 4\nNEURON n0 relu bias=0\n CONN 0 w=200\n"
"OUTPUT n0\nEND\n"
)
def test_comments_and_blank_lines_ignored(self):
src = "; comment\nNET graph\n\nINPUTS 4 ; trailing comment\n" \
"NEURON n0 relu bias=0\n CONN 0 w=1\nOUTPUT n0\nEND\n"
net = parse(src)
self.assertEqual(net.n_inputs, 4)
class TestGraphAssembler(unittest.TestCase):
def setUp(self):
self.net = parse(GRAPH_SRC)
def test_id_assignment(self):
layout = assemble_graph(self.net, parallel=2)
self.assertEqual(layout.id_of["n4"], 4)
self.assertEqual(layout.id_of["n5"], 5)
self.assertEqual(layout.num_neurons, 2)
self.assertEqual(layout.n_out, 1)
self.assertEqual(layout.n_total, 6) # 4 inputs + 2 neurons
def test_byte_exact_descriptor_and_edges_no_padding(self):
# PARALLEL=2, n_conn=2 for both neurons -> n_conn_padded=2,
# i.e. no padding edges at all: exactly matches the byte
# layout hand-verified in sim/graph_format_tb.v.
layout = assemble_graph(
self.net, parallel=2, table_base=0x000000, edges_base=0x000100
)
n4_edges_addr = 0x000100
n5_edges_addr = 0x000100 + 8 # n4 has exactly 2 edges, 4 bytes each, no padding
expected_desc = bytes([
# n4: conn_ptr, n_conn=2, out_id=4, act=RELU(1), bias=2, reserved
(n4_edges_addr >> 16) & 0xFF, (n4_edges_addr >> 8) & 0xFF, n4_edges_addr & 0xFF,
0x00, 0x02,
0x00, 0x04,
0x01,
0x02,
0x00, 0x00,
# n5: conn_ptr, n_conn=2, out_id=5, act=NONE(0), bias=0, reserved
(n5_edges_addr >> 16) & 0xFF, (n5_edges_addr >> 8) & 0xFF, n5_edges_addr & 0xFF,
0x00, 0x02,
0x00, 0x05,
0x00,
0x00,
0x00, 0x00,
])
self.assertEqual(layout.descriptor_bytes, expected_desc)
expected_n4_edges = bytes([
0x00, 0x00, 0x05, 0x00, # src=0, w=5
0x00, 0x01, (-3) & 0xFF, 0x00, # src=1, w=-3
])
expected_n5_edges = bytes([
0x00, 0x04, 0x02, 0x00, # src=4 (n4), w=2
0x00, 0x02, 0x07, 0x00, # src=2, w=7
])
self.assertEqual(layout.edge_bytes["n4"], expected_n4_edges)
self.assertEqual(layout.edge_bytes["n5"], expected_n5_edges)
def test_parallel_padding(self):
# Same graph, PARALLEL=4 -> n_conn=2 pads to 4 (2 extra
# zero-weight, src=0 edges per neuron), as exercised end to
# end in sim/graph_engine_tb.v.
layout = assemble_graph(self.net, parallel=4, max_conn=8)
self.assertEqual(layout.n_conn_padded["n4"], 4)
self.assertEqual(layout.n_conn_padded["n5"], 4)
self.assertEqual(len(layout.edge_bytes["n4"]), 16)
# padding tail is (src=0, w=0, reserved=0)
self.assertEqual(layout.edge_bytes["n4"][8:], bytes(8))
self.assertEqual(layout.edge_bytes["n5"][8:], bytes(8))
# descriptor's n_conn is the REAL count, not padded (§4.2)
self.assertEqual(layout.descriptor_bytes[3:5], bytes([0x00, 0x02]))
def test_zero_conn_neuron_still_pads_to_one_group(self):
src = (
"NET graph\nINPUTS 2\n"
"NEURON n2 none bias=5\nOUTPUT n2\nEND\n"
)
net = parse(src)
layout = assemble_graph(net, parallel=4, max_conn=8)
self.assertEqual(layout.n_conn_padded["n2"], 4)
def test_frames_sequence(self):
layout = assemble_graph(self.net, parallel=2)
labels = [fr.label.split("(")[0] for fr in layout.frames]
self.assertEqual(
labels,
[
"WRITE_RAM", # table
"WRITE_RAM", # n4 edges
"WRITE_RAM", # n5 edges
"SET_NET_TYPE",
"SET_BASE", # x_base
"SET_BASE", # table_base
"SET_BASE", # out_base (buf_a_base)
"SET_BASE", # n_inputs_real (N_in)
"SET_BASE", # num_neurons_graph
"SET_BASE", # n_out
"RUN_NETWORK",
],
)
run_frame = layout.frames[-1]
self.assertEqual(run_frame.data[0], F.OP_RUN_NETWORK)
# ---- compile-time guard tests (mirrors sim/graph_engine_guard_tb.v) ----
def test_self_reference_rejected(self):
# n1 references itself; n2 (not n1) is the actual OUTPUT, so
# this isolates the src_id < out_id check from the separate
# "output used as source" check below.
src = (
"NET graph\nINPUTS 1\n"
"NEURON n1 relu bias=0\n CONN n1 w=1\n"
"NEURON n2 relu bias=0\n CONN 0 w=1\n"
"OUTPUT n2\nEND\n"
)
net = parse(src)
with self.assertRaisesRegex(NetasmError, "not a strictly earlier signal"):
assemble_graph(net, parallel=2)
def test_forward_reference_rejected(self):
# n1 references n2, declared AFTER it; n3 (not n2) is the
# actual OUTPUT, so n2 is an ordinary (non-output) neuron and
# this isolates the src_id < out_id check from "output used
# as source" below.
src = (
"NET graph\nINPUTS 1\n"
"NEURON n1 relu bias=0\n CONN n2 w=1\n"
"NEURON n2 relu bias=0\n CONN 0 w=1\n"
"NEURON n3 relu bias=0\n CONN n1 w=1\n"
"OUTPUT n3\nEND\n"
)
net = parse(src)
with self.assertRaisesRegex(NetasmError, "not a strictly earlier signal"):
assemble_graph(net, parallel=2)
def test_output_used_as_source_rejected(self):
src = (
"NET graph\nINPUTS 2\n"
"NEURON n2 relu bias=0\n CONN 0 w=1\n"
"NEURON n3 relu bias=0\n CONN n2 w=1\n"
"OUTPUT n2\nOUTPUT n3\nEND\n"
)
net = parse(src)
with self.assertRaisesRegex(NetasmError, "used as a source"):
assemble_graph(net, parallel=2)
def test_max_conn_overflow_rejected(self):
conns = "\n".join(f" CONN 0 w=1" for _ in range(10))
src = f"NET graph\nINPUTS 1\nNEURON n1 relu bias=0\n{conns}\nOUTPUT n1\nEND\n"
net = parse(src)
with self.assertRaisesRegex(NetasmError, "MAX_CONN"):
assemble_graph(net, parallel=4, max_conn=8) # 10 conns pad to 12 > 8
def test_n_total_overflow_rejected(self):
layout_ok = assemble_graph(self.net, parallel=2, n_total=6) # exactly fits
self.assertEqual(layout_ok.n_total, 6)
with self.assertRaisesRegex(NetasmError, "N_TOTAL"):
assemble_graph(self.net, parallel=2, n_total=5)
def test_undeclared_output_rejected(self):
src = "NET graph\nINPUTS 1\nNEURON n1 relu bias=0\n CONN 0 w=1\nOUTPUT ghost\nEND\n"
net = parse(src)
with self.assertRaisesRegex(NetasmError, "undeclared"):
assemble_graph(net, parallel=2)
class TestDenseAssembler(unittest.TestCase):
def test_layout_and_descriptor(self):
src = "NET dense\nINPUTS 8\nLAYER 4 relu\nLAYER 2 none\nEND\n"
net = parse(src)
layout = assemble_dense(net, parallel=4, table_base=0, weights_base=0x1000)
self.assertEqual(layout.layers[0].n_inputs_real, 8)
self.assertEqual(layout.layers[0].n_neurons_real, 4)
self.assertEqual(layout.layers[1].n_inputs_real, 4)
self.assertEqual(layout.layers[1].n_neurons_real, 2)
# layer0: w_base at weights_base, 8*4=32 bytes, then bias 4 bytes
self.assertEqual(layout.layers[0].w_base, 0x1000)
self.assertEqual(layout.layers[0].bias_addr, 0x1000 + 32)
# layer1 follows immediately after layer0's bias region
self.assertEqual(layout.layers[1].w_base, 0x1000 + 32 + 4)
self.assertEqual(len(layout.descriptor_bytes), 11 * 2)
def test_non_multiple_of_parallel_rejected(self):
src = "NET dense\nINPUTS 6\nLAYER 4 relu\nEND\n" # 6 not a multiple of 4
net = parse(src)
with self.assertRaisesRegex(NetasmError, "PARALLEL"):
assemble_dense(net, parallel=4)
if __name__ == "__main__":
unittest.main()