Tsubaki

A toy Julia-like language, hosted in OCaml, compiled to WebAssembly via wasm_of_ocaml (real WasmGC, not a linear-memory emulation of one). Its numeric kernel calls out across a wasm module boundary into faer, a pure-Rust linear algebra library (no BLAS/LAPACK, no C, no Fortran), compiled separately to wasm32-unknown-unknown (plain linear memory, no GC needed).

It exists to answer one question honestly: how much of Julia's actual semantics — multiple dispatch, a real abstract-type hierarchy, structs, closures, keyword arguments — can a small, hand-written interpreter cover, and how does building that in OCaml compare to building the same thing in JavaScript. (Short version: the type system's exhaustiveness checking catches every place a new value variant needs handling, at compile time, by name. JavaScript's switch has no idea the space of tags is closed, so the same mistake surfaces later, at runtime, in an unrelated-looking stack trace.)

This is not a serious Julia implementation. Treat it as a demo of what's reachable in an afternoon-scale project, not as a foundation to build on without expecting to rewrite large parts of it. Everything documented here was verified by actually running it — the embedded demo, a real benchmark, or a real headless-browser harness — not by reading the code.

Benchmarked against real Julia's own microbenchmark suite

examples/ holds four benchmarks with their algorithm bodies taken verbatim from JuliaLang/Microbenchmarks (only the @test/@timeit macro harness is swapped for plain Tsubaki code) — real code Julia's own team uses, not code written to flatter this interpreter. All four produce the same answer as real Julia; none are fast.

Benchmark no cache fully optimized real Julia (1.12.6) slowdown
fib(20) (recursive) 0.128 s 0.006 s 0.00023 s ~25×
qsort! 5,000 floats 0.489 s 0.027 s 0.00049 s ~56×
pisum (5,000,000 float divisions) 29.8 s 0.80 s 0.0032 s ~250×
mandelperf (complex-plane sweep, maxiter=80) 0.013 s 0.00026 s ~50×

The honesty practice behind those numbers, in short:

Build & run

Requires: OCaml + dune + wasm_of_ocaml-compiler (opam), Rust + the wasm32-unknown-unknown target (rustup), Node.js 22+ (needs WasmGC, default from Node 22 on).

make repl                      # an interactive prompt
make run                       # runs bin/main.ml's embedded demo program
make run FILE=path/to/prog.jl  # runs that file instead
make test                      # every tests/*.jl against its recorded output
make test-julia                # ...and the julia-compatible ones under real Julia too

The REPL (--repl) keeps its state from one line to the next, reads a declaration that spans several lines until the last end closes it, prints what an expression came to (a trailing ; keeps it quiet, same as real Julia), and survives every error — a MethodError at one prompt leaves everything defined so far still defined:

tsubaki> function square(n)
      ..     return n * n
      .. end
tsubaki> square(7)
49
tsubaki> square("nope", 2)
ERROR: MethodError: no method matching square(String, Int)
tsubaki> square(7)
49

A real .jl-style file works too: node -r ./preload.js _build/default/bin/main.bc.wasm.js path/to/prog.jl (paths resolve relative to wherever node was launched from). With no path, it falls back to the fixed demo at the bottom of bin/main.ml.

include("other.jl") reads a sibling file and runs it right there, resolving its argument against the including file's directory the way real Julia does — so a program can be several files, and examples/keel_bounce.jl finds examples/keel.jl no matter where the process was started from.

import Shapes (or using Shapes) does the same lookup for a module that hasn't been declared: Shapes.jl, then Shapes.tsubaki, beside the file that asked. The file runs as its own top level — a module Shapes in it is Shapes, even when the import was written inside another module's body — and from then on it is an ordinary module. tests/imports.jl walks the whole of it, including what happens when the file isn't what it was asked to be, and when two files ask each other.

Where an error happened

