An interactive, educational playground designed to demystify how systolic arrays and pipelined processing elements perform high-throughput matrix multiplication.
π Live demo: systolic-array-simulator.georgesung.com Β Β |Β Β π Companion blog post: Systolic Array Matmul
The project is split into two core layers:
- The Rust Core (Hardware Model): A cycle-accurate, deterministic software simulation of physical hardware-level behaviors (register latching, clock-edge synchronization, and Multiply-Accumulate logic).
- The Next.js Frontend (End-User App): A high-fidelity, interactive web application that compiles the Rust hardware simulator to WebAssembly (WASM), allowing users to stream data, step through clock cycles, inspect registers in real-time, and play with different systolic array dimensions.
A note on names: the crate is
rust-hw-playground, the local directory ispipeline_add(a historical name from the very first pipelined-adder experiment), and the published repo isgeorgesung/systolic-array-simulator. They all refer to this same project.
Below is an overview of the key directories and files comprising the repository, showing how the frontend, the Rust simulation core, the CLI interfaces, and the documentation are organized:
pipeline_add/
βββ Cargo.toml # Rust package configuration (CDYLib/RLib) and dependencies
βββ README.md # This comprehensive simulator guide and architecture overview
βββ AGENTS.md # Orientation guide for coding agents working in this repo
βββ .github/workflows/
β βββ nextjs.yml # CI: builds WASM + Next.js static export, deploys to GitHub Pages
βββ src/ # π¦ Core Rust Hardware Simulation Engine
β βββ lib.rs # Module entrypoint + WASM panic hook (`init_panic_hook`)
β βββ processing_element.rs # Weight-stationary processing element (MAC unit)
β βββ dot_product.rs # 1D Pipelined array of PEs
β βββ systolic_array.rs # 2D Systolic Array model
β βββ pipeline_add.rs # Educational 2-stage pipelined integer adder
β βββ wasm_bindings.rs # WebAssembly wrappers (DotProductSim, SystolicArray2DSim)
β βββ bin/ # Rust native CLI simulators for debugging
β βββ cli_dot_product.rs
β βββ cli_systolic_array.rs
βββ pkg/ # π§ Generated by `wasm-pack build` (git-ignored)
β # Consumed by the frontend as a local npm dependency
βββ web/ # π» Next.js Frontend Application
β βββ package.json # Node.js dependencies, scripts, and build metadata
β βββ next.config.ts # Static export (`output: 'export'`) + async WASM webpack flag
β βββ public/CNAME # Custom domain for GitHub Pages
β βββ src/
β βββ app/ # Main React App Router (layout.tsx, page.tsx, globals.css)
β βββ components/ # Interactive simulation UI views
β β βββ Instructions.tsx # Tab 1: Educational overview and orientation
β β βββ PESimulator.tsx # Tab 2: Single PE MAC view
β β βββ Simulator.tsx # Tab 3: 1D Dot Product simulator
β β βββ MatrixMultiplySimulator.tsx # Tab 4: 2D Matrix Multiply simulator
β β βββ TiledMatmulSimulator.tsx # Tab 5: Tiled Matrix Multiply simulator
β β βββ SystolicGrid.tsx # Shared PE-grid render used by Tabs 4 and 5
β β βββ PipelineVisualizer.tsx # SVG datapath visualizer used by Tab 3
β β βββ ui/ # shadcn/ui primitives (button, card, input, ...)
β βββ hooks/ # Custom React Hooks interfacing with Rust WebAssembly
β β βββ usePipeline.ts # Connects Tab 3 to DotProductSim
β β βββ useMatrixMultiply.ts # Connects Tab 4 to SystolicArray2DSim
β β βββ useTiledMatmul.ts # Connects Tab 5 to SystolicArray2DSim (one pass per tile)
β βββ lib/
β βββ utils.ts # Tailwind class-merge helper
β βββ tones.ts # Shared color palettes for the grid visualizers
βββ docs/ # π Standalone MkDocs site (theory + architecture notes)
βββ mkdocs.yml # MkDocs config β see the note under "Documentation" below
The user touchpoint is a modern, responsive web application located in ./web/. It uses Next.js 16 (App Router), React 19, TypeScript, and Tailwind CSS v4 with shadcn/ui components, turning hardware concepts into intuitive, interactive visual schematics.
Users can step through simulations cycle-by-cycle, or auto-play them at a fixed pace (1 s per cycle in the 1D tab, 1.5 s in the 2D tab, 0.7 s in the tiled tab). The whole app is a single client-rendered page; the five views are tabs, each deep-linkable via a URL hash:
| Tab | URL hash |
|---|---|
| Instructions | #instructions (also #overview, or no hash) |
| Processing Element | #pe |
| 1D Dot Product | #dot-product (also #dotproduct) |
| 2D Matrix Multiply | #matmul (also #matrix-multiply) |
| Tiled Matrix Multiply | #tiled (also #tiled-matmul) |
- Concept: Provides an educational overview and visual guide on how to interact with the simulators.
- Content: Hand-authored React/JSX cards explaining what a systolic array is, what each simulator tab teaches, and the color-coding conventions used throughout the app, plus a "Start" button into the PE simulator.
- Educational Takeaway: Serves as a foundational landing page, ensuring students understand the underlying hardware definitions and color-coding schemes before running the simulators.
- Concept: Models the atomic Multiply-Accumulate (MAC) cell of a systolic array.
-
Inputs: Allows entering an internal stationary
weightregister and comma-separated lists of stream values for$X$ (Activations) and$Y$ (Accumulating partial sums). - Visual Representation: Renders the PE as a central box highlighting its internal stationary register, input arrows, and physical boundary registers that latch and delay outputs.
-
Educational Takeaway: Demonstrates why a value entering on cycle
$N$ only affects downstream cells on cycle$N+1$ due to synchronous latching/delay.
-
Concept: Connects PEs into a single vertical column to calculate a vector dot product (
$\vec{x} \cdot \vec{w}$ ). -
Inputs: Customizable vector size
$N$ , batch size$M$ (how many input vectors stream through back-to-back), the weight vector, and the input matrix. - Visual Representation: Renders a vertical line of linked PEs, showing data elements actively shifting and computing step-by-step.
-
Educational Takeaway: Illustrates pipeline latencyβthe first dot-product result takes
$N$ cycles to emerge from the bottom, but subsequent results cascade out sequentially every clock cycle thereafter.
-
Concept: Computes Weight-Stationary Matrix Multiplication (
$C = A \times B$ ) using a 2D grid of PEs, with$B$ held stationary in the PE weight registers. -
Inputs: Square size selector (
$N \times N$ , adjustable from$2 \times 2$ up to$8 \times 8$ ) and multi-matrix sequential batching (simultaneously feed$A_1, A_2, A_3$ through the array). -
Visual Representation: Highlights the skewed input wavefront (where rows of Matrix A are delayed diagonally by
$0, 1, 2, \dots$ cycles) so they intersect the correct stationary weight at the exact cycle their accumulating$Y$ sum arrives from the PE above. - Educational Takeaway: Clarifies spatial and temporal concurrency. Students see multiple independent multiplications and accumulations happening in parallel across different physical components in real-time.
The most advanced tab, and the one that models a real accelerator end to end. It follows the TPU model: a fixed weight-stationary array, a weight FIFO, and an accumulator β deliberately not the CPU/BLIS cache-blocking model, because a systolic array's whole argument is that it gets its reuse spatially (as data propagates across PEs) rather than temporally (through a cache hierarchy).
-
Concept: Runs a matrix multiply that is larger than the hardware. The physical array is fixed (1Γ1 up to 4Γ4 PEs) while
$B$ is chopped into tiles that are loaded into the array one at a time. -
Inputs:
$M$ ,$K$ and$N$ are set independently ($M$ up to 16,$K$ /$N$ up to 8), plus the physical array size and a weight-FIFO toggle.$M$ is the important knob β it controls how many rows stream through one resident weight tile. -
Loop order: N outer, K middle, M streamed innermost. This is the Goto/BLIS order:
$B$ is read once,$A$ is re-read once per N-tile, and each$C$ cell is written once per K-tile. -
Visual Representation:
- A tile map showing
$A$ ,$B$ and$C$ with tile boundaries drawn and the current tile window highlighted. - The shared PE grid running the current tile pass, with zero-padded PEs dimmed on edge tiles, and β during a weight load β the tile visibly shifting in one row per cycle from the top.
- A weight FIFO panel staging the next tile while the current one computes.
- A pass timeline: one bar per cycle, height = active MACs, colored by phase (weight load β pipeline fill β steady state β pipeline drain).
- A live accumulator for
$C$ : cells are dashed while partial and solid once every K-tile has landed.
- A tile map showing
-
Educational Takeaway: Every tile pass pays two fixed taxes regardless of how much work it does β shifting the weights in (one cycle per array row) and filling then draining the pipeline. The only way to make them cheap is to amortize them over more work. Raise
$M$ and the steady-state plateau widens as utilization climbs (on a 2Γ2 array with$K=N=4$ : 36% at$M=2$ , 63% at$M=6$ , 82% at$M=16$ ). That is the whole argument for large batch sizes on an accelerator. Toggling the weight FIFO off exposes every load as a stall, which is why real hardware double-buffers them. -
Note: This tab required no changes to the Rust core. It orchestrates repeated passes of the same
SystolicArray2DSimfrom TypeScript, and models the weight-load timing in the scheduler β which is exactly how a fixed-size array is driven in real life.
To guarantee true hardware fidelity, the underlying computation engine is written in Rust (src/) and acts as a cycle-accurate hardware model.
-
ProcessingElement(src/processing_element.rs): A physical struct containing actual storage registers:-
weight: Loaded beforehand (weight-stationary configuration register). -
reg_x_out: Register holding activation$x$ passed to the right neighbor on the next tick. -
reg_y_out: Register holding the accumulated$y$ value passed to the bottom neighbor on the next tick.
-
-
DotProduct1D(src/dot_product.rs): A 1D array ofProcessingElements. -
SystolicArray2D(src/systolic_array.rs): A 2D grid ofProcessingElements. -
PipelinedAdder(src/pipeline_add.rs): An auxiliary educational module representing a 2-stage pipelined integer adder. It is not exposed to the frontend.
Both array types are generic over their storage S, so the same logic backs a fixed-size stack array (new_static(), no allocation) and a runtime-sized Vec (new_dynamic(), used by the WASM bindings).
In actual hardware, all registers update simultaneously on a single clock edge. In sequential software, a naive loop would overwrite a register prematurely, causing a cascade of bad values within a single tick.
The 2D array solves this by iterating backward (from bottom-right to top-left). This allows reading neighbors' register values from the previous clock cycle before they are updated by the current tick:
// Iterating backwards simulates concurrent clock edge propagation
for r in (0..rows).rev() {
for c in (0..cols).rev() {
// Read input X from left PE's previous cycle register
let x_in = if c == 0 { left_ins[r] } else { pes[r * cols + (c - 1)].reg_x_out() };
// Read input Y from top PE's previous cycle register
let y_in = if r == 0 { top_ins[c] } else { pes[(r - 1) * cols + c].reg_y_out() };
// Compute combinatorial logic & latch outputs in registers
let idx = r * cols + c;
pes[idx].tick(x_in, y_in);
}
}The 1D array achieves the same effect differently: it walks forward from top to bottom, but snapshots each PE's reg_y_out before ticking it, then feeds that saved previous-cycle value into the PE below. Both approaches guarantee that every PE in a tick observes only pre-clock-edge state.
The boundary between the user's browser (React) and the cycle-accurate compiled core (Rust) is crossed cleanly using WebAssembly and wasm-bindgen bindings.
[ Next.js UI ] <--- Real-time Redraw of State --- [ React State ]
| ^
| (User ticks or Auto-Plays) | (get_state)
v |
[ Custom React Hooks (usePipeline / useMatrixMultiply) ] ------+
|
v (WebAssembly Interface)
[ Rust WASM Simulators (DotProductSim / SystolicArray2DSim) ]
- WASM Binding Compilation (
src/wasm_bindings.rs): Exposes stateful Rust objectsDotProductSimandSystolicArray2DSimdirectly to JavaScript. - State Management via Hooks (
web/src/hooks/): React hooks (usePipeline.ts,useMatrixMultiply.tsanduseTiledMatmul.ts) instantiate the simulators and maintain UI synchronicity. - The Cycle Loop:
- When a user steps forward or toggles Auto-Play, the hook invokes the simulator's
.tick()method. - The simulator executes one hardware-level cycle.
- The hook immediately pulls a flat representation of all internal PE registers via
.get_state()β a flat[W, X, Y, W, X, Y, ...]array in row-major PE order. - This updates the React state, prompting Tailwind/HTML elements to render updated numbers, flow arrows, and color highlights instantly on screen.
- When a user steps forward or toggles Auto-Play, the hook invokes the simulator's
The generated pkg/ directory is wired into the frontend as a local file dependency ("rust-hw-playground": "file:../pkg" in web/package.json), so the WASM package must be built before the web app.
If you prefer to run simulations directly from the terminal, the Rust core includes interactive command-line interface simulators:
cargo run --bin cli_dot_productcargo run --bin cli_systolic_arrayFollow these steps to build the WebAssembly module and run the Next.js visual dashboard locally:
- Install Rust and Cargo.
- Install
wasm-packfor compiling Rust to WebAssembly:cargo install wasm-pack
- Install Node.js (v18+ recommended; CI uses v20).
In the root directory of the project, run:
wasm-pack build --target webThis compiles the Rust crate and outputs a web-ready package inside the ./pkg/ directory, complete with generated JavaScript/TypeScript bindings. Re-run this after any change to src/ β the frontend consumes the built artifact, not the Rust source.
Navigate to the web/ directory, install dependencies, and start the development server:
cd web
npm install
npm run devOpen http://localhost:3000 in your browser to interact with the simulator dashboard.
To generate a static build of the website and serve it:
npm run build
npm run servenext.config.ts sets output: 'export', so npm run build emits a fully static site into web/out/. It also enables webpack's asyncWebAssembly experiment, which is what allows the .wasm module to be imported.
Every push to main triggers .github/workflows/nextjs.yml, which:
- Installs Rust +
wasm-packand runswasm-pack build --target web. - Installs the
web/dependencies and runsnext build --webpack. - Uploads
web/out/and deploys it to GitHub Pages.
The site is served at https://systolic-array-simulator.georgesung.com via the custom domain in web/public/CNAME.
Note that the workflow does not run cargo test β run the tests locally before pushing.
The Rust hardware logic is tested to ensure correct behavior and compliance with theoretical models:
# Run all native unit tests (PE, 1D dot product, 2D systolic array, pipelined adder)
cargo test
# Run a specific educational pipeline-add test with verbose output
cargo test test_pipeline_add_educational -- --nocaptureThe tests in src/wasm_bindings.rs are #[wasm_bindgen_test]s and are skipped by cargo test. Run them against Node with:
wasm-pack test --nodeHighlights of the suite:
processing_element.rsβ a fixed hand-verified case plus a 1000-iteration randomized MAC check.systolic_array.rsβ 2Γ2 and 3Γ3 hand-traced walkthroughs, plus a randomized test that compares the array's skewed-wavefront output against a naive triple-loop matmul for every size from 2Γ2 to 10Γ10.
docs/ holds a standalone MkDocs site (theory of operation and architecture notes), configured by mkdocs.yml with the Material theme and MathJax:
pip install mkdocs-material # or activate the local .venv
mkdocs serveMIT β see LICENSE.