systolic-arrays-explained

/model

The cycle-accurate model

The model computes C = AB (A is M × K, B is K × N) on an R × C array. A frame is the state of every register after the clock edge that ends a cycle, computed only from the previous frame and the values entering at the edges, so data moves exactly one PE per cycle. A PE holds up to four registers: s, the stationary operand; h, the operand moving right; v, what moves down (a partial sum, or in output-stationary an operand); and acc, output-stationary's accumulator. Each register holds a tag naming the element of A, B or C it carries, so the captions can say which value is where. Source: reference/systolic.py and its exact TypeScript port src/lib/sa/model.ts.

Dataflows and closed forms

The array block each dataflow needs, what stays in it, where and when each product happens, the cycles of one pass (load, compute and drain) and the partial sums moved between PEs. The tests count all of these from the frames.

DataflowBlockStationaryProductCyclesPartial-sum hops
weight-stationaryK × NB (weights)aₘₖ in PE(k, n) at m + k + nK + (M + K + N − 2)M N (K − 1)
output-stationaryM × NC (outputs)aₘₖ, bₖₙ in PE(m, n) at k + m + n(K + M + N − 2) + MN M (M − 1) / 2
input-stationaryK × MA (inputs)bₖₙ in PE(k, m) at n + k + mK + (N + K + M − 2)M N (K − 1)

For a 128 × 128 × 128 problem on a 128 × 128 array:

DataflowCyclesBuffer readsBuffer writesPE-to-PE hopsPartial-sum hopsRegister writes
weight-stationary51032,76816,3845,201,9202,080,7685,251,072
output-stationary51032,76816,3845,201,9201,040,3847,331,840
input-stationary51032,76816,3845,201,9202,080,7685,251,072

The RTL cross-check

The design is a parameterised N × N output-stationary array with internal skew registers, from the author's Interview RTL challenge (modules pe and systolic_array, vendored unchanged at commit b37984f). rtl/tb_trace.sv drives one matrix multiply as the challenge's own testbench does and prints every accumulator after every rising edge; rtl/check_rtl.py builds it in Verilator 5.020, runs it and compares. Edge p is the model's compute cycle p − 1; after the last product (edge K + 2(N − 1)) the RTL holds its results and pulses valid_out, so there the check requires C = AB. Run with --shift 1, comparing each edge with the next cycle, it reports mismatches in every case: the comparison can fail. The same testbench in Vivado xsim 2025.2 gives byte-identical traces (run locally; CI has no Vivado).

CaseNKValues inEdgesAccumulators comparedMismatchesvalid_out at edge
n4_k4_small44-4 … 514224010
n4_k7_int847-128 … 12717272013
n3_k5_small35-4 … 51311709

The weight- and input-stationary dataflows are not checked against RTL here; they are checked against a direct matrix multiply, their textbook timing and their closed forms.

The processing element against its RTL

Chapter 6's PE is the author's three-stage mixed-precision MAC unit (modules fp16_mul_to_fp32, fp32_add and mac_unit_mixed_precision of the MAC unit challenge, vendored unchanged at commit b37984f as rtl/mac_unit.sv). rtl/tb_mac_trace.sv drives one operation per cycle and prints every pipeline register after every rising edge; the same script compares them with reference/pe.py, which models the unit register for register (FP16 mode: the stage-1 operands, the exact FP32 product, the FP32 accumulator, valid and clear; INT8 mode: the operand bytes, the INT16 product and the INT32 accumulator). With --shift 1 every case reports mismatches. The same five runs in Vivado xsim 2025.2 give byte-identical traces (run locally). The site's bfloat16 mode has no RTL counterpart and is checked against NumPy.

CaseModeOperationsValidDot productsEdgesRegisters comparedMismatches
mac_fp16_demoFP1688211990
mac_int8_demoINT888211990
mac_fp16_wideFP16400331254033,6270
mac_fp16_longFP1640031644033,6270
mac_int8_randINT8400319254033,6270

Tiles back to back

Chapter 5 adds a shadow weight register to every PE and runs a GEMM's weight-stationary tiles back to back (simulateWsStream). The model checks, every cycle, that a PE swaps in the weight of the tile whose activation has just arrived and that every partial sum meets its next product in step; moving one tile's load or stream a cycle earlier makes it stop with an error. Its cycle counts equal the closed form Sj+1 = max(Sj + M, Lj+1 + kj+1), and with the shadow registers off they equal chapter 4's tiles in sequence.

The lowering against Torch_Sim_Frontend

Chapter 9 lowers a small ONNX model onto the array. scripts/check_simfront.py runs the author's Torch_Sim_Frontend (commit de3acd4) on the same file: its ONNX front end and GEMM rule give the same shapes, and its cycle-approximate array formula the same numbers, on a 8 × 8 array.

Nodesimfront categoryGEMM (batch, M, K, N)simfront cyclesAgree
Convmatmul1 × 36 × 27 × 8151yes
Reluelementwisenone–yes
Flattenviewnone–yes
Gemmmatmul1 × 1 × 288 × 10592yes

Row-stationary, as a reference

For a convolution, Eyeriss's row-stationary dataflow gives PE(i, j) filter row i and input row i + j; each PE slides its filter row along its input row, one MAC per cycle, and each column of PEs adds its rows into one output row. The model runs it too (simulateRs): a 6 × 7 input and a 3 × 3 filter take 17 cycles on 3 × 4 PEs, and the output equals a direct convolution. Chapter 8 animates it.