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( + <g key={`h-${row}-${col}`}> + <line x1={x1} y1={ay} x2={x2 - 4} y2={ay} strokeWidth={1} /> + <polygon + points={`${x2 - 6},${ay - 3} ${x2},${ay} ${x2 - 6},${ay + 3}`} + /> + </g>, + ); + } + } + + // 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( + <g key={`v-${row}-${col}`}> + <line x1={ax} y1={y1} x2={ax} y2={y2 - 4} strokeWidth={1} /> + <polygon + points={`${ax - 3},${y2 - 6} ${ax},${y2} ${ax + 3},${y2 - 6}`} + /> + </g>, + ); + } + } + + return ( + <g + fill="var(--muted-foreground)" + stroke="var(--muted-foreground)" + opacity={0.3} + > + {arrows} + </g> + ); +} + +// ─── 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 ( + <g + role="img" + aria-label={`PE[${pe.row}][${pe.col}] weight=${pe.weight} actIn=${pe.actIn} psum=${pe.psum}${pe.active ? " active" : ""}`} + > + {/* Background - CSS transition for CSS-var color interpolation */} + <rect + x={x} + y={y} + width={CELL} + height={CELL} + rx={5} + style={{ fill, transition: colorTr }} + /> + {/* Border */} + <rect + x={x} + y={y} + width={CELL} + height={CELL} + rx={5} + fill="none" + stroke="var(--border)" + strokeWidth={1} + /> + + {/* Weight - top-left corner */} + <text + x={x + 7} + y={y + 13} + fontSize={9} + fontFamily="'Geist Mono', ui-monospace, monospace" + fill="var(--muted-foreground)" + > + w={pe.weight} + </text> + + {/* psum - center, large */} + <text + x={x + CELL / 2} + y={y + CELL / 2 + 9} + textAnchor="middle" + fontSize={20} + fontWeight="bold" + fontFamily="'Geist Mono', ui-monospace, monospace" + style={{ fill: psumColor, transition: colorTr }} + > + <title>{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 ( +
+ + {/* Flow direction arrows (static) */} + + + {/* Column headers C0–C3 */} + {Array.from({ length: N }, (_, col) => ( + + C{col} + + ))} + + {/* Row labels R0–R3 */} + {Array.from({ length: N }, (_, row) => ( + + R{row} + + ))} + + {/* West input tokens - animate in/out as activations arrive */} + + {state.westInputs + .map((val, row) => ({ val, row })) + .filter(({ val }) => val !== 0) + .map(({ val, row }) => { + const tcx = WEST_W / 2 - 2; + const tcy = cellY(row) + CELL / 2; + return ( + + + + + {val} + + {/* Arrow pointing into grid */} + + + + ); + })} + + + {/* PE cells */} + {state.pes.map((pe) => ( + + ))} + + {/* South output tokens */} + {state.southOutputs.map((so) => ( + + ))} + +
+ ); +} 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; }