A runtime error carries its place: the file and line of the statement that raised it, then the calls that led there, innermost first. Parse errors have had line/column all along, and parse_stmt_list recovers across statements — one pass reports every independent mistake, each with its own line/col (it still won't run with any errors, only diagnoses better).

$ make run FILE=tests/errors_position.jl
about to fail
tsubaki: Int is not a struct, has no fields
  at tests/errors_position.jl:10
  in:
    boom, called from line 14
    middle, called from line 18
    outer, called from line 29

The position rides along as a marker statement the parser puts in front of every statement (bin/ast.ml's SLine) rather than as a field on every AST node — see that comment for why, and Compile.strip_lines for how the bytecode compiler goes on seeing exactly the statement lists it saw before. A caught error is untouched by any of this — catch e still binds the same bare value it did before, with no position glued onto its message.

What it costs, measured. Between 1.5% and 7%, depending on how much of a program is calls: pisum +1.5%, qsort! +2.7%, mandelperf +4.3%, and fib(25) — which is nothing but calls — +6.8% (0.1036 s → 0.1107 s, mean of eight runs each). Two earlier versions cost considerably more and were thrown away rather than shipped:

The first number was only found because the original measurement compared against a build whose own float display was %.3f, which had nowhere near the resolution to show it.

Functions declared in an included file report their own file, not the caller's, because a function captures the file it was written in the same way it already captured its module.

--frames N runs a program's on_frame callback N times headlessly (fixed 1/60s dt, no browser) — enough to exercise a frame's logic with no pixels. The draw/input/audio host functions are stubbed by preload.js; a frame that raises stops the run with a non-zero exit code. Flags may come before or after the path, and an unrecognized argument is an error (it used to match no shape at all and quietly run the built-in demo instead).

make build alone produces _build/default/bin/main.bc.wasm.js (OCaml side, real WasmGC) and kernel/target/.../tsubaki_kernel.wasm (Rust side, plain linear memory). preload.js wires them together — see the comment at its top for why it's a separate --require preload (short version: wasm_of_ocaml's loader resolves its .assets/ dir from require.main.filename).

Browser GPU compute (wgpu)

gpu/ is a second, independent Rust crate (tsubaki-gpu) exposing WebGPU to the browser, aimed at GPU-accelerated visualizations (N-body, wave equations, Julia/Mandelbrot sets — anything embarrassingly parallel over a buffer) from a page that also hosts Tsubaki.

It is deliberately not part of kernel/. That module is raw linear memory, loaded synchronously with zero imports. wgpu's web backend can't be that: adapter/device requests and buffer readback are all async Promise calls, which only work through wasm-bindgen (JS glue, Promise-returning exports, an externref table) — a different-shaped wasm module. So gpu/ is its own crate with its own build step.

The API is a small resource-handle model (buffers/pipelines are opaque u32 handles the caller creates and destroys explicitly), so a toy gets real control over shape and lifetime. See gpu/src/lib.rs for full per-function docs; summary:

Rendering to a canvas is the same handle model, one resource deeper:

Build with make build-gpu (needs wasm-bindgen-cli, version-matched to the wasm-bindgen crate — a mismatch fails loudly). Produces gpu/pkg/tsubaki_gpu.js

Disclosed limits. Only f32 (WGSL/WebGPU has no f64; a Tsubaki-side caller narrows/widens at the boundary). WebGPU's own error-reporting mechanisms (error scopes, on_uncaptured_error) both route through a wgpu-30 conversion that panics on any error class beyond Validation/OOM (headless Chrome reports GPUInternalError), so neither is used — create_pipeline catches shader-source errors via ShaderModule::get_compilation_info() instead; later-stage errors (a bind-group layout not matching the shader) still have no safe catchable path.

Reachable from real Tsubaki source now, no await keyword needed

The API above is genuinely async, and Eval.eval is a plain synchronous recursive function — calling one of these from a Tsubaki builtin needs the whole interpreter call stack to suspend and resume. bin/async.ml performs an OCaml 5 effect (AwaitJs) at the await point inside a builtin (see bin/gpuBridge.ml), with an Effect.Deep handler around both entry points (Eval.run, GpuBridge.run_frame). From a Tsubaki script's own point of view, gpu_init() reads and behaves like any other synchronous call:

info = gpu_init("high-performance")
buf_in = create_buffer(32, "storage-read")
buf_out = create_buffer(16, "storage-read-write")
write_buffer(buf_in, [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
pipeline = create_pipeline(wgsl_source, "main", ["storage-read", "storage-read-write"])
dispatch(pipeline, [buf_in, buf_out], 1, 1, 1)
result = read_buffer(buf_out)  # suspends & resumes transparently

bin/dune builds with --effects=cps (whole-program CPS), not the default --effects=jspi (which needs the runtime's JS Promise Integration, absent under plain node). Cost: a mostly-fixed per-run overhead (~2.8–3× on the three short benchmarks, ~1.08× on pisum, which amortizes it over 5,000,000 iterations). A rejected Promise resumes with discontinue k (Failure msg) — the exact exception a script's try/catch already catches. Verified end to end in headless Chrome via examples/gpu_reduce.jl + web/gpu-compute-demo.html.

ECS SoA storage feeds this directly. A component struct with all-::Float fields and no mutable gets stored columnar (Runtime.soa_eligible); soa_flatten(kind, fields) reads its whole live column set into a flat Vector in one OCaml pass (~53× faster than a per-entity interpreter loop), ready for write_buffer. draw_frame's instance_count draws N entities from one call (a WGSL vertex shader indexing a storage buffer by @builtin(instance_index)); a camera transform is just a small uniform the vertex shader reads. Examples: ecs_gpu_soa_reduce.jl / ecs_gpu_instanced.jl / ecs_gpu_camera.jl, each verified in headless Chrome. offset_xy(vec, dx, dy) rebases large-magnitude world coordinates onto a local origin before the f32 narrowing loses bits.

to_wgsl — compute kernels written IN Tsubaki, not hand-written WGSL

to_wgsl(kernel, buffers) (bin/compile.ml's Compile.Wgsl) compiles a restricted subset of Tsubaki syntax into real WGSL — the same Julia-to-shader idea WGPUCompute.jl established for real Julia, mirrored here. kernel is a quote ... end block; buffers is a Dict mapping each buffer name to its BindingKind string, in insertion order (which becomes the binding index), so the result feeds straight into create_pipeline:

kernel = quote
    output[gid] = input[gid] * 2.0
end
buffers = Dict()
buffers["input"] = "storage-read"
buffers["output"] = "storage-read-write"
wgsl = to_wgsl(kernel, buffers)  # real WGSL, ready for create_pipeline

gid is a magic free name — the invocation's element index (@builtin(global_invocation_id)). The accepted subset is arithmetic/ comparison, if/for/while, buffer read/write, and vec2/vec3/vec4/ mat4 (below); a comparison's result may only be used directly in an if/while condition (WGSL's bool is a distinct type this compiler never casts to). Workgroup size is a fixed 64, entry point always "main" — both a disclosed v1 cut. Verified as a full round trip (examples/wgsl_double.jl: Tsubaki → WGSL → real GPU compute → [2,4,…,16]), not just "the string looks right."

to_glsl — render kernels (vertex+fragment) written in Tsubaki

to_wgsl's compute shape has nowhere to run as GLSL — real WebGL2 has no compute stage (that needs OpenGL ES 3.1, one major version past WebGL2's ES 3.0 base). to_glsl(vertex_kernel, fragment_kernel, uniforms) (bin/compile.ml's Compile.Glsl) targets what WebGL2 actually runs: a vertex+fragment pair. Structural near-twin of Compile.Wgsl (same restricted subset, same first-assignment-wins Int/Float inference), different magic names:

vertex_kernel = quote
    if vertex_index == 0
        pos_x = 0.0; pos_y = 0.6
    elseif vertex_index == 1
        pos_x = -0.6; pos_y = -0.6
    else
        pos_x = 0.6; pos_y = -0.6
    end
end
fragment_kernel = quote
    c = color
    frag_r = c.x; frag_g = c.y; frag_b = c.z; frag_a = 1.0
end
uniforms = Dict()
uniforms["color"] = "Vec3"
shaders = to_glsl(vertex_kernel, fragment_kernel, uniforms)  # [vertexGlsl, fragmentGlsl]

vertex_index (vertex only — GLSL's gl_VertexID) is the magic input; pos_x/pos_y and frag_r/frag_g/frag_b/frag_a are the magic outputs, assembled into gl_Position/fragColor. uniforms maps each name to its type ("Float", a vecN struct name, or "mat4") — WebGL2 sets uniforms by name, so there's no positional binding index like to_wgsl's buffers. No varyings yet (the vertex stage can't hand the fragment stage anything beyond gl_Position) — a disclosed v1 cut. Verified against a real WebGL2 context (gl.compileShader/linkProgram/drawArrays, read-back pixels, screenshot).

Running to_glsl output on WebGL2, straight from Tsubaki

to_glsl emits GLSL; a small synchronous runtime actually runs it, driven from Tsubaki source, through the plain browser WebGL2 API — no wgpu/Rust in the path (bin/webglBridge.ml, the JS side in web/webgl-demo.html's host_webgl_* functions). WebGL2 is entirely synchronous (context, compile, link, uniforms, draw), so unlike the WebGPU path this needs none of async.ml's effect machinery — every call is an ordinary host_call, same shape as gpuBridge.ml's draw_rect.

shaders = to_glsl(vertex_kernel, fragment_kernel, uniforms)
prog = webgl_program(shaders[1], shaders[2])
transform = [1.0 0.0 0.0 0.3
             0.0 1.0 0.0 0.0
             0.0 0.0 1.0 0.0
             0.0 0.0 0.0 1.0]
webgl_uniform(prog, "transform", transform)
webgl_uniform(prog, "color", [0.25, 0.85, 0.45])
webgl_clear(0.06, 0.07, 0.12, 1.0)
webgl_draw(prog, "triangles", 3)

Verified as a real rendered image (examples/webgl_triangle.jl + web/webgl-demo.html, headless Chrome + screenshot): a green triangle, visibly shifted right by the mat4 translation — which is what proves the row-major→transpose→column-major path is correct, not just plausible. Serve the repo root (python3 -m http.server) and open /web/webgl-demo.html?src=../examples/webgl_triangle.jl.

vec2/vec3/vec4/mat4 — for both to_wgsl and to_glsl

A Tsubaki struct is "vecN-eligible" when it's flat, immutable, non-parametric, has 2–4 fields, every field is exactly ::Float, and the field names are exactly x/y[/z[/w]] in order (WGSL's/GLSL's own swizzle names):

struct Vec2
    x::Float
    y::Float
end

Construction, swizzle reads (.x/.y/.z/.w), and arithmetic (vecN ± vecN, vecN * scalar either order, vecN / scalar) all compile to the real operators (* on two vecNs is componentwise, as in WGSL/GLSL). to_wgsl buffers can hold vec2/vec4 elements (buffers["p"] = "storage-read-write:Vec2") but not vec3 (WGSL pads array<vec3> to 16 bytes, which a tightly-packed write_buffer can't match — a vec3 uniform is fine). A mat4 comes from a Tsubaki matrix literal (4×4 of Floats), as a uniform only, and is transposed on the way out (Tsubaki's rows are row-major, WGSL/GLSL's constructors fill column-major). Verified against real GPU/WebGL2 execution (examples/wgsl_vec2.jl, examples/glsl_triangle.jl — the latter's triangle visibly shifts under a mat4 translation).

Trig/general math builtins

cos/sin/tan/asin/acos/atan/atan2/hypot/exp/log/floor/ ceil/round (bin/runtime.ml) — thin wrappers over OCaml's Stdlib, accepting Int or Float uniformly (pi is a plain global). Without them, anything needing an angle (circular motion, rotation, bearing) had no way to be written in Tsubaki at all. round breaks a tie toward the even neighbour, real Julia's RoundNearest (round(2.5) is 2.0, round(3.5) is 4.0) — OCaml's own Float.round rounds a tie away from zero, which disagreed on exactly the halves.

Integer division in all three of real Julia's roundings — div (truncated, also spelled ÷), fld (floored), cld (ceiling) — plus sign. % was here already; there had been no way to write the other half of a divmod.

==/!= answer for any two values, not only numbers: strings, Bool, nothing, Symbol, first-class types, Tuples, Arrays, Dicts, and structs field by field, with a mutable struct compared by identity instead — real Julia's own split, and verified against it. A user's own == method on their own type is more specific and still wins, including for a value nested inside a container. Strings also order (</<=/>/>=, lexicographic), which is what sort on a Vector of names goes through.

Strings: length, lowercase/uppercase, and * for concatenation — real Julia's spelling, and its absence used to stop real Julia source dead. The pre-existing + still concatenates too.

What it can actually do

What it deliberately does not do

Layout