diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts
index 06efa2e..7979ae5 100644
--- a/web/e2e/smoke.spec.ts
+++ b/web/e2e/smoke.spec.ts
@@ -28,6 +28,17 @@ test.describe("Compiler Explorer smoke", () => {
await expect(page.getByText("bit-exact").first()).toBeVisible();
});
+ test("the tiny-tpu tab shows the array, cycle by cycle, from the RTL trace", async ({ page }) => {
+ await page.goto("/app");
+ await page.getByRole("button", { name: "Compile & run" }).click();
+ await expect(page.getByText("bit-exact").first()).toBeVisible({ timeout: 150_000 });
+ await page.getByRole("tab", { name: "tiny-tpu" }).click();
+ await expect(page.getByRole("img", { name: /^PE\[0\]\[0\] / }).first()).toBeVisible();
+ await expect(page.getByText(/cycle 0 \/ \d+ · IDLE|cycle 0 \/ \d+ · LOAD_WEIGHTS/)).toBeVisible();
+ await page.getByRole("button", { name: "pass 2" }).click();
+ await expect(page.getByText(/cycle 0 \/ \d+/)).toBeVisible();
+ });
+
test("a rejected program is shown as an error, not a crash", async ({ page }) => {
await page.goto("/app");
await page.getByLabel("source").fill("input A : i8[2, 2]\nC = A @ A +\noutput C\n");
diff --git a/web/package.json b/web/package.json
index 38453a4..ddc3106 100644
--- a/web/package.json
+++ b/web/package.json
@@ -17,6 +17,7 @@
"dependencies": {
"@astrojs/react": "^4.0.0",
"astro": "^5.0.0",
+ "framer-motion": "^12.43.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 580deaf..bad0aad 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
astro:
specifier: ^5.0.0
version: 5.18.2(rollup@4.65.0)(supports-color@7.2.0)(typescript@5.9.3)(yaml@2.9.1)
+ framer-motion:
+ specifier: ^12.43.0
+ version: 12.43.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react:
specifier: ^18.3.1
version: 18.3.1
@@ -1743,6 +1746,20 @@ packages:
resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==}
engines: {node: '>=20'}
+ framer-motion@12.43.0:
+ resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2099,6 +2116,12 @@ packages:
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+ motion-dom@12.43.0:
+ resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==}
+
+ motion-utils@12.39.0:
+ resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
+
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -4617,6 +4640,15 @@ snapshots:
dependencies:
tiny-inflate: 1.0.3
+ framer-motion@12.43.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ motion-dom: 12.43.0
+ motion-utils: 12.39.0
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
fsevents@2.3.2:
optional: true
@@ -5179,6 +5211,12 @@ snapshots:
dependencies:
brace-expansion: 1.1.21
+ motion-dom@12.43.0:
+ dependencies:
+ motion-utils: 12.39.0
+
+ motion-utils@12.39.0: {}
+
mrmime@2.0.1: {}
ms@2.1.3: {}
diff --git a/web/src/components/Explorer.tsx b/web/src/components/Explorer.tsx
index 5d39cee..c82779e 100644
--- a/web/src/components/Explorer.tsx
+++ b/web/src/components/Explorer.tsx
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { analyze } from "../lib/compiler";
import { EXAMPLES } from "../lib/examples.generated";
import { runTpu, type TpuRun } from "../lib/tpu";
+import TpuSchedule from "./TpuSchedule";
import { tpuExact } from "../lib/verify";
import type { Analysis, GpuRun } from "../lib/types";
@@ -184,6 +185,12 @@ export default function Explorer() {
(estimate)
{analysis.tpu.tile ?? ""}
+ Array, cycle by cycle
+ {tpuRun ? (
+
+ ) : (
+ {tpuError ?? "Running on the tiny-tpu RTL…"}
+ )}
>
)}
>
@@ -269,7 +276,7 @@ function Compare({
| cycles |
{gpuRun ? `${gpuRun.cycles}` : "—"} |
- {tpuRun ? `${tpuRun.totalCycles} (${tpuRun.passes} pass${tpuRun.passes === 1 ? "" : "es"} × 15)` : "—"} |
+ {tpuRun ? `${tpuRun.totalCycles} (${tpuRun.passes.length} pass${tpuRun.passes.length === 1 ? "" : "es"})` : "—"} |
| instructions |
diff --git a/web/src/components/PEGrid.tsx b/web/src/components/PEGrid.tsx
new file mode 100644
index 0000000..bb03c55
--- /dev/null
+++ b/web/src/components/PEGrid.tsx
@@ -0,0 +1,413 @@
+/**
+ * PEGrid - pure presentational SVG rendering of the 4×4 systolic array.
+ *
+ * Accepts a single CycleState snapshot and renders:
+ * - 4×4 PE cells colored by hardware state (idle / weight-loaded / active / result)
+ * - Weight and psum values inside each cell
+ * - Activation-in indicator (bottom-left of cell, appears when actIn ≠ 0)
+ * - West input tokens on the left edge (animated, appear during STREAM)
+ * - South output tokens below the grid (highlighted when valid)
+ * - Static flow arrows indicating activation (→) and psum (↓) direction
+ *
+ * Color transitions use CSS `transition` so CSS variables resolve natively.
+ * Token appearance/disappearance uses Framer Motion for spring-based animation.
+ * `prefers-reduced-motion` disables all transitions (snaps to new state).
+ *
+ * RULE: no WASM calls here. No side effects. Keyed elements for stable identity.
+ */
+
+import { motion, AnimatePresence, useReducedMotion } from "framer-motion";
+import type { ReactNode } from "react";
+import type { CycleState, PEState, FsmState } from "../lib/state-schema";
+
+// ─── Layout constants (SVG user-units, 1:1 with px at natural size) ──────────
+
+const CELL = 88;
+const GAP = 10;
+const N = 4;
+const WEST_W = 70;
+const TOP_H = 40;
+const SOUTH_H = 64;
+const GRID_SIDE = N * CELL + (N - 1) * GAP; // 382
+const SVG_W = WEST_W + GRID_SIDE + 12; // 464
+const SVG_H = TOP_H + GRID_SIDE + SOUTH_H; // 486
+
+/** SVG x-origin of a PE column (left edge of cell). */
+function cellX(col: number): number {
+ return WEST_W + col * (CELL + GAP);
+}
+/** SVG y-origin of a PE row (top edge of cell). */
+function cellY(row: number): number {
+ return TOP_H + row * (CELL + GAP);
+}
+
+// ─── PE state color logic ─────────────────────────────────────────────────────
+
+function peFill(pe: PEState, fsm: FsmState): string {
+ if (pe.active) return "var(--pe-active)";
+ if ((fsm === "DRAIN" || fsm === "DONE") && pe.psum !== 0) return "var(--pe-result)";
+ if (pe.weight !== 0) return "var(--pe-weight-loaded)";
+ return "var(--pe-idle)";
+}
+
+function pePsumColor(pe: PEState, fsm: FsmState): string {
+ if (pe.active) return "var(--primary-foreground)";
+ if ((fsm === "DRAIN" || fsm === "DONE") && pe.psum !== 0) return "var(--primary-foreground)";
+ return "var(--foreground)";
+}
+
+/** Clamp a number to fit in the cell (≤ 6 chars). Full value in SVG . */
+function fmt(n: number): string {
+ const s = n.toString();
+ return s.length > 6 ? s.slice(0, 5) + "…" : s;
+}
+
+// ─── Static flow arrows ───────────────────────────────────────────────────────
+
+function FlowArrows() {
+ const arrows: ReactNode[] = [];
+
+ // Horizontal: activations flow right (→) between PE columns
+ for (let row = 0; row < N; row++) {
+ for (let col = 0; col < N - 1; col++) {
+ const x1 = cellX(col) + CELL + 1;
+ const x2 = cellX(col + 1) - 1;
+ const ay = cellY(row) + CELL / 2;
+ arrows.push(
+
+
+
+ ,
+ );
+ }
+ }
+
+ // Vertical: partial sums flow down (↓) between PE rows
+ for (let row = 0; row < N - 1; row++) {
+ for (let col = 0; col < N; col++) {
+ const ax = cellX(col) + CELL / 2;
+ const y1 = cellY(row) + CELL + 1;
+ const y2 = cellY(row + 1) - 1;
+ arrows.push(
+
+
+
+ ,
+ );
+ }
+ }
+
+ return (
+
+ {arrows}
+
+ );
+}
+
+// ─── PE cell ──────────────────────────────────────────────────────────────────
+
+interface PECellProps {
+ pe: PEState;
+ fsm: FsmState;
+ /** When true, all CSS transitions are disabled (prefers-reduced-motion). */
+ reduced: boolean;
+}
+
+function PECell({ pe, fsm, reduced }: PECellProps) {
+ const x = cellX(pe.col);
+ const y = cellY(pe.row);
+ const fill = peFill(pe, fsm);
+ const psumColor = pePsumColor(pe, fsm);
+ const colorTr = reduced ? "none" : "fill 120ms ease-out";
+ const motionTr = reduced ? { duration: 0 } : { duration: 0.18, ease: "easeOut" as const };
+
+ return (
+
+ {/* Background - CSS transition for CSS-var color interpolation */}
+
+ {/* Border */}
+
+
+ {/* Weight - top-left corner */}
+
+ w={pe.weight}
+
+
+ {/* psum - center, large */}
+
+ {pe.psum}
+ {fmt(pe.psum)}
+
+
+ {/* actIn indicator - bottom-left, shown when activation is non-zero */}
+
+ {pe.actIn !== 0 && (
+
+
+
+ {pe.actIn}
+
+
+ )}
+
+
+ );
+}
+
+// ─── South output token ───────────────────────────────────────────────────────
+
+interface SouthTokenProps {
+ col: number;
+ value: number;
+ valid: boolean;
+ reduced: boolean;
+}
+
+function SouthToken({ col, value, valid, reduced }: SouthTokenProps) {
+ const x = cellX(col) + CELL / 2;
+ const lineY1 = TOP_H + GRID_SIDE + 1;
+ const circY = lineY1 + 28;
+ const colorTr = reduced ? "none" : "fill 120ms ease-out, stroke 120ms ease-out";
+
+ return (
+
+
+
+
+ {value}
+ {fmt(value)}
+
+
+ );
+}
+
+// ─── PEGrid (main export) ─────────────────────────────────────────────────────
+
+export interface PEGridProps {
+ /** Hardware state snapshot for a single clock cycle. Pure read - no side effects. */
+ state: CycleState;
+}
+
+export function PEGrid({ state }: PEGridProps) {
+ const reduced = useReducedMotion() ?? false;
+ const tokenTr = reduced
+ ? { duration: 0 }
+ : { duration: 0.12, ease: "easeOut" as const };
+
+ return (
+
+
+
+ );
+}
diff --git a/web/src/components/TpuSchedule.tsx b/web/src/components/TpuSchedule.tsx
new file mode 100644
index 0000000..ff823a3
--- /dev/null
+++ b/web/src/components/TpuSchedule.tsx
@@ -0,0 +1,70 @@
+import { useEffect, useState } from "react";
+import { PEGrid } from "./PEGrid";
+import type { TpuRun } from "../lib/tpu";
+
+// Plays the RTL's per-cycle trace of each chip pass on the systolic-array grid.
+export default function TpuSchedule({ run }: { run: TpuRun }) {
+ const [pass, setPass] = useState(0);
+ const [cycle, setCycle] = useState(0);
+ const [playing, setPlaying] = useState(false);
+
+ const current = run.passes[pass];
+ const last = current ? current.states.length - 1 : 0;
+
+ // Advance one cycle per tick while playing; stop at the end of the pass.
+ useEffect(() => {
+ if (!playing) return;
+ const id = window.setInterval(() => {
+ setCycle((c) => {
+ if (c >= last) {
+ setPlaying(false);
+ return c;
+ }
+ return c + 1;
+ });
+ }, 250);
+ return () => window.clearInterval(id);
+ }, [playing, last]);
+
+ if (!current) return This program has no chip passes.
;
+ const state = current.states[cycle];
+
+ function choosePass(index: number) {
+ setPass(index);
+ setCycle(0);
+ setPlaying(false);
+ }
+
+ return (
+
+
+ {run.passes.map((p, i) => (
+
+ ))}
+
+
+
+ {
+ setPlaying(false);
+ setCycle(Number(e.target.value));
+ }}
+ aria-label="cycle"
+ />
+
+ cycle {cycle} / {last} · {state?.fsmState}
+
+
+ {state &&
}
+
+ Each pass runs on the real tiny-tpu RTL; this grid shows the RTL's own signals, cycle by cycle.
+
+
+ );
+}
diff --git a/web/src/lib/state-schema.ts b/web/src/lib/state-schema.ts
new file mode 100644
index 0000000..347d221
--- /dev/null
+++ b/web/src/lib/state-schema.ts
@@ -0,0 +1,122 @@
+/**
+ * state-schema.ts - TinyTPU per-cycle state types (RTL↔Viz contract)
+ *
+ * Sync requirement: this file and docs/STATE_SCHEMA.md define the same contract.
+ * Any field added, removed, or renamed here must be reflected there, and vice versa.
+ *
+ * All integer values use `number` (JS number is 64-bit float, sufficient for int32).
+ * `weight` and `actIn` are signed int8 (−128..127).
+ * `psum` and south `value` are signed int32.
+ */
+
+// ---------------------------------------------------------------------------
+// FSM state
+// ---------------------------------------------------------------------------
+
+/**
+ * Controller FSM state for a given cycle.
+ *
+ * "DONE" is a harness-derived convenience value - the hardware FSM has no DONE
+ * state; after DRAIN it returns to IDLE. The harness reports "DONE" on the single
+ * cycle where the hardware `done` signal is asserted.
+ */
+export type FsmState = "IDLE" | "LOAD_WEIGHTS" | "STREAM" | "DRAIN" | "DONE";
+
+// ---------------------------------------------------------------------------
+// Processing element state
+// ---------------------------------------------------------------------------
+
+/**
+ * State of one PE (processing element) for a single clock cycle.
+ * 16 entries per CycleState, ordered row-major: index i*4 + j = PE[row=i][col=j].
+ */
+export interface PEState {
+ /** Row index (0–3). */
+ readonly row: number;
+ /** Column index (0–3). */
+ readonly col: number;
+ /**
+ * Stationary weight loaded into this PE.
+ * Hardware source: dbg_weight[i][j] (signed int8).
+ */
+ readonly weight: number;
+ /**
+ * Activation input to this PE on this cycle.
+ *
+ * Derived in the harness - not a direct register read:
+ * actIn[i][j] = (j === 0) ? westInputs[i] : dbg_act[i][j-1]
+ *
+ * dbg_act[i][j] is pe[i][j].act_out (registered passthrough of the previous
+ * cycle's act_in); actIn reconstructs the current-cycle activation input.
+ * Signed int8 range (−128..127).
+ */
+ readonly actIn: number;
+ /**
+ * Registered partial sum output of this PE on this cycle.
+ * Hardware source: dbg_psum[i][j] (signed int32).
+ */
+ readonly psum: number;
+ /**
+ * True when this PE is performing a MAC this cycle.
+ * Derived: fsmState === "STREAM" && actIn !== 0.
+ */
+ readonly active: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// South (bottom-edge) output
+// ---------------------------------------------------------------------------
+
+/** State of one column's bottom-edge output for a single clock cycle. */
+export interface SouthOutput {
+ /** Column index (0–3). */
+ readonly col: number;
+ /**
+ * Accumulated partial sum leaving the bottom of this column.
+ * Hardware source: dbg_south[j] = psum_south[j] (signed int32).
+ */
+ readonly value: number;
+ /**
+ * True when this column's output carries a final result element on this cycle.
+ * Computed in the harness from FSM state and internal cycle counters.
+ */
+ readonly valid: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// Top-level per-cycle state
+// ---------------------------------------------------------------------------
+
+/** Complete hardware state snapshot for one clock cycle. */
+export interface CycleState {
+ /**
+ * Cycle index, starting at 0 on the cycle start fires.
+ * Increments by 1 each tick until done is true.
+ */
+ readonly cycle: number;
+ /** Controller FSM state for this cycle. */
+ readonly fsmState: FsmState;
+ /**
+ * State of all 16 PEs, row-major order.
+ * Index i*4 + j corresponds to PE[row=i][col=j].
+ * Length is always N*N = 16.
+ */
+ readonly pes: readonly PEState[];
+ /**
+ * Activations entering the left (west) edge of each row this cycle.
+ * westInputs[i] = act_west[i]; hardware source: dbg_west[i].
+ * Length is always N = 4. Signed int8 range (−128..127).
+ * Zero when FSM is not in STREAM or row i is outside its skew window.
+ */
+ readonly westInputs: readonly number[];
+ /**
+ * Bottom-edge outputs, one per column. Length is always N = 4.
+ */
+ readonly southOutputs: readonly SouthOutput[];
+ /**
+ * True on the single cycle where the hardware done signal is asserted.
+ * At this point all 16 result elements are captured and fsmState === "DONE".
+ * For N=4 this fires 14 cycles after start (matches golden.py expected_cycles()).
+ */
+ readonly done: boolean;
+}
diff --git a/web/src/lib/tpu.ts b/web/src/lib/tpu.ts
index bf65766..e1f874b 100644
--- a/web/src/lib/tpu.ts
+++ b/web/src/lib/tpu.ts
@@ -1,18 +1,38 @@
// Runs a .ttpu.json program on the real tiny-tpu RTL (WASM), in the browser, through the same
// executor the Node tests use (public/runtime/ttpu-exec.mjs).
import type { TtpuProgram } from "./types";
+import type { CycleState } from "./state-schema";
+
+/** One chip pass: its RTL cycle count and, for the grid view, the state of every cycle. */
+export interface PassTrace {
+ id: number;
+ cycles: number;
+ states: CycleState[];
+}
export interface TpuRun {
outputs: Record;
totalCycles: number;
- passes: number;
+ passes: PassTrace[];
hostOps: Record;
}
-let executor: Promise<{ executeProgram: (p: unknown, i: unknown) => unknown }> | null = null;
+interface Executor {
+ executeProgram: (
+ p: unknown,
+ i: unknown,
+ options: { states: boolean },
+ ) => {
+ outputs: TpuRun["outputs"];
+ passes: PassTrace[];
+ totalCycles: number;
+ hostOps: Record;
+ };
+}
+let executor: Promise | null = null;
-function loadExecutor() {
+function loadExecutor(): Promise {
if (!executor) {
executor = (async () => {
// Public files are served as-is; a variable URL keeps Vite from trying to bundle them.
@@ -28,11 +48,6 @@ function loadExecutor() {
export async function runTpu(program: TtpuProgram, inputs: Record): Promise {
const tpu = await loadExecutor();
- const out = tpu.executeProgram(program, inputs) as {
- outputs: TpuRun["outputs"];
- passes: unknown[];
- totalCycles: number;
- hostOps: Record;
- };
- return { outputs: out.outputs, totalCycles: out.totalCycles, passes: out.passes.length, hostOps: out.hostOps };
+ const out = tpu.executeProgram(program, inputs, { states: true });
+ return { outputs: out.outputs, totalCycles: out.totalCycles, passes: out.passes, hostOps: out.hostOps };
}
diff --git a/web/src/styles/global.css b/web/src/styles/global.css
index 828fbb0..ec3edd2 100644
--- a/web/src/styles/global.css
+++ b/web/src/styles/global.css
@@ -8,6 +8,14 @@
--ok: #1b7f4b;
--bad: #b3261e;
--code-bg: #f3f1ec;
+ --border: var(--line);
+ --foreground: var(--fg);
+ --muted-foreground: var(--muted);
+ --primary-foreground: #ffffff;
+ --pe-idle: #ececea;
+ --pe-active: #d7e86a;
+ --pe-weight-loaded: #6fd3e6;
+ --pe-result: #2e9d6e;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
@@ -21,6 +29,14 @@
--ok: #5fd08f;
--bad: #ff8a80;
--code-bg: #24231f;
+ --border: var(--line);
+ --foreground: var(--fg);
+ --muted-foreground: var(--muted);
+ --primary-foreground: #161513;
+ --pe-idle: #2a2925;
+ --pe-active: #d7e86a;
+ --pe-weight-loaded: #3fb8cc;
+ --pe-result: #3fbf8a;
}
}
:root[data-theme="dark"] {
@@ -33,6 +49,14 @@
--ok: #5fd08f;
--bad: #ff8a80;
--code-bg: #24231f;
+ --border: var(--line);
+ --foreground: var(--fg);
+ --muted-foreground: var(--muted);
+ --primary-foreground: #161513;
+ --pe-idle: #2a2925;
+ --pe-active: #d7e86a;
+ --pe-weight-loaded: #3fb8cc;
+ --pe-result: #3fbf8a;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }