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/wasm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,16 @@ jobs:
run: |
test -f web/public/spike/counter.wasm
echo "spike wasm: $(du -h web/public/spike/counter.wasm | cut -f1)"

# The engine: Verilate tiny_attn_top, build the module, run the golden fixtures in Node, check the size budget.
- name: Build engine WASM
run: bash wasm/build.sh

- name: Engine golden fixtures (Node)
run: node wasm/test.mjs

- name: Size budget (500 KB gzipped)
run: |
GZ=$(gzip -9 -c web/public/tiny_attn.wasm | wc -c)
echo "engine wasm gzip: $GZ bytes"
test "$GZ" -le 512000
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ 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 Down
4 changes: 3 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ On a fresh machine delete the symlinks and run `.tools/setup.sh` and `.tools/ems
- Phase 5 (overlap, NAIVE, perf pins): DONE. `naive_ctrl` (4 passes, block-by-block), FLASH double-buffered K/V prefetch
(`cfg_overlap`), tile operations OP_NZ / OP_NM / OP_NP / OP_NPV, HBM 2048 words. Full e2e (200 seeds) and unit suites
pass; perf baseline in `sim/perf_baseline.json` (re-record with `PERF_RECORD=1 COCOTB_TEST_FILTER=perf_grid bash sim/run_e2e.sh`).
- Next: Phase 6 (WASM harness: dual models, bindings, state schema, Node test against the golden fixtures).
- 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.

## 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
5 changes: 3 additions & 2 deletions 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-5 done: the FLASH engine, the NAIVE baseline and K/V overlap are bit-exact against the golden model, with byte counts, tile counts and cycle counts verified (FLASH sequential exactly against a closed-form model; overlap and NAIVE against pinned baselines). See [`docs/PLAN.md`](docs/PLAN.md) for the roadmap.
**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.

## Quick start

Expand All @@ -17,7 +17,8 @@ bash .tools/setup.sh && bash .tools/emsdk.sh # Verilator 5.036 + Emscripten
python3 -m venv .venv && .venv/bin/pip install -r sim/requirements.txt
source scripts/env.sh

bash wasm/spike/build.sh && node wasm/spike/test.mjs # WASM spike
bash wasm/build.sh && node wasm/test.mjs # engine WASM + golden fixtures
bash wasm/spike/build.sh && node wasm/spike/test.mjs # toolchain spike
cd web && pnpm install && pnpm dev # open /spike
```

Expand Down
16 changes: 16 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,3 +339,19 @@ Deviations from the first plan, recorded here so they are not forgotten:
- NAIVE runs block by block, not pass by pass over all blocks. Values and traffic totals are identical (the
per-row computations are independent), but the sequencing differs from `fixed.naive`'s loop order.
- Overlap applies to FLASH only. NAIVE is sequential.

## 11. Phase 6: WebAssembly harness

- `wasm/harness/harness.{h,cpp}`: `TinyAttnSim`, one instance of `tiny_attn_top` with its own `VerilatedContext`. Drives
the engine as a host would: load Q/K/V through the HBM load port, one-cycle start, read O through the readback port.
- `wasm/harness/bindings.cpp`: embind bindings. The JavaScript API and the per-clock snapshot are in `docs/STATE_SCHEMA.md`.
- `wasm/build.sh`: Verilator `--prefix Vtiny_attn_top`, `-O3`, then `em++`. `-Wno-UNOPTFLAT` is needed for the
combinational paths through the staging buffer and the DMA word mux (no logic loop: the loop is through registers).
- Size: 206 KB raw, 81 KB gzipped. The budget is 500 KB gzipped, enforced by `wasm.yml`.

Results (`node wasm/test.mjs`, three golden fixtures interleaved on separate instances): O, HBM bytes, tiles, K/V loads and
busy cycles equal the golden values for FLASH sequential, FLASH with overlap and causal masking, and NAIVE with causal
masking. A fresh instance reproduces the run, so no state leaks between instances.

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.
2 changes: 1 addition & 1 deletion docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ The estimates assume one person part-time, at tiny-cpu's pace.
- **Exit:** the whole random matrix (§5 layer 3) is green; `perf_baseline.json` is pinned; the accuracy test is green.
*At this point the engine is fully verified hardware and worth publishing on its own.*

