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

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

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

247 lines
9.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
//
// PIPELINE STAGE (timing closure, step 2): this now adds bias to
// the ALREADY-REGISTERED `acc` (the complete sum, latched at the
// end of the last MAC group -- see the `finishing` stage below),
// not to `acc_next` combinationally chained onto the same cycle
// as the last group's own MAC-tree add. Measured on real
// place&route (see WORKLOG.md "Timing closure"): the previous
// same-cycle chain [mac8 tree add] -> [bias add] -> [saturate]
// was the critical path; splitting the bias-add+saturate half
// into its own cycle (operating on a value already settled in a
// register) breaks that chain by construction, independent of
// synthesis/placement heuristics -- unlike the bit-test rewrite
// below, which measurably shortens the logic but was swamped by
// seed-to-seed placement noise on its own. Adds exactly one
// cycle of latency per neuron (`start` to `done`), transparent
// to every caller's busy/done handshake (neuron_memory.v,
// layer_sequencer.v, graph_engine.v) -- no protocol change, no
// caller-visible interface change, only one extra `finishing`
// clock edge already absorbed by that handshake.
assign final_acc = acc + bias_ext;
// ============================================================
// SATURATION / ACTIVATION -- bit-test form (timing closure)
//
// final_acc (ACC_WIDTH bits) fits the signed DATA_WIDTH range
// iff bits [ACC_WIDTH-1:DATA_WIDTH-1] are all equal (all 0 ->
// non-negative and in range, all 1 -> negative and in range) --
// exactly what sign-extending the truncated DATA_WIDTH-bit value
// back up to ACC_WIDTH bits would reproduce. Bit-exact
// equivalent of the previous arithmetic comparisons in every
// case (verified in sim/neuron_parallel_saturation_bounds_tb.v);
// no ACT_NONE/ACT_RELU behavior change. NOTE (measured, see
// WORKLOG.md): on this device/toolchain, Yosys's abc9 mapper
// maps this wide AND/OR reduction onto CCU2C carry-chain cells
// too, not a shallow LUT tree -- so on its own this rewrite only
// trims the chain slightly (fewer CCU2C hops, ~0.2-0.9ns less
// logic delay measured) rather than eliminating it; kept anyway
// as a genuine, bit-exact-verified simplification, with the
// pipeline stage above doing the real timing-closure work.
// ============================================================
wire final_acc_sign = final_acc[ACC_WIDTH-1];
wire final_acc_upper_all0 = ~(|final_acc[ACC_WIDTH-1:DATA_WIDTH-1]);
wire final_acc_upper_all1 = &final_acc[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);
// ACT_NONE: saturate to [-2^(DATA_WIDTH-1), 2^(DATA_WIDTH-1)-1]
wire signed [DATA_WIDTH-1:0] y_none =
final_acc_in_range ? final_acc[DATA_WIDTH-1:0]
: (final_acc_sign ? {1'b1, {(DATA_WIDTH-1){1'b0}}}
: {1'b0, {(DATA_WIDTH-1){1'b1}}});
// ACT_RELU: max(0, final_acc), then saturate positive
wire signed [DATA_WIDTH-1:0] y_relu =
final_acc_le_zero ? {DATA_WIDTH{1'b0}}
: (final_acc_upper_all0 ? final_acc[DATA_WIDTH-1:0]
: {1'b0, {(DATA_WIDTH-1){1'b1}}});
// `finishing`: one-cycle pipeline stage between the last MAC
// group's accumulate and the bias-add+saturate/activation step
// (see the `final_acc` comment above). `busy` stays high through
// it, so it is invisible to every existing start/busy/done
// caller -- just one extra clock of latency.
reg finishing;
always @(posedge clk) begin
if (rst) begin
group_index <= 0;
acc <= 0;
y <= 0;
busy <= 0;
done <= 0;
finishing <= 0;
end else begin
done <= 0;
if (start && !busy) begin
group_index <= 0;
acc <= 0;
busy <= 1;
finishing <= 0;
end else if (finishing) begin
case (activation)
ACT_NONE: y <= y_none; // linear: saturate both directions
default: y <= y_relu; // ACT_RELU (also the fallback for any reserved encoding)
endcase
busy <= 0;
done <= 1;
finishing <= 0;
end else if (busy) begin
if (group_index == groups_real[GROUP_INDEX_WIDTH-1:0] - 1'b1) begin
acc <= acc_next; // complete sum, bias not yet added
finishing <= 1;
end else begin
acc <= acc_next;
group_index <= group_index + 1'b1;
end
end
end
end
endmodule