From 7f7ecace23cb8f209b9f202714c6206c6d14a85b Mon Sep 17 00:00:00 2001 From: Dineth Hettiarachchi Date: Mon, 26 Oct 2026 07:40:47 +0530 Subject: [PATCH] feat: Phase 8 views: softmax cell (L1), tile dataflow (L2), naive vs flash (L4) Engine readouts: tileDone (one pulse per completed tile) and getDatapath (MM1 and MM2 weights, activations and partial sums, live). Node test checks one completion per FLASH tile and that the running row max never decreases within a row group. L1 (SoftmaxCellView): the engine's row state at each tile completion for a chosen row, against the float softmax of the same INT8 inputs. L2 (TileView): live datapath values, kept from the last busy clock. L4 (CompareView): two engine instances on the same inputs; bytes, cycles, tiles and K/V loads measured; footprint and occupancy as labelled formulas; error grids against float; an N sweep (4 to 32) of HBM bytes on seeded inputs. Layout: level tabs over the schedule view. Fixes found by the new checks: output values printed as raw floats overflowed at 375 px; tile grids overhung by 1 px. Playwright: 12 tests (all views at 375 px, the light and dark colour schemes, the NAIVE message). Vitest: 15 tests. Docs: ARCHITECTURE section 13, STATE_SCHEMA, PLAN, CONTEXT, README. --- CONTEXT.md | 13 +- README.md | 2 +- docs/ARCHITECTURE.md | 30 ++++ docs/PLAN.md | 2 +- docs/STATE_SCHEMA.md | 8 + rtl/tiny_attn_top.sv | 4 +- wasm/harness/bindings.cpp | 13 ++ wasm/harness/harness.cpp | 28 ++++ wasm/harness/harness.h | 9 ++ wasm/test.mjs | 39 ++++- web/e2e/levels.spec.ts | 85 +++++++++++ web/e2e/smoke.spec.ts | 2 +- web/public/tiny_attn.wasm | Bin 212397 -> 215648 bytes web/src/components/CompareView.tsx | 203 +++++++++++++++++++++++++ web/src/components/LevelTabs.tsx | 39 +++++ web/src/components/SoftmaxCellView.tsx | 126 +++++++++++++++ web/src/components/TileView.tsx | 82 ++++++++++ web/src/components/Visualizer.tsx | 141 ++++++++++------- web/src/lib/engine-run.ts | 76 +++++++++ web/src/lib/softmax-cell.test.ts | 60 ++++++++ web/src/lib/softmax-cell.ts | 65 ++++++++ web/src/lib/state-schema.ts | 12 ++ 22 files changed, 974 insertions(+), 65 deletions(-) create mode 100644 web/e2e/levels.spec.ts create mode 100644 web/src/components/CompareView.tsx create mode 100644 web/src/components/LevelTabs.tsx create mode 100644 web/src/components/SoftmaxCellView.tsx create mode 100644 web/src/components/TileView.tsx create mode 100644 web/src/lib/engine-run.ts create mode 100644 web/src/lib/softmax-cell.test.ts create mode 100644 web/src/lib/softmax-cell.ts diff --git a/CONTEXT.md b/CONTEXT.md index 9f83467..e8d31ad 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -36,7 +36,18 @@ On a fresh machine delete the symlinks and run `.tools/setup.sh` and `.tools/ems - 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). +- 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. + +## 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. +- A datapath or buffer value is only meaningful while the engine is inside a tile; say so in the UI. +- Verilator's wide ports are word arrays; the `wordsOf` template reads any of them. +- A full run in the browser is cheap, but the pages overflowed at 375 px only after a run (long decimals in the output grid, + then a 1 px overhang in the tile grids). The phone check must run a computation first. +- `astro preview` hung (process state D) during Phase 8; a static file server over `dist/` works. Restart it after each build, + because the build removes its working directory. ## 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 diff --git a/README.md b/README.md index abb9cbf..1325cd0 100644 --- a/README.md +++ b/README.md @@ -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-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. +**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. ## Quick start diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 868d4cf..d7f3655 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -391,3 +391,33 @@ browser checks use `astro build` + `astro preview`. 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. + +## 13. Phase 8: the other levels (L1, L2, L4) + +The `/app` page has four views over one engine: L1 softmax cell, L2 tile dataflow, L3 schedule (Phase 7), L4 naive vs +flash. + +- **Engine readouts added.** `tileDone` (a one-cycle pulse, from the tile core's `tile_done`) and `getDatapath()` (the + MM1 and MM2 weights, activations and partial sums, live). Checked in `wasm/test.mjs`: one completion per FLASH tile, and + the running row max never decreases within a row group (keyed on the global row group; the in-block index repeats across + Q blocks). +- **L1 (`SoftmaxCellView`).** The visualizer records the engine's row state at every tile completion. For a chosen row, + the table shows each K/V tile's four logits (engine and float), m, α and l. The float reference (`softmax-cell.ts`) + uses the same INT8 inputs dequantized, so the difference is fixed-point rounding. A FLASH run is required; NAIVE has no + running max. +- **L2 (`TileView`).** Live wires of MM1 and MM2 and the tile's S, Z, P, m, α, l, O. The values are from the last busy + clock of the run so far; between tiles the wires are idle. Stepping through a tile shows the activations streaming. +- **L4 (`CompareView`).** Two engine instances on the same inputs, each freed after its run. Bars for HBM bytes, busy + cycles, tiles and K/V loads (measured). The on-chip footprint, the full-S-matrix size and the tile-core occupancy are + formulas, labelled as such. The error panel shows |engine − float| per element for both schedules on one colour scale. + The N sweep runs N = 4 to 32 on seeded inputs and plots HBM bytes against N for both schedules. + +Gaps against the plan's Phase 8 text: +- The N sweep uses `runToDone` (one call per run), not a separate `runFast`; the cost is the same. +- The L2 view is a grid of values, not an animated wire diagram. Each block shows its live values. +- L1's exp2 split is described in text; the view does not draw the integer and LUT parts separately. +- "Every level works in light and dark": the tests check that both colour schemes render with a background. Nobody has + reviewed the light-mode screenshots yet. + +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`. diff --git a/docs/PLAN.md b/docs/PLAN.md index f008036..19bec65 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -452,7 +452,7 @@ The estimates assume one person part-time, at tiny-cpu's pace. `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. -### Phase 8: Remaining levels (1.5 weeks) +### Phase 8: Remaining levels (1.5 weeks) ✅ DONE (see ARCHITECTURE §13) - 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. diff --git a/docs/STATE_SCHEMA.md b/docs/STATE_SCHEMA.md index 9666bfb..bcb8416 100644 --- a/docs/STATE_SCHEMA.md +++ b/docs/STATE_SCHEMA.md @@ -20,6 +20,7 @@ the ports for it (`dbg_*` in `tiny_attn_top` and `attn_tile`); only the bindings | `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) | +| `tileDone` | boolean | one-cycle pulse: a tile completed on the last edge (FLASH: one per tile; NAIVE: one per tile operation) | **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. @@ -41,6 +42,7 @@ class TinyAttnSim { 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 + getDatapath(): Datapath; // MM1 and MM2 weights, activations and partial sums (live wires; 16 values each) readProbabilities(): number[]; // NAIVE: P from HBM, n*n values (UQ1.8) delete(): void; } @@ -58,6 +60,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. +## Datapath (`getDatapath`) + +Index `r*4 + c` is PE (r, c). `mm1W` (Kᵀ weights) and `mm1A` (Q activations) are int8; `mm1Psum` (S partial sums) is int17. +`mm2W` (V weights) is int8, `mm2A` (P activations) is uint9, `mm2Psum` (P·V partial sums) is int18. These are the wires at +the current clock. + ## 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 diff --git a/rtl/tiny_attn_top.sv b/rtl/tiny_attn_top.sv index 3a4305a..5d54174 100644 --- a/rtl/tiny_attn_top.sv +++ b/rtl/tiny_attn_top.sv @@ -58,7 +58,8 @@ module tiny_attn_top // 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 + output logic dbg_kv_sel, + output logic dbg_tile_done // one-cycle pulse: a group tile (or NAIVE row group) completed ); // ---- controllers (only the one selected by cfg_naive is started) logic f_busy, f_done, n_busy, n_done; @@ -117,6 +118,7 @@ module tiny_attn_top end end assign dbg_kv_sel = kv_sel; + assign dbg_tile_done = tile_done; logic [31:0] tin_words [8]; logic q_load_valid; diff --git a/wasm/harness/bindings.cpp b/wasm/harness/bindings.cpp index 3b1290e..560ed19 100644 --- a/wasm/harness/bindings.cpp +++ b/wasm/harness/bindings.cpp @@ -12,6 +12,17 @@ using namespace emscripten; namespace { std::vector toI32(const val& arr) { return vecFromJSArray(arr); } +val datapathToJs(const Datapath& d) { + val o = val::object(); + o.set("mm1W", val::array(d.mm1W.begin(), d.mm1W.end())); + o.set("mm1A", val::array(d.mm1A.begin(), d.mm1A.end())); + o.set("mm1Psum", val::array(d.mm1Psum.begin(), d.mm1Psum.end())); + o.set("mm2W", val::array(d.mm2W.begin(), d.mm2W.end())); + o.set("mm2A", val::array(d.mm2A.begin(), d.mm2A.end())); + o.set("mm2Psum", val::array(d.mm2Psum.begin(), d.mm2Psum.end())); + return o; +} + val kvToJs(const KVBuffers& kv) { val o = val::object(); o.set("sel", kv.sel); @@ -45,6 +56,7 @@ val snapshotToJs(const Snapshot& s) { o.set("cycles", s.cycles); o.set("tiles", s.tiles); o.set("kvLoads", s.kvLoads); + o.set("tileDone", s.tileDone); return o; } } // namespace @@ -73,6 +85,7 @@ EMSCRIPTEN_BINDINGS(tiny_attn) { })) .function("snapshot", optional_override([](TinyAttnSim& self) { return snapshotToJs(self.snapshot()); })) .function("isDone", &TinyAttnSim::isDone) + .function("getDatapath", optional_override([](TinyAttnSim& self) { return datapathToJs(self.getDatapath()); })) .function("getKVBuffers", optional_override([](TinyAttnSim& self) { return kvToJs(self.getKVBuffers()); })) .function("getTile", optional_override([](TinyAttnSim& self) { return tileToJs(self.getTile()); })) .function("readProbabilities", optional_override([](TinyAttnSim& self) { diff --git a/wasm/harness/harness.cpp b/wasm/harness/harness.cpp index c261864..01666ab 100644 --- a/wasm/harness/harness.cpp +++ b/wasm/harness/harness.cpp @@ -149,6 +149,7 @@ Snapshot TinyAttnSim::snapshot() const { s.cycles = top_->cycles; s.tiles = top_->tiles; s.kvLoads = top_->kv_loads; + s.tileDone = top_->dbg_tile_done != 0; return s; } @@ -243,3 +244,30 @@ KVBuffers TinyAttnSim::getKVBuffers() const { } return kv; } + +namespace { +// Signed or unsigned field of `width` bits at bit `lsb` of a word array, as int32. +int32_t field(const uint32_t* words, int lsb, int width, bool isSigned) { + uint64_t v = bitsAt(words, lsb, width); + return static_cast(isSigned ? signExtend(v, width) : static_cast(v)); +} +} // namespace + +Datapath TinyAttnSim::getDatapath() const { + Datapath d; + const uint32_t* w1 = wordsOf(top_->dbg_mm1_w); + const uint32_t* a1 = wordsOf(top_->dbg_mm1_a); + const uint32_t* p1 = wordsOf(top_->dbg_mm1_psum); + const uint32_t* w2 = wordsOf(top_->dbg_mm2_w); + const uint32_t* a2 = wordsOf(top_->dbg_mm2_a); + const uint32_t* p2 = wordsOf(top_->dbg_mm2_psum); + for (int i = 0; i < 16; ++i) { + d.mm1W.push_back(field(w1, i * 8, 8, true)); + d.mm1A.push_back(field(a1, i * 8, 8, true)); + d.mm1Psum.push_back(field(p1, i * 17, 17, true)); + d.mm2W.push_back(field(w2, i * 8, 8, true)); + d.mm2A.push_back(field(a2, i * 9, 9, false)); + d.mm2Psum.push_back(field(p2, i * 18, 18, true)); + } + return d; +} diff --git a/wasm/harness/harness.h b/wasm/harness/harness.h index 3243752..7a63ef9 100644 --- a/wasm/harness/harness.h +++ b/wasm/harness/harness.h @@ -21,6 +21,7 @@ struct Snapshot { uint32_t cycles; // busy cycles (perf counter) uint32_t tiles; uint32_t kvLoads; + bool tileDone; // one-cycle pulse: a tile completed on the last edge }; // Tile-core state of the most recent tile (see attn_tile.sv dbg_* ports). S, Z, P, m, l, alpha and O are for the @@ -39,6 +40,13 @@ struct TileState { // K and V tile buffers. k[(buf*4 + row)*4 + col] is K[row][col] of buffer buf (int8, sign-extended); same for v. // sel is the buffer the engine is using for the current tile. +// Datapath wires of the two systolic arrays, live. Index r*4 + c is PE (r, c). MM1 computes S = Q . Kt (weights Kt, +// activations Q rows); MM2 computes P . V (weights V, activations P). Values are signed except mm2A (P, unsigned). +struct Datapath { + std::vector mm1W, mm1A, mm1Psum; // 16 each: int8, int8, int17 + std::vector mm2W, mm2A, mm2Psum; // 16 each: int8, uint9, int18 +}; + struct KVBuffers { int sel; std::vector k; // 32 @@ -71,6 +79,7 @@ class TinyAttnSim { bool isDone() const; TileState getTile() const; KVBuffers getKVBuffers() const; + Datapath getDatapath() const; // P tiles written by a NAIVE run (HBM words 768..): n x n probabilities (UQ1.8), row-major. Requires n known. std::vector readProbabilities(); diff --git a/wasm/test.mjs b/wasm/test.mjs index bffdd99..c8c5c65 100644 --- a/wasm/test.mjs +++ b/wasm/test.mjs @@ -25,6 +25,25 @@ const check = (ok, msg) => { } }; +// FLASH tile order (web/src/lib/schedule.ts): Q blocks outer, K/V tiles, row groups; causal skips masked tiles. +function flashOrder(n, qrows, causal) { + const nb = n / 4; + const g = qrows / 4; + const order = []; + for (let gb = 0; gb < nb; gb += g) { + const bg = Math.min(g, nb - gb); + const last = causal ? gb + bg - 1 : nb - 1; + for (let kj = 0; kj <= last; kj++) { + for (let gi = 0; gi < bg; gi++) { + const qi = gb + gi; + if (causal && kj > qi) continue; + order.push({ qi, kj }); + } + } + } + return order; +} + function load(fx) { const sim = new mod.TinyAttnSim(); sim.configure(fx.n, fx.qrows, fx.causal, fx.mode === "NAIVE" ? 1 : 0, fx.overlap, fx.latency, fx.cfgScale); @@ -40,6 +59,23 @@ for (let t = 0; t < maxCycles && sims.some((s) => !s.finished); t++) { for (const s of sims) { if (s.finished) continue; s.last = s.sim.step(); + if (s.last.tileDone && s.fx.mode === "FLASH") { + // one completion per tile; the running row max m never decreases within a row group + s.tileDones = (s.tileDones ?? 0) + 1; + const t = s.sim.getTile(); + s.order = s.order ?? flashOrder(s.fx.n, s.fx.qrows, s.fx.causal); + const key = s.order[s.tileDones - 1].qi; // global row group of this completion + s.prevM = s.prevM ?? new Map(); + const prev = s.prevM.get(key); + if (prev) { + for (let r = 0; r < 4; r++) { + if (t.m[r] < prev[r]) { + check(false, `${s.fx.name}: m decreased for group ${key} row ${r}: ${prev[r]} -> ${t.m[r]}`); + } + } + } + s.prevM.set(key, t.m.slice()); + } if (s.last.done) { // the DONE cycle is counted by the busy-cycle counter at its end: one more edge makes the counters final s.last = s.sim.step(); @@ -48,7 +84,7 @@ for (let t = 0; t < maxCycles && sims.some((s) => !s.finished); t++) { } } -for (const { fx, sim, last, finished } of sims) { +for (const { fx, sim, last, finished, tileDones } of sims) { console.log(`${fx.name} (${fx.mode} N=${fx.n} causal=${fx.causal} qrows=${fx.qrows} overlap=${fx.overlap} L=${fx.latency})`); check(finished, "engine did not finish"); const out = Array.from(sim.readOutput()); @@ -57,6 +93,7 @@ for (const { fx, sim, last, finished } of sims) { check(last.hbmRdBytes === fx.hbmRdBytes, `read bytes ${last.hbmRdBytes} != ${fx.hbmRdBytes}`); check(last.hbmWrBytes === fx.hbmWrBytes, `write bytes ${last.hbmWrBytes} != ${fx.hbmWrBytes}`); check(last.tiles === fx.tiles, `tiles ${last.tiles} != ${fx.tiles}`); + if (fx.mode === "FLASH") check(tileDones === fx.tiles, `tile completions ${tileDones} != ${fx.tiles}`); check(last.kvLoads === fx.kvLoads, `K/V loads ${last.kvLoads} != ${fx.kvLoads}`); check(last.cycles === fx.cycles, `busy cycles ${last.cycles} != ${fx.cycles}`); if (fx.lastTile) { diff --git a/web/e2e/levels.spec.ts b/web/e2e/levels.spec.ts new file mode 100644 index 0000000..49e91cc --- /dev/null +++ b/web/e2e/levels.spec.ts @@ -0,0 +1,85 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function openApp(page: Page) { + await page.goto("/app"); + await expect(page.getByRole("button", { name: "Start" })).toBeVisible({ timeout: 30_000 }); +} + +test("L1 softmax cell: running max and sum for the chosen row, against the float reference", async ({ page }) => { + await openApp(page); + await page.getByRole("button", { name: "Start" }).click(); + await page.getByRole("button", { name: "Run to end" }).click(); + await page.getByRole("tab", { name: "L1 Softmax cell" }).click(); + + await expect(page.getByText("m engine / float")).toBeVisible(); + await expect(page.getByRole("cell", { name: "kv0" })).toBeVisible(); + const text = await page.locator("main").innerText(); + const m = text.match(/m engine \/ float\n([0-9.-]+) \/ ([0-9.-]+)/); + expect(m, "m engine / float line").not.toBeNull(); + expect(Math.abs(Number(m?.[1]) - Number(m?.[2]))).toBeLessThan(0.05); +}); + +test("L1 says so for NAIVE", async ({ page }) => { + await openApp(page); + await page.getByRole("combobox", { name: /^Schedule/ }).selectOption("1"); + await page.getByRole("tab", { name: "L1 Softmax cell" }).click(); + await expect(page.getByText("NAIVE has no running max")).toBeVisible(); +}); + +test("L2 tile dataflow shows the MM1 and MM2 arrays with live values", async ({ page }) => { + await openApp(page); + await page.getByRole("button", { name: "Start" }).click(); + await page.getByRole("button", { name: "Run to end" }).click(); + await page.getByRole("tab", { name: "L2 Tile dataflow" }).click(); + + await expect(page.getByText("MM1: S = Q · Kᵀ (array)")).toBeVisible(); + await expect(page.getByText("MM2: P · V (array)")).toBeVisible(); + await expect(page.getByText("O accumulator (rescaled by α)")).toBeVisible(); +}); + +test("L4 runs both schedules on the same inputs and sweeps N", async ({ page }) => { + await openApp(page); + await page.getByRole("tab", { name: "L4 Naive vs Flash" }).click(); + await page.getByRole("button", { name: "Run both on these inputs" }).click(); + await expect(page.getByText("Same inputs, both schedules")).toBeVisible(); + await expect(page.getByText(/FLASH, max [0-9.]+, mean [0-9.]+/)).toBeVisible(); + + await page.getByRole("button", { name: "Sweep N = 4 to 32" }).click(); + await expect(page.getByText("HBM bytes against N (seeded inputs, same settings)")).toBeVisible(); + // header row plus one row per N: 4, 8, ..., 32 + const sweep = page.locator("table").last(); + await expect(sweep.locator("tbody tr")).toHaveCount(8); +}); + +test("light and dark colour schemes both render the visualizer", async ({ browser }) => { + for (const scheme of ["light", "dark"] as const) { + const context = await browser.newContext({ colorScheme: scheme }); + const page = await context.newPage(); + await openApp(page); + const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); + expect(bg).not.toBe("rgba(0, 0, 0, 0)"); + await context.close(); + } +}); + +test("L4 at phone width: no horizontal scroll", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openApp(page); + await page.getByRole("tab", { name: "L4 Naive vs Flash" }).click(); + await page.getByRole("button", { name: "Run both on these inputs" }).click(); + await expect(page.getByText("Same inputs, both schedules")).toBeVisible(); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(0); +}); + +test("every view has no horizontal scroll at phone width", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openApp(page); + await page.getByRole("button", { name: "Start" }).click(); + await page.getByRole("button", { name: "Run to end" }).click(); + for (const name of ["L1 Softmax cell", "L2 Tile dataflow", "L3 Schedule", "L4 Naive vs Flash"]) { + await page.getByRole("tab", { name }).click(); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow, name).toBeLessThanOrEqual(0); + } +}); diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts index 5466029..28bc024 100644 --- a/web/e2e/smoke.spec.ts +++ b/web/e2e/smoke.spec.ts @@ -36,7 +36,7 @@ test("step forward and back moves the clock", async ({ page }) => { test("NAIVE run shows the engine's probability matrix from HBM", async ({ page }) => { await openApp(page); - await page.getByLabel("Schedule").selectOption("1"); + await page.getByRole("combobox", { name: /^Schedule/ }).selectOption("1"); await page.getByRole("button", { name: "Start" }).click(); await page.getByRole("button", { name: "Run to end" }).click(); diff --git a/web/public/tiny_attn.wasm b/web/public/tiny_attn.wasm index 3b8f7914d1e47f2a13b4e7a083cb919911da4337..6f65f01fed070cd77820c9593c4177265a635f22 100755 GIT binary patch delta 55888 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 l!SZUEh`7(-?ZE3^o7& delta 52821 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�@Or76YcdkzeN{pnG={LXpH^E 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 F`(HN9 string }) { + const max = Math.max(a, b, 1); + return ( +
+
{label}
+ {[{ name: "FLASH", v: a, c: "var(--accent)" }, { name: "NAIVE", v: b, c: "var(--err)" }].map((row) => ( +
+ {row.name} +
+
+
+ {fmtv(row.v)} +
+ ))} +
+ ); +} + +function ErrorGrid({ title, err, scale }: { title: string; err: number[][]; scale: number }) { + return ( +
+
{title}
+
+ {err.flatMap((row, i) => + row.map((e, c) => ( +
+ {e.toFixed(3)} +
+ )), + )} +
+
+ ); +} + +function SweepChart({ rows }: { rows: SweepRow[] }) { + const W = 360; + const H = 150; + const maxB = Math.max(...rows.flatMap((r) => [r.flashBytes, r.naiveBytes]), 1); + const x = (n: number) => 10 + ((n - 4) / 28) * (W - 20); + const y = (b: number) => H - 14 - (b / maxB) * (H - 28); + const line = (key: "flashBytes" | "naiveBytes") => rows.map((r) => `${x(r.n)},${y(r[key])}`).join(" "); + return ( +
+ + + + {rows.map((r) => ( + {r.n} + ))} + +
+ ━ FLASH HBM bytes + ━ NAIVE HBM bytes + x-axis: N +
+
+ + + + + + + + + {rows.map((r) => ( + + + + + ))} + +
NFLASH bytesNAIVE bytesFLASH cyclesNAIVE cycles
{r.n}{r.flashBytes}{r.naiveBytes}{r.flashCycles}{r.naiveCycles}
+
+
+ ); +} + +export default function CompareView({ mod, q, k, v, causal, qrows, latency, overlap }: Props) { + const [both, setBoth] = useState<{ flash: RunSummary; naive: RunSummary } | null>(null); + const [sweepRows, setSweepRows] = useState(null); + const [error, setError] = useState(null); + const n = q.length; + const flashQrows = Math.min(qrows, n); + + const reference = useMemo(() => attention(q, k, v, causal).out, [q, k, v, causal]); + + const runBoth = () => { + setError(null); + try { + const base = { n, causal, latency }; + const flash = runEngine(mod, q, k, v, { ...base, qrows: flashQrows, overlap, naive: false }); + const naive = runEngine(mod, q, k, v, { ...base, qrows: 4, overlap: false, naive: true }); + setBoth({ flash, naive }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const runSweep = () => { + setError(null); + try { + setSweepRows(sweep(mod, qrows, causal, latency, 1234)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const errs = (out: number[][]) => out.map((row, i) => row.map((x, c) => Math.abs(x - (reference[i]?.[c] ?? 0)))); + const flashErr = both ? errs(both.flash.out) : null; + const naiveErr = both ? errs(both.naive.out) : null; + const flatMean = (e: number[][]) => e.flat().reduce((a, b) => a + b, 0) / e.flat().length; + const flatMax = (e: number[][]) => Math.max(...e.flat()); + const heatScale = Math.max(0.02, flashErr && naiveErr ? Math.max(flatMax(flashErr), flatMax(naiveErr)) : 0.02); + + return ( +
+
+ + + + FLASH: Q block {flashQrows} rows{overlap ? ", overlap on" : ""}. NAIVE: 4-row blocks. Causal {causal ? "on" : "off"}. + +
+ {error &&

{error}

} + + {both && ( +
+

Same inputs, both schedules

+
+ `${x} B`} /> + `${x}`} /> + `${x}`} /> + `${x}`} /> +
+
+ + + + + + + + + + + + + + + + +
MeasureFLASHNAIVEBasis
On-chip footprint{flashQrows * 4 + 64 + flashQrows * 13} B32 B (one Z tile)formula: Q block + K/V double buffer + O accumulators (FLASH); one 4×4 Z tile (NAIVE)
Full S matrixnever stored{n * n * 2} B per tensor, in HBMformula: N² × 2 B for Z (NAIVE writes and reads it; P is the same size)
Tile-core occupancy{Math.round((both.flash.tiles * TILE_CORE_CYCLES / both.flash.cycles) * 100)}%n/aestimate: tiles × {TILE_CORE_CYCLES} / busy cycles; NAIVE per-pass cost is not counted separately
+
+ +
+

Error against float attention (same inputs, unquantized float reference)

+
+ + +
+

+ Both errors are dominated by the INT8 quantization of Q, K and V. NAIVE quantizes P against the global max; FLASH + rescales as it goes. The grids share one colour scale. +

+
+
+ )} + + {sweepRows && ( +
+

HBM bytes against N (seeded inputs, same settings)

+ +
+ )} +
+ ); +} diff --git a/web/src/components/LevelTabs.tsx b/web/src/components/LevelTabs.tsx new file mode 100644 index 0000000..312c015 --- /dev/null +++ b/web/src/components/LevelTabs.tsx @@ -0,0 +1,39 @@ +// Tab strip for the views (L1 to L4). Same look as tiny-cpu's tabs. +export interface LevelDef { + id: T; + label: string; +} + +interface Props { + levels: LevelDef[]; + value: T; + onChange: (id: T) => void; +} + +export default function LevelTabs({ levels, value, onChange }: Props) { + return ( +
+ {levels.map((l) => { + const selected = l.id === value; + return ( + + ); + })} +
+ ); +} diff --git a/web/src/components/SoftmaxCellView.tsx b/web/src/components/SoftmaxCellView.tsx new file mode 100644 index 0000000..9db3959 --- /dev/null +++ b/web/src/components/SoftmaxCellView.tsx @@ -0,0 +1,126 @@ +// L1: one row lane of the online softmax. Each K/V tile adds four logits; the running max m rises, the rescale α scales +// the old sum down, and the sum l grows. Values are the engine's, read at each tile completion. The reference column is +// the float softmax of the same INT8 inputs (rowReference), so the differences are fixed-point rounding only. +import { useMemo, useState } from "react"; +import { rowReference, rowSteps, type TileSample } from "@/lib/softmax-cell"; + +interface Props { + samples: TileSample[]; + q: number[][]; + k: number[][]; + causal: boolean; + labels: string[]; + naive: boolean; +} + +const muted = { color: "var(--muted)" } as const; +const fmt = (x: number, d = 2) => (Number.isFinite(x) ? x.toFixed(d) : "-"); + +export default function SoftmaxCellView({ samples, q, k, causal, labels, naive }: Props) { + const [row, setRow] = useState(0); + const n = q.length; + const steps = useMemo(() => rowSteps(samples, row), [samples, row]); + const ref = useMemo(() => rowReference(q, k, row, causal), [q, k, row, causal]); + + if (naive) { + return ( +

