systolic-arrays-explained
← /learn · 03

Output- and input-stationary

The same matrix multiply in three dataflows side by side: what stays put, what moves, and what that costs in memory reads, link hops and register writes.

Loading the animation…

Concept

Chapter 2 held the weights still. That is one of three choices for a matrix multiply, each named after the operand that stays put in each PE (the names come from Chen, Emer and Sze's taxonomy for the Eyeriss accelerator, which adds row-stationary for convolutions; SCALE-Sim, a systolic-array simulator, uses these three for matrix multiplies):

  • Weight-stationary (WS): BB stays; AA streams in from the left; partial sums move down. A K×NK \times N block of PEs.
  • Output-stationary (OS): CC stays. amka_{mk} enters row mm from the left and bknb_{kn} enters column nn from the top, both skewed, and they meet in PE(m,n)(m, n) at cycle k+m+nk + m + n, which adds their product to its own accumulator. Nothing but 8-bit operands moves until the end, when the finished results drain out. An M×NM \times N block.
  • Input-stationary (IS): AA stays (PE(k,m)(k, m) holds amka_{mk}); the columns of BB stream in from the left; partial sums move down. It is weight-stationary on the transposed problem C⊤=B⊤A⊤C^\top = B^\top A^\top. A K×MK \times M block.

The animation runs all three on the same 3 × 3 by 3 × 3 problem on one clock, and counts what each moves. When the problem fits the array, all three read every element of AA and BB from the buffers exactly once (18 reads) and write each result once (9), and all three make the same number of PE-to-PE hops (45). The difference is what hops. WS and IS pass a partial sum down at every hop between rows: 18 partial-sum hops here. OS keeps its sums in place and moves them only when draining: 9. That matters because partial sums are wide: 8-bit operands accumulate into 32-bit sums. At 128 × 128 × 128, WS moves 91,553,792 bits between PEs and OS 66,584,576, though OS writes its accumulator registers more often (7,331,840 register writes against 5,251,072).

The bigger difference shows when the problem does not fit, which is the normal case: the stationary matrix is the one that gets reused across a whole stream, and the dataflow decides which matrix is tiled, which is re-read, and where partial sums of a split KK go (chapter 5).

Loading the animation…

Concept

Here is output-stationary with the numbers (switch to input-stationary to watch AA held and BB streamed). Every PE's sky "c" is its accumulator: it gains one term per cycle as the blue aa from the left and the vermillion bb from above pass through. After the last product the results drain down the columns, one row per cycle.

This animation is checked against RTL. The author's Interview RTL challenge is a parameterised SystemVerilog output-stationary array with internal skew registers. This site's rtl/check_rtl.py runs it in Verilator 5.020 on 3 problems (including full-range signed INT8) and compares every accumulator after every clock edge with this model: 613 values over 44 edges, 0 mismatches. (The RTL reads its results out in parallel rather than draining them, so after the last product the check requires it to hold CC.) The model page has the details.

Maths

Output-stationary timing. Row mm of AA is delayed mm cycles and column nn of BB is delayed nn cycles, so amka_{mk} enters at cycle k+mk + m and reaches PE(m,n)(m, n) after nn more hops, at k+m+nk + m + n; bknb_{kn} enters at k+nk + n and reaches PE(m,n)(m, n) after mm hops, at the same cycle. The last product, k=K−1k = K-1 in PE(M−1,N−1)(M-1, N-1), happens at cycle K+M+N−3K + M + N - 3, so with an MM-cycle drain

TOS=(K+M+N−2)+M.T_{\text{OS}} = (K + M + N - 2) + M .

Counting the movement. Reads: every amka_{mk} and bknb_{kn} enters once, MK+KNMK + KN. Operand hops: amka_{mk} crosses N−1N - 1 links, bknb_{kn} crosses M−1M - 1: MK(N−1)+KN(M−1)MK(N-1) + KN(M-1). Draining moves cmnc_{mn} down M−1−mM - 1 - m rows: 12NM(M−1)\tfrac12 N M(M-1). For weight-stationary the activation hops are MK(N−1)MK(N-1), each cmnc_{mn}'s partial sum makes K−1K - 1 hops, MN(K−1)MN(K-1), and loading moves wknw_{kn} down kk rows, 12NK(K−1)\tfrac12 NK(K-1). With M=K=NM = K = N both totals are 52N2(N−1)\tfrac52 N^2 (N-1) hops. The model counts every hop in every frame, and the tests check the counts against these closed forms for every dataflow and shape.

Code

Output-stationary's PE, cut from src/lib/sa/model.ts: both operands arrive from neighbours (or the edges), and only the accumulator stays.

cell[1] = hIn;
cell[2] = vIn;
let acc = prev[mm]![nn]![3]!;
if (hIn !== null && vIn !== null) {
  /* c8 ignore next */
  if (hIn[2] !== vIn[1]) throw new Error("operands out of step");
  cell[4] = [hIn[0], vIn[0]];
  cnt.macs += 1;
  cnt.regWrites += 1;
  acc = [acc[0] + hIn[0] * vIn[0], mm, nn, acc[3] + 1];
}
cell[3] = acc;

The check that the two operands belong to the same kk never fires: the skew guarantees it, and the tests run every shape.