Files
FPGA-Neural/rtl/neuron_parallel.v
T
micheleandClaude Sonnet 5 a918c3f1e9 feat: configurable activation functions + runtime-configurable network topology
Two related Phase 5 additions, both threaded the same way (a new
runtime field defaulting to the pre-existing behavior, settable
per-layer via the descriptor table or per-run via SET_BASE):

Configurable activation functions:
- neuron_parallel.v gains a 2-bit `activation` port (ACT_NONE =
  linear + two-sided INT8 saturate, ACT_RELU = the original
  hardwired behavior, kept as the default so every pre-existing
  caller/testbench is unaffected), threaded through neuron_memory.v.
- spi_engine.v: SET_BASE sel=6 (single-layer path); the descriptor
  table gains a 7th byte (multi-layer path).
- Verified in neuron_parallel_tb.v (negative pass-through + negative
  saturation to -128) and end-to-end in
  spi_neuron_top_runnetwork_tb.v (a real negative accumulator that
  ACT_RELU would clamp to 0 comes through unclamped under ACT_NONE,
  over real SPI/RAM).

Runtime network width (one bitstream, any topology up to its
build-time max, entirely host-configured over SPI):
- neuron_parallel.v gains n_inputs_real, bounding its MAC group loop
  (n_inputs_real/PARALLEL groups instead of the fixed build-time
  count). neuron_memory.v gains n_inputs_real/n_neurons_real,
  bounding its X/W RAM-read loop and its neuron loop. All default to
  the build-time max, so unconnected callers are unaffected.
  n_inputs_real must stay a multiple of PARALLEL (same constraint
  N_INPUTS itself is held to at elaboration time, now the caller's
  runtime responsibility).
- spi_engine.v: SET_BASE sel=7/8 (single-layer path); the descriptor
  table grows to 11 bytes/layer (+n_inputs_real +n_neurons_real,
  multi-layer path) -- layer_sequencer.v also now copies only
  n_neurons_real bytes into the ping-pong buffer, not the full
  build width.
- This is real early termination, not bookkeeping: no RAM
  zero-padding needed for the unused tail, and it measurably
  completes faster. neuron_parallel_tb.v TEST 7: 3 cycles vs 6 for a
  reduced-vs-full run, with garbage loaded into the skipped lanes to
  prove they're never read. neuron_memory_tb.v TEST 5: through the
  real PSRAM stack, 209 cycles vs 788. layer_sequencer_tb.v proves a
  reduced n_neurons_real shortens the ping-pong copy-out itself
  (bytes beyond the real count stay untouched, not just differing).

docs/FPGA-NeuralNetwork-Engine.md: §8.1 opcode/SET_BASE table, new
"Runtime network width" subsection, Phase 5 checklist, Current
Status table, and the "Core architectural principle" statement
updated to reflect that topology (not just trained parameters) is
now host-configured at runtime up to a build-time ceiling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQV3vS9TXaGDJ5cRfnfidt
2026-09-02 20:18:24 +02:00

201 lines
6.5 KiB
Verilog