+ NAIVE has no running max: the softmax of every row is computed in three passes over HBM. Switch to FLASH to see the + online cell. +

+ ); + } + if (steps.length === 0) { + return

Run the engine (Start, then Step or Run to end) to fill this view.

; + } + + const last = steps[steps.length - 1]; + + // polyline of m and l across the row's tiles, each scaled to its own range + const W = 320; + const H = 110; + const pts = (vals: number[]) => { + const lo = Math.min(...vals); + const hi = Math.max(...vals); + const span = hi - lo || 1; + return vals.map((v, i) => [ + (i / Math.max(1, vals.length - 1)) * (W - 20) + 10, + H - 10 - ((v - lo) / span) * (H - 20), + ] as const); + }; + const polyline = (vals: number[]) => pts(vals).map(([x, y]) => `${x},${y}`).join(" "); + const mVals = steps.map((s) => s.m); + const lVals = steps.map((s) => s.l); + + return ( +
+ + +
+ + + + + + + + + + + + + {steps.map((s, i) => ( + + + + + + + + + ))} + +
K/V tilelogits, engine (log2)logits, floatmαl
kv{s.kj} + {s.z.map((x) => (x < -127 ? "masked" : fmt(x))).join(" ")} + + {[0, 1, 2, 3].map((c) => fmt(ref.z[s.kj * 4 + c] ?? Number.NaN)).join(" ")} + {fmt(s.m)}{fmt(s.alpha, 3)}{fmt(s.l, 3)}
+
+ + + + + {pts(mVals).map(([x, y], i) => )} + {pts(lVals).map(([x, y], i) => )} + +
+ ━ m (running max, log2) + ┅ l (running sum, scaled per line) +
+ +
+
After all tiles
+
+ m engine / float + {fmt(last?.m ?? Number.NaN, 3)} / {fmt(ref.m, 3)} + l engine / float + {fmt(last?.l ?? Number.NaN, 3)} / {fmt(ref.l, 3)} + |Δm|, |Δl| + {fmt(Math.abs((last?.m ?? 0) - ref.m), 4)}, {fmt(Math.abs((last?.l ?? 0) - ref.l), 4)} +
+

