Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,16 @@ jobs:
- name: Build
working-directory: web
run: pnpm build

- name: Unit tests (Vitest)
working-directory: web
run: pnpm test

# The engine (web/public/tiny_attn.{mjs,wasm}) is committed, built by wasm.yml's toolchain; no Emscripten here.
- name: Install Playwright Chromium
working-directory: web
run: pnpm exec playwright install --with-deps chromium

- name: Smoke tests (Playwright, production build)
working-directory: web
run: pnpm exec playwright test
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ sim/results.xml

# WASM build intermediates and generated artifacts
wasm/build/
web/public/tiny_attn.mjs
!web/public/tiny_attn.mjs
web/public/spike/
*.o
*.wasm
!web/public/tiny_attn.wasm

# Toolchain checkouts (built by .tools/*.sh, or symlinked to a sibling project's)
.tools/*
Expand All @@ -40,3 +41,7 @@ web/public/spike/
# Agent scratch
.agents/
.claude/

# Playwright output
web/test-results/
web/playwright-report/
13 changes: 12 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,18 @@ On a fresh machine delete the symlinks and run `.tools/setup.sh` and `.tools/ems
pass; perf baseline in `sim/perf_baseline.json` (re-record with `PERF_RECORD=1 COCOTB_TEST_FILTER=perf_grid bash sim/run_e2e.sh`).
- Phase 6 (WASM harness): DONE. `wasm/build.sh` builds `web/public/tiny_attn.{mjs,wasm}` (81 KB gzipped); `node wasm/test.mjs`
checks three golden fixtures on interleaved instances, exactly. JS API in `docs/STATE_SCHEMA.md`.
- Next: Phase 7 (web UI). First: wide-signal access so the per-clock state carries per-PE values and softmax lanes.
- Phase 7 (web visualizer `/app`): DONE. Presets, editable Q/K/V (quantized in the browser), schedule, tile values, K/V
buffers (read from the RTL), HBM meter against the golden traffic model, heatmap (engine P for NAIVE, float reference for
FLASH), Playwright smoke test (5 tests, production build).
- Next: Phase 8 (L1 softmax cell, L2 tile dataflow, L4 naive vs flash with two instances, N sweep, error panel).

## Gotchas found in Phase 7
- `wasm/fixtures/` holds engine runs and `quantize_cases.json`; the Node test must skip the quantization file (it did not for
a while and aborted in configure with no useful message).
- Embind gives 64-bit integers to JavaScript as BigInt, which breaks JSON comparison. Tile values are 32-bit for that reason.
- The tile core's debug S/Z/P registers are only written by FLASH. NAIVE views must not show them.
- Playwright 1.64 needs its own Chromium build (`pnpm exec playwright install chromium`); the cache has other versions.
- `astro dev` hung without output in this environment; `astro build` + `astro preview` works for browser checks.

## Gotchas found in Phase 5
- The NAIVE tile operations reuse the online-softmax unit: OP_NM uses its m path, OP_NP uses its P and l path with alpha = 1
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Fourth project in the series after [tiny-gpu](https://github.com/deaneeth/tiny-g
[tiny-tpu](https://github.com/deaneeth/tiny-tpu) and tiny-cpu. Same rule as the others: nothing on screen is
faked; every value comes from a `dbg_*` port on the compiled RTL.

**Status:** Phases 0-6 done: the engine (FLASH, NAIVE, K/V overlap) is bit-exact against the golden model and compiled to WebAssembly (81 KB gzipped), with a Node test over golden fixtures. Next is the web UI. See [`docs/PLAN.md`](docs/PLAN.md) for the roadmap.
**Status:** Phases 0-7 done: the engine (FLASH, NAIVE, K/V overlap) is bit-exact against the golden model, compiled to WebAssembly, and shown clock by clock in a browser visualizer at `/app`, with editable inputs, K/V buffers, HBM traffic against the model, and a Playwright smoke test. Next: the L1, L2 and L4 views. See [`docs/PLAN.md`](docs/PLAN.md) for the roadmap.

## Quick start

Expand Down
36 changes: 36 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,39 @@ masking. A fresh instance reproduces the run, so no state leaks between instance

Deviation from the plan: the per-clock state exported to JavaScript is a subset (controller state, counters, byte counts).
Per-PE values, softmax lanes and SRAM tiles need wide-signal access and are the first item of Phase 7.

## 12. Phase 7: web visualizer (as built)

`/app` (`web/src/pages/app.astro`) runs the WASM engine in the browser and shows it clock by clock.

- **Presets** (`sim/golden/presets.py` → `web/src/lib/presets.ts`, generated): six attention patterns with token labels.
Their Q/K/V are quantized in the browser by `web/src/lib/quantize.ts`, a mirror of `fixed.quantize`. Both are checked
against golden values in `pytest` (freshness) and `vitest` (values).
- **Controls**: schedule (FLASH / NAIVE), causal, overlap (FLASH), `cfg_qrows`, HBM latency. Start, Step, Back (rebuilds
the run from reset and advances N−1 clocks), Run to end, Reset.
- **Views**: the tile schedule (FLASH: row groups × K/V tiles, with the causal mask; NAIVE: pass-1 Z tiles), the current
tile's S, Z, P, m, l, α and O (FLASH; NAIVE shows m, l and O only, since its S/Z/P never enter the tile core), HBM bytes,
tile count, K/V loads, and controller state names (`FLASH_STATES` / `NAIVE_STATES`, matching the enums in the RTL).
- **Result**: the dequantized O, its maximum deviation from float attention (`web/src/lib/reference.ts`), and for NAIVE the
probability matrix read back from HBM, with token labels.

Checks: `node wasm/test.mjs` (engine vs golden), `pnpm test` (quantization and tile order against the fixtures),
`pnpm typecheck`, `pnpm lint`, `pnpm build`. The browser run (built site, `astro preview`) showed no console errors; the
FLASH run and the NAIVE run both finished with max deviation 0.0049 on the toy preset; the phone width has no horizontal scroll.

Phase 7 items, all built: `MatrixEditor` (the Q, K and V values are editable; editing resets the run), `SramPanel` (the
K and V tile buffers, read from the RTL through `dbg_kbuf`, `dbg_vbuf`, `dbg_kv_sel`, with the buffer in use outlined),
`HbmMeter` (bytes so far against the expected total, from `web/src/lib/traffic-table.ts`, generated from
`sim/golden/traffic.py`), and `AttentionHeatmap` (engine probabilities from HBM for NAIVE; the float reference for FLASH,
which never stores P). The Playwright smoke test (`pnpm test:e2e`, five tests: FLASH output against float, step and back,
NAIVE probabilities, editing and re-running, phone width) runs against the production build.

Checks: `node wasm/test.mjs` (engine vs golden, now including the K/V buffer in use against the last tile loaded),
`pnpm test` (quantization, tile order), `pnpm test:e2e`, `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pytest sim/golden`
(fixture, web-data and traffic-table freshness). The `astro dev` server hangs without output in this environment, so
browser checks use `astro build` + `astro preview`.

**Committed engine build.** `web/public/tiny_attn.{mjs,wasm}` are tracked: the Vercel build has no Emscripten, so `/app`
needs the files in git. Rebuild with `bash wasm/build.sh` after any RTL or harness change and commit them with it. CI does
not yet check that the committed files match a fresh build; the wasm workflow's `node wasm/test.mjs` checks behavior, not
the files.
2 changes: 1 addition & 1 deletion docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ The estimates assume one person part-time, at tiny-cpu's pace.
- `harness.cpp`, `bindings.cpp`, `build.sh`, `STATE_SCHEMA.md`, `test.mjs` with golden fixtures.
- **Exit:** `wasm.yml` is green; the size budget is met; two instances run independently in Node.

### Phase 7: Core UI + L3 Schedule (2 weeks)
### Phase 7: Core UI + L3 Schedule (2 weeks) ✅ DONE (see ARCHITECTURE §12)
- `useTinyAttn`, `Controls`, `MatrixEditor`, presets, `quantize.ts`, `AttentionHeatmap`, `SramPanel`,
`HbmMeter`, and **L3 Schedule first**.
- **Exit:** pick a preset, step forward and back, and watch tiles, buffers and bytes update; the Playwright smoke test passes.
Expand Down
9 changes: 9 additions & 0 deletions docs/STATE_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class TinyAttnSim {
readHbm(addr: number, len: number): number[]; // raw 32-bit words (Z/P tiles are at 256 and 768)
snapshot(): Snapshot;
isDone(): boolean;
getTile(): TileState; // tile-core values of the most recent tile (S, Z, P, m, l, alpha, O)
getKVBuffers(): KVBuffers; // K and V tile buffers (both), and the buffer in use
readProbabilities(): number[]; // NAIVE: P from HBM, n*n values (UQ1.8)
delete(): void;
}
```
Expand All @@ -55,6 +58,12 @@ Input validation throws: `n` must be a multiple of 4 in 4..32, `qrows` one of 4,

Multiple instances are independent: each owns its Verilator context. Steps may be interleaved across instances.

## K/V buffers (`getKVBuffers`)

`k[(buf*4 + row)*4 + col]` is K[row][col] of buffer `buf` (int8, sign-extended); `v` the same for V. `sel` is the buffer
the engine uses for the current tile. Buffer 1 is used only by the overlap schedule. Checked in `wasm/test.mjs`: after a
run, the buffer in use holds the last K/V tile loaded.

## Address map of the HBM model (words)

| Region | Base | Size |
Expand Down
13 changes: 12 additions & 1 deletion rtl/tiny_attn_top.sv
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ module tiny_attn_top
output logic [16*S_W-1:0] dbg_mm1_psum,
output logic [16*Q_W-1:0] dbg_mm2_w,
output logic [16*P_W-1:0] dbg_mm2_a,
output logic [16*PV_W-1:0] dbg_mm2_psum
output logic [16*PV_W-1:0] dbg_mm2_psum,
// K/V buffers: word (buf*4 + row) holds row `row` of buffer `buf` (4 int8 lanes); dbg_kv_sel is the buffer in use
output logic [2*4*32-1:0] dbg_kbuf,
output logic [2*4*32-1:0] dbg_vbuf,
output logic dbg_kv_sel
);
// ---- controllers (only the one selected by cfg_naive is started)
logic f_busy, f_done, n_busy, n_done;
Expand Down Expand Up @@ -106,6 +110,13 @@ module tiny_attn_top
// ---- K/V double buffers and the NAIVE tile input (Z or P tile: 8 words, 4 rows x 4 lanes x 16 bit)
logic [31:0] kreg [2][4];
logic [31:0] vreg [2][4];
for (genvar b = 0; b < 2; b++) begin : g_kvdbg
for (genvar r = 0; r < 4; r++) begin : g_row
assign dbg_kbuf[(b*4+r)*32 +: 32] = kreg[b][r];
assign dbg_vbuf[(b*4+r)*32 +: 32] = vreg[b][r];
end
end
assign dbg_kv_sel = kv_sel;
logic [31:0] tin_words [8];

logic q_load_valid;
Expand Down
15 changes: 14 additions & 1 deletion sim/golden/export_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,22 @@ def build_fixture(c):
qz = case_inputs(c["seed"], c["n"])
naive = c["mode"] == "NAIVE"
qblock = 4 if naive else c["qrows"]
res = fixed.naive(qz, c["n"], c["causal"]) if naive else fixed.flash(qz, c["n"], c["causal"], qblock=qblock)
res = (fixed.naive(qz, c["n"], c["causal"]) if naive
else fixed.flash(qz, c["n"], c["causal"], qblock=qblock, trace=True))
t = traffic(c["n"], c["causal"], c["mode"], qblock=qblock)
extra = {}
if naive:
extra["probs"] = res.probs.reshape(-1).astype(int).tolist()
else:
# the last tile the engine computes: largest kj, then largest group index
last = max(res.trace, key=lambda e: (e["kj"], e["qi"]))
extra["lastTile"] = dict(
qi=last["qi"], kj=last["kj"],
m=[int(x) for x in last["m"]], l=[int(x) for x in last["l"]],
alpha=[int(x) for x in last["alpha"]], o=last["O"].reshape(-1).astype(int).tolist(),
)
return dict(
**extra,
name=c["name"],
mode=c["mode"],
n=c["n"],
Expand Down
112 changes: 112 additions & 0 deletions sim/golden/export_web_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Export data the web app and its tests share with the golden model.

- web/src/lib/presets.ts: the attention presets (presets.py), float Q/K/V with labels.
- wasm/fixtures/quantize_cases.json: float inputs and the quantized values and cfg_scale that fixed.quantize produces.
python sim/golden/export_web_data.py
"""

import json
from pathlib import Path

import numpy as np

import fixed
import presets
from traffic import traffic
from export_fixtures import ROOT

WEB_PRESETS = ROOT / "web" / "src" / "lib" / "presets.ts"
QUANT_CASES = ROOT / "wasm" / "fixtures" / "quantize_cases.json"
TRAFFIC_TS = ROOT / "web" / "src" / "lib" / "traffic-table.ts"


def r(x):
return round(float(x), 6)


def preset_entries():
out = []
for make in presets.ALL:
p = make()
out.append(dict(
name=p["name"],
labels=list(p["labels"]),
causal=bool(p["causal"]),
q=[[r(x) for x in row] for row in p["q"]],
k=[[r(x) for x in row] for row in p["k"]],
v=[[r(x) for x in row] for row in p["v"]],
))
return out


def presets_ts(entries):
body = json.dumps(entries, indent=1)
return ("// GENERATED by sim/golden/export_web_data.py from sim/golden/presets.py. Do not edit.\n"
"export interface Preset {\n"
" name: string;\n"
" labels: string[];\n"
" causal: boolean;\n"
" q: number[][];\n"
" k: number[][];\n"
" v: number[][];\n"
"}\n\n"
f"export const PRESETS: Preset[] = {body};\n")


def quant_cases():
rng = np.random.default_rng(61)
cases = []
for amp in (0.5, 2.5, 3.5):
n = 8
qf, kf, vf = (np.round(rng.normal(0, amp, (n, 4)), 6) for _ in range(3)) # what the web app will see
z = fixed.quantize(qf, kf, vf)
cases.append(dict(
q=[[r(x) for x in row] for row in qf],
k=[[r(x) for x in row] for row in kf],
v=[[r(x) for x in row] for row in vf],
expQ=z.q.reshape(-1).tolist(),
expK=z.k.reshape(-1).tolist(),
expV=z.v.reshape(-1).tolist(),
expCfgScale=int(z.cfg_scale),
))
# an all-zero tensor: scale falls back to 1.0
z0 = np.zeros((4, 4))
z = fixed.quantize(z0, z0, z0)
cases.append(dict(q=z0.tolist(), k=z0.tolist(), v=z0.tolist(),
expQ=z.q.reshape(-1).tolist(), expK=z.k.reshape(-1).tolist(),
expV=z.v.reshape(-1).tolist(), expCfgScale=int(z.cfg_scale)))
return cases


def traffic_entries():
"""Expected HBM bytes and tile counts for every (schedule, N, causal, Q block rows) the web app can run."""
out = {}
for n in range(4, 33, 4):
for causal in (False, True):
for qrows in (4, 8, 16, 32):
if qrows > n:
continue
t = traffic(n, causal, "FLASH", qblock=qrows)
out[f"FLASH:{n}:{int(causal)}:{qrows}"] = dict(rd=t["rd"], wr=t["wr"], tiles=t["tiles"])
t = traffic(n, causal, "NAIVE", qblock=4)
out[f"NAIVE:{n}:{int(causal)}:4"] = dict(rd=t["rd"], wr=t["wr"], tiles=t["tiles"])
return out


def traffic_ts(entries):
body = json.dumps(entries, indent=1, sort_keys=True)
return ("// GENERATED by sim/golden/export_web_data.py from sim/golden/traffic.py. Do not edit.\n"
"// Key: `${mode}:${n}:${causal ? 1 : 0}:${qrows}`. rd / wr: HBM bytes; tiles: tile count.\n"
"export interface TrafficEntry {\n rd: number;\n wr: number;\n tiles: number;\n}\n\n"
f"export const TRAFFIC: Record<string, TrafficEntry> = {body};\n")


def main():
WEB_PRESETS.write_text(presets_ts(preset_entries()))
QUANT_CASES.write_text(json.dumps(quant_cases()) + "\n")
TRAFFIC_TS.write_text(traffic_ts(traffic_entries()))
print(f"wrote {WEB_PRESETS.name} ({len(presets.ALL)} presets), {QUANT_CASES.name}, {TRAFFIC_TS.name}")


if __name__ == "__main__":
main()
11 changes: 11 additions & 0 deletions sim/golden/test_golden.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,14 @@ def test_wasm_fixtures_fresh():
for c in ef.CASES:
path = ef.OUT / f"{c['name']}.json"
assert path.read_text() == ef.fixture_text(c), f"{path.name} is stale: run sim/golden/export_fixtures.py"


def test_web_data_fresh():
"""web/src/lib/presets.ts and wasm/fixtures/quantize_cases.json must match the generator."""
import json

import export_web_data as ewd

assert ewd.WEB_PRESETS.read_text() == ewd.presets_ts(ewd.preset_entries()), "presets.ts is stale"
assert ewd.QUANT_CASES.read_text() == json.dumps(ewd.quant_cases()) + "\n", "quantize_cases.json is stale"
assert ewd.TRAFFIC_TS.read_text() == ewd.traffic_ts(ewd.traffic_entries()), "traffic-table.ts is stale"
2 changes: 1 addition & 1 deletion wasm/fixtures/flash_overlap_causal.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"name": "flash_overlap_causal", "mode": "FLASH", "n": 16, "causal": true, "qrows": 4, "overlap": true, "latency": 10, "cfgScale": 1780, "q": [-87, -67, -68, -18, -116, -9, -48, 45, 48, 70, 38, -3, 43, 75, -33, 31, -2, 72, -42, -15, 18, 13, -82, 18, -6, -12, -8, 11, -91, 78, -43, -112, -4, 73, -26, 78, 78, -43, -123, -62, 59, -41, -76, -67, 0, -1, 44, 49, -47, -8, -57, 4, -58, -60, 106, 2, 32, 127, 39, -6, 3, -37, 55, -17], "k": [94, -18, -19, -22, 33, 10, 73, -29, 15, 103, -13, 28, 83, 29, -34, -109, 127, 23, 67, -25, 97, 28, 3, -48, 20, -26, -12, -86, -26, 8, -66, 4, 35, -16, -33, -33, 50, -81, -16, -22, 8, -4, -59, 108, 9, -71, -47, 5, 33, -71, 38, -73, -42, 45, 90, -4, -16, 67, -57, 15, 12, 54, 16, -14], "v": [25, -45, 33, -27, -37, -15, 2, 29, -9, 30, 39, -4, -40, 37, 11, -85, -40, 6, -55, -27, -69, 16, 10, 7, 58, -14, -12, 81, -52, 29, -53, 10, 85, 11, -31, 96, -22, -8, 12, -54, 45, 65, 31, 13, -6, 36, -127, -5, 10, -80, 17, -5, -1, 4, 23, 18, -42, -5, -7, 2, -24, 39, 53, 61], "out": [6400, -11519, 8447, -6912, -8216, -4448, 1140, 6290, -2766, 6817, 9326, -464, -2304, 7680, 9983, -1024, -2687, 7766, 9637, -2026, -2846, 3410, 7471, -7763, 1010, -2319, 3089, 1805, -8336, 8409, 2015, -17765, -2304, 7680, 9983, -1024, -10111, 9309, 2860, -21645, -10061, 9296, 2816, -21507, 2772, 9415, 2634, 2012, -8948, 8810, -11825, 2467, -253, 418, 5807, 4474, -2477, 7514, 9419, -1137, 675, -16832, 3075, -612], "hbmRdBytes": 384, "hbmWrBytes": 128, "tiles": 10, "kvLoads": 10, "cycles": 611}
{"lastTile": {"qi": 3, "kj": 3, "m": [2145, 4027, 5599, 2632], "l": [5190, 4240, 4208, 5076], "alpha": [32768, 262, 32768, 3848], "o": [-181411, 178605, -239739, 50020, -4191, 6931, 96179, 74110, -40720, 123520, 154832, -18688, 13393, -333752, 60969, -12130]}, "name": "flash_overlap_causal", "mode": "FLASH", "n": 16, "causal": true, "qrows": 4, "overlap": true, "latency": 10, "cfgScale": 1780, "q": [-87, -67, -68, -18, -116, -9, -48, 45, 48, 70, 38, -3, 43, 75, -33, 31, -2, 72, -42, -15, 18, 13, -82, 18, -6, -12, -8, 11, -91, 78, -43, -112, -4, 73, -26, 78, 78, -43, -123, -62, 59, -41, -76, -67, 0, -1, 44, 49, -47, -8, -57, 4, -58, -60, 106, 2, 32, 127, 39, -6, 3, -37, 55, -17], "k": [94, -18, -19, -22, 33, 10, 73, -29, 15, 103, -13, 28, 83, 29, -34, -109, 127, 23, 67, -25, 97, 28, 3, -48, 20, -26, -12, -86, -26, 8, -66, 4, 35, -16, -33, -33, 50, -81, -16, -22, 8, -4, -59, 108, 9, -71, -47, 5, 33, -71, 38, -73, -42, 45, 90, -4, -16, 67, -57, 15, 12, 54, 16, -14], "v": [25, -45, 33, -27, -37, -15, 2, 29, -9, 30, 39, -4, -40, 37, 11, -85, -40, 6, -55, -27, -69, 16, 10, 7, 58, -14, -12, 81, -52, 29, -53, 10, 85, 11, -31, 96, -22, -8, 12, -54, 45, 65, 31, 13, -6, 36, -127, -5, 10, -80, 17, -5, -1, 4, 23, 18, -42, -5, -7, 2, -24, 39, 53, 61], "out": [6400, -11519, 8447, -6912, -8216, -4448, 1140, 6290, -2766, 6817, 9326, -464, -2304, 7680, 9983, -1024, -2687, 7766, 9637, -2026, -2846, 3410, 7471, -7763, 1010, -2319, 3089, 1805, -8336, 8409, 2015, -17765, -2304, 7680, 9983, -1024, -10111, 9309, 2860, -21645, -10061, 9296, 2816, -21507, 2772, 9415, 2634, 2012, -8948, 8810, -11825, 2467, -253, 418, 5807, 4474, -2477, 7514, 9419, -1137, 675, -16832, 3075, -612], "hbmRdBytes": 384, "hbmWrBytes": 128, "tiles": 10, "kvLoads": 10, "cycles": 611}
Loading
Loading