Files
FPGA-Neural/tools/netasm/tests/test_netasm.py
T
micheleandClaude Sonnet 5 55c827bedf feat: PSRAM page-mode reads + graph engine (Type #2) + real pinout/IRQ pins
PSRAM page-mode read burst support in psram_controller.v: enables the
ISSI IS66WVE4M16EBLL-70BLI's page mode via its configuration-register
software-access sequence at boot (disabled by default on the real
chip), then keeps CE#/OE# asserted after a read so a same-page
continuation only pays tAPA (20ns) instead of a full tAA (70ns)
random access, with automatic tCEM-safe session closing. Only a WRITE
closes the page -- byte-enable changes do not, since
int8_memory_access.v alternates them on nearly every access and an
early implementation attempt that treated them as a close condition
measured a real regression (53.25->61.25 cycles/edge) before being
corrected (53.25->37.53 cycles/edge, +42% gather bandwidth).
sim/psram_model.v gained independent tAPA/tAA and tCEM enforcement
(with a real Verilog same-timestep event-ordering race found and
fixed via a #0 sync) so the regression proves real timing compliance,
not just data correctness. New sim/psram_page_mode_tb.v; full 26-file
regression suite re-run clean. Real nextpnr-ecp5 Fmax re-measured on
the full spi_neuron_top system: 75.73MHz (P2, up from 55.59MHz) and
65.13MHz (P8) -- still under the 80MHz target but not regressed, with
the critical path confirmed (not assumed) to remain entirely inside
neuron_parallel's accumulate chain, never psram_controller.

Also includes this session's other already-validated work: the graph
engine (Type #2 sparse-graph network: act_buffer, graph_engine,
netasm host assembler), real CABGA381 pinout (.lpf, place&route
verified) and physical IRQ_N/DATA_READY_N pins, and Phase 7 timing
closure logs -- all previously uncommitted, documented in WORKLOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LH3jPeJ3eFMfF2v8SQhpkk
2026-09-03 17:12:05 +02:00

286 lines
11 KiB
Python

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()