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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ web/.astro/

# fuzzer output (rename and commit as a regression once understood)
tests/regressions/_last_failure.tiny
.claude/
7 changes: 6 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ faked; host-side work is always labelled as host work.
- Phase 6 (differential fuzzing + hardening): DONE. 611 tests green. `tests/test_fuzz.py` runs random programs through gpu -O0/1/2 and tpu -O0/1 on
the real chips against the source oracle; two independent 5000-program runs were green; 13-program corner-case corpus in
`tests/regressions/`; `fuzz-nightly.yml`; `docs/VERIFICATION.md`. The CLI compiler is now publishable on its own.
- Next: Phase 7 (Compiler Explorer web app: Pyodide worker, pass explorer, TPU schedule view, GPU kernel view, head-to-head).
- Phase 7 (Compiler Explorer): DONE for the MVP. 620 tests green. `web/` (Astro + React, static). Pyodide worker runs `runtime/web_api.py`
(compile both chips, run tiny-gpu simulator, oracle); tiny-tpu RTL (WASM) runs in the page via `runtime/ttpu-exec.mjs`.
Verified in a real browser: default example bit-exact on both chips; mlp_gpu at -O2 bit-exact on gpu and rejected on tpu
with the legality message; phone width (375px) has no horizontal scroll. Not done: CodeMirror editor (plain textarea),
the TPU PE-grid animation and pass-by-pass replay, Vitest/Playwright tests, the L1-L4 level tabs beyond the four views.
- Next: Phase 8 (docs pages, landing page with hero clip, README verification table, Vercel deploy).