module neuron_parallel #(
parameter DATA_WIDTH = 8,
parameter N_INPUTS = 32,
parameter PARALLEL = 8,
parameter ACC_WIDTH = 32
)(
input clk,
input rst,
input start,
input signed [DATA_WIDTH*N_INPUTS-1:0] x_bus,
input signed [DATA_WIDTH*N_INPUTS-1:0] w_bus,
input signed [DATA_WIDTH-1:0] bias,
// Activation function applied to the final accumulator before
// the INT8 saturate, see ACT_* localparams below. Defaults to
// ACT_RELU (2'd1) -- the ONLY behavior this module had before
// this port existed -- so every pre-existing caller that leaves
// it unconnected (rtl/layer.v and its testbenches) is completely
// unaffected.
input [1:0] activation = 2'd1,
// Real (runtime) input width for THIS run, in elements -- must
// be a multiple of PARALLEL (same constraint N_INPUTS itself is
// held to at elaboration time, just now the caller's runtime
// responsibility instead of a build-time guard: an n_inputs_real
// that isn't a PARALLEL multiple, or is 0, reproduces the same
// "wrong result" / "hangs forever" failure modes documented
// below for a bad N_INPUTS/PARALLEL pair). Defaults to N_INPUTS
// (the full build-time width), so any caller that leaves this
// unconnected processes every group exactly as before this port
// existed.
input [15:0] n_inputs_real = N_INPUTS[15:0],
output reg signed [DATA_WIDTH-1:0] y,
output reg busy,
output reg done
);
// ============================================================
// ACTIVATION ENCODING
// ============================================================
localparam ACT_NONE = 2'd0; // linear: saturate both directions, no clamp
localparam ACT_RELU = 2'd1; // max(0, x), then saturate positive (default)
// ============================================================
// PARAMETER GUARD
//
// PARALLEL must evenly divide N_INPUTS. If it does not:
//
// - GROUPS = N_INPUTS / PARALLEL truncates (integer division),
// and the remainder inputs are silently never read by the
// accumulator: WRONG result, no error, no warning.
//
// - If PARALLEL > N_INPUTS, GROUPS = 0 and the controller's
// terminal condition (group_index == GROUPS-1) is never
// satisfied: the neuron hangs forever (busy stays high,
// done is never asserted).
//
// Both failure modes were confirmed empirically in
// sim/parameter_sweep_tb.v (Phase 2 of the roadmap). Rather than
// changing the validated datapath, this forces an elaboration-
// time failure in BOTH simulation and synthesis by instantiating
// a deliberately undefined module when the condition is
// violated. When N_INPUTS % PARALLEL == 0 this generate branch
// is never elaborated, so valid configurations are unaffected.
// ============================================================
generate
if (N_INPUTS % PARALLEL != 0) begin : PARAMETER_ERROR_N_INPUTS_NOT_MULTIPLE_OF_PARALLEL
neuron_parallel_requires_N_INPUTS_multiple_of_PARALLEL invalid_parameter_combination();
end
endgenerate
localparam GROUPS = N_INPUTS / PARALLEL;
localparam GROUP_INDEX_WIDTH =
(GROUPS <= 1) ? 1 : $clog2(GROUPS);
reg [GROUP_INDEX_WIDTH-1:0] group_index;
// Runtime group count for this run: n_inputs_real / PARALLEL.
// PARALLEL is a build-time constant, so this divide is a fixed
// combinational block sized once at synthesis (a shift when
// PARALLEL is a power of two, as in every config this project
// uses today), evaluated only at the start of a run -- not on
// the per-group critical path.
wire [15:0] groups_real = n_inputs_real / PARALLEL[15:0];
reg signed [ACC_WIDTH-1:0] acc;
wire signed [DATA_WIDTH*PARALLEL-1:0] x_group;
wire signed [DATA_WIDTH*PARALLEL-1:0] w_group;
wire signed [ACC_WIDTH-1:0] acc_next;
wire signed [ACC_WIDTH-1:0] bias_ext;
wire signed [ACC_WIDTH-1:0] final_acc;
assign x_group =
x_bus[
group_index*PARALLEL*DATA_WIDTH
+: PARALLEL*DATA_WIDTH
];
assign w_group =
w_bus[
group_index*PARALLEL*DATA_WIDTH
+: PARALLEL*DATA_WIDTH
];
mac8 #(
.DATA_WIDTH(DATA_WIDTH),
.ACC_WIDTH(ACC_WIDTH),
.PARALLEL(PARALLEL)
) u_mac8 (
.x_bus(x_group),
.w_bus(w_group),
.acc_in(acc),
.acc_out(acc_next)
);
// Sign extension INT8 -> INT32
assign bias_ext =
{{(ACC_WIDTH-DATA_WIDTH){bias[DATA_WIDTH-1]}}, bias};
// Accumulazione finale + bias
assign final_acc = acc_next + bias_ext;
always @(posedge clk) begin
if (rst) begin
group_index <= 0;
acc <= 0;
y <= 0;
busy <= 0;
done <= 0;
end else begin
done <= 0;
if (start && !busy) begin
group_index <= 0;
acc <= 0;
busy <= 1;
end else if (busy) begin
if (group_index == groups_real[GROUP_INDEX_WIDTH-1:0] - 1'b1) begin
acc <= final_acc;
case (activation)
ACT_NONE: begin
// Linear: no zero-clamp, saturate both
// directions to the INT8 range.
if (final_acc > 127) begin
y <= 8'sd127;
end else if (final_acc < -128) begin
y <= -8'sd128;
end else begin
y <= final_acc[DATA_WIDTH-1:0];
end
end
default: begin // ACT_RELU (also the fallback
// for any reserved encoding)
if (final_acc <= 0) begin
y <= 0;
end else if (final_acc > 127) begin
y <= 8'sd127;
end else begin
y <= final_acc[DATA_WIDTH-1:0];
end
end
endcase
busy <= 0;
done <= 1;
end else begin
acc <= acc_next;
group_index <= group_index + 1'b1;
end
end
end
end
endmodule