Files
FPGA-Neural/rtl/graph_engine.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

587 lines
22 KiB
Verilog

`timescale 1ns/1ps
// ================================================================
// GRAPH_ENGINE (Phase G3 -- Type #2 sparse-graph network)
//
// Orchestrates an arbitrary feed-forward DAG of neurons on top of
// the validated neuron_parallel core, exactly the way neuron_memory
// / layer_sequencer orchestrate the dense Type #1 path -- the
// compute datapath itself (neuron_parallel/mac8/mac_unit) is
// instantiated unmodified, never edited. See docs spec §2-§7 for
// the full design rationale; this header only covers what a reader
// of THIS file needs.
//
// REGISTER REUSE (no new SET_BASE selectors beyond sel 9/10):
// dense (#1) and graph (#2) modes are mutually exclusive at runtime
// (net_type dispatch, see spi_engine.v), so this module reuses three
// existing SET_BASE-driven registers for a different purpose while
// in graph mode -- documented here since it is not spelled out
// verbatim in the data-format spec:
// x_base -> base address of the N_in input bytes in PSRAM
// (same register/opcode host already uses to
// preload inputs for dense mode)
// table_base -> base of the graph descriptor table (§4.2),
// instead of the dense layer descriptor table
// buf_a_base -> out_base: where the n_out output bytes are
// copied to in PSRAM at the end of the run
// (dense mode's ping-pong buffer A is unused
// while graph mode runs, so no conflict)
// n_inputs_real -> N_in: number of graph input ids (0..N_in-1),
// instead of a dense layer's real input count
//
// OUTPUT COPY, FOLDED INTO THE MAIN LOOP: §6 describes a WRITE_OUTPUTS
// state as a separate pass after all neurons are computed. Since the
// spec's own out_id ordering guarantee (§4.4: "out_ids ... finiscono
// naturalmente con gli id piu alti") makes the output set EXACTLY
// the last n_out entries of the descriptor table, this engine copies
// a neuron's result to out_base the moment it computes it, if that
// neuron is one of the last n_out (WRITE_ACT / WRITE_OUT below) --
// no second pass over act_buf or extra id bookkeeping needed.
//
// GUARD (§7): a load-time check that src_id < out_id and
// src_id < N_TOTAL is done per-edge, right as each edge's src_id is
// read. On violation, `err` is raised (sticky until `rst`) and the
// run stops immediately -- no further memory traffic, no result
// written -- instead of silently computing a wrong answer. This
// module ALSO checks out_id < N_TOTAL and n_conn_padded (post-
// PARALLEL-padding) <= MAX_CONN for the same reason (out-of-range
// addressing / MAX_CONN overflow are just as unsafe as the two
// checks the spec calls out by name, and "stop instead of silently
// misbehaving" is the whole point of this section) -- an addition
// beyond the literal spec text, noted here for anyone diffing
// against it.
//
// PARALLEL-multiple padding (§2.6) is entirely a HOST/assembler
// responsibility: the edge blocks in PSRAM already physically
// contain the zero-weight padding edges tools/netasm inserts, so
// this engine just reads n_conn_padded = ceil(n_conn/PARALLEL)*
// PARALLEL physical edges per neuron -- no special-casing here, and
// neuron_parallel's own n_inputs_real convention (must already be a
// PARALLEL multiple) is satisfied by construction. A neuron with 0
// real connections still needs padding to a full PARALLEL group from
// the host: n_conn_padded==0 is treated as a load-time error (see
// guard above) rather than being forwarded to neuron_parallel, which
// would hang on it (documented hang mode of neuron_parallel/GROUPS=0,
// see rtl/neuron_parallel.v).
//
// ACTIVATION BUFFER GATHER TIMING: act_buffer's read port is
// registered (1-cycle latency, see rtl/act_buffer.v). This engine
// drives act_buf's read address directly (combinationally) from the
// edge's src_id as soon as it is known (after the edge's 2nd byte),
// and only consumes rd_data one FULL state later (ST_GATHER), by
// which point at least two more PSRAM byte round-trips (weight +
// reserved bytes of the same edge) have elapsed -- comfortably more
// than the 1 cycle act_buffer needs, on any realistic memory. See
// the ST_EDGE_WAIT / ST_GATHER case comments below.
// ================================================================
module graph_engine #(
parameter ADDR_WIDTH = 23,
parameter DATA_WIDTH = 8,
parameter ACC_WIDTH = 32,
parameter PARALLEL = 8,
parameter MAX_CONN = 32, // build-time max real+padding connections per neuron (must be a PARALLEL multiple -- enforced by neuron_parallel's own elaboration-time guard)
parameter N_TOTAL = 4096 // activation buffer depth / signal id space ceiling (§2)
)(
input wire clk,
input wire rst,
// ------------------------------------------------------------
// Trigger / status (from spi_engine's RUN_NETWORK dispatch when
// net_type == graph)
// ------------------------------------------------------------
input wire run_start, // one-cycle pulse
output reg busy,
output reg done, // one-cycle pulse
output reg err, // sticky guard-violation flag (§7), cleared on rst or the next run_start
// ------------------------------------------------------------
// Config registers (see REGISTER REUSE note above for x_base /
// table_base / buf_a_base / n_inputs_real)
// ------------------------------------------------------------
input wire [ADDR_WIDTH-1:0] x_base,
input wire [ADDR_WIDTH-1:0] table_base,
input wire [ADDR_WIDTH-1:0] out_base,
input wire [15:0] n_inputs_graph, // N_in
input wire [15:0] num_neurons_graph, // SET_BASE sel 9
input wire [15:0] n_out, // SET_BASE sel 10
// ------------------------------------------------------------
// Byte-level RAM master port (own arbiter port; shared with
// layer_sequencer's at the top level -- the two run modes are
// mutually exclusive, see rtl/spi_neuron_top.v)
// ------------------------------------------------------------
output reg ram_req,
output reg ram_wr,
output reg [ADDR_WIDTH-1:0] ram_addr,
output reg signed [7:0] ram_wdata,
input wire signed [7:0] ram_rdata,
input wire ram_ready
);
// ============================================================
// DERIVED WIDTHS
// ============================================================
localparam BUF_ADDR_WIDTH = $clog2(N_TOTAL);
localparam CONN_IDX_WIDTH = (MAX_CONN <= 1) ? 1 : $clog2(MAX_CONN + 1);
localparam PAR_SHIFT = $clog2(PARALLEL);
// ============================================================
// STATES / FSM REGISTERS
//
// Declared before the submodule instantiations below because
// act_buffer's read address is wired directly (combinationally)
// to src_id_acc.
// ============================================================
localparam ST_IDLE = 4'd0;
localparam ST_COPY_IN_RD = 4'd1;
localparam ST_COPY_IN_WAIT = 4'd2;
localparam ST_DESC_RD = 4'd3;
localparam ST_DESC_WAIT = 4'd4;
localparam ST_EDGE_RD = 4'd5;
localparam ST_EDGE_WAIT = 4'd6;
localparam ST_GATHER = 4'd7;
localparam ST_START_N = 4'd8;
localparam ST_WAIT_N = 4'd9;
localparam ST_WRITE_OUT_ISS = 4'd10;
localparam ST_WRITE_OUT_WAIT = 4'd11;
localparam ST_ERROR = 4'd12;
reg [3:0] state;
reg [15:0] in_idx;
reg [15:0] neuron_idx;
reg [ADDR_WIDTH-1:0] desc_addr;
reg [3:0] desc_byte_idx;
reg [23:0] conn_ptr_acc;
reg [15:0] n_conn_acc;
reg [15:0] out_id_acc;
reg [CONN_IDX_WIDTH-1:0] conn_i;
reg [1:0] edge_byte_idx;
reg [15:0] src_id_acc;
reg [7:0] weight_acc;
// Last n_out descriptor-table entries are the output sinks (§4.4):
// entries are in ascending out_id order, so this is a static
// range check on neuron_idx, no separate id lookup needed.
wire [15:0] out_threshold = num_neurons_graph - n_out;
wire is_output_sink = (neuron_idx >= out_threshold);
wire [15:0] out_offset = neuron_idx - out_threshold;
// ============================================================
// ACTIVATION BUFFER (private to this engine -- dense mode does
// not use it at all)
// ============================================================
reg act_wr_en;
reg [BUF_ADDR_WIDTH-1:0] act_wr_addr;
reg signed [DATA_WIDTH-1:0] act_wr_data;
wire signed [DATA_WIDTH-1:0] act_rd_data;
act_buffer #(
.N_TOTAL(N_TOTAL),
.DATA_WIDTH(DATA_WIDTH)
) u_act_buffer (
.clk(clk),
.wr_en(act_wr_en), .wr_addr(act_wr_addr), .wr_data(act_wr_data),
.rd_addr(src_id_acc[BUF_ADDR_WIDTH-1:0]), .rd_data(act_rd_data)
);
// ============================================================
// NEURON (private instance, reused across every graph neuron --
// same "one neuron computed at a time, memory-bound" design as
// neuron_memory.v)
// ============================================================
reg signed [DATA_WIDTH-1:0] x_mem [0:MAX_CONN-1];
reg signed [DATA_WIDTH-1:0] w_mem [0:MAX_CONN-1];
wire signed [DATA_WIDTH*MAX_CONN-1:0] x_bus;
wire signed [DATA_WIDTH*MAX_CONN-1:0] w_bus;
genvar gi;
generate
for (gi = 0; gi < MAX_CONN; gi = gi + 1) begin : GEN_BUS
assign x_bus[gi*DATA_WIDTH +: DATA_WIDTH] = x_mem[gi];
assign w_bus[gi*DATA_WIDTH +: DATA_WIDTH] = w_mem[gi];
end
endgenerate
reg neuron_start;
reg signed [7:0] bias_reg;
reg [1:0] activation_reg;
reg [15:0] n_conn_padded_reg;
wire signed [7:0] neuron_y;
wire neuron_busy;
wire neuron_done;
neuron_parallel #(
.DATA_WIDTH(DATA_WIDTH),
.N_INPUTS(MAX_CONN),
.PARALLEL(PARALLEL),
.ACC_WIDTH(ACC_WIDTH)
) u_neuron (
.clk(clk), .rst(rst), .start(neuron_start),
.x_bus(x_bus), .w_bus(w_bus), .bias(bias_reg),
.activation(activation_reg),
.n_inputs_real(n_conn_padded_reg),
.y(neuron_y), .busy(neuron_busy), .done(neuron_done)
);
// ============================================================
// MAIN FSM
// ============================================================
integer ri;
always @(posedge clk) begin
if (rst) begin
state <= ST_IDLE;
busy <= 1'b0;
done <= 1'b0;
err <= 1'b0;
in_idx <= 16'h0;
neuron_idx <= 16'h0;
desc_addr <= {ADDR_WIDTH{1'b0}};
desc_byte_idx <= 4'h0;
conn_ptr_acc <= 24'h0;
n_conn_acc <= 16'h0;
out_id_acc <= 16'h0;
n_conn_padded_reg <= 16'h0;
conn_i <= {CONN_IDX_WIDTH{1'b0}};
edge_byte_idx <= 2'h0;
src_id_acc <= 16'h0;
weight_acc <= 8'h0;
bias_reg <= 8'sd0;
activation_reg <= 2'd1;
act_wr_en <= 1'b0;
act_wr_addr <= {BUF_ADDR_WIDTH{1'b0}};
act_wr_data <= 8'sd0;
neuron_start <= 1'b0;
ram_req <= 1'b0;
ram_wr <= 1'b0;
ram_addr <= {ADDR_WIDTH{1'b0}};
ram_wdata <= 8'sd0;
for (ri = 0; ri < MAX_CONN; ri = ri + 1) begin
x_mem[ri] <= 8'sd0;
w_mem[ri] <= 8'sd0;
end
end else begin
// --------------------------------------------------
// Default pulses
// --------------------------------------------------
ram_req <= 1'b0;
act_wr_en <= 1'b0;
neuron_start <= 1'b0;
done <= 1'b0;
case (state)
// =============================================
// IDLE / ERROR: both accept run_start identically.
// A fresh run_start clears a stale `err` and gives
// the engine another attempt.
// =============================================
ST_IDLE, ST_ERROR: begin
if (run_start) begin
busy <= 1'b1;
err <= 1'b0;
in_idx <= 16'h0;
neuron_idx <= 16'h0;
desc_addr <= table_base;
state <= ST_COPY_IN_RD;
end
end
// =============================================
// COPY_INPUTS: act_buf[0..N_in-1] <- PSRAM[x_base..]
// =============================================
ST_COPY_IN_RD: begin
ram_req <= 1'b1;
ram_wr <= 1'b0;
ram_addr <= x_base + in_idx;
state <= ST_COPY_IN_WAIT;
end
ST_COPY_IN_WAIT: begin
if (ram_ready) begin
act_wr_en <= 1'b1;
act_wr_addr <= in_idx[BUF_ADDR_WIDTH-1:0];
act_wr_data <= ram_rdata;
if (in_idx == n_inputs_graph - 16'd1) begin
desc_byte_idx <= 4'h0;
state <= ST_DESC_RD;
end else begin
in_idx <= in_idx + 16'd1;
state <= ST_COPY_IN_RD;
end
end
end
// =============================================
// READ_DESC: 11-byte graph descriptor (§4.2)
// =============================================
ST_DESC_RD: begin
ram_req <= 1'b1;
ram_wr <= 1'b0;
ram_addr <= desc_addr + desc_byte_idx;
state <= ST_DESC_WAIT;
end
ST_DESC_WAIT: begin
if (ram_ready) begin
case (desc_byte_idx)
4'd0: conn_ptr_acc[23:16] <= ram_rdata;
4'd1: conn_ptr_acc[15:8] <= ram_rdata;
4'd2: conn_ptr_acc[7:0] <= ram_rdata;
4'd3: n_conn_acc[15:8] <= ram_rdata;
4'd4: n_conn_acc[7:0] <= ram_rdata;
4'd5: out_id_acc[15:8] <= ram_rdata;
4'd6: out_id_acc[7:0] <= ram_rdata;
4'd7: activation_reg <= ram_rdata[1:0];
4'd8: bias_reg <= ram_rdata;
default: ; // reserved bytes 9-10, ignored
endcase
if (desc_byte_idx == 4'd10) begin
// n_conn_acc is fully committed (bytes
// 3-4, several cycles ago) by now.
n_conn_padded_reg <=
((n_conn_acc + (PARALLEL - 1)) >> PAR_SHIFT) << PAR_SHIFT;
conn_i <= {CONN_IDX_WIDTH{1'b0}};
edge_byte_idx <= 2'h0;
state <= ST_EDGE_RD;
end else begin
desc_byte_idx <= desc_byte_idx + 4'd1;
state <= ST_DESC_RD;
end
end
end
// =============================================
// EDGE stream + gather (§4.3, §7)
// =============================================
ST_EDGE_RD: begin
// n_conn_padded_reg == 0 means the host sent a
// neuron with no padded connections at all --
// would forward n_inputs_real=0 to neuron_parallel,
// which hangs (GROUPS=0 failure mode). Treated as
// a load-time error instead (§7 rationale).
if (n_conn_padded_reg == 16'h0) begin
err <= 1'b1;
busy <= 1'b0;
state <= ST_ERROR;
end else begin
ram_req <= 1'b1;
ram_wr <= 1'b0;
ram_addr <= conn_ptr_acc[ADDR_WIDTH-1:0] + (conn_i * 4) + edge_byte_idx;
state <= ST_EDGE_WAIT;
end
end
ST_EDGE_WAIT: begin
if (ram_ready) begin
case (edge_byte_idx)
2'd0: src_id_acc[15:8] <= ram_rdata;
2'd1: src_id_acc[7:0] <= ram_rdata;
2'd2: weight_acc <= ram_rdata;
default: ; // reserved byte, ignored
endcase
if (edge_byte_idx == 2'd3) begin
edge_byte_idx <= 2'h0;
// §7 guard: src_id must be a strictly
// earlier-computed signal, and both ids
// must fit the activation buffer.
if (src_id_acc >= out_id_acc ||
src_id_acc >= N_TOTAL[15:0] ||
out_id_acc >= N_TOTAL[15:0]) begin
err <= 1'b1;
busy <= 1'b0;
state <= ST_ERROR;
end else begin
// act_buf.rd_addr is wired directly
// to src_id_acc (see instantiation
// above); it has been stable since
// this cycle already (both bytes of
// src_id are committed) and will
// still be stable through the
// ST_GATHER cycle below.
state <= ST_GATHER;
end
end else begin
edge_byte_idx <= edge_byte_idx + 2'd1;
state <= ST_EDGE_RD;
end
end
end
// =============================================
// GATHER: consume act_buf's registered read data
// (valid: rd_addr has been stable since the START
// of this same edge's byte 1, i.e. well over a full
// PSRAM byte round-trip ago -- see header comment).
// =============================================
ST_GATHER: begin
x_mem[conn_i] <= act_rd_data;
w_mem[conn_i] <= $signed(weight_acc);
if (conn_i == n_conn_padded_reg[CONN_IDX_WIDTH-1:0] - 1'b1) begin
conn_i <= {CONN_IDX_WIDTH{1'b0}};
state <= ST_START_N;
end else begin
conn_i <= conn_i + 1'b1;
state <= ST_EDGE_RD;
end
end
// =============================================
// START / WAIT neuron_parallel
// =============================================
ST_START_N: begin
neuron_start <= 1'b1;
state <= ST_WAIT_N;
end
ST_WAIT_N: begin
if (neuron_done) begin
act_wr_en <= 1'b1;
act_wr_addr <= out_id_acc[BUF_ADDR_WIDTH-1:0];
act_wr_data <= neuron_y;
if (is_output_sink) begin
state <= ST_WRITE_OUT_ISS;
end else if (neuron_idx == num_neurons_graph - 16'd1) begin
busy <= 1'b0;
done <= 1'b1;
state <= ST_IDLE;
end else begin
neuron_idx <= neuron_idx + 16'd1;
desc_addr <= desc_addr + 11;
desc_byte_idx <= 4'h0;
state <= ST_DESC_RD;
end
end
end
// =============================================
// Fold WRITE_OUTPUTS into the loop for sink neurons
// =============================================
ST_WRITE_OUT_ISS: begin
ram_req <= 1'b1;
ram_wr <= 1'b1;
ram_addr <= out_base + out_offset;
ram_wdata <= neuron_y;
state <= ST_WRITE_OUT_WAIT;
end
ST_WRITE_OUT_WAIT: begin
if (ram_ready) begin
if (neuron_idx == num_neurons_graph - 16'd1) begin
busy <= 1'b0;
done <= 1'b1;
state <= ST_IDLE;
end else begin
neuron_idx <= neuron_idx + 16'd1;
desc_addr <= desc_addr + 11;
desc_byte_idx <= 4'h0;
state <= ST_DESC_RD;
end
end
end
default: begin
state <= ST_IDLE;
end
endcase
end
end
endmodule