From 3757151f76eae7127d33abfa213b58b04eb5ff76 Mon Sep 17 00:00:00 2001 From: Dineth Hettiarachchi Date: Sat, 24 Oct 2026 23:57:12 +0530 Subject: [PATCH 1/2] Phase 1: frontend, Graph IR and numpy oracle Add the .tiny lexer/parser/AST with caret diagnostics, lowering with type checking to a typed SSA Graph IR (printer, parser round trip, verifier), a numpy int64 oracle and an independent scalar interpreter, a tinyc CLI, six examples, language/IR docs, and golden-file tests (20 error messages, IR for every example). --- CLAUDE.md | 7 +- CONTEXT.md | 4 +- README.md | 5 +- docs/IR.md | 35 +++ docs/LANGUAGE.md | 45 ++++ examples/big_mm.tiny | 5 + examples/const_mm.tiny | 6 + examples/mlp_gpu.tiny | 8 + examples/mm.tiny | 5 + examples/mm_bias_relu.tiny | 6 + examples/mm_relu.tiny | 5 + pyproject.toml | 7 +- tests/conftest.py | 22 ++ tests/frontend/test_diagnostics.py | 21 ++ tests/frontend/test_lexer_parser.py | 72 +++++++ tests/golden/errors/add_shape_mismatch.tiny | 4 + tests/golden/errors/add_shape_mismatch.txt | 3 + tests/golden/errors/bad_type.tiny | 2 + tests/golden/errors/bad_type.txt | 3 + tests/golden/errors/const_out_of_range.tiny | 2 + tests/golden/errors/const_out_of_range.txt | 4 + tests/golden/errors/const_too_deep.tiny | 2 + tests/golden/errors/const_too_deep.txt | 3 + tests/golden/errors/const_too_flat.tiny | 2 + tests/golden/errors/const_too_flat.txt | 3 + tests/golden/errors/const_wrong_length.tiny | 3 + tests/golden/errors/const_wrong_length.txt | 4 + .../golden/errors/matmul_inner_mismatch.tiny | 4 + tests/golden/errors/matmul_inner_mismatch.txt | 3 + tests/golden/errors/matmul_rank1.tiny | 4 + tests/golden/errors/matmul_rank1.txt | 3 + tests/golden/errors/missing_equals.tiny | 3 + tests/golden/errors/missing_equals.txt | 3 + tests/golden/errors/missing_paren.tiny | 3 + tests/golden/errors/missing_paren.txt | 3 + tests/golden/errors/no_outputs.tiny | 2 + tests/golden/errors/no_outputs.txt | 4 + tests/golden/errors/output_twice.tiny | 2 + tests/golden/errors/output_twice.txt | 3 + tests/golden/errors/output_undefined.tiny | 2 + tests/golden/errors/output_undefined.txt | 3 + tests/golden/errors/rank3.tiny | 2 + tests/golden/errors/rank3.txt | 3 + tests/golden/errors/redefinition.tiny | 3 + tests/golden/errors/redefinition.txt | 4 + tests/golden/errors/stray_char.tiny | 3 + tests/golden/errors/stray_char.txt | 3 + tests/golden/errors/tab_indent.tiny | 3 + tests/golden/errors/tab_indent.txt | 3 + tests/golden/errors/trailing_junk.tiny | 3 + tests/golden/errors/trailing_junk.txt | 3 + tests/golden/errors/undefined_name.tiny | 3 + tests/golden/errors/undefined_name.txt | 4 + tests/golden/errors/zero_dim.tiny | 2 + tests/golden/errors/zero_dim.txt | 3 + tests/golden/ir/big_mm.ir | 5 + tests/golden/ir/const_mm.ir | 6 + tests/golden/ir/mlp_gpu.ir | 7 + tests/golden/ir/mm.ir | 4 + tests/golden/ir/mm_bias_relu.ir | 6 + tests/golden/ir/mm_relu.ir | 5 + tests/ir/test_graph_ir.py | 200 ++++++++++++++++++ tests/test_examples.py | 47 ++++ tinyc/cli.py | 37 ++++ tinyc/frontend/__init__.py | 18 ++ tinyc/frontend/ast.py | 117 ++++++++++ tinyc/frontend/diagnostics.py | 58 +++++ tinyc/frontend/lexer.py | 59 ++++++ tinyc/frontend/lower.py | 129 +++++++++++ tinyc/frontend/parser.py | 174 +++++++++++++++ tinyc/ir/__init__.py | 1 + tinyc/ir/errors.py | 21 ++ tinyc/ir/graph.py | 67 ++++++ tinyc/ir/interp.py | 59 ++++++ tinyc/ir/loc.py | 13 ++ tinyc/ir/parser.py | 189 +++++++++++++++++ tinyc/ir/printer.py | 30 +++ tinyc/ir/ref.py | 67 ++++++ tinyc/ir/types.py | 74 +++++++ tinyc/ir/verifier.py | 77 +++++++ 80 files changed, 1834 insertions(+), 8 deletions(-) create mode 100644 docs/IR.md create mode 100644 docs/LANGUAGE.md create mode 100644 examples/big_mm.tiny create mode 100644 examples/const_mm.tiny create mode 100644 examples/mlp_gpu.tiny create mode 100644 examples/mm.tiny create mode 100644 examples/mm_bias_relu.tiny create mode 100644 examples/mm_relu.tiny create mode 100644 tests/frontend/test_diagnostics.py create mode 100644 tests/frontend/test_lexer_parser.py create mode 100644 tests/golden/errors/add_shape_mismatch.tiny create mode 100644 tests/golden/errors/add_shape_mismatch.txt create mode 100644 tests/golden/errors/bad_type.tiny create mode 100644 tests/golden/errors/bad_type.txt create mode 100644 tests/golden/errors/const_out_of_range.tiny create mode 100644 tests/golden/errors/const_out_of_range.txt create mode 100644 tests/golden/errors/const_too_deep.tiny create mode 100644 tests/golden/errors/const_too_deep.txt create mode 100644 tests/golden/errors/const_too_flat.tiny create mode 100644 tests/golden/errors/const_too_flat.txt create mode 100644 tests/golden/errors/const_wrong_length.tiny create mode 100644 tests/golden/errors/const_wrong_length.txt create mode 100644 tests/golden/errors/matmul_inner_mismatch.tiny create mode 100644 tests/golden/errors/matmul_inner_mismatch.txt create mode 100644 tests/golden/errors/matmul_rank1.tiny create mode 100644 tests/golden/errors/matmul_rank1.txt create mode 100644 tests/golden/errors/missing_equals.tiny create mode 100644 tests/golden/errors/missing_equals.txt create mode 100644 tests/golden/errors/missing_paren.tiny create mode 100644 tests/golden/errors/missing_paren.txt create mode 100644 tests/golden/errors/no_outputs.tiny create mode 100644 tests/golden/errors/no_outputs.txt create mode 100644 tests/golden/errors/output_twice.tiny create mode 100644 tests/golden/errors/output_twice.txt create mode 100644 tests/golden/errors/output_undefined.tiny create mode 100644 tests/golden/errors/output_undefined.txt create mode 100644 tests/golden/errors/rank3.tiny create mode 100644 tests/golden/errors/rank3.txt create mode 100644 tests/golden/errors/redefinition.tiny create mode 100644 tests/golden/errors/redefinition.txt create mode 100644 tests/golden/errors/stray_char.tiny create mode 100644 tests/golden/errors/stray_char.txt create mode 100644 tests/golden/errors/tab_indent.tiny create mode 100644 tests/golden/errors/tab_indent.txt create mode 100644 tests/golden/errors/trailing_junk.tiny create mode 100644 tests/golden/errors/trailing_junk.txt create mode 100644 tests/golden/errors/undefined_name.tiny create mode 100644 tests/golden/errors/undefined_name.txt create mode 100644 tests/golden/errors/zero_dim.tiny create mode 100644 tests/golden/errors/zero_dim.txt create mode 100644 tests/golden/ir/big_mm.ir create mode 100644 tests/golden/ir/const_mm.ir create mode 100644 tests/golden/ir/mlp_gpu.ir create mode 100644 tests/golden/ir/mm.ir create mode 100644 tests/golden/ir/mm_bias_relu.ir create mode 100644 tests/golden/ir/mm_relu.ir create mode 100644 tests/ir/test_graph_ir.py create mode 100644 tests/test_examples.py create mode 100644 tinyc/cli.py create mode 100644 tinyc/frontend/__init__.py create mode 100644 tinyc/frontend/ast.py create mode 100644 tinyc/frontend/diagnostics.py create mode 100644 tinyc/frontend/lexer.py create mode 100644 tinyc/frontend/lower.py create mode 100644 tinyc/frontend/parser.py create mode 100644 tinyc/ir/__init__.py create mode 100644 tinyc/ir/errors.py create mode 100644 tinyc/ir/graph.py create mode 100644 tinyc/ir/interp.py create mode 100644 tinyc/ir/loc.py create mode 100644 tinyc/ir/parser.py create mode 100644 tinyc/ir/printer.py create mode 100644 tinyc/ir/ref.py create mode 100644 tinyc/ir/types.py create mode 100644 tinyc/ir/verifier.py diff --git a/CLAUDE.md b/CLAUDE.md index 6c56f0b..72e5f6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,14 +10,15 @@ TinyCompiler compiles a tiny tensor language (`.tiny`) to two real chips: tiny-g assembly on its Python simulator) and tiny-tpu (`.ttpu.json` tile schedules on its Verilator→WASM RTL). Pipeline: `.tiny → AST → Graph IR → Tile IR | Kernel IR → .ttpu.json | .tgpu`. -## Commands (planned; fill in as Phase 0 lands) +## Commands (backends and `--run` arrive in later phases) ```bash git submodule update --init python3 -m venv .venv && .venv/bin/pip install -e ".[dev]" && .venv/bin/pip install -e targets/tiny-gpu --no-deps source scripts/env.sh -tinyc examples/mm_relu.tiny --target both -O2 --run --stats -pytest -q # full suite incl. 200 fuzz examples +tinyc examples/mm_relu.tiny --locs # Graph IR (or --emit ast); later: --target both -O2 --run --stats +pytest -q # full suite +pytest --update-golden # rewrite tests/golden/* after reviewing the diff cd web && pnpm dev | build | lint | typecheck | test ``` diff --git a/CONTEXT.md b/CONTEXT.md index d775aac..fb9bcda 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -11,7 +11,9 @@ faked; host-side work is always labelled as host work. ## Status - Phase 0 (scaffold + drive both targets from pytest): DONE. 17 tests green (runners + target contract), ruff/black clean. -- Next: Phase 1 (frontend + Graph IR + oracle). +- Phase 1 (frontend + Graph IR + oracle): DONE. 90 tests green: lexer/parser/diagnostics (20 golden error messages), Graph IR + printer/parser round trip, verifier, numpy oracle + independent scalar interpreter, 6 examples. +- Next: Phase 2 (pass manager + graph passes). ## Fixed decisions (do not re-litigate; change only via docs/ARCHITECTURE.md) | Decision | Choice | diff --git a/README.md b/README.md index 0b06a81..c8ac802 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ 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 0.** No compiler yet. Both targets can be driven from pytest. See -[docs/PLAN.md](docs/PLAN.md) for the roadmap. +**Status: Phase 1.** The frontend and Graph IR work (`tinyc examples/mm_relu.tiny`); no backends yet. +Both targets can be driven from pytest. See [docs/PLAN.md](docs/PLAN.md) for the roadmap, +[docs/LANGUAGE.md](docs/LANGUAGE.md) and [docs/IR.md](docs/IR.md) for the language and IR. ```bash git clone --recurse-submodules && cd tiny-compiler diff --git a/docs/IR.md b/docs/IR.md new file mode 100644 index 0000000..44651f5 --- /dev/null +++ b/docs/IR.md @@ -0,0 +1,35 @@ +# Graph IR (text format) + +One function, straight-line SSA. Values are `%name`: inputs keep their source names, op results +are numbered `%0, %1, ...`. Every type is printed without spaces (`i8[4,8]`). + +``` +func @main(%A: i8[4,8], %B: i8[8,4]) -> (i32[4,4]) { + %0 = matmul %A, %B : i32[4,4] loc("mm_relu.tiny":4:10) + %1 = relu %0 : i32[4,4] loc("mm_relu.tiny":4:5) + return %1 +} +``` + +| Op | Syntax | +|---|---| +| `const` | `%x = const [[1, 2], [3, 4]] : i8[2,2]` | +| `matmul` | `%x = matmul %a, %b : T` | +| `add` | `%x = add %a, %b : T` | +| `relu` | `%x = relu %a : T` | + +- `loc("file":line:col)` is optional and points at the start of the source expression. It survives + every lowering so the UI can map any output line back to the source. +- `//` starts a comment. Layout is free-form when parsing; the printer is canonical. +- `ir.printer.print_func` and `ir.parser.parse_func` round-trip exactly (tested over every example). + +## Code map +| Module | Role | +|---|---| +| `ir/types.py` | `TensorType` and `infer_result_type`, the one source of typing rules | +| `ir/graph.py` | `Value`, `Op`, `Func`, `FuncBuilder` | +| `ir/verifier.py` | structural + type checks; reports every problem | +| `ir/ref.py` | **the oracle**: numpy int64 evaluation, `RangeError` on i32 overflow | +| `ir/interp.py` | independent scalar interpreter (plain Python ints), cross-checked against the oracle | + +Inspect a program: `tinyc examples/mm_relu.tiny --locs` (or `--emit ast`). diff --git a/docs/LANGUAGE.md b/docs/LANGUAGE.md new file mode 100644 index 0000000..a3fe763 --- /dev/null +++ b/docs/LANGUAGE.md @@ -0,0 +1,45 @@ +# The `.tiny` language (v1) + +Integer tensors, static shapes, rank 1 or 2, no control flow. Statements end at a newline +(newlines inside `(...)` and `[...]` do not end a statement). `#` starts a comment. + +``` +input A : i8[4, 8] # declare an input +const W : i8[2, 2] = [[1, -2], # declare a constant (shape and range are checked) + [3, 4]] +C = relu(A @ B + b) # define a value (names are single-assignment) +output C, D # one or more outputs (at least one is required) +``` + +Reserved words: `input const output relu i8 i32`. + +## Types +`i8` or `i32`, with 1 or 2 positive dimensions: `i8[4, 8]`, `i32[6]`. + +## Expressions +`@` binds tighter than `+`; both are left-associative; parentheses group. + +| Expression | Rule | Result | +|---|---|---| +| `a @ b` | both rank 2, `a` is `[M,K]` and `b` is `[K,N]`; operand types may be `i8` or `i32` | `i32[M,N]` | +| `a + b` | same shape, or `[M,N] + [N]` in either order (the row is broadcast); `i8` operands are widened | `i32`, the larger shape | +| `relu(a)` | any | same shape and type as `a` | + +## Semantics +All arithmetic is exact (widened); **every op result must fit in `i32`**, otherwise evaluation +fails with `RangeError`, because tiny-gpu raises on overflow rather than wrapping. Inputs must +fit their declared type. + +## Errors +The first problem stops compilation and is shown with the source line and a caret: + +``` +matmul_inner_mismatch.tiny:3:5: error: matmul inner dimensions differ: 8 vs 4 (i8[4,8] @ i8[4,4]) +3 | C = A @ B + | ^^^^^ +``` +Every message is snapshot-tested (`tests/golden/errors/`). + +## Target legality (checked later, Phase 2) +Whether a program can run on a chip is not a language rule: tiny-tpu needs `i8` matmul operands, +and tiny-gpu has a memory budget. See `docs/PLAN.md` §3.3. diff --git a/examples/big_mm.tiny b/examples/big_mm.tiny new file mode 100644 index 0000000..b8cda6a --- /dev/null +++ b/examples/big_mm.tiny @@ -0,0 +1,5 @@ +# 12x12x12: 27 tiny-tpu passes once tiled. +input A : i8[12, 12] +input B : i8[12, 12] +C = relu(A @ B) +output C diff --git a/examples/const_mm.tiny b/examples/const_mm.tiny new file mode 100644 index 0000000..926218b --- /dev/null +++ b/examples/const_mm.tiny @@ -0,0 +1,6 @@ +# Weights baked into the program as a constant. +input X : i8[3, 2] +const W : i8[2, 2] = [[1, -2], + [3, 4]] +Y = relu(X @ W) +output Y diff --git a/examples/mlp_gpu.tiny b/examples/mlp_gpu.tiny new file mode 100644 index 0000000..3a61265 --- /dev/null +++ b/examples/mlp_gpu.tiny @@ -0,0 +1,8 @@ +# Two layers. The second matmul has an i32 operand, so only tiny-gpu can run it (tiny-tpu needs i8). +input X : i8[4, 8] +input W1 : i8[8, 6] +input b1 : i32[6] +input W2 : i8[6, 3] +H = relu(X @ W1 + b1) +Y = H @ W2 +output Y diff --git a/examples/mm.tiny b/examples/mm.tiny new file mode 100644 index 0000000..5251c6e --- /dev/null +++ b/examples/mm.tiny @@ -0,0 +1,5 @@ +# Plain 4x4 int8 matmul: one tiny-tpu pass, one thread per output element on tiny-gpu. +input A : i8[4, 4] +input B : i8[4, 4] +C = A @ B +output C diff --git a/examples/mm_bias_relu.tiny b/examples/mm_bias_relu.tiny new file mode 100644 index 0000000..f1a023f --- /dev/null +++ b/examples/mm_bias_relu.tiny @@ -0,0 +1,6 @@ +# A dense layer: matmul, row-broadcast bias, relu. +input X : i8[4, 8] +input W : i8[8, 4] +input b : i32[4] +Y = relu(X @ W + b) +output Y diff --git a/examples/mm_relu.tiny b/examples/mm_relu.tiny new file mode 100644 index 0000000..ba4beab --- /dev/null +++ b/examples/mm_relu.tiny @@ -0,0 +1,5 @@ +# The headline program: C = relu(A @ B). +input A : i8[4, 8] +input B : i8[8, 4] +C = relu(A @ B) +output C diff --git a/pyproject.toml b/pyproject.toml index c68c075..97f40fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,11 @@ dependencies = ["numpy>=2.0"] [project.optional-dependencies] dev = ["pytest>=8", "hypothesis>=6.100", "black>=24.10", "ruff>=0.6"] -[tool.setuptools] -packages = ["tinyc", "runtime"] +[tool.setuptools.packages.find] +include = ["tinyc*", "runtime*"] + +[project.scripts] +tinyc = "tinyc.cli:main" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py index c3dfb68..6197886 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,3 +7,25 @@ @pytest.fixture def rng() -> np.random.Generator: return np.random.default_rng(SEED) + + +def pytest_addoption(parser): + parser.addoption( + "--update-golden", action="store_true", help="rewrite tests/golden/* from current output" + ) + + +@pytest.fixture +def golden(request): + """`golden(path, actual)`: compare to a checked-in file; `--update-golden` rewrites it.""" + update = request.config.getoption("--update-golden") + + def check(path, actual: str) -> None: + if update: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(actual) + return + assert path.exists(), f"missing golden file {path}; run `pytest --update-golden`" + assert actual == path.read_text(), f"{path} differs; review, then `pytest --update-golden`" + + return check diff --git a/tests/frontend/test_diagnostics.py b/tests/frontend/test_diagnostics.py new file mode 100644 index 0000000..17eef6b --- /dev/null +++ b/tests/frontend/test_diagnostics.py @@ -0,0 +1,21 @@ +"""Error messages are a feature: each broken program's rendered diagnostic is a golden file.""" + +from pathlib import Path + +import pytest + +from tinyc.frontend import CompileError, compile_to_graph + +GOLDEN = Path(__file__).resolve().parent.parent / "golden" / "errors" +CASES = sorted(GOLDEN.glob("*.tiny")) + + +def test_cases_exist(): + assert len(CASES) >= 15 + + +@pytest.mark.parametrize("src", CASES, ids=lambda p: p.stem) +def test_diagnostic_snapshot(src, golden): + with pytest.raises(CompileError) as exc: + compile_to_graph(src.read_text(), src.name) + golden(src.with_suffix(".txt"), exc.value.render() + "\n") diff --git a/tests/frontend/test_lexer_parser.py b/tests/frontend/test_lexer_parser.py new file mode 100644 index 0000000..d3cd618 --- /dev/null +++ b/tests/frontend/test_lexer_parser.py @@ -0,0 +1,72 @@ +import pytest + +from tinyc.frontend import CompileError, SourceFile, ast, parse +from tinyc.frontend.lexer import lex + + +def toks(text): + return [t.kind for t in lex(SourceFile("t", text))] + + +def prog(text): + return parse(SourceFile("t", text)) + + +def test_newlines_collapse_and_comments_are_skipped(): + assert toks("\n\n# hi\nC = A # trailing\n\n") == ["NAME", "=", "NAME", "NEWLINE", "EOF"] + + +def test_newlines_inside_brackets_do_not_end_a_statement(): + kinds = toks("const W : i8[1, 2] = [\n 1,\n 2 ]\n") + assert kinds.count("NEWLINE") == 1 + + +def test_keywords_are_reserved(): + assert toks("input relu i8 i32 const output x") == [ + "input", "relu", "i8", "i32", "const", "output", "NAME", "NEWLINE", "EOF", + ] # fmt: skip + + +def test_matmul_binds_tighter_than_add_and_both_are_left_associative(): + (stmt,) = prog("C = A + B @ D @ E + F\n").stmts + # ((A + ((B @ D) @ E)) + F) + top = stmt.expr + assert isinstance(top, ast.Add) and top.rhs == ast.Name("F", top.rhs.span) + inner = top.lhs + assert isinstance(inner, ast.Add) and isinstance(inner.rhs, ast.MatMul) + assert isinstance(inner.rhs.lhs, ast.MatMul) + + +def test_parentheses_widen_the_span_but_not_the_tree(): + (stmt,) = prog("C = (A + B) @ D\n").stmts + assert isinstance(stmt.expr, ast.MatMul) and isinstance(stmt.expr.lhs, ast.Add) + assert stmt.expr.lhs.span.col == 5 # starts at '(' not at 'A' + + +def test_negative_literals_and_nested_lists(): + (stmt,) = prog("const W : i8[2, 2] = [[1, -2], [-3, 4]]\n").stmts + flat = [i.value for row in stmt.value.items for i in row.items] + assert flat == [1, -2, -3, 4] + + +def test_statements_parse_to_the_right_nodes(): + p = prog("input A : i8[2, 3]\nB = relu(A)\noutput B, A\n") + assert [type(s) for s in p.stmts] == [ast.InputDecl, ast.Assign, ast.Output] + assert p.stmts[0].type.shape == (2, 3) + assert [n.name for n in p.stmts[2].names] == ["B", "A"] + + +def test_empty_program_parses_to_no_statements(): + assert prog("\n# nothing\n").stmts == () + + +def test_error_spans_point_at_the_offending_token(): + with pytest.raises(CompileError) as exc: + prog("C = A @\n") + s = exc.value.diagnostic.span + assert (s.line, s.col) == (1, 8) # the end-of-line token + + +def test_ast_dump_is_stable_and_omits_spans(): + out = ast.dump(prog("C = relu(A)\n").stmts) + assert out == "Assign(name='C')\n Relu()\n Name(name='A')" diff --git a/tests/golden/errors/add_shape_mismatch.tiny b/tests/golden/errors/add_shape_mismatch.tiny new file mode 100644 index 0000000..24c3ac9 --- /dev/null +++ b/tests/golden/errors/add_shape_mismatch.tiny @@ -0,0 +1,4 @@ +input A : i8[4, 4] +input B : i8[3, 4] +C = A + B +output C diff --git a/tests/golden/errors/add_shape_mismatch.txt b/tests/golden/errors/add_shape_mismatch.txt new file mode 100644 index 0000000..ffa27b6 --- /dev/null +++ b/tests/golden/errors/add_shape_mismatch.txt @@ -0,0 +1,3 @@ +add_shape_mismatch.tiny:3:5: error: add shapes are incompatible: i8[4,4] + i8[3,4] (shapes must match, or be [M,N] + [N]) +3 | C = A + B + | ^^^^^ diff --git a/tests/golden/errors/bad_type.tiny b/tests/golden/errors/bad_type.tiny new file mode 100644 index 0000000..0f0ff95 --- /dev/null +++ b/tests/golden/errors/bad_type.tiny @@ -0,0 +1,2 @@ +input A : f32[2, 2] +output A diff --git a/tests/golden/errors/bad_type.txt b/tests/golden/errors/bad_type.txt new file mode 100644 index 0000000..6e8cc40 --- /dev/null +++ b/tests/golden/errors/bad_type.txt @@ -0,0 +1,3 @@ +bad_type.tiny:1:11: error: expected a type ('i8' or 'i32'), found 'f32' +1 | input A : f32[2, 2] + | ^^^ diff --git a/tests/golden/errors/const_out_of_range.tiny b/tests/golden/errors/const_out_of_range.tiny new file mode 100644 index 0000000..69b7f36 --- /dev/null +++ b/tests/golden/errors/const_out_of_range.tiny @@ -0,0 +1,2 @@ +const W : i8[1, 2] = [[1, 300]] +output W diff --git a/tests/golden/errors/const_out_of_range.txt b/tests/golden/errors/const_out_of_range.txt new file mode 100644 index 0000000..f989d9e --- /dev/null +++ b/tests/golden/errors/const_out_of_range.txt @@ -0,0 +1,4 @@ +const_out_of_range.tiny:1:27: error: value 300 does not fit in i8 +1 | const W : i8[1, 2] = [[1, 300]] + | ^^^ + = note: i8 holds -128..127 diff --git a/tests/golden/errors/const_too_deep.tiny b/tests/golden/errors/const_too_deep.tiny new file mode 100644 index 0000000..b69e47f --- /dev/null +++ b/tests/golden/errors/const_too_deep.tiny @@ -0,0 +1,2 @@ +const W : i8[2] = [[1], [2]] +output W diff --git a/tests/golden/errors/const_too_deep.txt b/tests/golden/errors/const_too_deep.txt new file mode 100644 index 0000000..885228d --- /dev/null +++ b/tests/golden/errors/const_too_deep.txt @@ -0,0 +1,3 @@ +const_too_deep.tiny:1:20: error: expected a number here, found a list +1 | const W : i8[2] = [[1], [2]] + | ^^^ diff --git a/tests/golden/errors/const_too_flat.tiny b/tests/golden/errors/const_too_flat.tiny new file mode 100644 index 0000000..7539c2c --- /dev/null +++ b/tests/golden/errors/const_too_flat.tiny @@ -0,0 +1,2 @@ +const W : i8[2, 2] = [1, 2] +output W diff --git a/tests/golden/errors/const_too_flat.txt b/tests/golden/errors/const_too_flat.txt new file mode 100644 index 0000000..1ed5b6a --- /dev/null +++ b/tests/golden/errors/const_too_flat.txt @@ -0,0 +1,3 @@ +const_too_flat.tiny:1:23: error: expected a list of 2 values, found a number +1 | const W : i8[2, 2] = [1, 2] + | ^ diff --git a/tests/golden/errors/const_wrong_length.tiny b/tests/golden/errors/const_wrong_length.tiny new file mode 100644 index 0000000..106c6b1 --- /dev/null +++ b/tests/golden/errors/const_wrong_length.tiny @@ -0,0 +1,3 @@ +const W : i8[2, 2] = [[1, 2, 3], + [4, 5]] +output W diff --git a/tests/golden/errors/const_wrong_length.txt b/tests/golden/errors/const_wrong_length.txt new file mode 100644 index 0000000..cd73f68 --- /dev/null +++ b/tests/golden/errors/const_wrong_length.txt @@ -0,0 +1,4 @@ +const_wrong_length.tiny:1:23: error: expected a list of 2 values, found 3 +1 | const W : i8[2, 2] = [[1, 2, 3], + | ^^^^^^^^^ + = note: declared type is i8[2,2] diff --git a/tests/golden/errors/matmul_inner_mismatch.tiny b/tests/golden/errors/matmul_inner_mismatch.tiny new file mode 100644 index 0000000..7541f00 --- /dev/null +++ b/tests/golden/errors/matmul_inner_mismatch.tiny @@ -0,0 +1,4 @@ +input A : i8[4, 8] +input B : i8[4, 4] +C = A @ B +output C diff --git a/tests/golden/errors/matmul_inner_mismatch.txt b/tests/golden/errors/matmul_inner_mismatch.txt new file mode 100644 index 0000000..3d6069f --- /dev/null +++ b/tests/golden/errors/matmul_inner_mismatch.txt @@ -0,0 +1,3 @@ +matmul_inner_mismatch.tiny:3:5: error: matmul inner dimensions differ: 8 vs 4 (i8[4,8] @ i8[4,4]) +3 | C = A @ B + | ^^^^^ diff --git a/tests/golden/errors/matmul_rank1.tiny b/tests/golden/errors/matmul_rank1.tiny new file mode 100644 index 0000000..ef57f7d --- /dev/null +++ b/tests/golden/errors/matmul_rank1.tiny @@ -0,0 +1,4 @@ +input A : i8[4] +input B : i8[4, 4] +C = A @ B +output C diff --git a/tests/golden/errors/matmul_rank1.txt b/tests/golden/errors/matmul_rank1.txt new file mode 100644 index 0000000..2e44750 --- /dev/null +++ b/tests/golden/errors/matmul_rank1.txt @@ -0,0 +1,3 @@ +matmul_rank1.tiny:3:5: error: matmul needs rank-2 operands (got i8[4] @ i8[4,4]) +3 | C = A @ B + | ^^^^^ diff --git a/tests/golden/errors/missing_equals.tiny b/tests/golden/errors/missing_equals.tiny new file mode 100644 index 0000000..4f7b263 --- /dev/null +++ b/tests/golden/errors/missing_equals.tiny @@ -0,0 +1,3 @@ +input A : i8[2, 2] +C relu(A) +output C diff --git a/tests/golden/errors/missing_equals.txt b/tests/golden/errors/missing_equals.txt new file mode 100644 index 0000000..fe5e822 --- /dev/null +++ b/tests/golden/errors/missing_equals.txt @@ -0,0 +1,3 @@ +missing_equals.tiny:2:3: error: expected '=', found 'relu' +2 | C relu(A) + | ^^^^ diff --git a/tests/golden/errors/missing_paren.tiny b/tests/golden/errors/missing_paren.tiny new file mode 100644 index 0000000..ad5f802 --- /dev/null +++ b/tests/golden/errors/missing_paren.tiny @@ -0,0 +1,3 @@ +input A : i8[2, 2] +C = relu(A +output C diff --git a/tests/golden/errors/missing_paren.txt b/tests/golden/errors/missing_paren.txt new file mode 100644 index 0000000..a6fa7b3 --- /dev/null +++ b/tests/golden/errors/missing_paren.txt @@ -0,0 +1,3 @@ +missing_paren.tiny:3:1: error: expected ')', found 'output' +3 | output C + | ^^^^^^ diff --git a/tests/golden/errors/no_outputs.tiny b/tests/golden/errors/no_outputs.tiny new file mode 100644 index 0000000..fd9efa1 --- /dev/null +++ b/tests/golden/errors/no_outputs.tiny @@ -0,0 +1,2 @@ +input A : i8[2, 2] +C = relu(A) diff --git a/tests/golden/errors/no_outputs.txt b/tests/golden/errors/no_outputs.txt new file mode 100644 index 0000000..d818361 --- /dev/null +++ b/tests/golden/errors/no_outputs.txt @@ -0,0 +1,4 @@ +no_outputs.tiny:2:1: error: program has no outputs +2 | C = relu(A) + | ^^^^^^^^^^^ + = note: add a line like `output C` diff --git a/tests/golden/errors/output_twice.tiny b/tests/golden/errors/output_twice.tiny new file mode 100644 index 0000000..fc48d90 --- /dev/null +++ b/tests/golden/errors/output_twice.tiny @@ -0,0 +1,2 @@ +input A : i8[2, 2] +output A, A diff --git a/tests/golden/errors/output_twice.txt b/tests/golden/errors/output_twice.txt new file mode 100644 index 0000000..63687b5 --- /dev/null +++ b/tests/golden/errors/output_twice.txt @@ -0,0 +1,3 @@ +output_twice.tiny:2:11: error: 'A' is already an output +2 | output A, A + | ^ diff --git a/tests/golden/errors/output_undefined.tiny b/tests/golden/errors/output_undefined.tiny new file mode 100644 index 0000000..4fae5b0 --- /dev/null +++ b/tests/golden/errors/output_undefined.tiny @@ -0,0 +1,2 @@ +input A : i8[2, 2] +output B diff --git a/tests/golden/errors/output_undefined.txt b/tests/golden/errors/output_undefined.txt new file mode 100644 index 0000000..56f0efb --- /dev/null +++ b/tests/golden/errors/output_undefined.txt @@ -0,0 +1,3 @@ +output_undefined.tiny:2:8: error: undefined name 'B' +2 | output B + | ^ diff --git a/tests/golden/errors/rank3.tiny b/tests/golden/errors/rank3.tiny new file mode 100644 index 0000000..0821dbd --- /dev/null +++ b/tests/golden/errors/rank3.tiny @@ -0,0 +1,2 @@ +input A : i8[2, 2, 2] +output A diff --git a/tests/golden/errors/rank3.txt b/tests/golden/errors/rank3.txt new file mode 100644 index 0000000..c58f623 --- /dev/null +++ b/tests/golden/errors/rank3.txt @@ -0,0 +1,3 @@ +rank3.tiny:1:11: error: only rank 1 and 2 tensors are supported (got rank 3) +1 | input A : i8[2, 2, 2] + | ^^^^^^^^^^^ diff --git a/tests/golden/errors/redefinition.tiny b/tests/golden/errors/redefinition.tiny new file mode 100644 index 0000000..40eac8f --- /dev/null +++ b/tests/golden/errors/redefinition.tiny @@ -0,0 +1,3 @@ +input A : i8[2, 2] +A = relu(A) +output A diff --git a/tests/golden/errors/redefinition.txt b/tests/golden/errors/redefinition.txt new file mode 100644 index 0000000..03d5e28 --- /dev/null +++ b/tests/golden/errors/redefinition.txt @@ -0,0 +1,4 @@ +redefinition.tiny:2:1: error: 'A' is already defined +2 | A = relu(A) + | ^^^^^^^^^^^ + = note: first defined on line 1 diff --git a/tests/golden/errors/stray_char.tiny b/tests/golden/errors/stray_char.tiny new file mode 100644 index 0000000..7fd1a1a --- /dev/null +++ b/tests/golden/errors/stray_char.tiny @@ -0,0 +1,3 @@ +input A : i8[2, 2] +C = A * A +output C diff --git a/tests/golden/errors/stray_char.txt b/tests/golden/errors/stray_char.txt new file mode 100644 index 0000000..ce9351c --- /dev/null +++ b/tests/golden/errors/stray_char.txt @@ -0,0 +1,3 @@ +stray_char.tiny:2:7: error: unexpected character '*' +2 | C = A * A + | ^ diff --git a/tests/golden/errors/tab_indent.tiny b/tests/golden/errors/tab_indent.tiny new file mode 100644 index 0000000..50b8195 --- /dev/null +++ b/tests/golden/errors/tab_indent.tiny @@ -0,0 +1,3 @@ +input A : i8[2, 2] + C = relu(A ) @ undefined_thing +output C diff --git a/tests/golden/errors/tab_indent.txt b/tests/golden/errors/tab_indent.txt new file mode 100644 index 0000000..4b5196e --- /dev/null +++ b/tests/golden/errors/tab_indent.txt @@ -0,0 +1,3 @@ +tab_indent.tiny:2:17: error: undefined name 'undefined_thing' +2 | C = relu(A ) @ undefined_thing + | ^^^^^^^^^^^^^^^ diff --git a/tests/golden/errors/trailing_junk.tiny b/tests/golden/errors/trailing_junk.tiny new file mode 100644 index 0000000..25741ad --- /dev/null +++ b/tests/golden/errors/trailing_junk.tiny @@ -0,0 +1,3 @@ +input A : i8[2, 2] +C = relu(A) A +output C diff --git a/tests/golden/errors/trailing_junk.txt b/tests/golden/errors/trailing_junk.txt new file mode 100644 index 0000000..13bbbbd --- /dev/null +++ b/tests/golden/errors/trailing_junk.txt @@ -0,0 +1,3 @@ +trailing_junk.tiny:2:13: error: expected end of line, found 'A' +2 | C = relu(A) A + | ^ diff --git a/tests/golden/errors/undefined_name.tiny b/tests/golden/errors/undefined_name.tiny new file mode 100644 index 0000000..e1db944 --- /dev/null +++ b/tests/golden/errors/undefined_name.tiny @@ -0,0 +1,3 @@ +input features : i8[4, 4] +C = relu(feature) +output C diff --git a/tests/golden/errors/undefined_name.txt b/tests/golden/errors/undefined_name.txt new file mode 100644 index 0000000..a17b5ef --- /dev/null +++ b/tests/golden/errors/undefined_name.txt @@ -0,0 +1,4 @@ +undefined_name.tiny:2:10: error: undefined name 'feature' +2 | C = relu(feature) + | ^^^^^^^ + = note: did you mean 'features'? diff --git a/tests/golden/errors/zero_dim.tiny b/tests/golden/errors/zero_dim.tiny new file mode 100644 index 0000000..63dc5d4 --- /dev/null +++ b/tests/golden/errors/zero_dim.tiny @@ -0,0 +1,2 @@ +input A : i8[0, 2] +output A diff --git a/tests/golden/errors/zero_dim.txt b/tests/golden/errors/zero_dim.txt new file mode 100644 index 0000000..a26582c --- /dev/null +++ b/tests/golden/errors/zero_dim.txt @@ -0,0 +1,3 @@ +zero_dim.tiny:1:14: error: dimensions must be positive +1 | input A : i8[0, 2] + | ^ diff --git a/tests/golden/ir/big_mm.ir b/tests/golden/ir/big_mm.ir new file mode 100644 index 0000000..66cc435 --- /dev/null +++ b/tests/golden/ir/big_mm.ir @@ -0,0 +1,5 @@ +func @main(%A: i8[12,12], %B: i8[12,12]) -> (i32[12,12]) { + %0 = matmul %A, %B : i32[12,12] loc("big_mm.tiny":4:10) + %1 = relu %0 : i32[12,12] loc("big_mm.tiny":4:5) + return %1 +} diff --git a/tests/golden/ir/const_mm.ir b/tests/golden/ir/const_mm.ir new file mode 100644 index 0000000..e688921 --- /dev/null +++ b/tests/golden/ir/const_mm.ir @@ -0,0 +1,6 @@ +func @main(%X: i8[3,2]) -> (i32[3,2]) { + %0 = const [[1, -2], [3, 4]] : i8[2,2] loc("const_mm.tiny":3:1) + %1 = matmul %X, %0 : i32[3,2] loc("const_mm.tiny":5:10) + %2 = relu %1 : i32[3,2] loc("const_mm.tiny":5:5) + return %2 +} diff --git a/tests/golden/ir/mlp_gpu.ir b/tests/golden/ir/mlp_gpu.ir new file mode 100644 index 0000000..06ad92b --- /dev/null +++ b/tests/golden/ir/mlp_gpu.ir @@ -0,0 +1,7 @@ +func @main(%X: i8[4,8], %W1: i8[8,6], %b1: i32[6], %W2: i8[6,3]) -> (i32[4,3]) { + %0 = matmul %X, %W1 : i32[4,6] loc("mlp_gpu.tiny":6:10) + %1 = add %0, %b1 : i32[4,6] loc("mlp_gpu.tiny":6:10) + %2 = relu %1 : i32[4,6] loc("mlp_gpu.tiny":6:5) + %3 = matmul %2, %W2 : i32[4,3] loc("mlp_gpu.tiny":7:5) + return %3 +} diff --git a/tests/golden/ir/mm.ir b/tests/golden/ir/mm.ir new file mode 100644 index 0000000..7c208c5 --- /dev/null +++ b/tests/golden/ir/mm.ir @@ -0,0 +1,4 @@ +func @main(%A: i8[4,4], %B: i8[4,4]) -> (i32[4,4]) { + %0 = matmul %A, %B : i32[4,4] loc("mm.tiny":4:5) + return %0 +} diff --git a/tests/golden/ir/mm_bias_relu.ir b/tests/golden/ir/mm_bias_relu.ir new file mode 100644 index 0000000..eb63239 --- /dev/null +++ b/tests/golden/ir/mm_bias_relu.ir @@ -0,0 +1,6 @@ +func @main(%X: i8[4,8], %W: i8[8,4], %b: i32[4]) -> (i32[4,4]) { + %0 = matmul %X, %W : i32[4,4] loc("mm_bias_relu.tiny":5:10) + %1 = add %0, %b : i32[4,4] loc("mm_bias_relu.tiny":5:10) + %2 = relu %1 : i32[4,4] loc("mm_bias_relu.tiny":5:5) + return %2 +} diff --git a/tests/golden/ir/mm_relu.ir b/tests/golden/ir/mm_relu.ir new file mode 100644 index 0000000..769bda0 --- /dev/null +++ b/tests/golden/ir/mm_relu.ir @@ -0,0 +1,5 @@ +func @main(%A: i8[4,8], %B: i8[8,4]) -> (i32[4,4]) { + %0 = matmul %A, %B : i32[4,4] loc("mm_relu.tiny":4:10) + %1 = relu %0 : i32[4,4] loc("mm_relu.tiny":4:5) + return %1 +} diff --git a/tests/ir/test_graph_ir.py b/tests/ir/test_graph_ir.py new file mode 100644 index 0000000..d577d46 --- /dev/null +++ b/tests/ir/test_graph_ir.py @@ -0,0 +1,200 @@ +import numpy as np +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from tinyc.frontend import compile_to_graph +from tinyc.ir import interp, ref +from tinyc.ir.errors import InputError, RangeError, VerifyError +from tinyc.ir.graph import Func, Op, Value +from tinyc.ir.parser import parse_func +from tinyc.ir.printer import print_func +from tinyc.ir.types import TensorType +from tinyc.ir.verifier import verify + +MM_RELU = "input A : i8[2, 3]\ninput B : i8[3, 2]\nC = relu(A @ B)\noutput C\n" + + +def func(text=MM_RELU): + return compile_to_graph(text, "t.tiny") + + +# --- printer / parser round trip ------------------------------------------------------------ + + +@pytest.mark.parametrize("locs", [False, True]) +def test_print_parse_print_is_stable(locs): + f = func("input A : i8[2, 2]\nconst W : i8[2, 2] = [[1, -2], [3, 4]]\n" + "input b : i32[2]\nY = relu(A @ W + b)\noutput Y, W\n") # fmt: skip + text = print_func(f, locs=locs) + assert print_func(parse_func(text), locs=locs) == text + + +def test_ir_text_matches_the_plan_format(): + assert print_func(func(), locs=True) == ( + "func @main(%A: i8[2,3], %B: i8[3,2]) -> (i32[2,2]) {\n" + ' %0 = matmul %A, %B : i32[2,2] loc("t.tiny":3:10)\n' + ' %1 = relu %0 : i32[2,2] loc("t.tiny":3:5)\n' + " return %1\n" + "}\n" + ) + + +def test_ir_parser_accepts_comments_and_free_layout(): + f = parse_func("// c\nfunc @main(%A: i8[2]) -> (i8[2]) { %0 = relu %A : i8[2]\n return %0 }") + assert [o.kind for o in f.ops] == ["relu"] + + +@pytest.mark.parametrize( + "text, fragment", + [ + ( + "func @m(%A: i8[2]) -> (i8[2]) {\n %0 = relu %Z : i8[2]\n return %0\n}", + "undefined value", + ), + ("func @m(%A: i8[2]) -> (i8[2]) {\n %0 = frob %A : i8[2]\n return %0\n}", "unknown op"), + ("func @m(%A: i8[2]) -> (i8[3]) {\n return %A\n}", "declared result types"), + ( + "func @m(%A: i8[2]) -> (i8[2]) {\n %A = relu %A : i8[2]\n return %A\n}", + "already defined", + ), + ("func @m() -> (i8[2]) {\n %0 = const [[1], [2]] : i8[2]\n return %0\n}", "shape"), + ("func @m() -> (i8[2]) {\n %0 = const [1, [2]] : i8[2]\n return %0\n}", "ragged"), + ], +) +def test_ir_parser_errors(text, fragment): + with pytest.raises(Exception, match=fragment): + parse_func(text) + + +# --- verifier ------------------------------------------------------------------------------- + + +def test_verifier_accepts_frontend_output(): + verify(func()) + + +def test_verifier_reports_every_problem(): + f = func() + f.ops[0].result.type = TensorType("i32", (9, 9)) # wrong inferred type + f.ops[1].operands[0] = Value("0", f.ops[0].result.type) # not the defined object + f.outputs = [] + with pytest.raises(VerifyError) as exc: + verify(f) + text = str(exc.value) + assert "operands give" in text and "not defined before use" in text and "no outputs" in text + + +def test_verifier_catches_use_before_def_and_duplicates(): + a = Value("A", TensorType("i8", (2,))) + r = Value("0", TensorType("i8", (2,))) + f = Func("m", [a, a], [Op("relu", [r], r)], [r]) + with pytest.raises(VerifyError) as exc: + verify(f) + assert "defined twice" in str(exc.value) and "not defined before use" in str(exc.value) + + +def test_verifier_checks_const_data(): + f = func("const W : i8[1, 2] = [[1, 2]]\noutput W\n") + f.ops[0].data = np.array([[1, 500]], dtype=np.int64) + with pytest.raises(VerifyError, match="does not fit"): + verify(f) + f.ops[0].data = np.array([1, 2], dtype=np.int64) + with pytest.raises(VerifyError, match="shape"): + verify(f) + + +def test_verifier_rejects_illegal_types(): + f = func() + f.args[0].type = TensorType("i8", (2, 2, 2)) + with pytest.raises(VerifyError, match="rank"): + verify(f) + + +# --- semantics: hand-checked values --------------------------------------------------------- + + +def test_matmul_relu_by_hand(): + f = func() + a = [[1, -2, 3], [0, 4, -1]] + b = [[1, 0], [2, -1], [-1, 5]] + expect = np.maximum(np.array(a) @ np.array(b), 0) # [[-6, 17], [9, -9]] -> relu + for run in (ref.evaluate, interp.run): + (out,) = run(f, {"A": a, "B": b}) + assert out.tolist() == [[0, 17], [9, 0]] == expect.tolist() + + +@pytest.mark.parametrize("order", ["M + b", "b + M"]) +def test_add_row_broadcast_in_either_order(order): + f = func(f"input M : i8[2, 3]\ninput b : i32[3]\nY = {order}\noutput Y\n") + inputs = {"M": [[1, 2, 3], [4, 5, 6]], "b": [10, 20, 30]} + for run in (ref.evaluate, interp.run): + assert run(f, inputs)[0].tolist() == [[11, 22, 33], [14, 25, 36]] + + +def test_rank1_add_and_relu(): + f = func("input a : i8[3]\ninput b : i8[3]\nY = relu(a + b)\noutput Y\n") + for run in (ref.evaluate, interp.run): + assert run(f, {"a": [-5, 2, 3], "b": [1, 1, -9]})[0].tolist() == [0, 3, 0] + + +def test_i8_operands_widen_so_extremes_do_not_wrap(): + f = func("input a : i8[1, 2]\ninput b : i8[2, 1]\nC = a @ b\noutput C\n") + for run in (ref.evaluate, interp.run): + assert run(f, {"a": [[-128, -128]], "b": [[-128], [-128]]})[0].tolist() == [[32768]] + + +def test_multiple_outputs_come_back_in_order(): + f = func("input A : i8[2]\nR = relu(A)\noutput R, A\n") + r, a = interp.run(f, {"A": [-1, 2]}) + assert r.tolist() == [0, 2] and a.tolist() == [-1, 2] + + +def test_i32_overflow_raises_in_both_evaluators(): + f = func("input a : i32[1, 2]\ninput b : i32[2, 1]\nC = a @ b\noutput C\n") + big = {"a": [[2**20, 2**20]], "b": [[2**20], [2**20]]} # 2 * 2**40 + for run in (ref.evaluate, interp.run): + with pytest.raises(RangeError): + run(f, big) + + +@pytest.mark.parametrize( + "inputs, fragment", + [ + ({"A": [[0, 0, 0]] * 2}, "missing input"), + ({"A": [[0, 0, 0]] * 2, "B": [[0, 0]] * 3, "Z": 1}, "unexpected"), + ({"A": [[0, 0]], "B": [[0, 0]] * 3}, "shape"), + ({"A": [[0, 0, 200]] * 2, "B": [[0, 0]] * 3}, "outside i8"), + ], +) +def test_input_validation(inputs, fragment): + for run in (ref.evaluate, interp.run): + with pytest.raises(InputError, match=fragment): + run(func(), inputs) + + +# --- the two evaluators are independent implementations: cross-check them ------------------- + + +def test_random_inputs_are_seeded(): + f = func() + a = ref.random_inputs(f, np.random.default_rng(42)) + b = ref.random_inputs(f, np.random.default_rng(42)) + assert all((a[k] == b[k]).all() for k in a) + + +@settings(max_examples=60, deadline=None) +@given( + m=st.integers(1, 6), k=st.integers(1, 6), n=st.integers(1, 6), + seed=st.integers(0, 2**32 - 1), bias=st.booleans(), second=st.booleans(), +) # fmt: skip +def test_interp_equals_oracle_on_random_programs(m, k, n, seed, bias, second): + lines = [f"input A : i8[{m}, {k}]", f"input B : i8[{k}, {n}]", f"input c : i32[{n}]", + f"input D : i8[{n}, {m}]"] # fmt: skip + lines.append("H = relu(A @ B + c)" if bias else "H = A @ B") + lines.append("Y = H @ D" if second else "Y = H") + lines.append("output Y") + f = func("\n".join(lines) + "\n") + inputs = ref.random_inputs(f, np.random.default_rng(seed)) + (want,), (got,) = ref.evaluate(f, inputs), interp.run(f, inputs) + np.testing.assert_array_equal(got, want) diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..1e96542 --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,47 @@ +"""Every file in examples/ parses, type-checks, round-trips and agrees across both evaluators.""" + +from pathlib import Path + +import numpy as np +import pytest + +from tinyc.frontend import compile_to_graph +from tinyc.ir import interp, ref +from tinyc.ir.parser import parse_func +from tinyc.ir.printer import print_func + +ROOT = Path(__file__).resolve().parent.parent +EXAMPLES = sorted((ROOT / "examples").glob("*.tiny")) +IDS = [p.stem for p in EXAMPLES] + + +def load(path): + return compile_to_graph(path.read_text(), path.name) + + +def test_examples_exist(): + assert {"mm", "mm_relu", "mm_bias_relu", "const_mm", "mlp_gpu", "big_mm"} <= set(IDS) + + +@pytest.mark.parametrize("path", EXAMPLES, ids=IDS) +def test_ir_golden_and_round_trip(path, golden): + f = load(path) + text = print_func(f, locs=True) + golden(ROOT / "tests" / "golden" / "ir" / f"{path.stem}.ir", text) + assert print_func(parse_func(text), locs=True) == text + + +@pytest.mark.parametrize("path", EXAMPLES, ids=IDS) +def test_interp_equals_oracle(path, rng): + f = load(path) + for _ in range(10): + inputs = ref.random_inputs(f, rng) + for got, want in zip(interp.run(f, inputs), ref.evaluate(f, inputs)): + np.testing.assert_array_equal(got, want) + + +def test_const_example_by_hand(): + f = load(ROOT / "examples" / "const_mm.tiny") + (out,) = ref.evaluate(f, {"X": [[1, 1], [2, 0], [-1, 0]]}) + # W = [[1,-2],[3,4]]: rows -> [4,2], [2,-4], [-1,2]; relu clamps negatives + assert out.tolist() == [[4, 2], [2, 0], [0, 2]] diff --git a/tinyc/cli.py b/tinyc/cli.py new file mode 100644 index 0000000..1677a9d --- /dev/null +++ b/tinyc/cli.py @@ -0,0 +1,37 @@ +"""`tinyc FILE.tiny [--emit ast|ir] [--locs]`: print the AST or Graph IR, or a caret diagnostic.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from tinyc.frontend import CompileError, SourceFile, lower, parse +from tinyc.frontend.ast import dump +from tinyc.ir.printer import print_func +from tinyc.ir.verifier import verify + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(prog="tinyc", description=__doc__) + ap.add_argument("file", type=Path) + ap.add_argument("--emit", choices=["ast", "ir"], default="ir") + ap.add_argument("--locs", action="store_true", help="print source locations in the IR") + args = ap.parse_args(argv) + source = SourceFile(args.file.name, args.file.read_text()) + try: + program = parse(source) + if args.emit == "ast": + print(dump(program.stmts)) + return 0 + func = lower(program, source) + verify(func) + except CompileError as err: + print(err.render(), file=sys.stderr) + return 1 + print(print_func(func, locs=args.locs), end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tinyc/frontend/__init__.py b/tinyc/frontend/__init__.py new file mode 100644 index 0000000..6253a92 --- /dev/null +++ b/tinyc/frontend/__init__.py @@ -0,0 +1,18 @@ +"""`.tiny` source -> Graph IR.""" + +from tinyc.frontend.diagnostics import CompileError, SourceFile +from tinyc.frontend.lower import lower +from tinyc.frontend.parser import parse +from tinyc.ir.graph import Func +from tinyc.ir.verifier import verify + + +def compile_to_graph(text: str, filename: str = "") -> Func: + """Parse, type-check and verify. Raises CompileError with a caret-style message.""" + source = SourceFile(filename, text) + func = lower(parse(source), source) + verify(func) + return func + + +__all__ = ["CompileError", "SourceFile", "compile_to_graph", "lower", "parse"] diff --git a/tinyc/frontend/ast.py b/tinyc/frontend/ast.py new file mode 100644 index 0000000..967131b --- /dev/null +++ b/tinyc/frontend/ast.py @@ -0,0 +1,117 @@ +"""AST for `.tiny`. Every node carries its source span.""" + +from __future__ import annotations + +from dataclasses import dataclass, fields, is_dataclass + +from tinyc.frontend.diagnostics import Span + + +@dataclass(frozen=True) +class TypeAnn: + dtype: str + shape: tuple[int, ...] + span: Span + + +@dataclass(frozen=True) +class IntLit: + value: int + span: Span + + +@dataclass(frozen=True) +class ListLit: + items: tuple[IntLit | ListLit, ...] + span: Span + + +@dataclass(frozen=True) +class Name: + name: str + span: Span + + +@dataclass(frozen=True) +class MatMul: + lhs: Expr + rhs: Expr + span: Span + + +@dataclass(frozen=True) +class Add: + lhs: Expr + rhs: Expr + span: Span + + +@dataclass(frozen=True) +class Relu: + arg: Expr + span: Span + + +Expr = Name | MatMul | Add | Relu + + +@dataclass(frozen=True) +class InputDecl: + name: str + type: TypeAnn + span: Span + + +@dataclass(frozen=True) +class ConstDecl: + name: str + type: TypeAnn + value: ListLit | IntLit + span: Span + + +@dataclass(frozen=True) +class Assign: + name: str + expr: Expr + span: Span + + +@dataclass(frozen=True) +class Output: + names: tuple[Name, ...] + span: Span + + +Stmt = InputDecl | ConstDecl | Assign | Output + + +@dataclass(frozen=True) +class Program: + stmts: tuple[Stmt, ...] + + +def dump(node, indent: int = 0) -> str: + """Readable tree dump (spans omitted) for `tinyc --emit ast` and the web UI.""" + pad = " " * indent + if isinstance(node, tuple): + return "\n".join(dump(n, indent) for n in node) + if isinstance(node, IntLit): + return f"{pad}{node.value}" + if isinstance(node, TypeAnn): + return f"{pad}{node.dtype}{list(node.shape)}" + if not is_dataclass(node): + return f"{pad}{node!r}" + scalars = [ + f"{f.name}={getattr(node, f.name)!r}" + for f in fields(node) + if f.name != "span" + and not is_dataclass(getattr(node, f.name)) + and not isinstance(getattr(node, f.name), tuple) + ] + lines = [f"{pad}{type(node).__name__}({', '.join(scalars)})"] + for f in fields(node): + v = getattr(node, f.name) + if f.name != "span" and (is_dataclass(v) or isinstance(v, tuple)): + lines.append(dump(v, indent + 1)) + return "\n".join(line for line in lines if line) diff --git a/tinyc/frontend/diagnostics.py b/tinyc/frontend/diagnostics.py new file mode 100644 index 0000000..f116631 --- /dev/null +++ b/tinyc/frontend/diagnostics.py @@ -0,0 +1,58 @@ +"""Source spans and caret-style error rendering. Error messages are a feature (PLAN §3.2).""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Span: + """1-based, end column exclusive.""" + + line: int + col: int + end_line: int + end_col: int + + @staticmethod + def join(a: Span, b: Span) -> Span: + return Span(a.line, a.col, b.end_line, b.end_col) + + +class SourceFile: + def __init__(self, name: str, text: str): + self.name = name + self.text = text + self.lines = text.split("\n") + + def line_text(self, n: int) -> str: + return self.lines[n - 1] if 1 <= n <= len(self.lines) else "" + + +@dataclass(frozen=True) +class Diagnostic: + message: str + span: Span + note: str | None = None + + +class CompileError(Exception): + def __init__(self, source: SourceFile, diagnostic: Diagnostic): + self.source = source + self.diagnostic = diagnostic + super().__init__(self.render()) + + def render(self) -> str: + d, s = self.diagnostic, self.diagnostic.span + text = self.source.line_text(s.line) + width = (s.end_col - s.col) if s.end_line == s.line else (len(text) - s.col + 1) + gutter = len(str(s.line)) + pad = "".join(ch if ch == "\t" else " " for ch in text[: s.col - 1]) + out = [ + f"{self.source.name}:{s.line}:{s.col}: error: {d.message}", + f"{s.line} | {text}", + f"{' ' * gutter} | {pad}{'^' * max(width, 1)}", + ] + if d.note: + out.append(f"{' ' * gutter} = note: {d.note}") + return "\n".join(out) diff --git a/tinyc/frontend/lexer.py b/tinyc/frontend/lexer.py new file mode 100644 index 0000000..56a2363 --- /dev/null +++ b/tinyc/frontend/lexer.py @@ -0,0 +1,59 @@ +"""Lexer for `.tiny`. Newlines end statements, except inside (...) and [...].""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from tinyc.frontend.diagnostics import CompileError, Diagnostic, SourceFile, Span + +KEYWORDS = {"input", "const", "output", "relu", "i8", "i32"} + +_TOKEN = re.compile( + r"(?P[ \t\r]+)|(?P\#[^\n]*)|(?P\n)" + r"|(?P[A-Za-z_][A-Za-z0-9_]*)|(?P[0-9]+)|(?P[:=@+()\[\],-])" +) + + +@dataclass(frozen=True) +class Token: + kind: str # NAME | INT | NEWLINE | EOF | a keyword | a punctuation character + text: str + span: Span + + +def lex(source: SourceFile) -> list[Token]: + text = source.text + tokens: list[Token] = [] + pos, line, line_start, depth = 0, 1, 0, 0 + + def span(start: int, end: int) -> Span: + return Span(line, start - line_start + 1, line, end - line_start + 1) + + def newline(start: int) -> None: + if depth == 0 and tokens and tokens[-1].kind != "NEWLINE": + tokens.append(Token("NEWLINE", "\n", span(start, start + 1))) + + while pos < len(text): + m = _TOKEN.match(text, pos) + if m is None: + bad = Diagnostic(f"unexpected character {text[pos]!r}", span(pos, pos + 1)) + raise CompileError(source, bad) + kind, lexeme = m.lastgroup, m.group() + if kind == "nl": + newline(pos) + line, line_start = line + 1, m.end() + elif kind == "name": + tokens.append( + Token(lexeme if lexeme in KEYWORDS else "NAME", lexeme, span(pos, m.end())) + ) + elif kind == "int": + tokens.append(Token("INT", lexeme, span(pos, m.end()))) + elif kind == "punct": + depth = max(depth + {"(": 1, "[": 1, ")": -1, "]": -1}.get(lexeme, 0), 0) + tokens.append(Token(lexeme, lexeme, span(pos, m.end()))) + pos = m.end() + + newline(pos) + tokens.append(Token("EOF", "", span(pos, pos))) + return tokens diff --git a/tinyc/frontend/lower.py b/tinyc/frontend/lower.py new file mode 100644 index 0000000..743abb8 --- /dev/null +++ b/tinyc/frontend/lower.py @@ -0,0 +1,129 @@ +"""AST -> Graph IR, with name resolution and type checking. + +Types are inferred while lowering through the same rules the verifier uses (`ir.types`). +Errors stop at the first problem and carry a span. +""" + +from __future__ import annotations + +import difflib + +import numpy as np + +from tinyc.frontend import ast +from tinyc.frontend.diagnostics import CompileError, Diagnostic, SourceFile, Span +from tinyc.ir.errors import OpTypeError +from tinyc.ir.graph import Func, FuncBuilder, Value +from tinyc.ir.loc import Loc +from tinyc.ir.types import RANGES, TensorType + + +class _Lowerer: + def __init__(self, source: SourceFile): + self.source = source + self.b = FuncBuilder("main") + self.env: dict[str, Value] = {} + self.defined_at: dict[str, Span] = {} + + def error(self, message: str, span: Span, note: str | None = None) -> CompileError: + return CompileError(self.source, Diagnostic(message, span, note)) + + def loc(self, span: Span) -> Loc: + return Loc(self.source.name, span.line, span.col) + + def define(self, name: str, value: Value, span: Span) -> None: + if name in self.env: + first = self.defined_at[name] + raise self.error( + f"'{name}' is already defined", span, f"first defined on line {first.line}" + ) + self.env[name], self.defined_at[name] = value, span + + def lookup(self, node: ast.Name) -> Value: + if node.name not in self.env: + close = difflib.get_close_matches(node.name, self.env, n=1) + raise self.error( + f"undefined name '{node.name}'", + node.span, + f"did you mean '{close[0]}'?" if close else None, + ) + return self.env[node.name] + + def expr(self, e: ast.Expr) -> Value: + if isinstance(e, ast.Name): + return self.lookup(e) + kind, args = { + ast.MatMul: ("matmul", lambda: (e.lhs, e.rhs)), + ast.Add: ("add", lambda: (e.lhs, e.rhs)), + ast.Relu: ("relu", lambda: (e.arg,)), + }[type(e)] + operands = [self.expr(x) for x in args()] + try: + return self.b.op(kind, operands, self.loc(e.span)) + except OpTypeError as err: + raise self.error(str(err), e.span) from None + + def tensor_type(self, ann: ast.TypeAnn) -> TensorType: + return TensorType(ann.dtype, ann.shape) + + def literal(self, lit: ast.ListLit | ast.IntLit, ty: TensorType) -> np.ndarray: + lo, hi = RANGES[ty.dtype] + out = np.zeros(ty.shape, dtype=np.int64) + + def fill(node, index: tuple[int, ...]) -> None: + depth = len(index) + if depth == ty.rank: + if not isinstance(node, ast.IntLit): + raise self.error("expected a number here, found a list", node.span) + if not lo <= node.value <= hi: + raise self.error( + f"value {node.value} does not fit in {ty.dtype}", + node.span, + f"{ty.dtype} holds {lo}..{hi}", + ) + out[index] = node.value + return + if not isinstance(node, ast.ListLit): + raise self.error( + f"expected a list of {ty.shape[depth]} values, found a number", node.span + ) + if len(node.items) != ty.shape[depth]: + raise self.error( + f"expected a list of {ty.shape[depth]} values, found {len(node.items)}", + node.span, + f"declared type is {ty}", + ) + for i, item in enumerate(node.items): + fill(item, (*index, i)) + + fill(lit, ()) + return out + + def stmt(self, s: ast.Stmt, outputs: list[Value]) -> None: + if isinstance(s, ast.InputDecl): + self.define(s.name, self.b.add_arg(s.name, self.tensor_type(s.type)), s.span) + elif isinstance(s, ast.ConstDecl): + ty = self.tensor_type(s.type) + value = self.b.const(self.literal(s.value, ty), ty, self.loc(s.span)) + self.define(s.name, value, s.span) + elif isinstance(s, ast.Assign): + self.define(s.name, self.expr(s.expr), s.span) + else: + for n in s.names: + v = self.lookup(n) + if any(v is o for o in outputs): + raise self.error(f"'{n.name}' is already an output", n.span) + outputs.append(v) + + def run(self, prog: ast.Program) -> Func: + outputs: list[Value] = [] + for s in prog.stmts: + self.stmt(s, outputs) + if not outputs: + span = prog.stmts[-1].span if prog.stmts else Span(1, 1, 1, 1) + raise self.error("program has no outputs", span, "add a line like `output C`") + return self.b.finish(outputs) + + +def lower(prog: ast.Program, source: SourceFile) -> Func: + return _Lowerer(source).run(prog) diff --git a/tinyc/frontend/parser.py b/tinyc/frontend/parser.py new file mode 100644 index 0000000..9430b34 --- /dev/null +++ b/tinyc/frontend/parser.py @@ -0,0 +1,174 @@ +"""Recursive-descent parser for `.tiny`. Precedence: `@` binds tighter than `+`; both left-assoc.""" + +from __future__ import annotations + +import dataclasses + +from tinyc.frontend import ast +from tinyc.frontend.diagnostics import CompileError, Diagnostic, SourceFile, Span +from tinyc.frontend.lexer import Token, lex +from tinyc.ir.types import MAX_RANK + + +class Parser: + def __init__(self, source: SourceFile): + self.source = source + self.tokens = lex(source) + self.pos = 0 + + # -- helpers --------------------------------------------------------------------------- + @property + def tok(self) -> Token: + return self.tokens[self.pos] + + def error(self, message: str, span: Span, note: str | None = None) -> CompileError: + return CompileError(self.source, Diagnostic(message, span, note)) + + @staticmethod + def describe(tok: Token) -> str: + return {"NEWLINE": "end of line", "EOF": "end of file"}.get(tok.kind, f"'{tok.text}'") + + def expect(self, kind: str, what: str | None = None) -> Token: + if self.tok.kind != kind: + want = what or f"'{kind}'" + raise self.error(f"expected {want}, found {self.describe(self.tok)}", self.tok.span) + tok = self.tok + self.pos += 1 + return tok + + # -- grammar --------------------------------------------------------------------------- + def program(self) -> ast.Program: + stmts = [] + while self.tok.kind != "EOF": + stmts.append(self.stmt()) + return ast.Program(tuple(stmts)) + + def stmt(self) -> ast.Stmt: + first = self.tok + if first.kind == "input": + self.pos += 1 + name = self.expect("NAME", "a name") + self.expect(":") + stmt = ast.InputDecl( + name.text, self.type_ann(), Span.join(first.span, self.prev_span()) + ) + elif first.kind == "const": + self.pos += 1 + name = self.expect("NAME", "a name") + self.expect(":") + ty = self.type_ann() + self.expect("=") + value = self.literal() + stmt = ast.ConstDecl(name.text, ty, value, Span.join(first.span, value.span)) + elif first.kind == "output": + self.pos += 1 + names = [self.name()] + while self.tok.kind == ",": + self.pos += 1 + names.append(self.name()) + stmt = ast.Output(tuple(names), Span.join(first.span, names[-1].span)) + elif first.kind == "NAME": + self.pos += 1 + self.expect("=") + expr = self.expr() + stmt = ast.Assign(first.text, expr, Span.join(first.span, expr.span)) + else: + raise self.error( + f"expected a statement ('input', 'const', 'output' or 'NAME = ...'), " + f"found {self.describe(first)}", + first.span, + ) + if self.tok.kind != "NEWLINE": + raise self.error( + f"expected end of line, found {self.describe(self.tok)}", self.tok.span + ) + self.pos += 1 + return stmt + + def prev_span(self) -> Span: + return self.tokens[self.pos - 1].span + + def name(self) -> ast.Name: + tok = self.expect("NAME", "a name") + return ast.Name(tok.text, tok.span) + + def type_ann(self) -> ast.TypeAnn: + if self.tok.kind not in ("i8", "i32"): + raise self.error( + f"expected a type ('i8' or 'i32'), found {self.describe(self.tok)}", self.tok.span + ) + start = self.tok + self.pos += 1 + self.expect("[") + dims = [self.dim()] + while self.tok.kind == ",": + self.pos += 1 + dims.append(self.dim()) + close = self.expect("]") + span = Span.join(start.span, close.span) + if len(dims) > MAX_RANK: + raise self.error( + f"only rank 1 and 2 tensors are supported (got rank {len(dims)})", span + ) + return ast.TypeAnn(start.kind, tuple(dims), span) + + def dim(self) -> int: + tok = self.expect("INT", "a dimension") + if int(tok.text) < 1: + raise self.error("dimensions must be positive", tok.span) + return int(tok.text) + + def literal(self) -> ast.ListLit | ast.IntLit: + if self.tok.kind == "[": + open_ = self.tok + self.pos += 1 + items = [self.literal()] + while self.tok.kind == ",": + self.pos += 1 + items.append(self.literal()) + close = self.expect("]") + return ast.ListLit(tuple(items), Span.join(open_.span, close.span)) + sign = self.tok if self.tok.kind == "-" else None + if sign: + self.pos += 1 + tok = self.expect("INT", "a number or '['") + value = int(tok.text) + return ast.IntLit(-value if sign else value, Span.join((sign or tok).span, tok.span)) + + def expr(self) -> ast.Expr: + lhs = self.term() + while self.tok.kind == "+": + self.pos += 1 + rhs = self.term() + lhs = ast.Add(lhs, rhs, Span.join(lhs.span, rhs.span)) + return lhs + + def term(self) -> ast.Expr: + lhs = self.atom() + while self.tok.kind == "@": + self.pos += 1 + rhs = self.atom() + lhs = ast.MatMul(lhs, rhs, Span.join(lhs.span, rhs.span)) + return lhs + + def atom(self) -> ast.Expr: + tok = self.tok + if tok.kind == "NAME": + self.pos += 1 + return ast.Name(tok.text, tok.span) + if tok.kind == "relu": + self.pos += 1 + self.expect("(") + arg = self.expr() + close = self.expect(")") + return ast.Relu(arg, Span.join(tok.span, close.span)) + if tok.kind == "(": + self.pos += 1 + inner = self.expr() + close = self.expect(")") + return dataclasses.replace(inner, span=Span.join(tok.span, close.span)) + raise self.error(f"expected an expression, found {self.describe(tok)}", tok.span) + + +def parse(source: SourceFile) -> ast.Program: + return Parser(source).program() diff --git a/tinyc/ir/__init__.py b/tinyc/ir/__init__.py new file mode 100644 index 0000000..6514276 --- /dev/null +++ b/tinyc/ir/__init__.py @@ -0,0 +1 @@ +"""Graph IR: data model, printer/parser, verifier, interpreter and numpy oracle.""" diff --git a/tinyc/ir/errors.py b/tinyc/ir/errors.py new file mode 100644 index 0000000..2ee1bd3 --- /dev/null +++ b/tinyc/ir/errors.py @@ -0,0 +1,21 @@ +"""Exception types shared by the IR layer.""" + + +class OpTypeError(Exception): + """An op's operands do not type-check (message is user-facing).""" + + +class VerifyError(Exception): + """The IR violates a structural invariant. `problems` lists every violation found.""" + + def __init__(self, problems: list[str]): + self.problems = problems + super().__init__("invalid IR:\n " + "\n ".join(problems)) + + +class InputError(ValueError): + """Inputs given to an evaluator do not match the function signature.""" + + +class RangeError(ArithmeticError): + """A value does not fit in i32. tiny-gpu raises on this, so the semantics reject it.""" diff --git a/tinyc/ir/graph.py b/tinyc/ir/graph.py new file mode 100644 index 0000000..34d3efc --- /dev/null +++ b/tinyc/ir/graph.py @@ -0,0 +1,67 @@ +"""Graph IR: a single straight-line function in SSA form. Values are compared by identity.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field + +import numpy as np + +from tinyc.ir.loc import Loc +from tinyc.ir.types import TensorType, infer_result_type + + +@dataclass(eq=False) +class Value: + name: str # printed as %name + type: TensorType + + def __repr__(self) -> str: + return f"%{self.name}: {self.type}" + + +@dataclass(eq=False) +class Op: + kind: str # "const" | "matmul" | "add" | "relu" + operands: list[Value] + result: Value + loc: Loc | None = None + data: np.ndarray | None = None # const only (int64) + + +@dataclass(eq=False) +class Func: + name: str + args: list[Value] = field(default_factory=list) + ops: list[Op] = field(default_factory=list) + outputs: list[Value] = field(default_factory=list) + + +class FuncBuilder: + """Builds a Func, inferring result types and naming results %0, %1, ...""" + + def __init__(self, name: str): + self.func = Func(name) + self._next = 0 + + def add_arg(self, name: str, type: TensorType) -> Value: + v = Value(name, type) + self.func.args.append(v) + return v + + def _emit(self, kind, operands, type, loc, data=None) -> Value: + result = Value(str(self._next), type) + self._next += 1 + self.func.ops.append(Op(kind, list(operands), result, loc, data)) + return result + + def op(self, kind: str, operands: Sequence[Value], loc: Loc | None = None) -> Value: + """Append matmul/add/relu. Raises OpTypeError when the operands do not type-check.""" + return self._emit(kind, operands, infer_result_type(kind, [v.type for v in operands]), loc) + + def const(self, data: np.ndarray, type: TensorType, loc: Loc | None = None) -> Value: + return self._emit("const", [], type, loc, np.asarray(data, dtype=np.int64)) + + def finish(self, outputs: Sequence[Value]) -> Func: + self.func.outputs = list(outputs) + return self.func diff --git a/tinyc/ir/interp.py b/tinyc/ir/interp.py new file mode 100644 index 0000000..0e3ec47 --- /dev/null +++ b/tinyc/ir/interp.py @@ -0,0 +1,59 @@ +"""Scalar Python interpreter for the Graph IR. + +Deliberately independent of `ir.ref` (plain loops over Python ints, no numpy arithmetic) so the +two can be cross-checked. It is the template for the Tile IR / Kernel IR interpreters later. +Same semantics as `ref.evaluate`; see that module's docstring. +""" + +from __future__ import annotations + +from collections.abc import Mapping + +import numpy as np + +from tinyc.ir.errors import RangeError +from tinyc.ir.graph import Func +from tinyc.ir.ref import I32, validate_inputs + + +def _fits(v: int) -> bool: + return I32[0] <= v <= I32[1] + + +def _rows(x): # rank-1 -> a single row + return x if isinstance(x[0], list) else [x] + + +def run(func: Func, inputs: Mapping[str, object]) -> list[np.ndarray]: + env = {k: v.tolist() for k, v in validate_inputs(func, inputs).items()} + for op in func.ops: + name = op.result.name + if op.kind == "const": + r = op.data.tolist() + elif op.kind == "relu": + x = env[op.operands[0].name] + r = ( + [[max(v, 0) for v in row] for row in x] + if isinstance(x[0], list) + else [max(v, 0) for v in x] + ) + elif op.kind == "matmul": + a, b = (env[v.name] for v in op.operands) + r = [ + [sum(a[i][k] * b[k][j] for k in range(len(b))) for j in range(len(b[0]))] + for i in range(len(a)) + ] + else: # add, with row broadcast of a rank-1 operand + a, b = (env[v.name] for v in op.operands) + if isinstance(a[0], list) and not isinstance(b[0], list): + r = [[x + y for x, y in zip(row, b)] for row in a] + elif isinstance(b[0], list) and not isinstance(a[0], list): + r = [[x + y for x, y in zip(a, row)] for row in b] + elif isinstance(a[0], list): + r = [[x + y for x, y in zip(ra, rb)] for ra, rb in zip(a, b)] + else: + r = [x + y for x, y in zip(a, b)] + if not all(_fits(v) for row in _rows(r) for v in row): + raise RangeError(f"%{name} ({op.kind}) does not fit in i32") + env[name] = r + return [np.array(env[v.name], dtype=np.int64) for v in func.outputs] diff --git a/tinyc/ir/loc.py b/tinyc/ir/loc.py new file mode 100644 index 0000000..33891bf --- /dev/null +++ b/tinyc/ir/loc.py @@ -0,0 +1,13 @@ +"""Source locations. Every op carries one so any lowered line can be mapped back to the source.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Loc: + file: str + line: int + col: int + + def __str__(self) -> str: + return f'loc("{self.file}":{self.line}:{self.col})' diff --git a/tinyc/ir/parser.py b/tinyc/ir/parser.py new file mode 100644 index 0000000..eeea3f7 --- /dev/null +++ b/tinyc/ir/parser.py @@ -0,0 +1,189 @@ +"""Parser for the textual Graph IR printed by `ir.printer` (round-trips exactly).""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +import numpy as np + +from tinyc.frontend.diagnostics import CompileError, Diagnostic, SourceFile, Span +from tinyc.ir.graph import Func, Op, Value +from tinyc.ir.loc import Loc +from tinyc.ir.types import OP_ARITY, TensorType +from tinyc.ir.verifier import verify + +_TOKEN = re.compile( + r"(?P[ \t\r\n]+)|(?P//[^\n]*)|(?P->)" + r'|(?P%[A-Za-z0-9_]+)|(?P@[A-Za-z_][A-Za-z0-9_]*)|(?P"[^"\n]*")' + r"|(?P[A-Za-z_][A-Za-z0-9_]*)|(?P-?[0-9]+)|(?P[:,=(){}\[\]])" +) + + +@dataclass(frozen=True) +class _Tok: + kind: str + text: str + span: Span + + +def _lex(source: SourceFile) -> list[_Tok]: + text, toks = source.text, [] + pos, line, line_start = 0, 1, 0 + while pos < len(text): + m = _TOKEN.match(text, pos) + col = pos - line_start + 1 + if m is None: + span = Span(line, col, line, col + 1) + raise CompileError(source, Diagnostic(f"unexpected character {text[pos]!r}", span)) + kind, lexeme = m.lastgroup, m.group() + if kind not in ("ws", "comment"): + toks.append(_Tok(kind, lexeme, Span(line, col, line, col + len(lexeme)))) + for i, ch in enumerate(lexeme): + if ch == "\n": + line, line_start = line + 1, pos + i + 1 + pos = m.end() + col = pos - line_start + 1 + toks.append(_Tok("eof", "", Span(line, col, line, col))) + return toks + + +class _Parser: + def __init__(self, source: SourceFile): + self.source, self.toks, self.pos = source, _lex(source), 0 + self.values: dict[str, Value] = {} + + @property + def tok(self) -> _Tok: + return self.toks[self.pos] + + def error(self, message: str, span: Span) -> CompileError: + return CompileError(self.source, Diagnostic(message, span)) + + def take(self, kind: str, text: str | None = None) -> _Tok: + t = self.tok + if t.kind != kind or (text is not None and t.text != text): + want = text or kind + raise self.error(f"expected '{want}', found '{t.text or 'end of file'}'", t.span) + self.pos += 1 + return t + + def at(self, kind: str, text: str | None = None) -> bool: + return self.tok.kind == kind and (text is None or self.tok.text == text) + + def type(self) -> TensorType: + dtype = self.take("word").text + self.take("punct", "[") + dims = [int(self.take("int").text)] + while self.at("punct", ","): + self.pos += 1 + dims.append(int(self.take("int").text)) + self.take("punct", "]") + return TensorType(dtype, tuple(dims)) + + def value_ref(self) -> Value: + t = self.take("val") + if t.text[1:] not in self.values: + raise self.error(f"undefined value {t.text}", t.span) + return self.values[t.text[1:]] + + def define(self, tok: _Tok, ty: TensorType) -> Value: + name = tok.text[1:] + if name in self.values: + raise self.error(f"{tok.text} is already defined", tok.span) + self.values[name] = Value(name, ty) + return self.values[name] + + def literal(self): + if self.at("punct", "["): + self.pos += 1 + items = [self.literal()] + while self.at("punct", ","): + self.pos += 1 + items.append(self.literal()) + self.take("punct", "]") + return items + return int(self.take("int").text) + + def loc(self) -> Loc | None: + if not self.at("word", "loc"): + return None + self.pos += 1 + self.take("punct", "(") + file = self.take("str").text[1:-1] + self.take("punct", ":") + line = int(self.take("int").text) + self.take("punct", ":") + col = int(self.take("int").text) + self.take("punct", ")") + return Loc(file, line, col) + + def op(self) -> Op: + res_tok = self.take("val") + self.take("punct", "=") + kind_tok = self.take("word") + kind = kind_tok.text + if kind not in OP_ARITY: + raise self.error(f"unknown op '{kind}'", kind_tok.span) + operands, data, lit_span = [], None, self.tok.span + if kind == "const": + lit = self.literal() + else: + operands = [self.value_ref()] + while self.at("punct", ","): + self.pos += 1 + operands.append(self.value_ref()) + self.take("punct", ":") + ty = self.type() + if kind == "const": + try: + data = np.array(lit, dtype=np.int64) + except ValueError: + raise self.error("const data is ragged", lit_span) from None + if data.shape != ty.shape: + raise self.error( + f"const data has shape {list(data.shape)}, type says {ty}", lit_span + ) + return Op(kind, operands, self.define(res_tok, ty), self.loc(), data) + + def func(self) -> Func: + self.take("word", "func") + name = self.take("sym").text[1:] + self.take("punct", "(") + args: list[Value] = [] + while not self.at("punct", ")"): + if args: + self.take("punct", ",") + tok = self.take("val") + self.take("punct", ":") + args.append(self.define(tok, self.type())) + self.take("punct", ")") + self.take("arrow") + self.take("punct", "(") + declared: list[TensorType] = [] + while not self.at("punct", ")"): + if declared: + self.take("punct", ",") + declared.append(self.type()) + self.take("punct", ")") + self.take("punct", "{") + ops = [] + while not self.at("word", "return"): + ops.append(self.op()) + ret = self.take("word", "return") + outputs = [self.value_ref()] + while self.at("punct", ","): + self.pos += 1 + outputs.append(self.value_ref()) + self.take("punct", "}") + self.take("eof") + if [v.type for v in outputs] != declared: + raise self.error("declared result types do not match the returned values", ret.span) + return Func(name, args, ops, outputs) + + +def parse_func(text: str, filename: str = "", *, check: bool = True) -> Func: + func = _Parser(SourceFile(filename, text)).func() + if check: + verify(func) + return func diff --git a/tinyc/ir/printer.py b/tinyc/ir/printer.py new file mode 100644 index 0000000..816e74f --- /dev/null +++ b/tinyc/ir/printer.py @@ -0,0 +1,30 @@ +"""Textual Graph IR (the format `ir.parser` reads back; see docs/IR.md).""" + +from __future__ import annotations + +from tinyc.ir.graph import Func, Op + + +def _op_text(op: Op) -> str: + if op.kind == "const": + assert op.data is not None + body = f"const {op.data.tolist()!r}" + else: + body = f"{op.kind} " + ", ".join(f"%{v.name}" for v in op.operands) + return f"%{op.result.name} = {body} : {op.result.type}" + + +def print_func(func: Func, *, locs: bool = False) -> str: + args = ", ".join(f"%{a.name}: {a.type}" for a in func.args) + rets = ", ".join(str(v.type) for v in func.outputs) + lines = [f"func @{func.name}({args}) -> ({rets}) {{"] + texts = [_op_text(op) for op in func.ops] + width = max(map(len, texts), default=0) + for op, text in zip(func.ops, texts): + line = f" {text}" + if locs and op.loc is not None: + line = f"{line.ljust(width + 2)} {op.loc}" + lines.append(line) + lines.append(" return " + ", ".join(f"%{v.name}" for v in func.outputs)) + lines.append("}") + return "\n".join(lines) + "\n" diff --git a/tinyc/ir/ref.py b/tinyc/ir/ref.py new file mode 100644 index 0000000..628576e --- /dev/null +++ b/tinyc/ir/ref.py @@ -0,0 +1,67 @@ +"""The oracle: numpy int64 evaluation of a Graph IR function. + +Semantics (shared with `ir.interp`, which is an independent scalar implementation): + * inputs must fit their declared dtype; every op computes in int64 on widened operands; + * every op result must fit in i32, otherwise RangeError (tiny-gpu raises on overflow). + Only final results are checked here; Phase 2's range analysis bounds partial sums as well. +""" + +from __future__ import annotations + +from collections.abc import Mapping + +import numpy as np + +from tinyc.ir.errors import InputError, RangeError +from tinyc.ir.graph import Func +from tinyc.ir.types import RANGES + +I32 = RANGES["i32"] + + +def validate_inputs(func: Func, inputs: Mapping[str, object]) -> dict[str, np.ndarray]: + """Check names, shapes and dtype ranges; return int64 arrays.""" + expected = {a.name for a in func.args} + if extra := set(inputs) - expected: + raise InputError(f"unexpected inputs: {sorted(extra)}") + out = {} + for a in func.args: + if a.name not in inputs: + raise InputError(f"missing input '{a.name}' ({a.type})") + arr = np.asarray(inputs[a.name], dtype=np.int64) + if arr.shape != a.type.shape: + raise InputError(f"input '{a.name}' has shape {list(arr.shape)}, expected {a.type}") + lo, hi = RANGES[a.type.dtype] + if arr.size and (arr.min() < lo or arr.max() > hi): + raise InputError(f"input '{a.name}' has values outside {a.type.dtype} ({lo}..{hi})") + out[a.name] = arr + return out + + +def evaluate(func: Func, inputs: Mapping[str, object]) -> list[np.ndarray]: + env = {k: v for k, v in validate_inputs(func, inputs).items()} + for op in func.ops: + x = [env[v.name] for v in op.operands] + if op.kind == "const": + r = op.data + elif op.kind == "matmul": + r = x[0] @ x[1] + elif op.kind == "add": + r = x[0] + x[1] + else: # relu + r = np.maximum(x[0], 0) + if r.size and (r.min() < I32[0] or r.max() > I32[1]): + raise RangeError(f"%{op.result.name} ({op.kind}) does not fit in i32") + env[op.result.name] = r + return [env[v.name] for v in func.outputs] + + +def random_inputs( + func: Func, rng: np.random.Generator, i32_range: tuple[int, int] = (-1000, 1000) +) -> dict[str, np.ndarray]: + """Seeded random inputs: full-range i8, small-range i32 (so i32 matmuls cannot overflow).""" + out = {} + for a in func.args: + lo, hi = RANGES["i8"] if a.type.dtype == "i8" else i32_range + out[a.name] = rng.integers(lo, hi + 1, size=a.type.shape, dtype=np.int64) + return out diff --git a/tinyc/ir/types.py b/tinyc/ir/types.py new file mode 100644 index 0000000..3a2d738 --- /dev/null +++ b/tinyc/ir/types.py @@ -0,0 +1,74 @@ +"""Tensor types and the single source of truth for op typing rules. + +The frontend builder and the verifier both call `infer_result_type`, so a rule can never +differ between "what lowering produced" and "what the verifier accepts". +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from tinyc.ir.errors import OpTypeError + +DTYPES = ("i8", "i32") +RANGES = {"i8": (-128, 127), "i32": (-(2**31), 2**31 - 1)} +MAX_RANK = 2 +OP_ARITY = {"const": 0, "relu": 1, "matmul": 2, "add": 2} + + +@dataclass(frozen=True) +class TensorType: + dtype: str + shape: tuple[int, ...] + + def __str__(self) -> str: + return f"{self.dtype}[{','.join(map(str, self.shape))}]" + + @property + def rank(self) -> int: + return len(self.shape) + + @property + def size(self) -> int: + n = 1 + for d in self.shape: + n *= d + return n + + +def type_problem(t: TensorType) -> str | None: + """Why `t` is not a legal v1 type, or None.""" + if t.dtype not in DTYPES: + return f"unknown element type '{t.dtype}'" + if not 1 <= t.rank <= MAX_RANK: + return f"only rank 1 and 2 tensors are supported (got rank {t.rank})" + if any(d < 1 for d in t.shape): + return f"dimensions must be positive (got {t})" + return None + + +def infer_result_type(kind: str, operands: Sequence[TensorType]) -> TensorType: + """Result type of a non-const op, or OpTypeError with a user-facing message.""" + if kind == "relu": + (x,) = operands + return x + a, b = operands + if kind == "matmul": + if a.rank != 2 or b.rank != 2: + raise OpTypeError(f"matmul needs rank-2 operands (got {a} @ {b})") + if a.shape[1] != b.shape[0]: + raise OpTypeError( + f"matmul inner dimensions differ: {a.shape[1]} vs {b.shape[0]} ({a} @ {b})" + ) + return TensorType("i32", (a.shape[0], b.shape[1])) + if kind == "add": + if a.shape == b.shape: + return TensorType("i32", a.shape) + for m, v in ((a, b), (b, a)): # [M,N] + [N] broadcasts the row + if m.rank == 2 and v.rank == 1 and m.shape[1] == v.shape[0]: + return TensorType("i32", m.shape) + raise OpTypeError( + f"add shapes are incompatible: {a} + {b} (shapes must match, or be [M,N] + [N])" + ) + raise OpTypeError(f"unknown op '{kind}'") diff --git a/tinyc/ir/verifier.py b/tinyc/ir/verifier.py new file mode 100644 index 0000000..ed39de0 --- /dev/null +++ b/tinyc/ir/verifier.py @@ -0,0 +1,77 @@ +"""Structural and type checks for the Graph IR. Reports every problem, not just the first.""" + +from __future__ import annotations + +import numpy as np + +from tinyc.ir.errors import OpTypeError, VerifyError +from tinyc.ir.graph import Func +from tinyc.ir.types import OP_ARITY, RANGES, infer_result_type, type_problem + + +def verify(func: Func) -> None: + problems: list[str] = [] + defined: dict[str, object] = {} + + def check_type(what: str, t) -> None: + if (p := type_problem(t)) is not None: + problems.append(f"{what}: {p}") + + for a in func.args: + if a.name in defined: + problems.append(f"%{a.name}: defined twice") + defined[a.name] = a + check_type(f"%{a.name}", a.type) + + for op in func.ops: + r = op.result + where = f"%{r.name} ({op.kind})" + if op.kind not in OP_ARITY: + problems.append(f"{where}: unknown op") + continue + if len(op.operands) != OP_ARITY[op.kind]: + problems.append( + f"{where}: expects {OP_ARITY[op.kind]} operands, got {len(op.operands)}" + ) + continue + if r.name in defined: + problems.append(f"{where}: name already defined") + check_type(where, r.type) + operands_ok = True + for v in op.operands: + if defined.get(v.name) is not v: + problems.append(f"{where}: operand %{v.name} is not defined before use") + operands_ok = False + if op.kind == "const": + d = op.data + if not isinstance(d, np.ndarray): + problems.append(f"{where}: const has no data") + elif d.shape != r.type.shape: + problems.append(f"{where}: data has shape {list(d.shape)}, type says {r.type}") + else: + lo, hi = RANGES.get(r.type.dtype, (0, -1)) + if d.size and (d.min() < lo or d.max() > hi): + problems.append(f"{where}: data does not fit in {r.type.dtype}") + else: + if op.data is not None: + problems.append(f"{where}: only const may carry data") + if operands_ok: + try: + want = infer_result_type(op.kind, [v.type for v in op.operands]) + except OpTypeError as e: + problems.append(f"{where}: {e}") + else: + if want != r.type: + problems.append( + f"{where}: result type is {r.type} but operands give {want}" + ) + defined[r.name] = r + + if not func.outputs: + problems.append("function has no outputs") + for v in func.outputs: + if defined.get(v.name) is not v: + problems.append(f"return: %{v.name} is not defined") + + if problems: + raise VerifyError(problems) From 18b802b3ab4c3f8151398a7bfbf1191bc4aa9490 Mon Sep 17 00:00:00 2001 From: Dineth Hettiarachchi Date: Sun, 25 Oct 2026 00:02:36 +0530 Subject: [PATCH 2/2] docs: forbid AI attribution in commits and PRs --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 72e5f6a..3df5844 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,3 +24,9 @@ cd web && pnpm dev | build | lint | typecheck | test ## Branching `main` is protected: work on `feat/*`, `fix/*`, `docs/*` branches and open PRs. + +## Commit and PR rules +- **Never add Claude (or any AI) as author or co-author** of a commit, PR or anything else: no + `Co-Authored-By: Claude ...` trailer, no "Generated with Claude Code" footer, no AI `--author`. + Commits carry only the repository owner's git identity. +- Before pushing: `git log --format=%B | grep -i co-authored` must print nothing.