+ Each logit is split into an integer shift and a LUT fraction inside exp2 (`2^z = 2^⌊z⌋ · 2^frac(z)`). The float + column is the same INT8 inputs in full precision. +

+
+
+ ); +} diff --git a/web/src/components/TileView.tsx b/web/src/components/TileView.tsx new file mode 100644 index 0000000..6ced87a --- /dev/null +++ b/web/src/components/TileView.tsx @@ -0,0 +1,82 @@ +// L2: the datapath of one (qi, kj) step, with the live wire values at the current clock. The two arrays are PE grids: +// PE (r, c) holds the value index r*4 + c. Values are the engine's; the grids show what the wires carry now. +import type { ReactNode } from "react"; +import type { Datapath, TileState } from "@/lib/state-schema"; + +interface Props { + datapath: Datapath | null; + tile: TileState | null; + naive: boolean; +} + +const muted = { color: "var(--muted)" } as const; + +function Grid({ title, values, scale = 1, digits = 0 }: { title: string; values: number[]; scale?: number; digits?: number }) { + const cells = values.slice(0, 16); + return ( +
+
{title}
+
+
+ {cells.map((v, i) => {(v * scale).toFixed(digits)})} +
+
+
+ ); +} + +function Stage({ name, children }: { name: string; children: ReactNode }) { + return ( +
+
{name}
+
{children}
+
+ ); +} + +export default function TileView({ datapath, tile, naive }: Props) { + if (naive) { + return ( +

+ NAIVE runs its passes through the same arrays, but its Z and P tiles go out to HBM between passes. Switch to FLASH + to see the single-tile datapath. +

+ ); + } + if (!datapath || !tile) { + return

Run the engine to fill this view.

; + } + return ( +
+

+ Wires at the last busy clock of the run so far (between tiles they are idle). The S, Z, P and O grids are for the row group of the most recent tile. +

+
+ + + + + + + + + +
+ + + +
+ +
+ + + + + + + + +
+
+ ); +} diff --git a/web/src/components/Visualizer.tsx b/web/src/components/Visualizer.tsx index ca0ca80..3093c5b 100644 --- a/web/src/components/Visualizer.tsx +++ b/web/src/components/Visualizer.tsx @@ -1,11 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import AttentionHeatmap from "@/components/AttentionHeatmap"; +import CompareView from "@/components/CompareView"; +import LevelTabs, { type LevelDef } from "@/components/LevelTabs"; +import SoftmaxCellView from "@/components/SoftmaxCellView"; +import TileView from "@/components/TileView"; import HbmMeter from "@/components/HbmMeter"; import MatrixEditor from "@/components/MatrixEditor"; import SramPanel from "@/components/SramPanel"; import { loadEngine } from "@/lib/engine"; import { PRESETS } from "@/lib/presets"; import { quantize } from "@/lib/quantize"; +import type { TileSample } from "@/lib/softmax-cell"; import { attention } from "@/lib/reference"; import { flashTileOrder, naiveTileOrder, type TileRef } from "@/lib/schedule"; import { TRAFFIC } from "@/lib/traffic-table"; @@ -13,6 +18,7 @@ import { FLASH_STATES, NAIVE_STATES, type EngineInstance, + type Datapath, type EngineModule, type KVBuffers, type Snapshot, @@ -21,6 +27,15 @@ import { const MAX_STEPS = 400_000; +type Level = "softmax" | "tile" | "schedule" | "compare"; + +const LEVELS: LevelDef[] = [ + { id: "softmax", label: "L1 Softmax cell" }, + { id: "tile", label: "L2 Tile dataflow" }, + { id: "schedule", label: "L3 Schedule" }, + { id: "compare", label: "L4 Naive vs Flash" }, +]; + interface Settings { preset: number; mode: 0 | 1; @@ -78,6 +93,14 @@ export default function Visualizer() { const [finished, setFinished] = useState(false); const [result, setResult] = useState(null); const simRef = useRef(null); + const [level, setLevel] = useState("schedule"); + const [datapath, setDatapath] = useState(null); + const [samples, setSamples] = useState([]); + // completed tiles of the current run, in order (filled at each tile_done pulse; cleared when a run restarts) + const historyRef = useRef([]); + const orderRef = useRef([]); + // datapath wires at the last busy clock of the run so far (between tiles the wires are idle) + const lastBusyDatapath = useRef(null); const preset = PRESETS[settings.preset] ?? PRESETS[0]; const n = preset?.q.length ?? 4; @@ -95,6 +118,10 @@ export default function Visualizer() { setSnap(null); setTile(null); setKv(null); + setDatapath(null); + setSamples([]); + historyRef.current = []; + lastBusyDatapath.current = null; setFinished(false); setResult(null); }, []); @@ -112,18 +139,35 @@ export default function Visualizer() { [n, settings.mode, settings.qrows, settings.causal], ); + orderRef.current = order; + + /** Record a completed tile: its global row group and K/V tile, and the engine's row state at that point. */ + const observe = useCallback((sim: EngineInstance, s: Snapshot) => { + if (s.busy) lastBusyDatapath.current = sim.getDatapath(); + if (!s.tileDone) return; + const ref = orderRef.current[s.tiles - 1]; + if (!ref) return; + const t = sim.getTile(); + historyRef.current.push({ qi: ref.qi, kj: ref.kj, z: t.z, m: t.m, l: t.l, alpha: t.alpha }); + }, []); + /** Advance one clock. The DONE edge is followed by one finalizing edge (counters are final after it). */ - const advance = useCallback((sim: EngineInstance, state: { count: number; finished: boolean }) => { - if (state.finished) return null; - let s = sim.step(); - state.count++; - if (s.done) { - s = sim.step(); + const advance = useCallback( + (sim: EngineInstance, state: { count: number; finished: boolean }) => { + if (state.finished) return null; + let s = sim.step(); state.count++; - state.finished = true; - } - return s; - }, []); + observe(sim, s); + if (s.done) { + s = sim.step(); + state.count++; + observe(sim, s); + state.finished = true; + } + return s; + }, + [observe], + ); /** Publish the engine state to the view. Builds the result once the run has finished. */ const commit = useCallback( @@ -133,6 +177,8 @@ export default function Visualizer() { setFinished(done); setTile(sim.getTile()); setKv(sim.getKVBuffers()); + setDatapath(lastBusyDatapath.current); + setSamples(historyRef.current.slice()); if (!done) { setResult(null); return; @@ -156,6 +202,8 @@ export default function Visualizer() { (target: number) => { if (!mod) return; simRef.current?.delete(); + historyRef.current = []; + lastBusyDatapath.current = null; const qz = quantize(inputs.q, inputs.k, inputs.v); const sim = new mod.TinyAttnSim(); sim.configure(n, settings.qrows, settings.causal, settings.mode, settings.overlap, settings.latency, qz.cfgScale); @@ -233,7 +281,6 @@ export default function Visualizer() { return { bg: "var(--panel)", label: "" }; }; - const fmt = (v: number, digits = 0) => (Number.isFinite(v) ? v.toFixed(digits) : "-"); if (error) return

{error}

; if (!mod) return

Loading engine…

; @@ -337,7 +384,12 @@ export default function Visualizer() { -
+ + +
+ {level === "schedule" && ( + <> +
{/* schedule */}

@@ -368,48 +420,24 @@ export default function Visualizer() { {settings.mode === 1 ? " NAIVE passes 2 to 4 reuse these tiles." : ""}

- - {/* current tile */} -
-

Current tile values

- {tile && tilesStarted > 0 ? ( -
- {settings.mode === 0 ? ( - <> -

Row group {tile.curGrp} (rows {tile.curGrp * 4}–{tile.curGrp * 4 + 3})

- - - -
- fmt(v)} /> - fmt(v)} /> - fmt(v)} /> -
- - ) : ( - <> -

- NAIVE tiles go through HBM, not the tile core, so the per-element S, Z and P are not shown here. - The row max m and sum l are shown below; the final probabilities are in the heatmap after the run. -

-
- fmt(v)} /> - fmt(v)} /> -
- - )} -
- ) : ( -

Press Start, then Step.

- )} -
-

On-chip K/V buffers

+ + )} + {level === "softmax" && ( + + )} + {level === "tile" && } + {level === "compare" && ( + + )} +
{/* result */} {result && ( @@ -432,18 +460,13 @@ function Grid({ title, values, cols }: { title: string; values: number[]; cols: return (
{title}
-
- {rows.flatMap((row, i) => row.map((v, j) => {v}))} +
+
+ {rows.flatMap((row, i) => + row.map((v, j) => {Number.isInteger(v) ? v : v.toFixed(4)}), + )} +
); } - -function Vec({ title, values, fmt }: { title: string; values: number[]; fmt: (v: number) => string }) { - return ( -
-
{title}
-
{values.map((v) => fmt(v)).join(" ")}
-
- ); -} diff --git a/web/src/lib/engine-run.ts b/web/src/lib/engine-run.ts new file mode 100644 index 0000000..6f2acce --- /dev/null +++ b/web/src/lib/engine-run.ts @@ -0,0 +1,76 @@ +// Whole runs of the engine, for the comparison view and the N sweep. Each run uses its own instance, freed afterwards. +import { quantize } from "@/lib/quantize"; +import type { EngineModule } from "@/lib/state-schema"; + +const MAX_CYCLES = 400_000; + +export interface RunConfig { + n: number; + qrows: number; + causal: boolean; + naive: boolean; + overlap: boolean; + latency: number; +} + +export interface RunSummary { + out: number[][]; // n rows x 4, dequantized (float units) + hbmRd: number; + hbmWr: number; + cycles: number; + tiles: number; + kvLoads: number; +} + +/** Run to DONE and read everything. The counters are final one edge after DONE, so one more edge is taken. */ +export function runEngine(mod: EngineModule, q: number[][], k: number[][], v: number[][], cfg: RunConfig): RunSummary { + const qz = quantize(q, k, v); + const sim = new mod.TinyAttnSim(); + try { + sim.configure(cfg.n, cfg.qrows, cfg.causal, cfg.naive ? 1 : 0, cfg.overlap && !cfg.naive, cfg.latency, qz.cfgScale); + sim.loadQKV(qz.q, qz.k, qz.v); + sim.start(); + if (sim.runToDone(MAX_CYCLES) < 0) throw new Error("engine did not finish"); + const last = sim.step(); + const flat = sim.readOutput(); + const out: number[][] = []; + for (let i = 0; i < cfg.n; i++) { + out.push([0, 1, 2, 3].map((c) => ((flat[i * 4 + c] ?? 0) / 256) * qz.sv)); + } + return { out, hbmRd: last.hbmRdBytes, hbmWr: last.hbmWrBytes, cycles: last.cycles, tiles: last.tiles, kvLoads: last.kvLoads }; + } finally { + sim.delete(); + } +} + +/** Deterministic inputs for sweeps (not the preset): uniform Q and K in [-2, 2), V in [-1, 1). */ +export function seededInputs(n: number, seed: number): { q: number[][]; k: number[][]; v: number[][] } { + let s = seed >>> 0; + const next = () => { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; + return s / 4294967296; + }; + const mat = (amp: number) => Array.from({ length: n }, () => [0, 1, 2, 3].map(() => (next() * 2 - 1) * amp)); + return { q: mat(2), k: mat(2), v: mat(1) }; +} + +export interface SweepRow { + n: number; + flashBytes: number; + naiveBytes: number; + flashCycles: number; + naiveCycles: number; +} + +/** HBM bytes and cycles for N = 4, 8, ..., 32 under both schedules, on seeded inputs. */ +export function sweep(mod: EngineModule, qrows: number, causal: boolean, latency: number, seed: number): SweepRow[] { + const rows: SweepRow[] = []; + for (let n = 4; n <= 32; n += 4) { + const { q, k, v } = seededInputs(n, seed + n); + const base = { n, qrows: Math.min(qrows, n), causal, overlap: false, latency }; + const f = runEngine(mod, q, k, v, { ...base, naive: false }); + const nv = runEngine(mod, q, k, v, { ...base, qrows: 4, naive: true }); + rows.push({ n, flashBytes: f.hbmRd + f.hbmWr, naiveBytes: nv.hbmRd + nv.hbmWr, flashCycles: f.cycles, naiveCycles: nv.cycles }); + } + return rows; +} diff --git a/web/src/lib/softmax-cell.test.ts b/web/src/lib/softmax-cell.test.ts new file mode 100644 index 0000000..913ef46 --- /dev/null +++ b/web/src/lib/softmax-cell.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { rowReference, rowSteps, type TileSample } from "@/lib/softmax-cell"; +import { seededInputs } from "@/lib/engine-run"; + +const q = seededInputs(8, 7).q; +const k = seededInputs(8, 7).k; + +describe("rowReference (float, same INT8 inputs)", () => { + it("causal row 0 sees only key 0: m is its logit and l is 1", () => { + const r = rowReference(q, k, 0, true); + expect(r.z.slice(1).every((x) => x === -Infinity)).toBe(true); + expect(r.m).toBe(r.z[0]); + expect(r.l).toBeCloseTo(1, 12); + }); + + it("non-causal: l is the sum of 2^(z - m) and is at least 1", () => { + const r = rowReference(q, k, 3, false); + expect(r.z.every((x) => Number.isFinite(x))).toBe(true); + expect(r.l).toBeGreaterThanOrEqual(1); + const direct = r.z.reduce((acc, x) => acc + 2 ** (x - r.m), 0); + expect(r.l).toBeCloseTo(direct, 12); + }); +}); + +describe("rowSteps", () => { + const sample = (qi: number, kj: number, base: number): TileSample => ({ + qi, + kj, + z: Array.from({ length: 16 }, (_, i) => (base + i) * 256), + m: [base * 256, 0, 0, 0], + l: [base * 4096, 0, 0, 0], + alpha: [32768, 0, 0, 0], + }); + + it("keeps only the tiles of the row's group and reads its lane", () => { + const samples = [sample(0, 0, 1), sample(1, 0, 5), sample(0, 1, 2)]; + const steps = rowSteps(samples, 0); + expect(steps.map((s) => s.kj)).toEqual([0, 1]); + expect(steps[0]?.z).toEqual([1, 2, 3, 4]); + expect(steps[1]?.m).toBe(2); + expect(steps[0]?.alpha).toBe(1); + }); + + it("row 5 is lane 1 of group 1", () => { + const steps = rowSteps([sample(1, 0, 5)], 5); + expect(steps[0]?.z).toEqual([5 + 4, 5 + 5, 5 + 6, 5 + 7]); + }); +}); + +describe("seededInputs", () => { + it("is deterministic and has the requested shape and bounds", () => { + const a = seededInputs(12, 99); + const b = seededInputs(12, 99); + expect(a).toEqual(b); + expect(a.q.length).toBe(12); + expect(a.q[0]?.length).toBe(4); + expect(a.q.flat().every((x) => Math.abs(x) <= 2)).toBe(true); + expect(a.v.flat().every((x) => Math.abs(x) <= 1)).toBe(true); + }); +}); diff --git a/web/src/lib/softmax-cell.ts b/web/src/lib/softmax-cell.ts new file mode 100644 index 0000000..3c0cfee --- /dev/null +++ b/web/src/lib/softmax-cell.ts @@ -0,0 +1,65 @@ +// One softmax row, in the engine's units, and the float reference for the same INT8 inputs. +// +// The engine works in log2 units: z = S · log2(e) / sqrt(d), with S = q·k computed on INT8 values. The float reference +// here uses the same INT8 values dequantized (q_int · s_q), so the only difference left is the engine's fixed-point +// rounding, not the quantization. +import { D, LOG2E, quantize } from "@/lib/quantize"; + +export interface RowReference { + z: number[]; // log2-domain logit per key; -Infinity where the causal mask removes the key + m: number; // max over the allowed keys + l: number; // sum of 2^(z - m) over the allowed keys +} + +export function rowReference(q: number[][], k: number[][], row: number, causal: boolean): RowReference { + const qz = quantize(q, k, q); + const n = q.length; + const qd = (i: number, c: number) => (qz.q[i * D + c] ?? 0) * qz.sq; + const kd = (j: number, c: number) => (qz.k[j * D + c] ?? 0) * qz.sk; + const z: number[] = []; + for (let j = 0; j < n; j++) { + if (causal && j > row) { + z.push(-Infinity); + continue; + } + let dot = 0; + for (let c = 0; c < D; c++) dot += qd(row, c) * kd(j, c); + z.push((dot / Math.sqrt(D)) * LOG2E); + } + const m = Math.max(...z.filter((x) => Number.isFinite(x))); + const l = z.reduce((acc, x) => (Number.isFinite(x) ? acc + 2 ** (x - m) : acc), 0); + return { z, m, l }; +} + +/** A completed tile as the engine reported it (recorded at the tile_done pulse). */ +export interface TileSample { + qi: number; // global row group + kj: number; // K/V tile + z: number[]; // 16 logits, Q8.8 log2 units (row-major: row, key column) + m: number[]; // 4 rows, Q8.8 + l: number[]; // 4 rows, real × 2^12 + alpha: number[]; // 4 rows, UQ1.15 +} + +export interface RowStep { + kj: number; + z: number[]; // 4 logits of this tile for the row, real units + m: number; // running max after the tile + alpha: number; // rescale factor applied to the old sum (0..1) + l: number; // running sum after the tile +} + +/** The steps row `row` went through, in tile order. */ +export function rowSteps(samples: TileSample[], row: number): RowStep[] { + const g = Math.floor(row / 4); + const r = row % 4; + return samples + .filter((s) => s.qi === g) + .map((s) => ({ + kj: s.kj, + z: [0, 1, 2, 3].map((c) => (s.z[r * 4 + c] ?? 0) / 256), + m: (s.m[r] ?? 0) / 256, + alpha: (s.alpha[r] ?? 0) / 32768, + l: (s.l[r] ?? 0) / 4096, + })); +} diff --git a/web/src/lib/state-schema.ts b/web/src/lib/state-schema.ts index 70a5009..22165c5 100644 --- a/web/src/lib/state-schema.ts +++ b/web/src/lib/state-schema.ts @@ -11,6 +11,7 @@ export interface Snapshot { cycles: number; tiles: number; kvLoads: number; + tileDone: boolean; // one-cycle pulse: a tile completed on the last edge } /** Tile-core state of the most recent tile (4 rows of one row group). */ @@ -26,6 +27,16 @@ export interface TileState { o: number[]; // 16 values, row-major (4 rows x 4 columns) } +/** Datapath wires of the two systolic arrays. Index r*4 + c is PE (r, c). */ +export interface Datapath { + mm1W: number[]; // Kᵀ weights, int8 + mm1A: number[]; // Q activations, int8 + mm1Psum: number[]; // S partial sums, int17 + mm2W: number[]; // V weights, int8 + mm2A: number[]; // P activations, uint9 + mm2Psum: number[]; // P·V partial sums, int18 +} + /** K and V tile buffers. k[(buf*4 + row)*4 + col] is K[row][col] of buffer buf (int8). sel: buffer in use. */ export interface KVBuffers { sel: number; @@ -47,6 +58,7 @@ export interface EngineInstance { isDone(): boolean; getTile(): TileState; getKVBuffers(): KVBuffers; + getDatapath(): Datapath; delete(): void; }