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
+382
View File
@@ -0,0 +1,382 @@
// ============================================================
// FPGA-Neural V2 -- Neural Processor (M1, docs/v2-description.md §5/§6/§7)
//
// Autonomous, pipelined perceptron unit. Processes ONE neuron (job) at a
// time, but the internal MAC datapath is a genuine 8-stage pipeline
// (P_IN=8 baseline): a new tile of P_IN inputs/weights can be accepted
// every cycle while the previous tiles are still draining through the
// adder tree/accumulator/activation stages -- throughput-oriented per
// §5 ("l'obiettivo principale e' il throughput, non la minima latenza").
//
// Arithmetic is bit-exact with hardware/v1/rtl/neuron_parallel.v +
// mac8.v + mac_unit.v (same MAC order, same sign-extended INT32-style
// accumulation, same bias/activation/saturation logic) -- verified in
// sim/tb_neural_processor.v against the frozen V1 reference. See
// hardware/v2/logs/decisions.log DEC-0002 for why the outer FSM merges
// the LOAD_TILE/MAC/ACCUM/NEXT_TILE states from §6's baseline list into
// a single pipelined NP_WAIT_OPERANDS state.
//
// Stage pipeline (P_IN=8, TREE_LEVELS=log2(P_IN)=3):
// Stage 0 input alignment/register
// Stage 1 P_IN multipliers (INT8 x INT8)
// Stage 2..(1+TREE_LEVELS) balanced adder tree, one level per stage
// Stage (2+TREE_LEVELS) accumulator (running sum across tiles)
// Stage (3+TREE_LEVELS) bias add + activation
// Stage (4+TREE_LEVELS) INT8 saturation / output register
// For P_IN=8 this is stages 0..7 (8 stages total), matching §5 exactly.
// ============================================================
module neural_processor #(
parameter DATA_WIDTH = 8,
parameter P_IN = 8,
parameter ACC_WIDTH = 32
)(
input clk,
input rst,
// ---- job descriptor (NP_LOAD_JOB) ----
// Address fields (input/weight/bias base addresses) from §6's
// baseline job descriptor are intentionally NOT part of this
// interface yet -- M1 has no Memory Manager (§v2-description.md
// roadmap M4), operands are streamed directly by the testbench.
// They will be added when the Memory Manager (M4) is integrated.
input job_valid,
output job_ready,
input [15:0] job_node_id,
input signed [DATA_WIDTH-1:0] job_bias,
input [1:0] job_activation,
// ---- operand stream (valid/ready/data/last, §7) ----
input operand_valid,
output operand_ready,
input signed [DATA_WIDTH*P_IN-1:0] input_data,
input signed [DATA_WIDTH*P_IN-1:0] weight_data,
input tile_last,
// ---- result stream (valid/ready/data, §7) ----
output reg result_valid,
input result_ready,
output reg signed [DATA_WIDTH-1:0] result_data,
output reg [15:0] result_node_id,
// observability (testbench/debug, not part of the handshake contract)
output reg [3:0] np_state,
output reg np_error
);
// ============================================================
// ACTIVATION ENCODING -- identical to hardware/v1/rtl/neuron_parallel.v
// ============================================================
localparam ACT_NONE = 2'd0;
localparam ACT_RELU = 2'd1;
// ============================================================
// OUTER FSM STATE ENCODING (§6 baseline, see DEC-0002 for the
// 4-state merge)
// ============================================================
localparam NP_IDLE = 4'd0;
localparam NP_LOAD_JOB = 4'd1;
localparam NP_WAIT_OPERANDS = 4'd2; // absorbs LOAD_TILE/MAC/ACCUM/NEXT_TILE
localparam NP_FINISH = 4'd3; // draining pipeline after tile_last
localparam NP_WRITE_RESULT = 4'd4;
localparam NP_DONE = 4'd5;
localparam NP_ERROR = 4'd6;
localparam TREE_LEVELS = $clog2(P_IN);
localparam PROD_WIDTH = 2 * DATA_WIDTH;
// ---- job context (latched at NP_LOAD_JOB, held for the whole job) ----
reg signed [DATA_WIDTH-1:0] bias_reg;
reg [1:0] activation_reg;
reg [15:0] node_id_reg;
assign operand_ready = (np_state == NP_WAIT_OPERANDS);
// ============================================================
// STAGE 0 -- input alignment/register
// ============================================================
reg valid0, last0;
reg signed [DATA_WIDTH-1:0] x0 [0:P_IN-1];
reg signed [DATA_WIDTH-1:0] w0 [0:P_IN-1];
integer gi;
always @(posedge clk) begin
if (rst) begin
valid0 <= 1'b0;
last0 <= 1'b0;
end else begin
valid0 <= operand_valid && operand_ready;
// last0 must be gated exactly like valid0 -- otherwise a
// tile_last asserted by the master before operand_ready
// rises (ordinary valid-before-ready behavior, see
// operand_ready's own comment above) leaks a "last" tag
// into the pipeline with no corresponding valid tile,
// racing ahead of the real one and corrupting the
// valid5/last5-derived capture at stage 6/7 (see
// hardware/v2/logs/errors.log ERR-0003).
last0 <= (operand_valid && operand_ready) ? tile_last : 1'b0;
if (operand_valid && operand_ready) begin
for (gi = 0; gi < P_IN; gi = gi + 1) begin
x0[gi] <= input_data[gi*DATA_WIDTH +: DATA_WIDTH];
w0[gi] <= weight_data[gi*DATA_WIDTH +: DATA_WIDTH];
end
end
end
end
// ============================================================
// STAGE 1 -- P_IN multipliers, sign-extended to ACC_WIDTH
// (same per-lane product as hardware/v1/rtl/mac_unit.v)
// ============================================================
reg valid1, last1;
reg signed [ACC_WIDTH-1:0] prod1 [0:P_IN-1];
wire signed [PROD_WIDTH-1:0] product_comb [0:P_IN-1];
genvar gm;
generate
for (gm = 0; gm < P_IN; gm = gm + 1) begin : GEN_MUL
assign product_comb[gm] = x0[gm] * w0[gm];
end
endgenerate
always @(posedge clk) begin
if (rst) begin
valid1 <= 1'b0;
last1 <= 1'b0;
end else begin
valid1 <= valid0;
last1 <= last0;
for (gi = 0; gi < P_IN; gi = gi + 1) begin
prod1[gi] <= {{(ACC_WIDTH-PROD_WIDTH){product_comb[gi][PROD_WIDTH-1]}}, product_comb[gi]};
end
end
end
// ============================================================
// STAGES 2..(1+TREE_LEVELS) -- balanced adder tree, one level/stage
// (same tree order as hardware/v1/rtl/mac8.v)
// ============================================================
// Level 0 (combinational view of the registered products) is kept
// as plain wires, never written by an always block -- only levels
// 1..TREE_LEVELS are real registers, each with exactly one driver
// (its own GEN_TREE_NODE always block), avoiding any reg array
// with mixed combinational/sequential drivers across generate
// blocks.
wire signed [ACC_WIDTH-1:0] level0 [0:P_IN-1];
genvar gz;
generate
for (gz = 0; gz < P_IN; gz = gz + 1) begin : GEN_TREE_L0
assign level0[gz] = prod1[gz];
end
endgenerate
reg [TREE_LEVELS-1:0] valid_tree;
reg [TREE_LEVELS-1:0] last_tree;
reg signed [ACC_WIDTH-1:0] tree [1:TREE_LEVELS][0:P_IN-1];
genvar gl, gn;
generate
for (gl = 0; gl < TREE_LEVELS; gl = gl + 1) begin : GEN_TREE_LEVEL
always @(posedge clk) begin
if (rst) begin
valid_tree[gl] <= 1'b0;
last_tree[gl] <= 1'b0;
end else begin
valid_tree[gl] <= (gl == 0) ? valid1 : valid_tree[gl-1];
last_tree[gl] <= (gl == 0) ? last1 : last_tree[gl-1];
end
end
for (gn = 0; gn < (P_IN >> (gl+1)); gn = gn + 1) begin : GEN_TREE_NODE
if (gl == 0) begin : GEN_FROM_LEVEL0
always @(posedge clk) begin
tree[1][gn] <= level0[2*gn] + level0[2*gn+1];
end
end else begin : GEN_FROM_TREE
always @(posedge clk) begin
tree[gl+1][gn] <= tree[gl][2*gn] + tree[gl][2*gn+1];
end
end
end
end
endgenerate
wire valid_tree_out = (TREE_LEVELS == 0) ? valid1 : valid_tree[TREE_LEVELS-1];
wire last_tree_out = (TREE_LEVELS == 0) ? last1 : last_tree[TREE_LEVELS-1];
wire signed [ACC_WIDTH-1:0] tile_sum = (TREE_LEVELS == 0) ? prod1[0] : tree[TREE_LEVELS][0];
// ============================================================
// STAGE (2+TREE_LEVELS) -- accumulator (running sum across tiles
// of the SAME job; cleared at NP_LOAD_JOB)
// ============================================================
reg signed [ACC_WIDTH-1:0] acc_reg;
reg valid5, last5;
always @(posedge clk) begin
if (rst) begin
acc_reg <= {ACC_WIDTH{1'b0}};
valid5 <= 1'b0;
last5 <= 1'b0;
end else begin
valid5 <= valid_tree_out;
last5 <= last_tree_out;
if (np_state == NP_LOAD_JOB) begin
acc_reg <= {ACC_WIDTH{1'b0}};
end else if (valid_tree_out) begin
acc_reg <= acc_reg + tile_sum;
end
end
end
// ============================================================
// STAGE (3+TREE_LEVELS) -- bias add + activation
// (same encoding/logic as hardware/v1/rtl/neuron_parallel.v)
// ============================================================
wire signed [ACC_WIDTH-1:0] bias_ext =
{{(ACC_WIDTH-DATA_WIDTH){bias_reg[DATA_WIDTH-1]}}, bias_reg};
// acc_reg already reflects THIS cycle's update (non-blocking), so
// when last5 is set the just-updated acc_reg (available next
// cycle) is the complete sum -- final_acc is therefore computed
// one cycle after last5/valid5 using the settled acc_reg value.
reg valid6, last6;
reg signed [ACC_WIDTH-1:0] final_acc_reg;
always @(posedge clk) begin
if (rst) begin
valid6 <= 1'b0;
last6 <= 1'b0;
end else begin
valid6 <= valid5;
last6 <= last5;
final_acc_reg <= acc_reg + bias_ext;
end
end
wire final_acc_sign = final_acc_reg[ACC_WIDTH-1];
wire final_acc_upper_all0 = ~(|final_acc_reg[ACC_WIDTH-1:DATA_WIDTH-1]);
wire final_acc_upper_all1 = &final_acc_reg[ACC_WIDTH-1:DATA_WIDTH-1];
wire final_acc_in_range = final_acc_upper_all0 | final_acc_upper_all1;
wire final_acc_le_zero = final_acc_sign | ~(|final_acc_reg);
wire signed [DATA_WIDTH-1:0] y_none =
final_acc_in_range ? final_acc_reg[DATA_WIDTH-1:0]
: (final_acc_sign ? {1'b1, {(DATA_WIDTH-1){1'b0}}}
: {1'b0, {(DATA_WIDTH-1){1'b1}}});
wire signed [DATA_WIDTH-1:0] y_relu =
final_acc_le_zero ? {DATA_WIDTH{1'b0}}
: (final_acc_upper_all0 ? final_acc_reg[DATA_WIDTH-1:0]
: {1'b0, {(DATA_WIDTH-1){1'b1}}});
// ============================================================
// STAGE (4+TREE_LEVELS) -- output register / saturation result
// ============================================================
reg valid7;
reg signed [DATA_WIDTH-1:0] y7;
always @(posedge clk) begin
if (rst) begin
valid7 <= 1'b0;
end else begin
valid7 <= last6;
case (activation_reg)
ACT_NONE: y7 <= y_none;
default: y7 <= y_relu;
endcase
end
end
// A new job must not be accepted (and acc_reg must not be cleared)
// while any tile from a PREVIOUS job is still draining through the
// datapath -- otherwise a back-to-back job launched immediately
// after NP_DONE could have its own first tile(s) race a stale
// in-flight tile still working through stages 0..7, or have
// NP_LOAD_JOB clear acc_reg while it still holds a not-yet-read
// partial sum. job_ready is gated on this so NP_IDLE simply holds
// one extra cycle when needed -- transparent to the job handshake,
// no protocol change.
wire pipeline_busy = valid0 || valid1 || (|valid_tree) || valid5 || valid6 || valid7;
assign job_ready = (np_state == NP_IDLE) && !pipeline_busy;
// ============================================================
// OUTER FSM (§6) -- job/result handshake around the pipeline above
// ============================================================
always @(posedge clk) begin
if (rst) begin
np_state <= NP_IDLE;
np_error <= 1'b0;
result_valid <= 1'b0;
result_data <= {DATA_WIDTH{1'b0}};
result_node_id <= 16'h0;
bias_reg <= {DATA_WIDTH{1'b0}};
activation_reg <= ACT_RELU;
node_id_reg <= 16'h0;
end else begin
case (np_state)
NP_IDLE: begin
if (job_valid && job_ready) begin
bias_reg <= job_bias;
activation_reg <= job_activation;
node_id_reg <= job_node_id;
np_state <= NP_LOAD_JOB;
end
end
NP_LOAD_JOB: begin
np_state <= NP_WAIT_OPERANDS;
end
NP_WAIT_OPERANDS: begin
if (operand_valid && operand_ready && tile_last) begin
np_state <= NP_FINISH;
end
end
NP_FINISH: begin
// Draining: wait for the tagged-last tile's result
// to reach the output stage (valid7).
if (valid7) begin
result_valid <= 1'b1;
result_data <= y7;
result_node_id <= node_id_reg;
np_state <= NP_WRITE_RESULT;
end
end
NP_WRITE_RESULT: begin
if (result_valid && result_ready) begin
result_valid <= 1'b0;
np_state <= NP_DONE;
end
end
NP_DONE: begin
np_state <= NP_IDLE;
end
NP_ERROR: begin
// Recoverable only via rst -- documented in
// hardware/v2/logs/architecture.log: an error must
// not block the rest of the Neural Processor
// Array (§34), only this one processor.
end
// Reachable only if np_state ever holds a value outside
// 0..6 (a genuine encoding corruption -- not exercised
// in normal operation). Upstream protocol misuse
// (an operand arriving when this processor cannot
// consume it) is NOT policed here -- see
// hardware/v2/logs/decisions.log DEC-0003: that
// responsibility belongs to the Neural Director (M5),
// which is the actual issuer of operand traffic and
// the only component that can otherwise arbitrate
// among multiple Neural Processors.
default: np_state <= NP_ERROR;
endcase
end
end
endmodule