Files
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

323 lines
14 KiB
Verilog

`timescale 1ns/1ps
// ============================================================
// V3 -- packed_pe.v: real weight-sharing PE for a systolic_group.v
// (docs/ARCHITECTURE_ANALYSIS.md S5.6, EXP-0089), derived directly from
// packed_slot.v -- SAME activation-fetch (ddr_prefetch_mgr.v), compute
// (neural_processor_packed.v), and result-writeback (result_writeback.v)
// subsystems, unmodified. The ONLY real difference: this module does
// NOT own a layer_prefetch_ctrl.v/layer_weight_buffer.v/weight_tile_
// gather.v of its own -- weight tile data is received via a real,
// broadcast, GROUP-shared interface instead (group_tcnt/group_tile_
// data/group_tile_valid), since the whole point of grouping 4 PEs is
// ONE real weight fetch shared by all 4 (docs' own quantified
// rationale: DDR3 requester count reduction), not 4 independent ones.
//
// REAL SYNCHRONIZATION (the part that needed real design, not a
// trivial extension -- see docs S5.6's own "not a trivial extension"
// disclosure): this PE's own `tcnt` IS the join key. It waits in
// S_TILEWAIT for `group_tile_valid && (group_tcnt == tcnt)` before
// consuming -- a real, self-synchronizing comparison, not a bare
// pulse/level race. A PE that's briefly slower than its groupmates
// (e.g. its own activation fetch hit a real DDR3 row switch the others
// didn't) simply keeps waiting; when it finally reaches S_TILEWAIT for
// its own `tcnt`, the comparison is either already true (if the group
// had to wait for THIS PE, i.e. this PE IS the slow one) or becomes
// true the moment the group's own barrier (systolic_group.v, all 4
// pe_tile_ack seen) lets it advance -- correct regardless of which PE
// is momentarily ahead or behind, no risk of double-consuming or
// skipping a tile.
//
// group_n_tiles (broadcast, held stable for the whole job -- all 4 PEs
// in a group process the SAME layer, same real weight-stationary
// premise this module's own name comes from) replaces packed_slot.v's
// own per-instance n_tiles input.
// ============================================================
module packed_pe #(
parameter DATA_WIDTH = 8,
parameter P_IN = 8,
parameter ACC_WIDTH = 32,
parameter BURST_LEN = 8,
parameter ADDR_WIDTH = 26
)(
input wire clk,
input wire rst,
// ---- own per-PE job trigger: x_base_a/b (this PE's own two
// activation positions), result_addr_a/b, node_id_a/b -- w_base and
// n_tiles are NOT here, they're group-broadcast (see group_n_tiles
// below; w_base never reaches this module at all, only the group
// controller needs it). ----
input wire job_start,
input wire [ADDR_WIDTH-1:0] x_base_a,
input wire [ADDR_WIDTH-1:0] x_base_b,
input wire [ADDR_WIDTH-1:0] result_addr_a,
input wire [ADDR_WIDTH-1:0] result_addr_b,
input wire [15:0] node_id_a,
input wire [15:0] node_id_b,
output reg job_done, // one-cycle pulse
output reg signed [DATA_WIDTH-1:0] result_data_a,
output reg signed [DATA_WIDTH-1:0] result_data_b,
output reg [15:0] result_node_id_a,
output reg [15:0] result_node_id_b,
output reg [ADDR_WIDTH-1:0] result_addr_a_out,
output reg [ADDR_WIDTH-1:0] result_addr_b_out,
// ---- group-broadcast weight interface (real, level-held-until-
// consumed, same discipline ddr_prefetch_mgr.v's own tile_valid
// already established) ----
input wire [15:0] group_n_tiles,
input wire [15:0] group_tcnt,
input wire [DATA_WIDTH*P_IN-1:0] group_tile_data,
input wire group_tile_valid,
output reg pe_tile_ack, // one-shot pulse
// ---- own activation-fetch + result-writeback arbiter port (still
// one per PE -- activation data is NOT shared across PEs, each PE
// computes different positions) ----
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
);
localparam S_IDLE = 3'd0,
S_JOBSTART = 3'd1,
S_TILEWAIT = 3'd2,
S_OPERAND = 3'd3,
S_RESULT = 3'd4,
S_WRITEBACK = 3'd5;
reg [2:0] state;
reg [ADDR_WIDTH-1:0] x_base_a_lat, x_base_b_lat;
reg [15:0] n_tiles_lat;
reg [ADDR_WIDTH-1:0] result_addr_a_lat, result_addr_b_lat;
reg [15:0] node_id_a_lat, node_id_b_lat;
reg [15:0] tcnt;
// ---- ddr_prefetch_mgr.v: own activation-tile look-ahead, exactly
// as packed_slot.v already uses it ----
reg ddrpf_job_start;
wire ddrpf_tile_valid;
reg ddrpf_tile_consume;
wire signed [DATA_WIDTH*P_IN-1:0] act_data_a_w, act_data_b_w;
wire act_mem_active;
wire act_ctrl_req, act_ctrl_wr;
wire [ADDR_WIDTH-2:0] act_ctrl_addr;
wire [32*BURST_LEN-1:0] act_ctrl_wdata;
wire [4*BURST_LEN-1:0] act_ctrl_wmask;
ddr_prefetch_mgr #(
.DATA_WIDTH(DATA_WIDTH), .P_IN(P_IN), .BURST_LEN(BURST_LEN), .ADDR_WIDTH(ADDR_WIDTH-1)
) u_ddrpf (
.clk(clk), .rst(rst),
.job_start(ddrpf_job_start),
.base_a(x_base_a_lat[ADDR_WIDTH-2:0]), .base_b(x_base_b_lat[ADDR_WIDTH-2:0]),
.n_tiles(n_tiles_lat),
.tile_valid(ddrpf_tile_valid), .data_a(act_data_a_w), .data_b(act_data_b_w),
.tile_consume(ddrpf_tile_consume),
.mem_active(act_mem_active), .mem_grant(mem_grant),
.ctrl_req(act_ctrl_req), .ctrl_wr(act_ctrl_wr), .ctrl_addr(act_ctrl_addr),
.ctrl_wdata(act_ctrl_wdata), .ctrl_wmask(act_ctrl_wmask),
.ctrl_rdata(ctrl_rdata), .ctrl_ready(ctrl_ready), .ctrl_busy(ctrl_busy)
);
// ---- result_writeback.v: own result, own DDR3 write, exactly as
// packed_slot.v already uses it ----
reg wb_start;
wire wb_busy, wb_done;
wire wb_mem_active;
wire wb_ctrl_req, wb_ctrl_wr;
wire [ADDR_WIDTH-2:0] wb_ctrl_addr;
wire [32*BURST_LEN-1:0] wb_ctrl_wdata;
wire [4*BURST_LEN-1:0] wb_ctrl_wmask;
result_writeback #(
.BURST_LEN(BURST_LEN), .DATA_WIDTH(DATA_WIDTH),
.JOB_ADDR_WIDTH(ADDR_WIDTH), .ADDR_WIDTH(ADDR_WIDTH-1)
) u_wb (
.clk(clk), .rst(rst),
.start(wb_start),
.result_addr_a(result_addr_a_lat), .result_addr_b(result_addr_b_lat),
.result_data_a(result_data_a), .result_data_b(result_data_b),
.result_node_id_a(result_node_id_a), .result_node_id_b(result_node_id_b),
.busy(wb_busy), .done(wb_done),
.mem_active(wb_mem_active), .mem_grant(mem_grant),
.ctrl_req(wb_ctrl_req), .ctrl_wr(wb_ctrl_wr), .ctrl_addr(wb_ctrl_addr),
.ctrl_wdata(wb_ctrl_wdata), .ctrl_wmask(wb_ctrl_wmask),
.ctrl_rdata(ctrl_rdata), .ctrl_ready(ctrl_ready), .ctrl_busy(ctrl_busy)
);
// mutually exclusive by FSM construction (activation fetch always
// finishes each tile's own consume before writeback ever starts,
// and writeback only starts once the whole tile loop is done) --
// 2-way mux, one fewer branch than packed_slot.v's own 3-way (no
// local weight-fetch mux here, weight is group-broadcast).
assign ctrl_req = act_mem_active ? act_ctrl_req : wb_ctrl_req;
assign ctrl_wr = act_mem_active ? act_ctrl_wr : wb_ctrl_wr;
assign ctrl_addr = act_mem_active ? act_ctrl_addr : wb_ctrl_addr;
assign ctrl_wdata = act_mem_active ? act_ctrl_wdata : wb_ctrl_wdata;
assign ctrl_wmask = act_mem_active ? act_ctrl_wmask : wb_ctrl_wmask;
assign mem_active = act_mem_active || wb_mem_active;
// ---- neural_processor_packed.v (unmodified, same as packed_slot.v) ----
reg job_valid_np;
wire job_ready_np;
reg [1:0] job_activation;
reg signed [DATA_WIDTH-1:0] job_bias;
reg operand_valid;
wire operand_ready;
reg signed [DATA_WIDTH*P_IN-1:0] input_data_a_r, input_data_b_r;
reg [DATA_WIDTH*P_IN-1:0] weight_data_r;
reg tile_last;
wire result_valid_np;
reg result_ready;
wire signed [DATA_WIDTH-1:0] result_data_a_np, result_data_b_np;
wire [15:0] result_node_id_a_np, result_node_id_b_np;
wire [3:0] np_state;
wire np_error;
neural_processor_packed #(
.DATA_WIDTH(DATA_WIDTH), .P_IN(P_IN), .ACC_WIDTH(ACC_WIDTH)
) u_np (
.clk(clk), .rst(rst),
.job_valid(job_valid_np), .job_ready(job_ready_np),
.job_node_id_a(node_id_a_lat), .job_node_id_b(node_id_b_lat),
.job_bias(job_bias), .job_activation(job_activation),
.operand_valid(operand_valid), .operand_ready(operand_ready),
.input_data_a(input_data_a_r), .input_data_b(input_data_b_r),
.weight_data(weight_data_r), .tile_last(tile_last),
.result_valid(result_valid_np), .result_ready(result_ready),
.result_data_a(result_data_a_np), .result_data_b(result_data_b_np),
.result_node_id_a(result_node_id_a_np), .result_node_id_b(result_node_id_b_np),
.np_state(np_state), .np_error(np_error)
);
localparam ACT_RELU = 2'd1;
always @(posedge clk) begin
if (rst) begin
state <= S_IDLE;
job_done <= 1'b0;
ddrpf_job_start <= 1'b0;
ddrpf_tile_consume <= 1'b0;
job_valid_np <= 1'b0;
operand_valid<= 1'b0;
tile_last <= 1'b0;
result_ready <= 1'b0;
wb_start <= 1'b0;
pe_tile_ack <= 1'b0;
job_bias <= {DATA_WIDTH{1'b0}};
job_activation <= ACT_RELU;
tcnt <= 16'd0;
end else begin
job_done <= 1'b0;
ddrpf_job_start <= 1'b0;
ddrpf_tile_consume <= 1'b0;
wb_start <= 1'b0;
pe_tile_ack <= 1'b0;
case (state)
S_IDLE: begin
if (job_start) begin
x_base_a_lat <= x_base_a;
x_base_b_lat <= x_base_b;
n_tiles_lat <= group_n_tiles;
result_addr_a_lat <= result_addr_a;
result_addr_b_lat <= result_addr_b;
node_id_a_lat <= node_id_a;
node_id_b_lat <= node_id_b;
job_bias <= {DATA_WIDTH{1'b0}};
job_activation <= ACT_RELU;
tcnt <= 16'd0;
job_valid_np <= 1'b1;
state <= S_JOBSTART;
end
end
// real, proven sequencing (matches packed_slot.v's own
// S_JOBSTART exactly): only kick off the activation
// look-ahead loop and enter the tile-consumption join
// AFTER neural_processor_packed.v has actually accepted
// the job -- issuing operand_valid before job_ready_np
// would race its own internal job-acceptance state.
S_JOBSTART: begin
if (job_valid_np && job_ready_np) begin
job_valid_np <= 1'b0;
ddrpf_job_start <= 1'b1; // one-shot: kicks off this PE's
// own activation look-ahead loop
state <= S_TILEWAIT;
end
end
// real join: group's broadcast weight tile (matched by
// tcnt, see header) AND this PE's own activation fetch
// -- same 2-source join shape packed_slot.v's own
// S_TILEWAIT already uses, just with group_tile_valid+
// tcnt-match replacing the local tile_valid.
S_TILEWAIT: begin
if (group_tile_valid && (group_tcnt == tcnt) && ddrpf_tile_valid) begin
weight_data_r <= group_tile_data;
input_data_a_r <= act_data_a_w;
input_data_b_r <= act_data_b_w;
pe_tile_ack <= 1'b1;
ddrpf_tile_consume <= 1'b1;
tile_last <= (tcnt == n_tiles_lat - 16'd1);
operand_valid <= 1'b1;
state <= S_OPERAND;
end
end
S_OPERAND: begin
if (operand_valid && operand_ready) begin
operand_valid <= 1'b0;
tile_last <= 1'b0;
if (tcnt == n_tiles_lat - 16'd1) begin
result_ready <= 1'b1;
state <= S_RESULT;
end else begin
tcnt <= tcnt + 16'd1;
state <= S_TILEWAIT;
end
end
end
S_RESULT: begin
if (result_valid_np) begin
result_data_a <= result_data_a_np;
result_data_b <= result_data_b_np;
result_node_id_a <= result_node_id_a_np;
result_node_id_b <= result_node_id_b_np;
result_addr_a_out <= result_addr_a_lat;
result_addr_b_out <= result_addr_b_lat;
result_ready <= 1'b0;
wb_start <= 1'b1;
state <= S_WRITEBACK;
end
end
S_WRITEBACK: begin
if (wb_done) begin
job_done <= 1'b1;
state <= S_IDLE;
end
end
default: state <= S_IDLE;
endcase
end
end
endmodule