### Phase 6: WASM harness (1 week)
### Phase 6: WASM harness (1 week) ✅ DONE
- `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.

Expand Down
72 changes: 72 additions & 0 deletions docs/STATE_SCHEMA.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# TinyAttention: JavaScript state schema (Phase 6)

What the WebAssembly module exposes per clock, and how to drive it. The schema mirrors the C++ structs in
`wasm/harness/harness.h`; if one changes, the other must change in the same commit.

Phase 6 exports a **subset** of the planned per-cycle state. The full `CycleState` (per-PE values, softmax lanes, SRAM
tags, tile matrices) needs wide-signal access through Verilator and is the first item of Phase 7. The RTL already has
the ports for it (`dbg_*` in `tiny_attn_top` and `attn_tile`); only the bindings are missing.

## Snapshot (returned by `step()`, `snapshot()`)

| Field | Type | Meaning |
|---|---|---|
| `cycle` | number | clock edges since reset |
| `ctrlState` | number | `dbg_ctrl_state`: the active controller's state enum (`flash_ctrl` or `naive_ctrl`, see their source) |
| `busy` | boolean | controller not idle |
| `done` | boolean | one-cycle pulse: the DONE state |
| `hbmRdBytes` | number | DMA read bytes since start (4 per word) |
| `hbmWrBytes` | number | DMA write bytes since start |
| `cycles` | number | busy-cycle counter (state != idle, DONE included) |
| `tiles` | number | tile computations (FLASH: group tiles; NAIVE: pass-1 tile pairs) |
| `kvLoads` | number | K/V tile loads (FLASH: K loads incl. prefetch; NAIVE: one per tile pair) |

**Counters are final one edge after `done`.** `done` is high during the DONE cycle, and the busy counter increments at the
end of that cycle. `wasm/test.mjs` takes one more `step()` before reading.

## Methods

```ts
class TinyAttnSim {
reset(): void;
configure(n: number, qrows: number, causal: boolean, mode: 0 | 1, overlap: boolean,
latency: number, scale: number): void; // mode 0 = FLASH, 1 = NAIVE; scale < 2^24
loadQKV(q: number[], k: number[], v: number[]): void; // n*4 int8 values each, row-major; n*3 cycles
start(): void; // one-cycle start pulse; the next step() is the first cycle of the run
step(): Snapshot; // one clock
runToDone(maxCycles: number): number; // edges taken until done, -1 on timeout
readOutput(): number[]; // n*4 int16 values (O, Q8.8 V-LSB), row-major
readHbm(addr: number, len: number): number[]; // raw 32-bit words (Z/P tiles are at 256 and 768)
snapshot(): Snapshot;
isDone(): boolean;
delete(): void;
}
```

Input validation throws: `n` must be a multiple of 4 in 4..32, `qrows` one of 4, 8, 16, 32 and not above `n`, latency 0..64,
`scale` below 2^24.

## Run sequence

1. `new mod.TinyAttnSim()`, then `configure(...)`.
2. `loadQKV(q, k, v)`: quantized int8 values from `sim/golden/fixed.py` (`quantize`), and `scale = cfgScale`.
3. `start()`, then `step()` until `done`, or `runToDone(max)`.
4. `step()` once more, then `readOutput()`.

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

## Address map of the HBM model (words)

| Region | Base | Size |
|---|---|---|
| Q | 0 | 32 rows (one word each) |
| K | 32 | 32 rows |
| V | 64 | 32 rows |
| O | 96 | 64 words (two per row) |
| Z tiles (NAIVE) | 256 | 8 words per tile, tile (qi, kj) at `256 + 64·qi + 8·kj` |
| P tiles (NAIVE) | 768 | same layout |

## Fixtures

`wasm/fixtures/*.json` are exported by `sim/golden/export_fixtures.py` and checked for freshness by `pytest sim/golden`.
Each holds the quantized inputs, the expected O, bytes, tiles, K/V loads and cycles.
88 changes: 88 additions & 0 deletions sim/golden/export_fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Export golden fixtures for the WASM Node test (wasm/test.mjs).

Each fixture holds one engine run: the quantized inputs, the config, the expected O (int16, Q8.8 V-LSB), the expected
HBM bytes, tile count and K/V loads (traffic.py), and the expected busy cycles. Cycles come from the closed-form model
for sequential FLASH, and from sim/perf_baseline.json for overlap and NAIVE (those are pinned baselines).

python sim/golden/export_fixtures.py # writes wasm/fixtures/*.json
"""

import json
import random
from pathlib import Path

import numpy as np

import fixed
from schedule import cycles as sched_cycles
from traffic import kv_tile_loads, traffic

ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "wasm" / "fixtures"
BASELINE = json.loads((ROOT / "sim" / "perf_baseline.json").read_text())

CASES = [
dict(name="flash_seq", mode="FLASH", n=16, causal=False, qrows=8, overlap=False, latency=10, seed=7),
dict(name="flash_overlap_causal", mode="FLASH", n=16, causal=True, qrows=4, overlap=True, latency=10, seed=8),
dict(name="naive_causal", mode="NAIVE", n=16, causal=True, qrows=4, overlap=False, latency=10, seed=9),
]


def case_inputs(seed, n):
rng = np.random.default_rng(seed)
qf = rng.normal(0, 2.5, (n, 4))
kf = rng.normal(0, 2.5, (n, 4))
vf = rng.normal(0, 1.0, (n, 4))
return fixed.quantize(qf, kf, vf)


def expected_cycles(c):
if c["mode"] == "FLASH" and not c["overlap"]:
return sched_cycles(c["n"], c["causal"], c["qrows"], c["latency"])
key = (f"naive/N{c['n']}/c{int(c['causal'])}/L{c['latency']}" if c["mode"] == "NAIVE"
else f"flash/N{c['n']}/c{int(c['causal'])}/q{c['qrows']}/o{int(c['overlap'])}/L{c['latency']}")
return BASELINE[key]


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)
t = traffic(c["n"], c["causal"], c["mode"], qblock=qblock)
return dict(
name=c["name"],
mode=c["mode"],
n=c["n"],
causal=c["causal"],
qrows=qblock,
overlap=c["overlap"] and not naive,
latency=c["latency"],
cfgScale=int(qz.cfg_scale),
q=qz.q.reshape(-1).tolist(),
k=qz.k.reshape(-1).tolist(),
v=qz.v.reshape(-1).tolist(),
out=res.out.reshape(-1).astype(int).tolist(),
hbmRdBytes=t["rd"],
hbmWrBytes=t["wr"],
tiles=t["tiles"],
kvLoads=t["tiles"] if naive else kv_tile_loads(c["n"], c["causal"], qblock),
cycles=expected_cycles(c),
)


def fixture_text(c):
return json.dumps(build_fixture(c)) + "\n"


def main():
OUT.mkdir(parents=True, exist_ok=True)
for c in CASES:
text = fixture_text(c)
(OUT / f"{c['name']}.json").write_text(text)
f = json.loads(text)
print(f"{c['name']}: cycles {f['cycles']} bytes rd {f['hbmRdBytes']} wr {f['hbmWrBytes']} tiles {f['tiles']}")


if __name__ == "__main__":
main()
10 changes: 10 additions & 0 deletions sim/golden/test_golden.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,13 @@ def test_bit_width_bounds_worst_case():
assert np.abs(t["O"]).max() < 1 << 25, name # 26-bit signed
assert t["P"].max() <= 256 # 9-bit unsigned
assert np.abs(res.out).max() <= 127 * 256


# ------------------------------------------------------------------ WASM fixtures
def test_wasm_fixtures_fresh():
"""wasm/fixtures must be exactly what export_fixtures.py produces (the Node test trusts them)."""
import export_fixtures as ef

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"
14 changes: 10 additions & 4 deletions wasm/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
# wasm/

`spike/` is the Phase 0 proof that Verilator + Emscripten work here, including **two independent
instances of one Verilated model** (each with its own `VerilatedContext`) in one module. Phase 6 adds
`harness.cpp`, `bindings.cpp`, `build.sh`, `test.mjs`.
- `build.sh`: Verilates `rtl/` (top `tiny_attn_top`) and links the engine into `web/public/tiny_attn.{mjs,wasm}`.
Output: 206 KB raw, 81 KB gzipped (budget 500 KB gzipped, enforced by `wasm.yml`).
- `harness/`: `TinyAttnSim` (one model instance with its own Verilator context) and the embind bindings.
- `test.mjs`: Node test. Runs the golden fixtures in `fixtures/` with two or more instances interleaved, checks O, bytes,
tiles, K/V loads and cycles exactly, and checks that a fresh instance reproduces a run.
- `fixtures/`: golden runs exported by `sim/golden/export_fixtures.py`.
- `spike/`: the Phase 0 toolchain spike (two counters in one module), kept as a regression check.

```bash
source scripts/env.sh
bash wasm/spike/build.sh && node wasm/spike/test.mjs
bash wasm/build.sh && node wasm/test.mjs
```

The JavaScript API is in `docs/STATE_SCHEMA.md`.
43 changes: 43 additions & 0 deletions wasm/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Build the TinyAttn engine as a WebAssembly module: Verilate rtl/ (top: tiny_attn_top), compile the C++ harness
# and the Verilator runtime with Emscripten, link into web/public/tiny_attn.{mjs,wasm}.
# Requires: verilator >= 5.036 and Emscripten (`source scripts/env.sh` first).
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OBJ="$ROOT/wasm/build/obj"
OUT="$ROOT/web/public"

VROOT="$(verilator --getenv VERILATOR_ROOT)"
[ -f "$VROOT/include/verilated.h" ] || { echo "VERILATOR_ROOT not found: $VROOT" >&2; exit 1; }

RTL_TOP=tiny_attn_top
SRCS=("$ROOT/rtl/lut_pkg.sv" "$ROOT/rtl/tiny_attn_pkg.sv")
for f in "$ROOT"/rtl/*.sv; do
case "$f" in */lut_pkg.sv|*/tiny_attn_pkg.sv) ;; *) SRCS+=("$f") ;; esac
done

