Files
FPGA-Neural/hardware/v3/rtl/systolic_group.v
T
micheleandClaude Sonnet 5 932aec2490 feat: real first step of 4x4 hybrid systolic architecture (EXP-0089)
Adds packed_pe.v (packed_slot.v's compute+activation-fetch+writeback
subsystem, reusing ddr_prefetch_mgr.v/neural_processor_packed.v/
result_writeback.v completely unmodified, with its own private
weight-fetch removed) and systolic_group.v (one real layer_prefetch_
ctrl.v+layer_weight_buffer.v+weight_tile_gather.v shared by 4x
packed_pe.v via a real, barrier-synchronized broadcast bus).

Real design choice confirmed with the user before writing any RTL
(AskUserQuestion, concrete topology preview): shared-weight broadcast,
not a literal PE-to-PE systolic shift register -- achieves the real,
quantified rationale (4x reduction in redundant weight-fetch DDR3
traffic per group of 4 PEs) with much lower real risk than genuine
inter-PE pipeline fill/drain.

The real new design is the barrier: each PE's own tcnt is the join key
against the group's broadcast tcnt, self-synchronizing regardless of
which PE is momentarily ahead/behind (e.g. a real DDR3 row-switch
stall on one PE's own activation fetch). Found and fixed a real bug
during verification (not by inspection): the first full test run
reported every result as undefined despite every control-flow signal
tracing correctly -- root-caused via real signal tracing down to a
5-way test arbiter bus mis-sliced at the wrong slot offset (single-bit
handshake buses happened to use a correct range and masked it from the
control-flow trace; only the wide, byte-offset buses were wrong).

Verified in isolation (tb_systolic_group.v, real Icarus xsim, real
sdram_arbiter_n.v generalized to NUM_REQ=5 with zero changes): 8/8
PASS across 2 consecutive group jobs (exercising the barrier's own
per-job reset path, not just cold start).

Deliberately scoped to the isolated mechanism only, per this project's
"one variable at a time" discipline -- Director/SPI job dispatch for
group jobs, a real N=16 top-level, and real P&R are real, disclosed
next steps, not done here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUG92aM9m68TRc4rG55BcC
2026-09-20 22:54:52 +02:00

324 lines
15 KiB
Verilog

`timescale 1ns/1ps
// ============================================================
// V3 -- systolic_group.v: real group controller for the "4 groups x
// 4-PE weight-stationary chains" hybrid scaling direction
// (docs/ARCHITECTURE_ANALYSIS.md S5.6, EXP-0089). User-confirmed real
// design choice (2026-09-20, explicit A/B decision): SHARED-WEIGHT
// BROADCAST, not a literal PE-to-PE systolic shift register -- one
// real weight fetch per group of 4 PEs (instead of 4 independent
// fetches), each PE then computes its own, independent activation
// positions in parallel. This achieves the doc's own real, quantified
// rationale (reduce redundant weight-fetch DDR3 traffic 4x per group)
// with far less real risk/complexity than a literal systolic pipeline
// -- no inter-PE result propagation, no pipeline fill/drain at chain
// boundaries, both of which the doc's own S5.6 explicitly flagged as
// "a real, new design, not a trivial extension".
//
// STRUCTURE: ONE real layer_prefetch_ctrl.v + layer_weight_buffer.v +
// weight_tile_gather.v (unmodified, identical instances to what
// packed_slot.v already owns per-slot -- just now group-level, shared
// by reference not duplicated), driving 4x packed_pe.v (packed_slot.v
// minus its own weight-fetch, see packed_pe.v's own header) via a
// real, level-held, tcnt-tagged broadcast bus.
//
// REAL BARRIER SYNCHRONIZATION (the actual new design, not asserted
// correct without real verification -- see tb_systolic_group.v): the
// group only advances to tile N+1's weight fetch once ALL 4 PEs have
// ack'd tile N (`pe_tile_ack[i]`, individually latched since PEs may
// consume at different real cycles -- e.g. one PE's own activation
// fetch hit a real DDR3 row switch the others didn't). This is a real,
// necessary extension of the "S_TILEWAIT join" discipline every other
// module in this project already uses for 2-source joins (weight +
// activation) -- here it's a 4-way join (one group weight source, 4
// independent PE acks), same underlying principle: never advance past
// a shared resource until every real consumer has confirmed it read
// what it needed.
// ============================================================
module systolic_group #(
parameter DATA_WIDTH = 8,
parameter P_IN = 8,
parameter ACC_WIDTH = 32,
parameter BURST_LEN = 8,
parameter ADDR_WIDTH = 26,
parameter LAYER_BYTES = 128,
parameter BUFADDRW = $clog2(LAYER_BYTES)
)(
input wire clk,
input wire rst,
// ---- group-level job dispatch: ONE shared w_base/n_tiles (all 4
// PEs process the SAME layer, weight-stationary), 4x independent
// per-PE x_base_a/b/result_addr_a/b/node_id_a/b ----
input wire job_start,
input wire [ADDR_WIDTH-1:0] w_base,
input wire [15:0] n_tiles,
input wire [4*ADDR_WIDTH-1:0] pe_x_base_a, pe_x_base_b,
input wire [4*ADDR_WIDTH-1:0] pe_result_addr_a, pe_result_addr_b,
input wire [4*16-1:0] pe_node_id_a, pe_node_id_b,
output reg job_done, // one-cycle pulse, ALL 4 PEs done
output wire [4*DATA_WIDTH-1:0] pe_result_data_a, pe_result_data_b,
output wire [4*16-1:0] pe_result_node_id_a, pe_result_node_id_b,
output wire [4*ADDR_WIDTH-1:0] pe_result_addr_a_out, pe_result_addr_b_out,
// ---- group's own single arbiter port, for the shared weight
// fetch only -- each of the 4 PEs still owns ITS OWN separate
// arbiter port for activation-fetch+writeback (see top-level
// integration; not this module's own concern) ----
output wire mem_active,
input wire mem_grant,
output wire ctrl_req,
output wire ctrl_wr,
output wire [ADDR_WIDTH-2:0] ctrl_addr,
output wire [32*BURST_LEN-1:0] ctrl_wdata,
output wire [4*BURST_LEN-1:0] ctrl_wmask,
input wire [32*BURST_LEN-1:0] ctrl_rdata,
input wire ctrl_ready,
input wire ctrl_busy,
// ---- 4 independent per-PE arbiter ports (activation-fetch +
// result-writeback, NOT shared -- flattened NUM_PE*width buses) ----
output wire [3:0] pe_mem_active,
input wire [3:0] pe_mem_grant,
output wire [3:0] pe_ctrl_req,
output wire [3:0] pe_ctrl_wr,
output wire [4*(ADDR_WIDTH-1)-1:0] pe_ctrl_addr,
output wire [4*32*BURST_LEN-1:0] pe_ctrl_wdata,
output wire [4*4*BURST_LEN-1:0] pe_ctrl_wmask,
input wire [4*32*BURST_LEN-1:0] pe_ctrl_rdata,
input wire [3:0] pe_ctrl_ready,
input wire [3:0] pe_ctrl_busy
);
localparam S_IDLE = 4'd0,
S_MEMWAIT = 4'd1,
S_PREFETCH = 4'd2,
S_SWAP = 4'd3,
S_PEJOBSTART = 4'd4,
S_TILELOOP = 4'd5,
S_WAITDONE = 4'd6;
reg [3:0] state;
reg [ADDR_WIDTH-1:0] w_base_lat;
reg [15:0] n_tiles_lat;
reg [15:0] tcnt;
// ---- shared weight fetch: layer_prefetch_ctrl.v -> layer_weight_
// buffer.v -> weight_tile_gather.v, IDENTICAL instances to what
// packed_slot.v already owns per-slot, just group-level now ----
reg pf_start;
wire pf_busy, pf_done;
wire pf_fill_we;
wire [BUFADDRW-1:0] pf_fill_addr;
wire [DATA_WIDTH-1:0] pf_fill_data;
reg consume_done;
layer_prefetch_ctrl #(
.DATA_WIDTH(DATA_WIDTH), .LAYER_BYTES(LAYER_BYTES), .BURST_LEN(BURST_LEN), .ADDR_WIDTH(ADDR_WIDTH-1)
) u_pf (
.clk(clk), .rst(rst),
.start(pf_start), .layer_base(w_base_lat[ADDR_WIDTH-2:0]), .busy(pf_busy), .done(pf_done),
.fill_we(pf_fill_we), .fill_addr(pf_fill_addr), .fill_data(pf_fill_data),
.ctrl_req(ctrl_req), .ctrl_wr(ctrl_wr), .ctrl_addr(ctrl_addr),
.ctrl_wdata(ctrl_wdata), .ctrl_wmask(ctrl_wmask),
.ctrl_rdata(ctrl_rdata), .ctrl_ready(ctrl_ready), .ctrl_busy(ctrl_busy)
);
assign mem_active = (state == S_MEMWAIT) || (state == S_PREFETCH);
wire [BUFADDRW-1:0] lwb_rd_addr;
wire [DATA_WIDTH-1:0] lwb_rd_data;
layer_weight_buffer #(.DATA_WIDTH(DATA_WIDTH), .LAYER_DEPTH(LAYER_BYTES)) u_lwb (
.clk(clk), .rst(rst),
.fill_we(pf_fill_we), .fill_addr(pf_fill_addr), .fill_data(pf_fill_data), .fill_done(pf_done),
.rd_addr(lwb_rd_addr), .rd_data(lwb_rd_data), .consume_done(consume_done),
.active_sel(), .swapped()
);
reg tile_req;
reg [BUFADDRW-1:0] tile_base;
wire tile_valid;
wire [DATA_WIDTH*P_IN-1:0] tile_data;
wire [15:0] tcnt_next = tcnt + 16'd1;
weight_tile_gather #(
.DATA_WIDTH(DATA_WIDTH), .P_IN(P_IN), .BUFADDRW(BUFADDRW)
) u_gather (
.clk(clk), .rst(rst),
.tile_req(tile_req), .tile_base(tile_base),
.tile_valid(tile_valid), .tile_data(tile_data),
.rd_addr(lwb_rd_addr), .rd_data(lwb_rd_data)
);
// ---- real broadcast bus to all 4 PEs (level-held, tcnt-tagged --
// see packed_pe.v's own header for the real join discipline) ----
reg group_tile_valid;
reg [DATA_WIDTH*P_IN-1:0] group_tile_data_r;
reg [3:0] pe_acked; // per-PE ack latch, cleared each tile
wire [3:0] pe_job_start_w = {4{(state == S_PEJOBSTART)}};
wire [3:0] pe_job_done_w;
wire [3:0] pe_acked_pulse;
genvar gi;
generate
for (gi = 0; gi < 4; gi = gi + 1) begin : GEN_PE
packed_pe #(
.DATA_WIDTH(DATA_WIDTH), .P_IN(P_IN), .ACC_WIDTH(ACC_WIDTH),
.BURST_LEN(BURST_LEN), .ADDR_WIDTH(ADDR_WIDTH)
) u_pe (
.clk(clk), .rst(rst),
.job_start(pe_job_start_w[gi]),
.x_base_a(pe_x_base_a[gi*ADDR_WIDTH +: ADDR_WIDTH]),
.x_base_b(pe_x_base_b[gi*ADDR_WIDTH +: ADDR_WIDTH]),
.result_addr_a(pe_result_addr_a[gi*ADDR_WIDTH +: ADDR_WIDTH]),
.result_addr_b(pe_result_addr_b[gi*ADDR_WIDTH +: ADDR_WIDTH]),
.node_id_a(pe_node_id_a[gi*16 +: 16]), .node_id_b(pe_node_id_b[gi*16 +: 16]),
.job_done(pe_job_done_w[gi]),
.result_data_a(pe_result_data_a[gi*DATA_WIDTH +: DATA_WIDTH]),
.result_data_b(pe_result_data_b[gi*DATA_WIDTH +: DATA_WIDTH]),
.result_node_id_a(pe_result_node_id_a[gi*16 +: 16]),
.result_node_id_b(pe_result_node_id_b[gi*16 +: 16]),
.result_addr_a_out(pe_result_addr_a_out[gi*ADDR_WIDTH +: ADDR_WIDTH]),
.result_addr_b_out(pe_result_addr_b_out[gi*ADDR_WIDTH +: ADDR_WIDTH]),
.group_n_tiles(n_tiles_lat), .group_tcnt(tcnt),
.group_tile_data(group_tile_data_r), .group_tile_valid(group_tile_valid),
.pe_tile_ack(pe_acked_pulse[gi]),
.mem_active(pe_mem_active[gi]), .mem_grant(pe_mem_grant[gi]),
.ctrl_req(pe_ctrl_req[gi]), .ctrl_wr(pe_ctrl_wr[gi]),
.ctrl_addr(pe_ctrl_addr[gi*(ADDR_WIDTH-1) +: (ADDR_WIDTH-1)]),
.ctrl_wdata(pe_ctrl_wdata[gi*32*BURST_LEN +: 32*BURST_LEN]),
.ctrl_wmask(pe_ctrl_wmask[gi*4*BURST_LEN +: 4*BURST_LEN]),
.ctrl_rdata(pe_ctrl_rdata[gi*32*BURST_LEN +: 32*BURST_LEN]),
.ctrl_ready(pe_ctrl_ready[gi]), .ctrl_busy(pe_ctrl_busy[gi])
);
end
endgenerate
reg [3:0] pe_done_latch;
// combinational "what pe_acked/pe_done_latch would be if we also
// fold in THIS cycle's own pulses" -- used both to DECIDE the
// barrier this cycle (no extra latency) and, explicitly, as what
// gets written back when the barrier hasn't cleared yet. Kept as
// named wires (not relying on nonblocking-assignment-order
// last-write-wins semantics) so the real intent is unambiguous to
// a future reader, not just technically correct.
wire [3:0] pe_acked_next = pe_acked | pe_acked_pulse;
wire [3:0] pe_done_latch_next = pe_done_latch | pe_job_done_w;
always @(posedge clk) begin
if (rst) begin
state <= S_IDLE;
job_done <= 1'b0;
pf_start <= 1'b0;
consume_done <= 1'b0;
tile_req <= 1'b0;
group_tile_valid <= 1'b0;
pe_acked <= 4'b0;
pe_done_latch <= 4'b0;
tcnt <= 16'd0;
end else begin
job_done <= 1'b0;
pf_start <= 1'b0;
consume_done <= 1'b0;
tile_req <= 1'b0;
// real race, deliberately handled: a PE's own job_done can
// pulse the SAME cycle S_TILELOOP's barrier clears for the
// LAST tile (i.e. the same cycle the group transitions to
// S_WAITDONE) -- its own downstream writeback can complete
// before the group has even formally entered S_WAITDONE.
// Accumulate unconditionally, every cycle, so no early
// pe_job_done_w pulse is ever missed; S_WAITDONE's own
// success branch below explicitly overrides this back to 0
// (Verilog's own last-nonblocking-write-wins rule within
// one always block -- intentional here, unlike pe_acked's
// window which is fully contained inside S_TILELOOP and so
// uses the more explicit if/else form instead).
pe_done_latch <= pe_done_latch_next;
case (state)
S_IDLE: begin
if (job_start) begin
w_base_lat <= w_base;
n_tiles_lat <= n_tiles;
tcnt <= 16'd0;
state <= S_MEMWAIT;
end
end
S_MEMWAIT: begin
if (mem_grant) begin
pf_start <= 1'b1;
state <= S_PREFETCH;
end
end
S_PREFETCH: begin
if (pf_done) begin
consume_done <= 1'b1;
state <= S_SWAP;
end
end
S_SWAP: begin
// one settle cycle, same real reason packed_slot.v's
// own S_SWAP exists (layer_weight_buffer.v's do_swap).
state <= S_PEJOBSTART;
end
S_PEJOBSTART: begin
// pe_job_start_w is combinational on (state ==
// S_PEJOBSTART), so all 4 PEs see job_start the
// SAME cycle -- real, established one-shot-pulse
// discipline (EXP-0066), now applied 4-way.
tile_req <= 1'b1;
tile_base <= tcnt[BUFADDRW-1:0]*P_IN[BUFADDRW-1:0];
state <= S_TILELOOP;
end
// real barrier: hold this tile's data broadcast until
// ALL 4 PEs have ack'd it (pe_acked_next all-ones), THEN
// advance tcnt and issue the next weight_tile_gather
// request -- see this module's own header for why a
// bare pulse/level race would be unsafe here. pe_acked
// is explicitly written on EVERY path through this
// state (either reset to 0 when the barrier clears, or
// folded forward via pe_acked_next otherwise) -- never
// relies on assignment order elsewhere in the block.
S_TILELOOP: begin
if (tile_valid && !group_tile_valid) begin
group_tile_data_r <= tile_data;
group_tile_valid <= 1'b1;
end
if (group_tile_valid && (&pe_acked_next)) begin
group_tile_valid <= 1'b0;
pe_acked <= 4'b0;
if (tcnt == n_tiles_lat - 16'd1) begin
state <= S_WAITDONE;
end else begin
tcnt <= tcnt_next;
tile_req <= 1'b1;
tile_base <= tcnt_next[BUFADDRW-1:0]*P_IN[BUFADDRW-1:0];
state <= S_TILELOOP;
end
end else begin
pe_acked <= pe_acked_next;
end
end
S_WAITDONE: begin
if (&pe_done_latch_next) begin
pe_done_latch <= 4'b0;
job_done <= 1'b1;
state <= S_IDLE;
end
end
default: state <= S_IDLE;
endcase
end
end
endmodule