## Fixed decisions (do not re-litigate; change only via docs/ARCHITECTURE.md)
| Decision | Choice |
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ One tiny tensor language, two real chips: `C = relu(A @ B)` compiles to
[tiny-gpu](https://github.com/deaneeth/tiny-gpu) assembly or to a
[tiny-tpu](https://github.com/deaneeth/tiny-tpu) tile schedule, and runs on the real simulator / RTL.

**Status: Phase 6.** The frontend, optimizing pass pipeline and **both backends** work. One program,
**Status: Phase 7.** The [Compiler Explorer](web/) runs the whole compiler in the browser. The frontend, optimizing pass pipeline and **both backends** work. One program,
two chips, bit-exact against numpy on each:

```bash
Expand All @@ -27,3 +27,12 @@ pytest
Requires Python 3.12+ and Node 20+ (the tiny-tpu WASM is loaded from the pinned submodule).

Everything is checked against a numpy oracle on the real chips; see [docs/VERIFICATION.md](docs/VERIFICATION.md).

## Compiler Explorer (web)

```bash
cd web && npm install && npm run dev # predev copies the Python bundle and the tiny-tpu WASM into web/public
```

Open `/app`: edit a program, pick an example and `-O` level, and see every pass, both chips' output, and
the results of running them on the real simulator and RTL.
1 change: 1 addition & 0 deletions docs/VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Every claim the compiler makes is checked against something independent. Layers,
| Differential fuzzing | random programs through *every* backend and level on the real chips | `tests/test_fuzz.py`, `tests/fuzz_support.py` |
| Regression corpus | corner cases and past bugs, replayed everywhere | `tests/regressions/*.tiny` |
| Goldens & pins | emitted IR/code/diagnostics; cost numbers per example and level | `tests/golden/`, `tests/perf_baseline.json` |
| Browser entry point | `analyze()` tested on CPython; the same code runs under Pyodide in the Explorer | `tests/test_web_api.py` |
| Docs that quote numbers | the measured optimization table must equal a fresh measurement | `tests/gpu/test_opt_table.py` |

## Differential fuzzing
Expand Down
123 changes: 123 additions & 0 deletions runtime/web_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""The browser-facing API: one call compiles for both chips, runs the tiny-gpu simulator, and
computes the numpy oracle. The tiny-tpu program is returned for the browser to run on its WASM RTL.

Runs under Pyodide (web/public/compiler-worker.js) and under CPython (tests). Returns a JSON string.
"""

from __future__ import annotations

import json

import numpy as np

from runtime.gpu_runner import run_launch
from tinyc.driver import compile_gpu, compile_tpu
from tinyc.frontend import CompileError, compile_to_graph
from tinyc.ir import ref
from tinyc.ir.errors import PassError, RangeError
from tinyc.passes import PassContext
from tinyc.tpu.tile_ir import print_schedule

FILENAME = "program.tiny"


def _render(e: Exception) -> str:
if isinstance(e, CompileError):
return e.render()
if isinstance(e, PassError):
return f"{e.loc}: error: {e.message}" if e.loc else f"error: {e.message}"
return f"internal error: {type(e).__name__}: {e}"


def _gpu(source: str, opt: int, inputs, want) -> dict:
dumps: list[dict] = []
try:
comp = compile_gpu(
source,
FILENAME,
opt_level=opt,
ctx=PassContext(),
dump=lambda name, text: dumps.append({"name": name, "ir": text}),
)
except Exception as e: # noqa: BLE001 - any failure is shown to the user, never a crash
return {"ok": False, "error": _render(e), "passes": dumps}
launch = comp.launch
est = comp.program.estimate
out = {
"ok": True,
"passes": dumps,
"asm": comp.program.asm_text(),
"kernels": [k["name"] + " " + str(k["grid"]) for k in launch["kernels"]],
"launch": launch,
"estimate": est,
"run": None,
}
if inputs is not None and want is not None:
res = run_launch(launch, inputs)
exact = all(
np.array_equal(res.outputs[name], w) for name, w in zip(launch["outputs"], want)
)
out["run"] = {
"outputs": {n: res.outputs[n].tolist() for n in launch["outputs"]},
"exact": bool(exact),
"reservedIntact": bool(res.reserved_intact),
"cycles": res.cycles,
"instructions": res.instructions,
"loads": res.op_counts["LD"],
"stores": res.op_counts["ST"],
"shared": res.op_counts["SHLD"] + res.op_counts["SHST"],
"barriers": res.op_counts["SYNCB"],
}
return out


def _tpu(source: str, opt: int) -> dict:
dumps: list[dict] = []
try:
comp = compile_tpu(
source,
FILENAME,
opt_level=opt,
dump=lambda name, text: dumps.append({"name": name, "ir": text}),
)
except Exception as e: # noqa: BLE001 - any failure is shown to the user, never a crash
return {"ok": False, "error": _render(e), "passes": dumps}
return {
"ok": True,
"passes": dumps,
"tile": print_schedule(comp.schedule),
"program": comp.program,
"estimate": comp.program["estimate"],
}


def _analyze(source: str, opt: int, seed: int) -> dict:
try:
func = compile_to_graph(source, FILENAME)
except CompileError as e:
return {"ok": False, "error": e.render()}
inputs = ref.random_inputs(func, np.random.default_rng(seed))
try:
want = ref.evaluate(func, inputs)
oracle_error = None
except RangeError as e:
want, oracle_error = None, f"the program overflows i32 on these inputs: {e}"
return {
"ok": True,
"opt": opt,
"seed": seed,
"inputs": {k: v.tolist() for k, v in inputs.items()},
"outputNames": [v.name for v in func.outputs],
"oracle": None if want is None else [w.tolist() for w in want],
"oracleError": oracle_error,
"gpu": _gpu(source, opt, inputs, want),
"tpu": _tpu(source, opt),
}


def analyze(source: str, opt: int = 1, seed: int = 42) -> str:
"""JSON string: compiled artifacts for both chips, the oracle, and the tiny-gpu measurement."""
return json.dumps(
_analyze(source, opt, seed),
default=lambda o: o.tolist() if hasattr(o, "tolist") else str(o),
)
60 changes: 60 additions & 0 deletions scripts/build_web.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Assemble everything the browser needs into web/ (run automatically by npm predev/prebuild).

web/public/py/bundle.json the Python sources (tinyc, runtime, tinygpu) for Pyodide
web/public/tiny_tpu.{mjs,wasm} the tiny-tpu WASM build from the pinned submodule
web/public/runtime/ttpu-exec.mjs the one tiny-tpu executor, shared with Node
web/src/lib/examples.generated.ts the example programs, so the UI cannot drift from examples/
"""

import json
import shutil
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
WEB = ROOT / "web"
PKGS = {
"tinyc": ROOT / "tinyc",
"runtime": ROOT / "runtime",
"tinygpu": ROOT / "targets" / "tiny-gpu" / "src" / "tinygpu",
}
ONLY_RUNTIME = {"__init__.py", "gpu_runner.py", "web_api.py"} # the rest of runtime/ is Node/CLI


def bundle() -> dict:
files = {}
for pkg, root in PKGS.items():
for path in sorted(root.rglob("*.py")):
if "__pycache__" in path.parts or path.name == "__main__.py":
continue
if pkg == "runtime" and path.name not in ONLY_RUNTIME:
continue
rel = f"{pkg}/{path.relative_to(root).as_posix()}"
files[rel] = path.read_text()
return {"files": files}


def main() -> None:
out_py = WEB / "public" / "py"
out_py.mkdir(parents=True, exist_ok=True)
data = bundle()
(out_py / "bundle.json").write_text(json.dumps(data))
print(f"bundle.json: {len(data['files'])} python files")

tpu_public = ROOT / "targets" / "tiny-tpu" / "web" / "public"
for name in ("tiny_tpu.mjs", "tiny_tpu.wasm"):
shutil.copyfile(tpu_public / name, WEB / "public" / name)
shutil.copyfile(
ROOT / "runtime" / "ttpu-exec.mjs", WEB / "public" / "runtime" / "ttpu-exec.mjs"
)

examples = {p.stem: p.read_text() for p in sorted((ROOT / "examples").glob("*.tiny"))}
(WEB / "src" / "lib" / "examples.generated.ts").write_text(
"// Generated by scripts/build_web.py from examples/. Do not edit.\n"
f"export const EXAMPLES: Record<string, string> = {json.dumps(examples, indent=2)};\n"
)
print(f"examples: {', '.join(examples)}")


if __name__ == "__main__":
main()
71 changes: 71 additions & 0 deletions tests/test_web_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""The browser's entry point, tested on CPython: the same code runs under Pyodide."""

import json
from pathlib import Path

import pytest

from runtime.web_api import analyze

EX = Path(__file__).resolve().parent.parent / "examples"


def run(source, opt=1, seed=42):
return json.loads(analyze(source, opt, seed))


def test_one_call_compiles_for_both_chips_and_checks_the_gpu_against_the_oracle():
r = run((EX / "mm_relu.tiny").read_text())
assert r["ok"] and r["oracle"] is not None
assert r["gpu"]["ok"] and r["gpu"]["run"]["exact"] and r["gpu"]["run"]["reservedIntact"]
assert r["gpu"]["run"]["outputs"]["1"] == r["oracle"][0]
assert r["tpu"]["ok"] and r["tpu"]["program"]["format"] == "ttpu"
assert r["tpu"]["estimate"]["passes"] == 2


def test_the_gpu_estimate_bounds_contain_the_run():
r = run((EX / "mm_bias_relu.tiny").read_text())
est, run_ = r["gpu"]["estimate"], r["gpu"]["run"]
assert est["instructions"]["min"] <= run_["instructions"] <= est["instructions"]["max"]
assert est["cycles"]["min"] <= run_["cycles"] <= est["cycles"]["max"]


def test_the_chip_rejections_are_reported_per_target_not_as_a_failure():
r = run((EX / "mlp_gpu.tiny").read_text())
assert r["ok"] and r["gpu"]["ok"] and r["gpu"]["run"]["exact"]
assert not r["tpu"]["ok"] and "tiny-tpu needs i8" in r["tpu"]["error"]


def test_passes_are_listed_in_order_for_the_pass_explorer():
r = run((EX / "mm_bias_relu.tiny").read_text())
names = [p["name"] for p in r["gpu"]["passes"]]
assert names[0] == "input" and "fuse" in names and "range-check" in names
assert "lower-to-kernel" in names and names[-1] == "regalloc-emit"
fused = next(p for p in r["gpu"]["passes"] if p["name"] == "fuse")
assert "matmul{epilogue=bias,relu}" in fused["ir"]


def test_source_errors_come_back_with_a_caret_and_no_artifacts():
r = run("input A : i8[2, 2]\nC = A @ A +\noutput C\n")
assert not r["ok"] and "expected an expression" in r["error"] and "^" in r["error"]


def test_an_overflow_on_the_inputs_still_compiles_and_reports_no_oracle(monkeypatch):
# Random inputs are bounded to +-1000, so no real program overflows them; force the path.
from tinyc.ir import ref
from tinyc.ir.errors import RangeError

def overflow(*_a, **_k):
raise RangeError("%0 (matmul) does not fit in i32")

monkeypatch.setattr(ref, "evaluate", overflow)
r = run((EX / "mm_relu.tiny").read_text())
assert r["ok"] and r["oracle"] is None and "overflows" in r["oracleError"]
assert r["gpu"]["ok"] and r["gpu"]["run"] is None # compiled, not run without an oracle
assert r["tpu"]["ok"]


@pytest.mark.parametrize("opt", [0, 1, 2])
def test_every_level_runs_exact_on_the_gpu(opt):
r = run((EX / "mm_relu.tiny").read_text(), opt=opt)
assert r["gpu"]["run"]["exact"]
8 changes: 8 additions & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
dist/
.astro/
public/py/bundle.json
public/tiny_tpu.mjs
public/tiny_tpu.wasm
public/runtime/ttpu-exec.mjs
src/lib/examples.generated.ts
7 changes: 7 additions & 0 deletions web/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from "astro/config";
import react from "@astrojs/react";

export default defineConfig({
integrations: [react()],
output: "static",
});
Loading
Loading