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
+105
View File
@@ -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.
+20
View File
@@ -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",
]
+405
View File
@@ -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)
+120
View File
@@ -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
+131
View File
@@ -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)
+229
View File
@@ -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()