Files
FPGA-Neural/hardware/v3/rtl/mac2_dsp_packed.v
micheleandClaude Sonnet 5 1cbe7b85d5 v3: real Artix-7 compute core -- DSP48 packing verified, 2 real bugs found and fixed
New hardware/v3/ (Artix-7 port, branch v3-artix7): the compute engine
that makes the 100x-vs-ESP32 target theoretically reachable on
XC7A100T's 240 DSP48E1 budget.

mac2_dsp_packed.v: packs 2 INT8 MACs sharing one resident weight into
a single DSP48-shaped 25x18 multiply, exploiting this project's own
weight-stationary reuse pattern (layer_weight_buffer.v, EXP-0057/0058)
where one weight is genuinely multiplied against many different
activations. Verified exhaustively: 16,777,216/16,777,216
(weight,x0,x1) combinations, 0 errors.

Two real bugs found and fixed during that verification (both purely
arithmetic/RTL, not toolchain-related):
1. An off-by-one in a declared wire width caused Verilog's part-select
   unsigned-by-default rule to corrupt sign extension on the upper
   product field -- ~50% of vectors failed.
2. After fixing (1), still ~50% failed: concatenating two independently
   sign-extended fields ({sext(x1,9), sext(x0,16)}) is NOT equivalent
   to the real arithmetic sum x1*2^16+x0 whenever the lower field is
   negative (its own two's-complement encoding "bleeds" an extra 2^16
   into the concatenated value). Fixed by building the packed operand
   with an explicit arithmetic shift-and-add instead of concatenation.

neural_processor_packed.v: full port of hardware/v2/rtl/
neural_processor.v's pipeline (same stage count/structure), doubled on
the accumulator/bias/activation/saturation side to process two
weight-reuse positions per weight-tile stream. Verified against TWO
real hardware/v2/rtl/neural_processor.v instances (job A / job B, same
shared weight, independent activations) -- 18/18 PASS, 0 errors,
covering the functional sweep, INT8 extremes, and back-to-back jobs.

