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
6 changes: 5 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ On a fresh machine delete the symlinks and run `.tools/setup.sh` and `.tools/ems
FLASH), Playwright smoke test (5 tests, production build).
- Phase 8 (L1 softmax cell, L2 tile dataflow, L4 naive vs flash, N sweep, error panel): DONE. Engine readouts `tileDone`
and `getDatapath`. 12 Playwright tests, 15 Vitest, Node WASM test, one-seed cocotb. Light-mode screenshots not reviewed.
- Next: Phase 9 (docs, landing page, launch). Also open: the `astro dev` hang.
- Phase 9 (docs, landing page, launch): built. Landing page with the animated FLASH schedule, six docs pages with live
demos, `/og` social card, generated `measured.ts`, README rewritten for the series. Deployment: see the PR and
ARCHITECTURE §14.
- Next: Phase 10 (FPGA synthesis, ECP5 for the ULX3S). Blocked here: no yosys / nextpnr / ecppack installed, and the disk
had under 1 GB free during Phase 9.

## Gotchas found in Phase 8
- The debug datapath wires are idle between tiles. The L2 view keeps the last busy clock's values, or the panel shows zeros.
Expand Down
73 changes: 64 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,47 @@
# TinyAttention

A real, synthesizable **FlashAttention engine** in SystemVerilog, compiled to WebAssembly and running live
in your browser: online softmax with a running max and sum, SRAM tiles filling and emptying, and standard
vs FlashAttention memory traffic side by side.
A synthesizable **FlashAttention engine** in SystemVerilog, verified bit-exact against a golden model, compiled to
WebAssembly, and running live in your browser.

**Live:** https://tiny-attention.vercel.app

![The visualizer running one FLASH pass](docs/media/visualizer.png)

Fourth project in the series after [tiny-gpu](https://github.com/deaneeth/tiny-gpu),
[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.
[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 debug port on the compiled RTL, or from the golden model the RTL is checked against.

## What it does

Attention computes `softmax(Q Kᵀ / √d) V`. The score matrix is N × N, and storing it is what makes standard attention
expensive in memory. FlashAttention computes the output one tile at a time with an online softmax, so the matrix never
exists in memory. This engine implements that schedule, plus the standard three-pass schedule for comparison, with
optional overlap of the K/V loads with compute.

## Verification

**Status:** Phases 0-8 done: the engine (FLASH, NAIVE, K/V overlap) is bit-exact against the golden model and compiled to WebAssembly. The browser visualizer at `/app` has four views (softmax cell, tile dataflow, schedule, naive vs flash with an N sweep) with editable inputs, K/V buffers, HBM traffic against the model, and 12 Playwright tests. Next: the landing page and the documentation pages. See [`docs/PLAN.md`](docs/PLAN.md) for the roadmap.
| What | Result |
|---|---|
| Engine vs golden model, end to end | Output, HBM bytes, tile count, K/V loads and busy cycles match on every run. Every sequence length 4 to 32, causal and not, every block size, both schedules, overlap on and off, random HBM latency, 200 seeds. |
| Exhaustive unit coverage | exp2 is checked on every input in its range; reciprocal on every mantissa. DMA burst timing is pinned cycle by cycle. |
| Output error vs float attention | Max 0.0028 to 0.0260 across the six presets (N = 12 to 16), in float units. The recorded 1000-seed study is in `sim/golden/study_results_1000seeds.txt`. |
| HBM traffic at N = 32 | Non-causal: FLASH 2432 B (4-row blocks) or 896 B (16-row blocks), NAIVE 12672 B. Causal: FLASH 1536 B or 768 B, NAIVE 7296 B. |
| WebAssembly | The compiled module, on golden fixtures for each schedule, matches the same quantities. 83 KB gzipped. |
| Web app | Browser tests of every page, the visualizer at phone width and in both colour schemes. |
| FPGA | Not yet synthesized (Phase 10). No timing or area numbers are claimed. |

Every row is a test in the repository, run by GitHub Actions on each pull request. The docs page
[Verification](https://tiny-attention.vercel.app/docs/verification) lists each check and its numbers.

## Repository

```
rtl/ SystemVerilog: arithmetic blocks, tile core, memory, controllers, top level
sim/ cocotb unit and end-to-end tests, the golden model (sim/golden), lint and run scripts
wasm/ Verilator + Emscripten build, the C++ harness, and the Node test over golden fixtures
web/ Astro + React site: the visualizer at /app, the docs, and the Vitest and Playwright tests
docs/ Architecture, the plan, the state schema, and the media for this README
```

## Quick start

Expand All @@ -17,10 +50,32 @@ 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/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
bash sim/lint.sh # Verilator -Wall on every module
bash sim/run_unit.sh # cocotb unit suites
SEEDS=200 bash sim/run_e2e.sh # engine vs golden, end to end
pytest sim/golden -q # golden model, fixtures, generated tables

bash wasm/build.sh && node wasm/test.mjs # WebAssembly engine + golden fixtures

cd web && pnpm install && pnpm dev # the site (dev server; see note below)
pnpm build && pnpm test:e2e # production build and browser tests
```