echo "[1/2] Verilating ${RTL_TOP}..."
rm -rf "$OBJ"
mkdir -p "$OBJ"
verilator --cc "${SRCS[@]}" --top-module "$RTL_TOP" --prefix Vtiny_attn_top --Mdir "$OBJ" \
-O3 -Wno-fatal -Wno-UNOPTFLAT >"$OBJ/verilator.log" 2>&1 || { tail -40 "$OBJ/verilator.log"; exit 1; }

echo "[2/2] Compiling to WASM..."
mkdir -p "$OUT"
em++ -O2 -std=c++17 -DVL_IGNORE_UNKNOWN_ARCH \
-I "$OBJ" -I "$VROOT/include" -I "$VROOT/include/vltstd" -I "$ROOT/wasm/harness" \
"$ROOT/wasm/harness/harness.cpp" "$ROOT/wasm/harness/bindings.cpp" \
"$OBJ"/Vtiny_attn_top*.cpp \
"$VROOT/include/verilated.cpp" \
"$VROOT/include/verilated_threads.cpp" \
"$ROOT/wasm/emscripten_stubs.cpp" \
-lembind \
-s MODULARIZE=1 -s EXPORT_ES6=1 -s ALLOW_MEMORY_GROWTH=1 \
-s EXPORT_NAME=createTinyAttn -s ENVIRONMENT=web,node \
-o "$OUT/tiny_attn.mjs"

