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:
@@ -0,0 +1,105 @@
|
||||
# netasm
|
||||
|
||||
Host-side assembler for the FPGA-Neural network engine. Compiles a
|
||||
small pseudo-assembly description of a network (dense Type #1 or
|
||||
sparse-graph Type #2, see the project spec §9) into:
|
||||
|
||||
- the exact on-disk byte layout (descriptor table + edge blocks,
|
||||
spec §4), and
|
||||
- the SPI command sequence (`SET_NET_TYPE` / `SET_BASE` / `WRITE_RAM`
|
||||
/ `RUN_NETWORK`) needed to load and start it.
|
||||
|
||||
This is **host tooling only**. Nothing here runs on the FPGA — see
|
||||
`rtl/graph_engine.v` and `rtl/spi_engine.v` for the hardware side of
|
||||
this protocol.
|
||||
|
||||
## Grammar
|
||||
|
||||
```
|
||||
; Tipo #1 (dense)
|
||||
NET dense
|
||||
INPUTS 256
|
||||
LAYER 64 relu
|
||||
LAYER 16 relu
|
||||
LAYER 4 none
|
||||
END
|
||||
```
|
||||
|
||||
```
|
||||
; Tipo #2 (graph)
|
||||
NET graph
|
||||
INPUTS 4 ; id 0..3
|
||||
NEURON n4 relu bias=2
|
||||
CONN 0 w=5
|
||||
CONN 1 w=-3
|
||||
NEURON n5 none bias=0
|
||||
CONN n4 w=2 ; symbolic reference to n4's output
|
||||
CONN 2 w=7
|
||||
OUTPUT n5
|
||||
END
|
||||
```
|
||||
|
||||
`;` starts a comment that runs to end of line. A `CONN <src> w=<int>`
|
||||
source is either a bare decimal id (typically one of the network's
|
||||
inputs) or the name of a previously declared `NEURON`.
|
||||
|
||||
For `NET dense`, only layer **sizes** and activations are declared —
|
||||
weight/bias **values** come from a trained model and are loaded by
|
||||
the host separately (unchanged `WRITE_RAM` flow); netasm's job there
|
||||
is layout (address allocation, PARALLEL-alignment validation,
|
||||
descriptor table, load/run commands).
|
||||
|
||||
For `NET graph`, netasm assigns every neuron's signal id (inputs get
|
||||
0..N_in-1; every `OUTPUT` neuron is guaranteed the highest ids, as
|
||||
required by spec §4.4 — reordering non-output neurons is never
|
||||
needed for correctness since the grammar already forces
|
||||
before-you-use declaration order), resolves symbolic `CONN`
|
||||
references, pads each neuron's edge list to a `PARALLEL` multiple
|
||||
with zero-weight edges (spec §2.6 — this is a full, physical
|
||||
edge block, not a hint: hardware just streams `n_conn_padded` real
|
||||
bytes from PSRAM), and emits the descriptor table + edge blocks +
|
||||
load/run command sequence.
|
||||
|
||||
## Compile-time validation
|
||||
|
||||
Catches these before the runtime load-time guard in
|
||||
`rtl/graph_engine.v` ever would (spec §9's whole point):
|
||||
|
||||
- `src_id < out_id` (no cycles / forward references)
|
||||
- `src_id`, `out_id` < `N_TOTAL`
|
||||
- an `OUTPUT` neuron is never used as another neuron's source
|
||||
- every `CONN` reference (symbolic or literal) resolves to a real id
|
||||
- a neuron's *padded* connection count fits the hardware's
|
||||
build-time `MAX_CONN`
|
||||
- (dense) every layer's real input count is a `PARALLEL` multiple
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
python3 tools/netasm/cli.py <input.netasm> -o <out_prefix> \
|
||||
[--parallel 8] [--max-conn 32] [--n-total 4096] \
|
||||
[--table-base 0x...] [--edges-base 0x...] [--x-base 0x...] \
|
||||
[--out-base 0x...] [--buf-b-base 0x...] [--weights-base 0x...]
|
||||
```
|
||||
|
||||
Produces:
|
||||
|
||||
- `<out_prefix>.frames.bin` — length-prefixed SPI transaction bytes
|
||||
(2-byte big-endian length + that many payload bytes, repeated); a
|
||||
host driver replays each record by asserting CS, shifting the
|
||||
bytes out, then deasserting CS.
|
||||
- `<out_prefix>.debug.txt` — human-readable id/address/byte dump for
|
||||
review before flashing real hardware.
|
||||
|
||||
See `examples/graph_example.netasm` and `examples/dense_example.netasm`.
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
python3 tools/netasm/tests/test_netasm.py -v
|
||||
```
|
||||
|
||||
Includes a byte-exact test against the same worked graph example
|
||||
used throughout the RTL testbenches (`sim/graph_format_tb.v`,
|
||||
`sim/graph_engine_tb.v`, `sim/spi_neuron_top_graph_tb.v`), plus one
|
||||
test per compile-time guard above.
|
||||
@@ -0,0 +1,20 @@
|
||||
from .parser import parse, DenseNet, GraphNet, NetasmSyntaxError
|
||||
from .assembler import (
|
||||
assemble_dense,
|
||||
assemble_graph,
|
||||
dump_dense_debug,
|
||||
dump_graph_debug,
|
||||
NetasmError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"parse",
|
||||
"DenseNet",
|
||||
"GraphNet",
|
||||
"NetasmSyntaxError",
|
||||
"assemble_dense",
|
||||
"assemble_graph",
|
||||
"dump_dense_debug",
|
||||
"dump_graph_debug",
|
||||
"NetasmError",
|
||||
]
|
||||
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
netasm assembler -- turns a parsed DenseNet/GraphNet (see parser.py)
|
||||
into exact byte layouts (descriptor tables, edge blocks) and the SPI
|
||||
command sequence to load them, per the data formats in the project
|
||||
spec (§4). Runs entirely on the host; nothing here executes on the
|
||||
FPGA (§10).
|
||||
|
||||
Compile-time validation performed here (spec §9's whole point: catch
|
||||
these BEFORE the runtime load-time guard in rtl/graph_engine.v ever
|
||||
sees them):
|
||||
- graph: src_id < out_id, src_id/out_id < N_TOTAL, an OUTPUT neuron
|
||||
is never used as another neuron's source, every symbolic/literal
|
||||
CONN reference resolves to a real signal id.
|
||||
- graph: a neuron's padded connection count (see PARALLEL padding
|
||||
below) must fit the hardware's build-time MAX_CONN.
|
||||
- dense: every layer's real input count must be a PARALLEL
|
||||
multiple (the same runtime convention neuron_parallel.v/
|
||||
neuron_memory.v already require of n_inputs_real).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
try:
|
||||
from . import frames as F
|
||||
from .parser import Conn, DenseNet, GraphNet, Neuron
|
||||
except ImportError:
|
||||
# See cli.py's matching fallback: allows this module to load when
|
||||
# imported as a bare top-level module too, not only as part of
|
||||
# the `tools.netasm` package.
|
||||
import frames as F
|
||||
from parser import Conn, DenseNet, GraphNet, Neuron
|
||||
|
||||
|
||||
class NetasmError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _act_code(name: str) -> int:
|
||||
return F.ACT_RELU if name.lower() == "relu" else F.ACT_NONE
|
||||
|
||||
|
||||
def _pad_to_parallel(n_conn: int, parallel: int) -> int:
|
||||
# At least one full PARALLEL group even for n_conn == 0 -- a
|
||||
# zero-group neuron would forward n_inputs_real=0 into
|
||||
# neuron_parallel, which hangs (see rtl/neuron_parallel.v's
|
||||
# GROUPS==0 failure mode, and rtl/graph_engine.v's matching
|
||||
# load-time guard). Padding edges are (src_id=0, weight=0);
|
||||
# src_id=0 is always a valid, already-computed signal for any
|
||||
# neuron with id > 0 (every real network has n_in >= 1 inputs at
|
||||
# id 0), so this never trips the src_id < out_id guard.
|
||||
return max(parallel, math.ceil(max(n_conn, 1) / parallel) * parallel)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# GRAPH (Type #2)
|
||||
# ================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphLayout:
|
||||
n_in: int
|
||||
num_neurons: int
|
||||
n_out: int
|
||||
n_total: int
|
||||
id_of: Dict[str, int]
|
||||
order: List[str] # neuron names in ascending-id order
|
||||
table_base: int
|
||||
x_base: int
|
||||
out_base: int
|
||||
edges_base: Dict[str, int]
|
||||
descriptor_bytes: bytes
|
||||
edge_bytes: Dict[str, bytes]
|
||||
n_conn_real: Dict[str, int]
|
||||
n_conn_padded: Dict[str, int]
|
||||
out_ids: List[str] # neuron names, in out_base byte order
|
||||
frames: List[F.Frame] = field(default_factory=list)
|
||||
|
||||
|
||||
def _resolve_src(
|
||||
src_token: str, id_of: Dict[str, int], n_in: int, line: int
|
||||
) -> int:
|
||||
if src_token in id_of:
|
||||
return id_of[src_token]
|
||||
try:
|
||||
v = int(src_token, 0)
|
||||
except ValueError:
|
||||
raise NetasmError(f"line {line}: unknown source '{src_token}'")
|
||||
return v
|
||||
|
||||
|
||||
def assemble_graph(
|
||||
net: GraphNet,
|
||||
parallel: int,
|
||||
max_conn: int = 32,
|
||||
n_total: int = 4096,
|
||||
table_base: int = 0x000000,
|
||||
edges_base: int = 0x010000,
|
||||
x_base: int = 0x000000,
|
||||
out_base: int = 0x020000,
|
||||
) -> GraphLayout:
|
||||
|
||||
if parallel <= 0 or (parallel & (parallel - 1)) != 0:
|
||||
raise NetasmError(f"PARALLEL must be a positive power of two, got {parallel}")
|
||||
|
||||
n_in = net.n_inputs
|
||||
declared_names = [n.name for n in net.neurons]
|
||||
neuron_by_name = {n.name: n for n in net.neurons}
|
||||
|
||||
for out_name in net.outputs:
|
||||
if out_name not in neuron_by_name:
|
||||
raise NetasmError(f"OUTPUT '{out_name}' refers to an undeclared neuron")
|
||||
if len(set(net.outputs)) != len(net.outputs):
|
||||
raise NetasmError("duplicate name in OUTPUT list")
|
||||
|
||||
# A neuron used as ANY other neuron's source can never be an
|
||||
# output sink (spec §4.4's invariant, enforced here at compile
|
||||
# time rather than left to the runtime guard).
|
||||
referenced_as_source = set()
|
||||
for n in net.neurons:
|
||||
for c in n.conns:
|
||||
if c.src in neuron_by_name:
|
||||
referenced_as_source.add(c.src)
|
||||
for out_name in net.outputs:
|
||||
if out_name in referenced_as_source:
|
||||
raise NetasmError(
|
||||
f"OUTPUT '{out_name}' is used as a source by another neuron -- "
|
||||
"output ids must be pure sinks (spec §4.4)"
|
||||
)
|
||||
|
||||
output_set = set(net.outputs)
|
||||
non_output_order = [n for n in declared_names if n not in output_set]
|
||||
order = non_output_order + list(net.outputs)
|
||||
|
||||
id_of: Dict[str, int] = {}
|
||||
for i, name in enumerate(order):
|
||||
id_of[name] = n_in + i
|
||||
|
||||
num_neurons = len(order)
|
||||
n_total_used = n_in + num_neurons
|
||||
if n_total_used > n_total:
|
||||
raise NetasmError(
|
||||
f"network needs {n_total_used} signal ids (N_in={n_in} + "
|
||||
f"{num_neurons} neurons), exceeds N_TOTAL={n_total}"
|
||||
)
|
||||
|
||||
edges_base_of: Dict[str, int] = {}
|
||||
edge_bytes: Dict[str, bytes] = {}
|
||||
n_conn_real: Dict[str, int] = {}
|
||||
n_conn_padded: Dict[str, int] = {}
|
||||
cursor = edges_base
|
||||
|
||||
for name in order:
|
||||
neuron = neuron_by_name[name]
|
||||
out_id = id_of[name]
|
||||
n_conn = len(neuron.conns)
|
||||
padded = _pad_to_parallel(n_conn, parallel)
|
||||
if padded > max_conn:
|
||||
raise NetasmError(
|
||||
f"neuron '{name}' (line {neuron.line}): {n_conn} connection(s) pad "
|
||||
f"to {padded} at PARALLEL={parallel}, exceeds MAX_CONN={max_conn}"
|
||||
)
|
||||
|
||||
edges_base_of[name] = cursor
|
||||
buf = bytearray()
|
||||
|
||||
for c in neuron.conns:
|
||||
src_id = _resolve_src(c.src, id_of, n_in, c.line)
|
||||
if src_id >= n_total:
|
||||
raise NetasmError(
|
||||
f"line {c.line}: src id {src_id} >= N_TOTAL={n_total}"
|
||||
)
|
||||
if src_id >= out_id:
|
||||
raise NetasmError(
|
||||
f"line {c.line}: neuron '{name}' (id {out_id}) connects from "
|
||||
f"src id {src_id}, which is not a strictly earlier signal "
|
||||
"(src_id must be < out_id, §7)"
|
||||
)
|
||||
buf += src_id.to_bytes(2, "big")
|
||||
buf.append(c.weight & 0xFF)
|
||||
buf.append(0x00) # reserved
|
||||
|
||||
for _ in range(padded - n_conn):
|
||||
buf += (0).to_bytes(2, "big") # src_id = 0 (always valid, weight 0)
|
||||
buf.append(0x00) # weight = 0
|
||||
buf.append(0x00) # reserved
|
||||
|
||||
edge_bytes[name] = bytes(buf)
|
||||
n_conn_real[name] = n_conn
|
||||
n_conn_padded[name] = padded
|
||||
cursor += len(buf)
|
||||
|
||||
descriptor = bytearray()
|
||||
for name in order:
|
||||
neuron = neuron_by_name[name]
|
||||
out_id = id_of[name]
|
||||
n_conn = n_conn_real[name]
|
||||
descriptor += edges_base_of[name].to_bytes(3, "big")
|
||||
descriptor += n_conn.to_bytes(2, "big")
|
||||
descriptor += out_id.to_bytes(2, "big")
|
||||
descriptor.append(_act_code(neuron.activation))
|
||||
descriptor.append(neuron.bias & 0xFF)
|
||||
descriptor += b"\x00\x00" # reserved
|
||||
|
||||
n_out = len(net.outputs)
|
||||
|
||||
layout = GraphLayout(
|
||||
n_in=n_in,
|
||||
num_neurons=num_neurons,
|
||||
n_out=n_out,
|
||||
n_total=n_total_used,
|
||||
id_of=id_of,
|
||||
order=order,
|
||||
table_base=table_base,
|
||||
x_base=x_base,
|
||||
out_base=out_base,
|
||||
edges_base=edges_base_of,
|
||||
descriptor_bytes=bytes(descriptor),
|
||||
edge_bytes=edge_bytes,
|
||||
n_conn_real=n_conn_real,
|
||||
n_conn_padded=n_conn_padded,
|
||||
out_ids=list(net.outputs),
|
||||
)
|
||||
|
||||
fr: List[F.Frame] = []
|
||||
fr.append(F.write_ram(table_base, layout.descriptor_bytes))
|
||||
for name in order:
|
||||
fr.append(F.write_ram(edges_base_of[name], edge_bytes[name]))
|
||||
fr.append(F.set_net_type(F.NET_TYPE_GRAPH))
|
||||
fr.append(F.set_base(F.SEL_X_BASE, x_base))
|
||||
fr.append(F.set_base(F.SEL_TABLE_BASE, table_base))
|
||||
fr.append(F.set_base(F.SEL_BUF_A_BASE, out_base))
|
||||
fr.append(F.set_base(F.SEL_N_INPUTS, n_in))
|
||||
fr.append(F.set_base(F.SEL_NUM_NEURONS_GRAPH, num_neurons))
|
||||
fr.append(F.set_base(F.SEL_N_OUT, n_out))
|
||||
fr.append(F.run_network(0))
|
||||
layout.frames = fr
|
||||
|
||||
return layout
|
||||
|
||||
|
||||
def dump_graph_debug(layout: GraphLayout) -> str:
|
||||
lines = []
|
||||
lines.append("=== netasm graph debug dump ===")
|
||||
lines.append(f"N_in={layout.n_in} num_neurons={layout.num_neurons} "
|
||||
f"n_out={layout.n_out} N_TOTAL_used={layout.n_total}")
|
||||
lines.append(f"table_base=0x{layout.table_base:06x} x_base=0x{layout.x_base:06x} "
|
||||
f"out_base=0x{layout.out_base:06x}")
|
||||
lines.append("")
|
||||
lines.append("id assignment (ascending):")
|
||||
for name in layout.order:
|
||||
marker = " <- OUTPUT" if name in layout.out_ids else ""
|
||||
lines.append(
|
||||
f" id={layout.id_of[name]:4d} {name:16s} n_conn={layout.n_conn_real[name]} "
|
||||
f"padded={layout.n_conn_padded[name]} edges@0x{layout.edges_base[name]:06x}{marker}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"descriptor table ({len(layout.descriptor_bytes)} bytes):")
|
||||
lines.append(" " + layout.descriptor_bytes.hex(" "))
|
||||
lines.append("")
|
||||
for name in layout.order:
|
||||
lines.append(f"edges for {name} ({len(layout.edge_bytes[name])} bytes):")
|
||||
lines.append(" " + layout.edge_bytes[name].hex(" "))
|
||||
lines.append("")
|
||||
lines.append("SPI load sequence:")
|
||||
lines.append(F.frames_as_hex(layout.frames))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# DENSE (Type #1)
|
||||
#
|
||||
# The grammar (spec §9) only declares layer SIZES/activations, not
|
||||
# weight VALUES -- those come from a trained model and are loaded by
|
||||
# the host separately (existing WRITE_RAM flow, unchanged from
|
||||
# before this tool existed). netasm's job for dense is therefore
|
||||
# layout + descriptor table + load/run command generation: it
|
||||
# allocates address ranges for each layer's weight matrix and bias
|
||||
# vector, validates PARALLEL alignment, and reports exactly where
|
||||
# the host must WRITE_RAM the real weight/bias content before
|
||||
# RUN_NETWORK.
|
||||
# ================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class DenseLayerLayout:
|
||||
n_inputs_real: int
|
||||
n_neurons_real: int
|
||||
activation: str
|
||||
w_base: int
|
||||
bias_addr: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DenseLayout:
|
||||
n_inputs: int
|
||||
layers: List[DenseLayerLayout]
|
||||
table_base: int
|
||||
x_base: int
|
||||
buf_a_base: int
|
||||
buf_b_base: int
|
||||
descriptor_bytes: bytes
|
||||
frames: List[F.Frame] = field(default_factory=list)
|
||||
|
||||
|
||||
def assemble_dense(
|
||||
net: DenseNet,
|
||||
parallel: int,
|
||||
table_base: int = 0x000000,
|
||||
weights_base: int = 0x010000,
|
||||
x_base: int = 0x000000,
|
||||
buf_a_base: int = 0x020000,
|
||||
buf_b_base: int = 0x021000,
|
||||
) -> DenseLayout:
|
||||
|
||||
if parallel <= 0 or (parallel & (parallel - 1)) != 0:
|
||||
raise NetasmError(f"PARALLEL must be a positive power of two, got {parallel}")
|
||||
|
||||
layers: List[DenseLayerLayout] = []
|
||||
cursor = weights_base
|
||||
prev_n = net.n_inputs
|
||||
|
||||
for layer in net.layers:
|
||||
if prev_n % parallel != 0:
|
||||
raise NetasmError(
|
||||
f"line {layer.line}: layer's real input count {prev_n} is not a "
|
||||
f"multiple of PARALLEL={parallel} (neuron_parallel.v/"
|
||||
"neuron_memory.v both require this at runtime)"
|
||||
)
|
||||
w_base = cursor
|
||||
cursor += prev_n * layer.n_neurons
|
||||
bias_addr = cursor
|
||||
cursor += layer.n_neurons
|
||||
layers.append(
|
||||
DenseLayerLayout(
|
||||
n_inputs_real=prev_n,
|
||||
n_neurons_real=layer.n_neurons,
|
||||
activation=layer.activation,
|
||||
w_base=w_base,
|
||||
bias_addr=bias_addr,
|
||||
)
|
||||
)
|
||||
prev_n = layer.n_neurons
|
||||
|
||||
descriptor = bytearray()
|
||||
for l in layers:
|
||||
descriptor += l.w_base.to_bytes(3, "big")
|
||||
descriptor += l.bias_addr.to_bytes(3, "big")
|
||||
descriptor.append(_act_code(l.activation))
|
||||
descriptor += l.n_inputs_real.to_bytes(2, "big")
|
||||
descriptor += l.n_neurons_real.to_bytes(2, "big")
|
||||
|
||||
layout = DenseLayout(
|
||||
n_inputs=net.n_inputs,
|
||||
layers=layers,
|
||||
table_base=table_base,
|
||||
x_base=x_base,
|
||||
buf_a_base=buf_a_base,
|
||||
buf_b_base=buf_b_base,
|
||||
descriptor_bytes=bytes(descriptor),
|
||||
)
|
||||
|
||||
fr: List[F.Frame] = []
|
||||
fr.append(F.write_ram(table_base, layout.descriptor_bytes))
|
||||
fr.append(F.set_net_type(F.NET_TYPE_DENSE))
|
||||
fr.append(F.set_base(F.SEL_X_BASE, x_base))
|
||||
fr.append(F.set_base(F.SEL_TABLE_BASE, table_base))
|
||||
fr.append(F.set_base(F.SEL_BUF_A_BASE, buf_a_base))
|
||||
fr.append(F.set_base(F.SEL_BUF_B_BASE, buf_b_base))
|
||||
fr.append(F.run_network(len(net.layers)))
|
||||
layout.frames = fr
|
||||
|
||||
return layout
|
||||
|
||||
|
||||
def dump_dense_debug(layout: DenseLayout) -> str:
|
||||
lines = []
|
||||
lines.append("=== netasm dense debug dump ===")
|
||||
lines.append(f"N_inputs={layout.n_inputs} layers={len(layout.layers)}")
|
||||
lines.append(f"table_base=0x{layout.table_base:06x} x_base=0x{layout.x_base:06x} "
|
||||
f"buf_a_base=0x{layout.buf_a_base:06x} buf_b_base=0x{layout.buf_b_base:06x}")
|
||||
lines.append("")
|
||||
lines.append("NOTE: weight/bias VALUES are not part of this program -- the")
|
||||
lines.append("host must WRITE_RAM the real trained weights/bias at the")
|
||||
lines.append("addresses below before RUN_NETWORK.")
|
||||
lines.append("")
|
||||
for i, l in enumerate(layout.layers):
|
||||
lines.append(
|
||||
f" layer {i}: n_inputs_real={l.n_inputs_real} n_neurons_real={l.n_neurons_real} "
|
||||
f"activation={l.activation}"
|
||||
)
|
||||
lines.append(
|
||||
f" w_base=0x{l.w_base:06x} ({l.n_inputs_real * l.n_neurons_real} bytes) "
|
||||
f"bias_addr=0x{l.bias_addr:06x} ({l.n_neurons_real} bytes)"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"descriptor table ({len(layout.descriptor_bytes)} bytes):")
|
||||
lines.append(" " + layout.descriptor_bytes.hex(" "))
|
||||
lines.append("")
|
||||
lines.append("SPI load sequence:")
|
||||
lines.append(F.frames_as_hex(layout.frames))
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
netasm CLI: compiles a .netasm source file (see parser.py's docstring
|
||||
for the grammar) into an SPI load sequence + human-readable debug
|
||||
dump. Host-side tool only -- see rtl/graph_engine.v / spi_engine.v
|
||||
for the hardware side of this protocol.
|
||||
|
||||
Usage:
|
||||
python3 -m tools.netasm.cli input.netasm -o out_prefix \\
|
||||
[--parallel 8] [--max-conn 32] [--n-total 4096]
|
||||
|
||||
Produces:
|
||||
out_prefix.frames.bin -- length-prefixed SPI transaction bytes
|
||||
out_prefix.debug.txt -- human-readable id/address/byte dump
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
try:
|
||||
from . import frames as F
|
||||
from .assembler import (
|
||||
NetasmError,
|
||||
assemble_dense,
|
||||
assemble_graph,
|
||||
dump_dense_debug,
|
||||
dump_graph_debug,
|
||||
)
|
||||
from .parser import DenseNet, GraphNet, NetasmSyntaxError, parse
|
||||
except ImportError:
|
||||
# Allow running this file directly (`python3 tools/netasm/cli.py`)
|
||||
# without the package being importable as `tools.netasm` -- e.g.
|
||||
# an unrelated `tools` namespace package earlier on PYTHONPATH
|
||||
# shadowing this repo's tools/ directory.
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import frames as F
|
||||
from assembler import (
|
||||
NetasmError,
|
||||
assemble_dense,
|
||||
assemble_graph,
|
||||
dump_dense_debug,
|
||||
dump_graph_debug,
|
||||
)
|
||||
from parser import DenseNet, GraphNet, NetasmSyntaxError, parse
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("source", help="path to a .netasm source file")
|
||||
ap.add_argument("-o", "--output", required=True, help="output file prefix")
|
||||
ap.add_argument("--parallel", type=int, default=8, help="hardware PARALLEL (default 8)")
|
||||
ap.add_argument("--max-conn", type=int, default=32, help="graph_engine's MAX_CONN (default 32)")
|
||||
ap.add_argument("--n-total", type=int, default=4096, help="graph_engine's N_TOTAL (default 4096)")
|
||||
ap.add_argument("--table-base", type=lambda s: int(s, 0), default=0x000000)
|
||||
ap.add_argument("--edges-base", type=lambda s: int(s, 0), default=0x010000)
|
||||
ap.add_argument("--x-base", type=lambda s: int(s, 0), default=0x000000)
|
||||
ap.add_argument("--out-base", type=lambda s: int(s, 0), default=0x020000,
|
||||
help="graph out_base / dense buf_a_base")
|
||||
ap.add_argument("--buf-b-base", type=lambda s: int(s, 0), default=0x021000,
|
||||
help="dense buf_b_base only")
|
||||
ap.add_argument("--weights-base", type=lambda s: int(s, 0), default=0x010000,
|
||||
help="dense weight/bias region base")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
with open(args.source, "r") as f:
|
||||
text = f.read()
|
||||
|
||||
try:
|
||||
net = parse(text)
|
||||
except NetasmSyntaxError as e:
|
||||
print(f"{args.source}: syntax error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
if isinstance(net, GraphNet):
|
||||
layout = assemble_graph(
|
||||
net,
|
||||
parallel=args.parallel,
|
||||
max_conn=args.max_conn,
|
||||
n_total=args.n_total,
|
||||
table_base=args.table_base,
|
||||
edges_base=args.edges_base,
|
||||
x_base=args.x_base,
|
||||
out_base=args.out_base,
|
||||
)
|
||||
debug = dump_graph_debug(layout)
|
||||
frames = layout.frames
|
||||
else:
|
||||
assert isinstance(net, DenseNet)
|
||||
layout = assemble_dense(
|
||||
net,
|
||||
parallel=args.parallel,
|
||||
table_base=args.table_base,
|
||||
weights_base=args.weights_base,
|
||||
x_base=args.x_base,
|
||||
buf_a_base=args.out_base,
|
||||
buf_b_base=args.buf_b_base,
|
||||
)
|
||||
debug = dump_dense_debug(layout)
|
||||
frames = layout.frames
|
||||
except NetasmError as e:
|
||||
print(f"{args.source}: assembly error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
F.dump_frames(frames, args.output + ".frames.bin")
|
||||
with open(args.output + ".debug.txt", "w") as f:
|
||||
f.write(debug + "\n")
|
||||
|
||||
print(f"wrote {args.output}.frames.bin ({sum(len(fr.data) for fr in frames)} payload bytes, "
|
||||
f"{len(frames)} SPI transactions)")
|
||||
print(f"wrote {args.output}.debug.txt")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,7 @@
|
||||
; Simple 3-layer dense classifier.
|
||||
NET dense
|
||||
INPUTS 256
|
||||
LAYER 64 relu
|
||||
LAYER 16 relu
|
||||
LAYER 4 none
|
||||
END
|
||||
@@ -0,0 +1,13 @@
|
||||
; Worked example from the project spec (§3): 4 inputs, two neurons,
|
||||
; n5 is the sole output. See docs for the hand-computed expected
|
||||
; result with x = [10, 1, 4, 0]: n4=49, n5=126.
|
||||
NET graph
|
||||
INPUTS 4 ; id 0..3
|
||||
NEURON n4 relu bias=2
|
||||
CONN 0 w=5
|
||||
CONN 1 w=-3
|
||||
NEURON n5 none bias=0
|
||||
CONN n4 w=2 ; symbolic reference to n4's output
|
||||
CONN 2 w=7
|
||||
OUTPUT n5
|
||||
END
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
SPI frame encoding matching rtl/spi_engine.v's opcode set (§5 of the
|
||||
spec). A "frame" is the exact sequence of bytes shifted over MOSI
|
||||
during one CS-low transaction -- opcode byte first, then whatever
|
||||
fixed-size payload that opcode expects. This module only builds
|
||||
byte sequences; it does not talk to real hardware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
OP_NOP = 0x00
|
||||
OP_WRITE_RAM = 0x01
|
||||
OP_READ_RAM = 0x02
|
||||
OP_RESET = 0x0F
|
||||
OP_SET_BASE = 0x10
|
||||
OP_SET_NET_TYPE = 0x11
|
||||
OP_START = 0x20
|
||||
OP_STATUS = 0x21
|
||||
OP_READ_OUTPUT = 0x22
|
||||
OP_RUN_NETWORK = 0x23
|
||||
OP_READ_CONFIG = 0x30
|
||||
|
||||
SEL_X_BASE = 0x00
|
||||
SEL_W_BASE = 0x01
|
||||
SEL_BIAS_ADDR = 0x02
|
||||
SEL_TABLE_BASE = 0x03
|
||||
SEL_BUF_A_BASE = 0x04
|
||||
SEL_BUF_B_BASE = 0x05
|
||||
SEL_ACTIVATION = 0x06
|
||||
SEL_N_INPUTS = 0x07
|
||||
SEL_N_NEURONS = 0x08
|
||||
SEL_NUM_NEURONS_GRAPH = 0x09
|
||||
SEL_N_OUT = 0x0A
|
||||
|
||||
NET_TYPE_DENSE = 0x01
|
||||
NET_TYPE_GRAPH = 0x02
|
||||
|
||||
ACT_NONE = 0
|
||||
ACT_RELU = 1
|
||||
|
||||
|
||||
def _u24(v: int) -> bytes:
|
||||
if not (0 <= v < (1 << 24)):
|
||||
raise ValueError(f"address 0x{v:x} does not fit in 24 bits")
|
||||
return bytes([(v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF])
|
||||
|
||||
|
||||
def _u16(v: int) -> bytes:
|
||||
if not (0 <= v < (1 << 16)):
|
||||
raise ValueError(f"value 0x{v:x} does not fit in 16 bits")
|
||||
return bytes([(v >> 8) & 0xFF, v & 0xFF])
|
||||
|
||||
|
||||
def _i8(v: int) -> int:
|
||||
if not (-128 <= v <= 127):
|
||||
raise ValueError(f"value {v} does not fit in a signed byte")
|
||||
return v & 0xFF
|
||||
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
label: str
|
||||
data: bytes
|
||||
|
||||
|
||||
def reset() -> Frame:
|
||||
return Frame("RESET", bytes([OP_RESET]))
|
||||
|
||||
|
||||
def set_net_type(net_type: int) -> Frame:
|
||||
return Frame(f"SET_NET_TYPE({net_type:#04x})", bytes([OP_SET_NET_TYPE, net_type & 0xFF]))
|
||||
|
||||
|
||||
def set_base(sel: int, addr: int) -> Frame:
|
||||
return Frame(
|
||||
f"SET_BASE(sel={sel:#04x}, addr={addr:#08x})",
|
||||
bytes([OP_SET_BASE, sel & 0xFF]) + _u24(addr),
|
||||
)
|
||||
|
||||
|
||||
def write_ram(addr: int, data: bytes) -> Frame:
|
||||
return Frame(
|
||||
f"WRITE_RAM(addr={addr:#08x}, len={len(data)})",
|
||||
bytes([OP_WRITE_RAM]) + _u24(addr) + _u16(len(data)) + bytes(data),
|
||||
)
|
||||
|
||||
|
||||
def read_ram(addr: int, length: int) -> Frame:
|
||||
return Frame(
|
||||
f"READ_RAM(addr={addr:#08x}, len={length})",
|
||||
bytes([OP_READ_RAM]) + _u24(addr) + _u16(length),
|
||||
)
|
||||
|
||||
|
||||
def run_network(payload_byte: int = 0) -> Frame:
|
||||
return Frame(f"RUN_NETWORK(payload={payload_byte:#04x})", bytes([OP_RUN_NETWORK, payload_byte & 0xFF]))
|
||||
|
||||
|
||||
def start() -> Frame:
|
||||
return Frame("START", bytes([OP_START]))
|
||||
|
||||
|
||||
def status() -> Frame:
|
||||
return Frame("STATUS", bytes([OP_STATUS, 0x00]))
|
||||
|
||||
|
||||
def read_config() -> Frame:
|
||||
return Frame("READ_CONFIG", bytes([OP_READ_CONFIG] + [0x00] * 10))
|
||||
|
||||
|
||||
def dump_frames(frames: List[Frame], path: str) -> None:
|
||||
"""Length-prefixed binary dump: for each frame, a 2-byte
|
||||
big-endian length followed by that many payload bytes. A simple
|
||||
host driver replays this by, for each record, asserting CS,
|
||||
shifting out the bytes, then deasserting CS."""
|
||||
with open(path, "wb") as f:
|
||||
for fr in frames:
|
||||
n = len(fr.data)
|
||||
f.write(bytes([(n >> 8) & 0xFF, n & 0xFF]))
|
||||
f.write(fr.data)
|
||||
|
||||
|
||||
def frames_as_hex(frames: List[Frame]) -> str:
|
||||
lines = []
|
||||
for fr in frames:
|
||||
hexbytes = " ".join(f"{b:02x}" for b in fr.data)
|
||||
lines.append(f"{fr.label:40s} : {hexbytes}")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
netasm parser -- turns the pseudo-assembly text described in the
|
||||
project spec (§9) into a small AST (DenseNet / GraphNet).
|
||||
|
||||
This is host-side tooling only, has nothing to do with synthesizable
|
||||
RTL, and does not run on the FPGA (see spec §10: "Non mettere un
|
||||
interprete di istruzioni nell'FPGA").
|
||||
|
||||
Grammar (line-oriented, `;` starts a comment that runs to end of line,
|
||||
blank lines ignored):
|
||||
|
||||
NET dense
|
||||
INPUTS <n>
|
||||
LAYER <n_neurons> <relu|none>
|
||||
...
|
||||
END
|
||||
|
||||
NET graph
|
||||
INPUTS <n>
|
||||
NEURON <name> <relu|none> bias=<int>
|
||||
CONN <src> w=<int>
|
||||
...
|
||||
OUTPUT <name>
|
||||
...
|
||||
END
|
||||
|
||||
`<src>` in a CONN line is either a bare decimal integer (a literal
|
||||
signal id -- typically one of the network's inputs, 0..INPUTS-1) or
|
||||
the symbolic name of a previously declared NEURON.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class NetasmSyntaxError(Exception):
|
||||
def __init__(self, message: str, line_no: int):
|
||||
super().__init__(f"line {line_no}: {message}")
|
||||
self.message = message
|
||||
self.line_no = line_no
|
||||
|
||||
|
||||
@dataclass
|
||||
class DenseLayer:
|
||||
n_neurons: int
|
||||
activation: str
|
||||
line: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DenseNet:
|
||||
n_inputs: int
|
||||
layers: List[DenseLayer] = field(default_factory=list)
|
||||
kind: str = "dense"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Conn:
|
||||
src: str # literal id (decimal string) or symbolic neuron name
|
||||
weight: int
|
||||
line: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Neuron:
|
||||
name: str
|
||||
activation: str
|
||||
bias: int
|
||||
conns: List[Conn] = field(default_factory=list)
|
||||
line: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphNet:
|
||||
n_inputs: int
|
||||
neurons: List[Neuron] = field(default_factory=list)
|
||||
outputs: List[str] = field(default_factory=list)
|
||||
kind: str = "graph"
|
||||
|
||||
|
||||
def _strip_comment(line: str) -> str:
|
||||
idx = line.find(";")
|
||||
return line if idx < 0 else line[:idx]
|
||||
|
||||
|
||||
def _parse_kv(token: str, key: str, line_no: int) -> int:
|
||||
prefix = key + "="
|
||||
if not token.startswith(prefix):
|
||||
raise NetasmSyntaxError(f"expected '{key}=<int>', got '{token}'", line_no)
|
||||
try:
|
||||
return int(token[len(prefix):], 0)
|
||||
except ValueError:
|
||||
raise NetasmSyntaxError(f"invalid integer in '{token}'", line_no)
|
||||
|
||||
|
||||
def _check_activation(tok: str, line_no: int) -> str:
|
||||
t = tok.lower()
|
||||
if t not in ("relu", "none"):
|
||||
raise NetasmSyntaxError(f"unknown activation '{tok}' (expected relu|none)", line_no)
|
||||
return t
|
||||
|
||||
|
||||
def parse(text: str) -> "DenseNet | GraphNet":
|
||||
lines = text.splitlines()
|
||||
|
||||
net_kind: Optional[str] = None
|
||||
n_inputs: Optional[int] = None
|
||||
dense_layers: List[DenseLayer] = []
|
||||
graph_neurons: List[Neuron] = []
|
||||
graph_outputs: List[str] = []
|
||||
seen_names = set()
|
||||
cur_neuron: Optional[Neuron] = None
|
||||
ended = False
|
||||
|
||||
for i, raw in enumerate(lines, start=1):
|
||||
line = _strip_comment(raw).strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
tokens = line.split()
|
||||
kw = tokens[0].upper()
|
||||
|
||||
if kw == "NET":
|
||||
if net_kind is not None:
|
||||
raise NetasmSyntaxError("duplicate NET directive", i)
|
||||
if len(tokens) != 2 or tokens[1].lower() not in ("dense", "graph"):
|
||||
raise NetasmSyntaxError("expected 'NET dense' or 'NET graph'", i)
|
||||
net_kind = tokens[1].lower()
|
||||
continue
|
||||
|
||||
if net_kind is None:
|
||||
raise NetasmSyntaxError("expected 'NET dense|graph' as the first directive", i)
|
||||
|
||||
if kw == "INPUTS":
|
||||
if n_inputs is not None:
|
||||
raise NetasmSyntaxError("duplicate INPUTS directive", i)
|
||||
if len(tokens) != 2:
|
||||
raise NetasmSyntaxError("expected 'INPUTS <n>'", i)
|
||||
try:
|
||||
n_inputs = int(tokens[1], 0)
|
||||
except ValueError:
|
||||
raise NetasmSyntaxError(f"invalid input count '{tokens[1]}'", i)
|
||||
if n_inputs <= 0:
|
||||
raise NetasmSyntaxError("INPUTS must be positive", i)
|
||||
continue
|
||||
|
||||
if n_inputs is None:
|
||||
raise NetasmSyntaxError("expected 'INPUTS <n>' before any layer/neuron", i)
|
||||
|
||||
if kw == "END":
|
||||
ended = True
|
||||
continue
|
||||
|
||||
if ended:
|
||||
raise NetasmSyntaxError("no directives allowed after END", i)
|
||||
|
||||
if net_kind == "dense":
|
||||
if kw != "LAYER":
|
||||
raise NetasmSyntaxError(f"unexpected directive '{tokens[0]}' in NET dense", i)
|
||||
if len(tokens) != 3:
|
||||
raise NetasmSyntaxError("expected 'LAYER <n_neurons> <relu|none>'", i)
|
||||
try:
|
||||
n_neurons = int(tokens[1], 0)
|
||||
except ValueError:
|
||||
raise NetasmSyntaxError(f"invalid neuron count '{tokens[1]}'", i)
|
||||
if n_neurons <= 0:
|
||||
raise NetasmSyntaxError("LAYER neuron count must be positive", i)
|
||||
activation = _check_activation(tokens[2], i)
|
||||
dense_layers.append(DenseLayer(n_neurons=n_neurons, activation=activation, line=i))
|
||||
continue
|
||||
|
||||
# net_kind == "graph"
|
||||
if kw == "NEURON":
|
||||
if len(tokens) != 4:
|
||||
raise NetasmSyntaxError(
|
||||
"expected 'NEURON <name> <relu|none> bias=<int>'", i
|
||||
)
|
||||
name = tokens[1]
|
||||
if name in seen_names or name.lstrip("-").isdigit():
|
||||
raise NetasmSyntaxError(f"duplicate or reserved neuron name '{name}'", i)
|
||||
seen_names.add(name)
|
||||
activation = _check_activation(tokens[2], i)
|
||||
bias = _parse_kv(tokens[3], "bias", i)
|
||||
if not (-128 <= bias <= 127):
|
||||
raise NetasmSyntaxError(f"bias {bias} out of INT8 range", i)
|
||||
cur_neuron = Neuron(name=name, activation=activation, bias=bias, line=i)
|
||||
graph_neurons.append(cur_neuron)
|
||||
continue
|
||||
|
||||
if kw == "CONN":
|
||||
if cur_neuron is None:
|
||||
raise NetasmSyntaxError("CONN outside of a NEURON block", i)
|
||||
if len(tokens) != 3:
|
||||
raise NetasmSyntaxError("expected 'CONN <src> w=<int>'", i)
|
||||
src = tokens[1]
|
||||
weight = _parse_kv(tokens[2], "w", i)
|
||||
if not (-128 <= weight <= 127):
|
||||
raise NetasmSyntaxError(f"weight {weight} out of INT8 range", i)
|
||||
cur_neuron.conns.append(Conn(src=src, weight=weight, line=i))
|
||||
continue
|
||||
|
||||
if kw == "OUTPUT":
|
||||
if len(tokens) != 2:
|
||||
raise NetasmSyntaxError("expected 'OUTPUT <name>'", i)
|
||||
graph_outputs.append(tokens[1])
|
||||
cur_neuron = None
|
||||
continue
|
||||
|
||||
raise NetasmSyntaxError(f"unexpected directive '{tokens[0]}' in NET graph", i)
|
||||
|
||||
if net_kind is None:
|
||||
raise NetasmSyntaxError("empty program: missing NET directive", len(lines) + 1)
|
||||
if not ended:
|
||||
raise NetasmSyntaxError("missing END directive", len(lines) + 1)
|
||||
if n_inputs is None:
|
||||
raise NetasmSyntaxError("missing INPUTS directive", len(lines) + 1)
|
||||
|
||||
if net_kind == "dense":
|
||||
if not dense_layers:
|
||||
raise NetasmSyntaxError("NET dense with no LAYER directives", len(lines) + 1)
|
||||
return DenseNet(n_inputs=n_inputs, layers=dense_layers)
|
||||
|
||||
if not graph_neurons:
|
||||
raise NetasmSyntaxError("NET graph with no NEURON directives", len(lines) + 1)
|
||||
if not graph_outputs:
|
||||
raise NetasmSyntaxError("NET graph with no OUTPUT directive", len(lines) + 1)
|
||||
return GraphNet(n_inputs=n_inputs, neurons=graph_neurons, outputs=graph_outputs)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user