Note: `astro dev` hangs without output in some environments. `pnpm build` followed by any static file server over
`web/dist` works.

## Docs

Six pages with live demos: [what attention computes](https://tiny-attention.vercel.app/docs/what-is-attention),
[the online softmax](https://tiny-attention.vercel.app/docs/online-softmax),
[the FlashAttention schedule](https://tiny-attention.vercel.app/docs/flashattention),
[fixed-point formats and error](https://tiny-attention.vercel.app/docs/fixed-point),
[the architecture](https://tiny-attention.vercel.app/docs/architecture), and
[verification](https://tiny-attention.vercel.app/docs/verification).

The plan and the status of each phase are in [`docs/PLAN.md`](docs/PLAN.md). The architecture, with the measured
schedule and traffic models, is in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).

## License

MIT
22 changes: 22 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,3 +421,25 @@ Gaps against the plan's Phase 8 text:

Checks: `pnpm test:e2e` (12 tests, including all four views at 375 px and the light/dark background), `pnpm test` (15),
`node wasm/test.mjs`, `SEEDS=1 bash sim/run_e2e.sh`, `bash sim/lint.sh`, `pnpm lint`, `pnpm typecheck`, `pnpm build`.

## 14. Phase 9: docs, landing page, launch

- **Landing page** (`/`): the hero animates the FLASH schedule for N = 16 on a causal mask (pure React, no engine), with
the three claims the site makes and links to the visualizer and the docs. Respects `prefers-reduced-motion`.
- **Docs** (`/docs/*`, six pages, plus an index). Each page has a live demo of what it explains:
- *What is attention*: float attention for a chosen preset row (the reference, not the engine).
- *Online softmax*: a FLASH run of the toy sentence recorded tile by tile (`web/src/lib/record.ts`), shown by the L1 view.
- *FlashAttention*: HBM bytes and tile counts for FLASH and NAIVE from the generated traffic table.
- *Fixed point*: an INT8 quantizer for one Q row and one K row, with the folded cfg_scale in hex; the formats table;
measured error from the generated `measured.ts`.
- *Architecture*: a static block diagram of the RTL.
- *Verification*: each check and its command, with the measured tables.
- **Generated numbers.** `sim/golden/export_web_data.py` writes `web/src/lib/measured.ts`: the per-preset error (golden
model), the recorded 1000-seed study (rows parsed from `study_results_1000seeds.txt`), and the N = 32 cycle baselines
(from `sim/perf_baseline.json`). `pytest sim/golden` fails if any generated file is stale.
- **Social card and screenshots.** `/og` is a 1200 × 630 card; `web/scripts/capture-assets.mjs` captures it to
`web/public/og.png` and the visualizer to `docs/media/visualizer.png`. The card is in the page's `og:image` meta tags.
- **Tests.** Playwright covers every page (no console errors, status 200, the islands render, the quantizer and traffic
demos respond) and every page at 375 px without horizontal scroll. Tables scroll inside their own box on phones.
- **Not in this phase:** FPGA numbers (Phase 10). The README and the Verification page say so.

2 changes: 1 addition & 1 deletion docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ The estimates assume one person part-time, at tiny-cpu's pace.
- L1 Softmax cell, L2 Tile dataflow, L4 Naive vs Flash (two instances, N sweep, error panel).
- **Exit:** every level works on desktop and at 375 px, in light and dark themes.

### Phase 9: Docs, landing page, launch (1 week)
### Phase 9: Docs, landing page, launch (1 week) ✅ DONE (see ARCHITECTURE §14; deployed as noted there)
- The six docs pages, each with an embedded live mini-visualizer. Hero capture and OG image (reuse the tiny-tpu scripts).
- README in the series style: live demo, verification table (bit-exact seeds, exhaustive unit coverage, measured
error, HBM savings at N = 32), and FPGA numbers.
Expand Down
Binary file added docs/media/visualizer.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
67 changes: 66 additions & 1 deletion sim/golden/export_web_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,23 @@
"""

import json
import re
from pathlib import Path

import numpy as np

import fixed
import presets
from float_ref import attention
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"
MEASURED_TS = ROOT / "web" / "src" / "lib" / "measured.ts"
STUDY_TXT = Path(__file__).resolve().parent / "study_results_1000seeds.txt"
PERF_JSON = ROOT / "sim" / "perf_baseline.json"


def r(x):
Expand Down Expand Up @@ -101,11 +106,71 @@ def traffic_ts(entries):
f"export const TRAFFIC: Record<string, TrafficEntry> = {body};\n")


STUDY_SEQ = re.compile(
r"N=\s*(\d+) causal=(\d) flash\s+dp max\s+([\d.]+) mean\s+([\d.]+) \| tot max\s+([\d.]+) mean\s+([\d.]+)")
STUDY_VS = re.compile(
r"^(flash|naive) N=32\s+dp max\s+([\d.]+) mean\s+([\d.]+) \| tot max\s+([\d.]+) mean\s+([\d.]+)", re.M)


def study_numbers():
"""Rows of the recorded 1000-seed study (sim/golden/study_results_1000seeds.txt), parsed verbatim."""
text = STUDY_TXT.read_text()
seq = []
for m in STUDY_SEQ.finditer(text):
n, c, dpm, dpa, tm, ta = m.groups()
seq.append(dict(n=int(n), causal=c == "1", dpMax=float(dpm), dpMean=float(dpa), totMax=float(tm), totMean=float(ta)))
vs = {}
for m in STUDY_VS.finditer(text):
mode, dpm, dpa, tm, ta = m.groups()
vs[mode] = dict(dpMax=float(dpm), dpMean=float(dpa), totMax=float(tm), totMean=float(ta))
return dict(sequence=seq, flashVsNaive=vs, raw=text)


PERF_KEYS = [
("FLASH, Q block 4 rows, no overlap", "flash/N32/c0/q4/o0/L10"),
("FLASH, Q block 4 rows, overlap", "flash/N32/c0/q4/o1/L10"),
("FLASH, Q block 16 rows, no overlap", "flash/N32/c0/q16/o0/L10"),
("FLASH, Q block 16 rows, overlap", "flash/N32/c0/q16/o1/L10"),
("NAIVE", "naive/N32/c0/L10"),
]


def perf_numbers():
base = json.loads(PERF_JSON.read_text())
return [dict(label=label, cycles=base[key]) for label, key in PERF_KEYS]


def preset_errors():
"""Max |engine - float| per preset, in float units, on the golden model (the same numbers the visualizer shows)."""
out = []
for make in presets.ALL:
p = make()
q, k, v = (np.round(np.asarray(p[x], dtype=np.float64), 6) for x in ("q", "k", "v"))
n = q.shape[0]
qz = fixed.quantize(q, k, v)
res = fixed.flash(qz, n, p["causal"], qblock=min(4, n))
out_f = fixed.dequant_out(res, qz)
ref, _ = attention(q, k, v, p["causal"])
# six decimals: the last bits of a float64 sum depend on the BLAS build, and the freshness test must not
out.append(dict(name=p["name"], n=n, causal=bool(p["causal"]), maxErr=round(float(np.max(np.abs(out_f - ref))), 6)))
return out


def measured_ts():
study = study_numbers()
body = dict(study=dict(sequence=study["sequence"], flashVsNaive=study["flashVsNaive"]),
studyRaw=study["raw"], perfN32=perf_numbers(), presetErrors=preset_errors())
return ("// GENERATED by sim/golden/export_web_data.py from the golden model, sim/golden/study_results_1000seeds.txt\n"
"// and sim/perf_baseline.json. Do not edit.\n"
"export const MEASURED = " + json.dumps(body, indent=1) + " as const;\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}")
MEASURED_TS.write_text(measured_ts())
print(f"wrote {WEB_PRESETS.name} ({len(presets.ALL)} presets), {QUANT_CASES.name}, {TRAFFIC_TS.name}, {MEASURED_TS.name}")


if __name__ == "__main__":
Expand Down
1 change: 1 addition & 0 deletions sim/golden/test_golden.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,4 @@ def test_web_data_fresh():
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"
assert ewd.MEASURED_TS.read_text() == ewd.measured_ts(), "measured.ts is stale"
97 changes: 97 additions & 0 deletions web/e2e/docs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { expect, test, type Page } from "@playwright/test";

const pages = [
{ path: "/", h1: "FlashAttention, built in hardware" },
{ path: "/docs", h1: "Docs" },
{ path: "/docs/what-is-attention", h1: "What is attention" },
{ path: "/docs/online-softmax", h1: "Online softmax" },
{ path: "/docs/flashattention", h1: "FlashAttention" },
{ path: "/docs/fixed-point", h1: "Fixed point" },
{ path: "/docs/architecture", h1: "Architecture" },
{ path: "/docs/verification", h1: "Verification" },
];

// Collects console errors and page errors so a broken island fails the test.
function watchErrors(page: Page) {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
page.on("console", (m) => {
if (m.type() === "error") errors.push(m.text());
});
return errors;
}

for (const p of pages) {
test(`${p.path} renders with no errors`, async ({ page }) => {
const errors = watchErrors(page);
const response = await page.goto(p.path);
expect(response?.status()).toBe(200);
await expect(page.getByRole("heading", { level: 1 })).toContainText(p.h1);
await page.waitForTimeout(500);
expect(errors, errors.join("\n")).toEqual([]);
});
}

test("landing page links to the engine and the docs", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("link", { name: "Open the engine" })).toHaveAttribute("href", "/app");
await expect(page.getByRole("link", { name: "Read the docs" })).toHaveAttribute("href", "/docs");
});

test("landing hero animates the tile schedule", async ({ page }) => {
await page.goto("/");
const grid = page.getByRole("img", { name: /FLASH tile schedule/ });
await expect(grid).toBeVisible();
const before = await grid.evaluate((g) => g.innerHTML);
await page.waitForTimeout(700);
const after = await grid.evaluate((g) => g.innerHTML);
expect(after).not.toEqual(before);
});

test("what is attention: the demo's probabilities sum to 100%", async ({ page }) => {
await page.goto("/docs/what-is-attention");
await expect(page.getByText("Token that asks (query)")).toBeVisible();
const text = await page.locator("main").innerText();
const shares = [...text.matchAll(/(\d+\.\d)%/g)].map((m) => Number(m[1]));
expect(shares.length).toBeGreaterThan(0);
const sum = shares.reduce((a, b) => a + b, 0);
expect(Math.abs(sum - 100)).toBeLessThan(1);
});

test("online softmax: the engine records a run and the row table fills", async ({ page }) => {
await page.goto("/docs/online-softmax");
await expect(page.getByRole("cell", { name: "kv0" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("m engine / float")).toBeVisible();
});

test("flashattention: traffic demo shows FLASH and NAIVE byte counts", async ({ page }) => {
await page.goto("/docs/flashattention");
await expect(page.getByText(/FLASH \(Q block 4 rows\)/)).toBeVisible();
await expect(page.getByText(/NAIVE \(N×N scores in HBM\)/)).toBeVisible();
// at N = 32 with causal off, FLASH with 4-row blocks moves a different total than NAIVE
await page.getByLabel("N").selectOption("32");
await expect(page.getByText(/FLASH moves [0-9.]+× fewer bytes than NAIVE here/)).toBeVisible();
});

test("fixed point: the quantizer shows the INT8 values and cfg_scale in hex", async ({ page }) => {
await page.goto("/docs/fixed-point");
await expect(page.getByText(/= \d+ = 0x[0-9a-f]{6}/)).toBeVisible();
await page.getByLabel("q dimension 0").fill("3.5");
await expect(page.getByText(/int8 127/).first()).toBeVisible();
});

for (const p of pages) {
test(`${p.path} has no horizontal scroll at phone width`, async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(p.path);
await page.waitForTimeout(300);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(0);
});
}

test("social metadata is present on the landing page", async ({ page }) => {
await page.goto("/");
await expect(page.locator('meta[property="og:image"]')).toHaveCount(1);
await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute("content", "summary_large_image");
});
Binary file added web/public/og.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading