SW/01 ← All Projects

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.

A rows in B columns in PE11PE12PE13PE14 PE21PE22PE23PE24 PE31PE32PE33PE34 PE41PE42PE43PE44 A flows east B flows south

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.

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)KernelKernel % of total
4x4159.60 ms98.30%
8x841.38 ms94.17%
16x1641.48 ms94.20%
32x3241.58 ms84.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)