FPGA · Digital Design · ELEC 522 Advanced VLSI (Coursework)
Systolic Array Matrix Multiplier on FPGA
A 4x4, 16-PE array in Vitis Model Composer, plus a CUDA study and an HLS accelerator on a Zynq board.
Individual coursework for Rice ELEC 522 (Fall 2026, Prof. Joseph R. Cavallaro), targeting the AUP-ZU3 board (Zynq UltraScale+ xczu3eg). Problem 1 was verified in simulation and by Vivado timing and resource analysis, not deployed on the board. The HLS accelerator in Part 3 did run on the board.
The Idea
In a systolic array, every processing element (PE) stays in place and, on every clock, does acc ← acc + a_in × b_in, then passes a_in east and b_in south. The result C(i,j) stays inside the PE that computed it. Row i of A enters i-1 cycles late and column j of B enters j-1 cycles late, so each aik meets its matching bkj in the right PE on the right clock edge.
Example: with A = [1 2; 3 4] and B = [5 6; 7 8], the four accumulators settle to exactly C = [19 22; 43 50], with c11 ready at t=3, c12 and c21 at t=4, and c22 at t=5.
The 4x4 array. Each PE keeps its own accumulator, and the staggered inputs enter along the top and left edges.
Design Decisions
Right-shift unload
The assignment forbade RAM readout and large multiplexers. I added one 2:1 mux per PE so a single unload pulse makes every PE load its settled result into its east-forwarding register, and the values then shift out through four row pins. Output pins fall from 256 (one per PE) to 64, a 75% cut, and the design uses 195 pins in total. For row 1 (C = 6, 4, 6, 4) the pin reads 4, 6, 4, 6 over four cycles, and the host reverses it.
Choosing not to build the faster option
I analyzed a diagonal-shift unload in full. It would drain in 2 cycles instead of 4, but pipelined throughput is limited by the 12-cycle settle time, so both schemes give 12 cycles per matrix. It needed three times the output pins and a new debug cycle for no gain in the metric that matters, so I did not build it.
Matrix-vector mode for three muxes
y = A x is the same hardware with the vector in column 1 of B and zeros elsewhere. Gating only the top of columns 2 to 4 zeroes everything below for free, so it costs 3 muxes, 1 pin, and no extra latency (exactly +48 LUTs). The tradeoff, stated plainly: the 12 unused PEs still compute against zero, so there is no power saving.
Back-to-back pipelining
Because reset and unload touch disjoint registers, a new matrix can start while the last one drains. The minimum safe interval is 12 cycles, set by the slowest PE, which settles at t=11. Firing at t=11 was shown to corrupt row 4 of the next matrix. That takes 17 cycles for an isolated multiply down to 12 cycles per matrix, with no new hardware.
Debugging: Bugs That Passed Silently
The most useful lesson was that zero tool errors does not mean a correct design.
- Wrong answer, no errors. The accumulator mux had backwards select polarity, and a latency setting added a hidden second register that doubled the accumulate loop. I found it by building an independent cycle-accurate Python model and diffing it against the exported data.
- Simulates fine, never synthesized. The matrix-vector muxes were built outside the DUT subsystem. The tell was that the resource report was byte-identical after I added hardware. Moving them inside produced exactly the predicted +48 LUTs.
- Native blocks posing as HDL blocks. Plain Simulink blocks simulate identically but fail code generation. I confirmed it by inspecting the model XML and swapped in the AMD HDL library blocks.
- Off-by-one readback window. A delay's output at time t reflects what was selected at t-1, so the readback window is t=13 to 16, not 12 to 15. I caught this before it reached the design.
Results
Resources (xczu3eg)
- DSP48E2: 16 of 360 (4.4%)
- LUTs: 560 of 70,560 (0.8%)
- Registers: 704 of 141,120 (0.5%)
- BRAM: 0 of 216
Timing and Throughput
- Slack +7.797 ns at 100 MHz
- Fmax about 454 MHz (estimate)
- Latency: 17 cycles (170 ns at 100 MHz)
- Throughput: 12 cycles per matrix
Fmax comes from the constraint minus the slack (10 − 7.797 = 2.203 ns), not from 1 over the data path delay. The shortcut gives 457.9 MHz and overstates by about 4 MHz because it ignores setup time and clock uncertainty. The 454 MHz figure is a post-synthesis static timing estimate, not a clock measured on hardware.
Verification: 224 data points (16 accumulators over 14 timesteps) matched an independent Python model with zero mismatches. Two randomized, self-checking MATLAB regression scripts replay exact cases from saved seeds and golden files, and test pipelined matrix-matrix and matrix-vector operations.
CUDA Matrix Multiplication Study
I profiled a one-thread-per-output CUDA kernel across 8 configurations (N = 4 and 1024, with 4x4, 8x8, 16x16 and 32x32 threads per block), keeping the second of two runs to avoid first-run caching effects.
| Block (N=1024) | Kernel | Kernel % of total |
|---|---|---|
| 4x4 | 159.60 ms | 98.30% |
| 8x8 | 41.38 ms | 94.17% |
| 16x16 | 41.48 ms | 94.20% |
| 32x32 | 41.58 ms | 84.63% |
At N=1024, 4x4 blocks were about 3.9x slower than 8x8. A 16-thread block fills only half of a 32-thread warp, and this un-tiled kernel's weak memory coalescing at small block sizes compounds the penalty beyond the roughly 2x that warp fill alone would explain. At N=4 the whole problem fits in one block, so launch overhead and noise dominate.
HLS Accelerator on the Board
A matrix multiplier written in Vitis HLS (pipeline II=1, 16 multipliers, matrix-matrix and matrix-vector modes) ran on the Zynq board against ARM software. Five tests passed in C simulation and on the board, with zero mismatches at both 100 MHz and 187.5 MHz.
The hardware was slower than the ARM core: about 26.6 µs per problem versus 2.19 µs (0.08x), and raising the fabric clock from 100 to 187.5 MHz barely changed it. That points to per-call overhead (driver and AXI transactions, 8 IP calls per problem) dominating tiny 4x4 problems. This is my interpretation of the numbers. Confirming it would need per-call latency measurements or batching more work into each call.
Tools
Vitis Model Composer · Simulink · MATLAB · Python · Vivado timing and resource analysis · Vitis HLS · CUDA · AMD Zynq UltraScale+ (AUP-ZU3)