WASM="$OUT/tiny_attn.wasm"
RAW=$(stat -c %s "$WASM")
GZ=$(gzip -9 -c "$WASM" | wc -c)
echo "Done: $OUT/tiny_attn.mjs + tiny_attn.wasm (raw $RAW bytes, gzip $GZ bytes)"
1 change: 1 addition & 0 deletions wasm/fixtures/flash_overlap_causal.json
Original file line number Diff line number Diff line change
@@ -0,0 +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}
1 change: 1 addition & 0 deletions wasm/fixtures/flash_seq.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name": "flash_seq", "mode": "FLASH", "n": 16, "causal": false, "qrows": 8, "overlap": false, "latency": 10, "cfgScale": 1656, "q": [0, 15, -14, -45, -23, -50, 3, 68, -25, -31, 25, 18, 5, -47, -1, 35, -68, -23, -96, -65, -93, -12, -64, 14, 8, -9, -127, -27, -2, 6, -77, -24, -49, -41, 54, -41, -2, 45, -29, -6, 6, 3, -62, 4, 69, -78, 43, 6, -32, 101, 38, -61, 4, 29, -10, 34, -3, 34, 73, -34, 10, -23, 6, -60], "k": [-33, -11, 51, 65, -75, -45, 37, -113, -26, -6, 71, 39, -19, -21, -14, 86, -24, -17, 20, -7, -11, -63, -1, -25, 66, 37, -1, 38, -19, 60, 0, 33, -73, 20, -96, -115, -17, -51, 9, 127, -47, -35, 12, 28, -10, -12, 40, 29, -58, -4, 2, -60, 15, -49, 55, 11, 5, -33, -7, -113, -64, 21, -120, 48], "v": [-126, 55, -61, 56, 9, -111, 90, 104, -5, -20, -12, -70, 79, -39, -4, -57, -45, -92, 91, -11, 70, 1, -50, -24, -40, 1, -27, -22, -99, -58, 119, -48, -76, 24, 101, -105, -15, -46, -127, 53, -2, 5, -54, 33, -39, -10, -80, -88, 96, -37, 21, -2, -32, -37, 45, -22, -11, 2, 85, 49, 28, -41, -100, 68], "out": [-16809, 4267, 25180, -21357, -3681, -11710, -32198, 13316, -6644, -5700, -17150, 5840, -3052, -11228, -30137, 11818, -19455, 6144, 25854, -26878, 6177, -9878, -23686, 15760, -14265, 2900, 15824, -18245, -17001, 4610, 21112, -22797, 2304, -28414, 23039, 26622, -8871, -1900, 5151, -7939, 4955, -9115, -21290, 13686, -7432, -9175, 9662, -5171, -14326, -57, 24554, -17481, -3808, -8679, -9204, 2405, 1231, -22508, 17578, 17145, -1175, -11168, 22263, 16684], "hbmRdBytes": 320, "hbmWrBytes": 128, "tiles": 16, "kvLoads": 8, "cycles": 749}
Loading
Loading