A third real bug found in the process (in the new testbench, not the
RTL): clearing operand_valid/tile_last in the same simulation delta as
the handshake edge that should register tile_last=1 races against the
DUTs' own FSM evaluation of that same edge -- the same pulse-clearing
race class found three times already today in hardware/v2/sim (EXP-0058
and its follow-up commits). Fixed the same way: hold the pulse past the
edge with a real time delay (#1) before clearing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUG92aM9m68TRc4rG55BcC
2026-09-16 19:48:46 +02:00

100 lines
4.8 KiB
Verilog

`timescale 1ns/1ps
// ============================================================
// v3 (Artix-7 port) -- 2 INT8 MACs sharing one resident weight, packed
// into a single DSP48E1-shaped 25x18 multiply.
//
// Fits this project's own weight-stationary reuse architecture
// (layer_weight_buffer.v, EXP-0057/0058) exactly: one weight stays
// resident and is multiplied against MANY different activations
// (spatial reuse positions). This packs TWO of those activations
// (x0, x1) against the SAME shared weight into one multiply, instead
// of two separate DSP48 multiplies -- doubling effective MAC/DSP
// throughput for exactly this access pattern.
//
// Packing scheme (signed INT8 x0, x1, weight, all in [-128, 127]):
// packed_a = (x1 <<< 16) + sign_extend(x0, 25) (25 bits, matches
// DSP48E1 port A width)
// product = packed_a * weight (33 bits here;
// widens to 43 bits with a real 18-bit weight port on
// actual DSP48E1 silicon)
//
// packed_a is built with a real ARITHMETIC add, not bit concatenation
// -- concatenating two independently sign-extended fields ({sext(x1,9),
// sext(x0,16)}) looks equivalent on paper but is NOT: whenever x0 is
// negative, its own two's-complement encoding contributes an extra
// +2^16 into the concatenated field's value that a real sum x1*2^16+x0
// does not have (found via exhaustive verification below -- an earlier
// concatenation-based version failed exactly 8,355,840 / 16,777,216
// vectors, all sharing x0<0). The explicit shift-and-add avoids this
// class of bug entirely by construction.
//
// Because x1's field sits at bit 16 (a multiple of 2^16), the low 16
// bits of `product` always equal x0*weight exactly, taken as signed
// (modular arithmetic: (x1<<16)*weight is a multiple of 2^16, so it
// never disturbs bits [15:0] of the sum). x0*weight's magnitude is at
// most 128*128=16384, safely inside signed 16-bit range
// (-32768..32767), so no truncation.
//
// Extracting x1*weight from the upper bits needs one correction: an
// arithmetic right-shift of `product` by 16 computes
// floor(product / 2^16), which is x1*weight - 1 (not exactly
// x1*weight) whenever the low-16-bit product (x0*weight) is negative
// -- the classic "borrow" of splitting one real two's-complement sum
// into two fields after the fact (concatenating BEFORE the multiply is
// exact by construction; recovering the two products AFTER a real
// multiply-and-add requires this one correction). Fixed by adding 1
// back whenever the low product's sign bit is set.
// ============================================================
module mac2_dsp_packed #(
parameter DATA_WIDTH = 8
)(
input wire clk,
input wire rst,
input wire signed [DATA_WIDTH-1:0] weight, // shared, resident
input wire signed [DATA_WIDTH-1:0] x0,
input wire signed [DATA_WIDTH-1:0] x1,
input wire valid_in,
output reg signed [2*DATA_WIDTH-1:0] p0, // = x0 * weight, exact
output reg signed [2*DATA_WIDTH-1:0] p1, // = x1 * weight, exact
output reg valid_out
);
localparam A_WIDTH = 3*DATA_WIDTH + 1; // 25 for DATA_WIDTH=8
localparam PROD_WIDTH = A_WIDTH + DATA_WIDTH; // 43 for DATA_WIDTH=8
wire signed [A_WIDTH-1:0] x0_sext25 = {{(A_WIDTH-DATA_WIDTH){x0[DATA_WIDTH-1]}}, x0};
wire signed [A_WIDTH-1:0] x1_shifted = $signed(x1) <<< (2*DATA_WIDTH);
wire signed [A_WIDTH-1:0] packed_a = x1_shifted + x0_sext25;
wire signed [PROD_WIDTH-1:0] product = packed_a * weight;
// NOTE: a Verilog part-select (product[hi:lo]) always yields an
// UNSIGNED value regardless of the source's own `signed` keyword
// (LRM rule -- part-selects are never signed) -- explicit $signed()
// casts below are therefore load-bearing, not decorative: without
// them the arithmetic right shift used to recover p1_raw would
// truncate/zero-extend instead of sign-extending, corrupting every
// case where x1*weight is negative (found via exhaustive
// verification, tb_mac2_dsp_packed.v -- an earlier version without
// these casts, and with an off-by-one in p1_raw's declared width,
// failed ~50% of all 16,777,216 (weight,x0,x1) vectors).
wire signed [2*DATA_WIDTH-1:0] p0_comb = product[2*DATA_WIDTH-1:0];
wire signed [A_WIDTH+DATA_WIDTH-2*DATA_WIDTH-1:0] p1_raw = $signed(product) >>> (2*DATA_WIDTH);
wire signed [2*DATA_WIDTH-1:0] p1_comb = p1_raw[2*DATA_WIDTH-1:0] + (p0_comb[2*DATA_WIDTH-1] ? 1'b1 : 1'b0);
always @(posedge clk) begin
if (rst) begin
p0 <= {2*DATA_WIDTH{1'b0}};
p1 <= {2*DATA_WIDTH{1'b0}};
valid_out <= 1'b0;
end else begin
p0 <= p0_comb;
p1 <= p1_comb;
valid_out <= valid_in;
end
end
endmodule