toyc
A toy expression compiler with CPU and GPU (Vulkan compute) backends, written in Python. Source text is lexed, parsed to an AST, and executed either by a tree-walking CPU evaluator or by a compute shader acting as a register machine — then both results can be compared.
Install
pip install toyc
You also need a Vulkan driver plus one GLSL→SPIR-V compiler (only used to build the bundled shaders, automatically at first run):
sudo apt install glslang-tools # glslangValidator (preferred)
# or
sudo apt install glslc # shaderc (fallback)
Requires Python 3.10+ and the vulkan + pyopengl packages (installed
as dependencies).
The language
Arithmetic expressions with correct precedence: + - * /, parentheses,
int/float literals, and variables.
1 + 2 * 3 → 7
(a * b) + (c * d) / (a - b)
Quickstart
from toyc import Cpu, GpuVulkan
Cpu.run("program.toy") # 15 (CPU tree-walk / bytecode VM)
GpuVulkan.run("program.toy") # 15.0 (Vulkan compute shader)
How it works (pipeline)
source text ──► Lexer ──► Parser ──► AST ──┬──► Evaluator / Cpu ──► CPU result
│
└──► Flattener ──► flat bytecode
[op, dest, src1, src2] ──► GPU ──► GPU result
| Stage | Module | What it does |
|---|---|---|
| Lexer | toyc/lexer.py |
Tokenizes numbers, identifiers and + - * / ( ) |
| Parser | toyc/parser.py |
Recursive-descent parser, correct precedence, builds the AST (Number, Var, BinOp) |
| Evaluator | toyc/evaluator.py |
Tree-walking interpreter, the CPU reference |
| Compiler | toyc/compiler.py |
Emits stack bytecode (PUSH/LOAD/ADD/SUB/MUL/DIV), saves/loads .toyc files |
| Cpu | toyc/vm.py |
Stack VM executing the bytecode |
| Flattener | toyc/gpu/flattener.py |
Lowers the AST to register bytecode + constant/variable pools |
| ISA | toyc/gpu/instructions.py |
Opcodes ADD=0 SUB=1 MUL=2 DIV=3 LOAD=4 VAR=5 — must match the shaders |
| Shaders | toyc/gpu/vulkan/comp.glsl / comp_batch.glsl |
Single-value and data-parallel batch interpreters, compiled to .spv on first use |
| Vulkan backend | toyc/gpu/vulkan/backend.py |
Instance/device/pipeline management, dispatch, readback |
| GPU detection | toyc/gpu/vulkan/gpu_detect.py |
Device profiles, VRAM-based batch recommendation, JSON cache |
CPU backend
from toyc import Cpu, Compiler, Lexer, Parser
Cpu.run("program.toy") # lex+parse+compile+run, prints 15
Cpu.run("program.toy", env={"a": 1}) # variable bindings
Cpu.run("program.toy", silent=True) # capture without printing
Cpu.run("out.toyc") # run a compiled file
Compiler.compile("program.toy") # write program.toyc to disk
tokens = Lexer.tokenize("a + b * 2")
ast = Parser.parse(tokens)
Cpu.run accepts a .toy path, a .toyc path, or an in-memory
list[Instr].
GPU backend (single value)
from toyc import GpuVulkan
GpuVulkan.run("program.toy") # 15.0
GpuVulkan.run("program.toy", env={"a": 10.0, "b": 5.0})
GpuVulkan.run("program.toy", silent=True) # capture without printing
GpuVulkan.run("program.toyc") # cached 4-byte result, no dispatch
GpuVulkan.compile("program.toy") # evaluate + cache program.toyc
How a single run works: the AST is flattened to
[n_instrs, n_consts, n_vars, op, dest, src1, src2, …] plus a constant
pool, your env values, and an output + error slot. That buffer is
uploaded to one storage buffer, one compute workgroup runs the
comp shader (a 256-register interpreter loop), and the output float
is mapped back. The .toyc file written next to the source holds the
cached 4-byte result, so run("program.toyc") decodes it without
touching the GPU.
Session lifetime
Creating the Vulkan instance + device + pipelines costs ~15–40 ms; a dispatch costs ~1 ms. So the backend keeps one shared session per process instead of rebuilding it per call:
GpuVulkan.startup() # build the session now (optional; first call does it lazily)
GpuVulkan.run(...) # reuses the live session — never tears it down
GpuVulkan.shutdown() # release it (also happens automatically at process exit)
The session is guarded by a lock, so concurrent run() calls from
threads share it safely. Recompiling the shaders transparently rebuilds
the pipelines (the cache key includes both .spv mtimes).
GPU backend (batch — where the GPU wins)
run_batch evaluates one expression for many variable sets with one
dispatch per chunk — one shader invocation per instance
(local_size_x = 64, comp_batch shader):
envs = [{"a": 1.0, "b": 2.0}, {"a": 3.0, "b": 4.0}, ...] # N sets
results = GpuVulkan.run_batch("prog.toy", envs) # [3.0, 7.0, ...]
- The AST is flattened once; the variable pool holds all N sets
(instance-major) and the shader writes N outputs. Bigger
envslists are split into chunks of the recommended batch size (gpu_detect.batch, e.g. 1,638 on an 8 GB card) with a VRAM-budget safety cap — never silently wrong. - Returns one float per instance, in input order.
- Same error contract as everywhere:
NameErrorfor a variable missing from any set,ZeroDivisionErrorif any instance divides by zero (signalled through a shared error slot, since a shader cannot throw).
Rule of thumb (RX 580): single expressions → CPU (~0.1 ms vs ~1 ms
GPU dispatch + overhead); bulk evaluation → run_batch, where the
fixed per-call overhead amortises and throughput wins (~10× at N=500
and growing).
Timing, debug output, and benchmarking
Every entry point takes timed=False (print a report + return timing
data as a second value) and the GPU calls also take debug=False
(print device/VRAM diagnostics):
cpu_val, cpu_t = Cpu.run("program.toy", timed=True)
# [CPU timing] total=0.105 ms (resolve=0.099 ms, execute=0.006 ms)
gpu_raw, gpu_t = GpuVulkan.run("program.toy", timed=True)
# [GPU timing] total=0.843 ms (flatten=0.069 ms, select=0.010 ms,
# init=0.027 ms, exec=0.666 ms, teardown=0.000 ms, decode=0.000 ms)
Timing dicts hold seconds (perf_counter); stages that didn't run
are 0.0. Untimed calls return exactly what they always did, so
existing code is unaffected.
toyc.bench — table-ready profiling for pandas
Dependency-free (stdlib only). Every call returns plain row-dicts — one per run — that drop straight into a DataFrame:
from toyc.bench import profile, compare, compare_batch, summarize, to_csv
a = 10
b = 20
rows = compare(["1 + 2 * 7", "a + b * 2"], repeats=5)
# env auto-collected from your variables — no envs= needed
import pandas as pd
df = pd.DataFrame(rows)
df.groupby("backend")["total"].mean().plot.bar() # CPU vs GPU
| Function | What it does | Row columns |
|---|---|---|
profile(backend, program, env?, repeats?, …) |
Repeat one program on "cpu" or "gpu" |
backend, program, repeat, result, total + stage columns |
compare(programs, envs?, repeats?, backends?, …) |
Each program on the selected backends (default both) | same as above, concatenated |
compare_batch(programs, env_sets, repeats?, backends?, …) |
Program(s) over N sets: sequential CPU loop ("cpu") vs run_batch ("gpu-batch") |
backend, program, n, repeat, total, per_eval, max_err + stage columns |
summarize(rows) |
One summary row per (backend, program): runs, mean_total, std_total, mean_per_eval, mean_max_err |
ready for pd.DataFrame or plain printing |
to_csv(rows, path) |
Write rows to CSV (pd.read_csv-ready) |
fixed column order |
Run one backend at a time and save separately with
backends=["cpu"] / backends=["gpu"] (or ["gpu-batch"]).
max_err needs both backends in one call — single-backend runs
report it as None (no reference to compare against).
compare_batch takes one equation or many. Pass one sets-spec
(a list of env dicts or a column dict) and every equation automatically
uses all of it; pass one entry per program for different sets:
sets = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
compare_batch(["a + b", "a * b"], sets) # shared: both use both sets
compare_batch("a + b * 2", {"a": [...], "b": [...]}) # one equation
env_setsaccepts a list of env dicts or a column dict of equal-length value lists:{"a": [1, 3], "b": [2, 4]}.max_erris the max abs GPU-vs-CPU difference in that repeat (0.0on CPU rows) — the table proves the GPU results, so vectors aren't stored per row.- Single-value
resultis the decoded float; batch vectors collapse tomax_err(see above). - The GPU session is warmed up before repeats, per-run prints are
suppressed unless
verbose=True, and temp.toyfiles are cleaned up automatically.
Plotting later (matplotlib — yours to add)
The library ships no plotters on purpose. When you're ready, the hook
is the row table — seconds in total/per_eval, one row per repeat:
import matplotlib.pyplot as plt
import pandas as pd
from toyc.bench import compare_batch
rows = compare_batch("a + b * 2",
{"a": list(range(2000)), "b": list(range(2000))},
repeats=3)
df = pd.DataFrame(rows)
df["ms"] = df["total"] * 1e3
df.groupby("backend")["ms"].mean().plot.bar() # cpu vs gpu-batch
plt.ylabel("ms")
plt.show()
Useful columns to plot: total vs n (scaling curve), per_eval
(cost per instance), exec (pure dispatch time), max_err (validity).
to_csv(rows, "timings.csv") saves anything for later sessions.
Semantics (CPU ≡ GPU, guaranteed)
| Case | Both backends |
|---|---|
| Value | Numerically equal; CPU keeps int for int-only expressions (15), GPU always returns float32-based float (15.0) |
Division by zero (1/0, 1/(a-a), 0/0) |
ZeroDivisionError("Division by zero in VM") |
| Missing variable | NameError("Undefined variable: 'q'") |
| Float precision | GPU computes in float32 — expect ~1e-6 relative error vs CPU float64 on large magnitudes |
License
See LICENSE.
Metadata
Release files for toyc 0.2.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| toyc-0.2.2.tar.gz | 43.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| toyc-0.2.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 85.5 kB
Release files / toyc-0.2.2.tar.gz
| Download URL | toyc-0.2.2.tar.gz |
|---|---|
| Size | 43.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
484f7e1548f3c280a1040b00ffb5d56a08cfc7527bcda12f8b3cdbd736cdbec9
|
|
BLAKE2b-256 checksum How to use checksums |
b003a8a4481cc26e29c535dc8545343bd8e2d489ec7a4adfa9d06c04f8d04b23
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.16
|
Release files / toyc-0.2.2-py3-none-any.whl
| Download URL | toyc-0.2.2-py3-none-any.whl |
|---|---|
| Size | 42.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b9500a0669d93a9f12b93e0cdb9cedf2c8ea1963a9691ec74a7d50cf9cfcded7
|
|
BLAKE2b-256 checksum How to use checksums |
e1ab669b012c82e45cd4804fc6cddb36f81c7d6c37d55d115265e29d0ed36d31
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.16
|