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
100 lines
4.8 KiB
Verilog
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
|