- Zig 91.9%
- Scheme 4.9%
- Shell 1.3%
- Lua 1.1%
- Tree-sitter Query 0.6%
- Other 0.2%
zscheme
A high-performance Scheme implementation written in Zig, designed for embedding in applications.
Features
- Fast register-based bytecode VM with tagged pointer value representation (61-bit fixnums, headerless 16-byte cons cells)
- AOT compiler — Scheme to Zig to native code via LLVM. Competitive with Chez Scheme.
- R5RS compliant (259/259 tests passing, interpreter AND AOT)
- R7RS-small support (802/811 — the remaining 9 are documented non-goals: rational literals and one libm ULP)
- Hygienic macros via
syntax-ruleswith full ellipsis support - First-class continuations (
call/cc,dynamic-wind) - Proper tail calls as required by the Scheme standard
- Full Unicode character classification and case conversion (via zg)
- Generational mark-sweep GC with typed arenas (pairs, floats, closures) and shadow stack for AOT code
- Native value types for zero-boxing embedding (custom types compile to stack-allocated Zig structs in AOT)
- Source-level debugger — breakpoints, stepping (into/over/out), stack traces, frame locals, eval-in-frame; designed for DAP integration in host editors, zero overhead when inactive (
-Ddebuggergate) #docreader extension — attach docstrings todefined functions, queryable at runtime via(zscheme meta)- Embeddable with clean Zig API — selective library loading for sandboxing
- Standalone REPL and script execution
Building
Requires Zig 0.16 or later.
# Debug build
zig build
# Release build (optimized)
zig build -Doptimize=ReleaseFast
# Run tests
zig build test
Dev tools (optional)
simgrep (code-duplication search) and zigmap (codemap generator) are useful while hacking on zscheme. They are tools, not project dependencies — bootstrap them once with:
scripts/setup-tools.sh # clone (if missing) and build both
scripts/setup-tools.sh -u # also `git pull --ff-only` first
This drops simgrep/ and zigmap/ at the project root.
Usage
REPL
./zig-out/bin/zscheme
> (define (fib n)
(if (< n 2) n (+ (fib (- n 1)) (fib (- n 2)))))
> (fib 10)
55
> (map (lambda (x) (* x x)) '(1 2 3 4 5))
(1 4 9 16 25)
Running Scripts
./zig-out/bin/zscheme script.scm [args...]
Command-line arguments after the script name are available in *args* as a list of strings.
AOT Compilation
Compile Scheme to native code via Zig:
# Generate Zig source
./zig-out/bin/zscheme --emit-zig program.scm > generated.zig
# Build as standalone binary
zig build-exe generated.zig --dep zscheme -Mzscheme=src/root.zig
The AOT compiler passes the same R7RS suite as the interpreter (802/811) — the two execution modes are gated to agree byte-for-byte on a differential corpus. Unsupported forms (call/cc in non-escape position, dynamic-wind, guard, case-lambda) fall back to embedded bytecode automatically. Optimizations include whole-program type inference (scalar and vector-element types via a union-find constraint solver), unboxed fixnum/float fast paths with exactness-preserving dispatch, native mutual recursion, cons-recursive optimization, CPS defunctionalization, and module-level global caching.
See examples/callback/ for a full embedding example with VM, mixed, and pure-AOT build modes.
Performance
Benchmarked on Apple M1 Pro using the R7RS benchmark suite. All benchmarks use identical algorithms and iteration counts.
AOT Compiler vs Chez Scheme
The AOT backend compiles Scheme to Zig, then to native code via LLVM. 27 benchmarks from the R7RS suite.
| Benchmark | zscheme AOT | Chez 10.3 | Ratio |
|---|---|---|---|
| fib | 0.72s | 1.43s | 0.50x |
| tak | 1.06s | 1.95s | 0.55x |
| takl | 1.15s | 3.16s | 0.36x |
| cpstak | 0.20s | 0.56s | 0.36x |
| ack | 0.18s | 0.84s | 0.21x |
| nqueens | 1.68s | 1.51s | 1.11x |
| deriv | 0.71s | 0.66s | 1.09x |
| destruc | 1.28s | 0.93s | 1.37x |
| diviter | 1.05s | 0.62s | 1.70x |
| divrec | 0.90s | 0.66s | 1.38x |
| triangl | 1.18s | 0.89s | 1.31x |
| puzzle | 1.02s | 0.97s | 1.05x |
| mazefun | 0.85s | 0.77s | 1.10x |
| array1 | 1.71s | 2.76s | 0.62x |
| primes | 0.37s | 0.42s | 0.87x |
| equal | 0.42s | 0.57s | 0.74x |
| lattice | 4.54s | 2.34s | 1.94x |
| earley | 0.05s | 0.03s | 1.57x |
| sboyer | 1.05s | 0.57s | 1.85x |
| ray | 2.33s | 2.19s | 1.07x |
| peval | 1.88s | 1.58s | 1.19x |
| nboyer | 1.91s | 1.26s | 1.51x |
| pnpoly | 0.85s | 1.32s | 0.64x |
| sumfp | 0.28s | 1.38s | 0.20x |
| mbrot | 0.52s | 1.23s | 0.42x |
| fibfp | 0.42s | 1.74s | 0.24x |
| string | 0.83s | 1.73s | 0.48x |
Geometric mean: 0.78x (AOT/Chez, <1.0 = AOT faster). AOT wins 13 of 27 benchmarks. Chez Scheme (--optimize-level 2) is a mature native-code compiler with decades of development; beating it on geomean is a strong result.
Float-heavy benchmarks (sumfp, mbrot, fibfp) benefit from unboxed f64 fast paths. Integer-heavy benchmarks (fib, tak, ack) benefit from zero-cost fixnum arithmetic (tag 000).
VM Interpreter vs Lua 5.4
| Benchmark | zscheme | Lua 5.4 | Ratio | Description |
|---|---|---|---|---|
| fib | 2.22s | 2.13s | 1.04x | Doubly-recursive Fibonacci |
| tak | 3.58s | 4.32s | 0.83x | Takeuchi function |
| sum | 1.34s | 1.00s | 1.34x | Tight loop summing integers |
| takl | 1.27s | 4.34s | 0.29x | Takeuchi with lists |
| nqueens | 3.40s | 10.68s | 0.32x | N-queens via backtracking |
| destruc | 0.56s | 4.06s | 0.14x | Destructive list splicing |
| deriv | 0.67s | 4.14s | 0.16x | Symbolic differentiation |
| diviter | 0.35s | 3.79s | 0.09x | Iterative list halving |
| divrec | 1.33s | 8.47s | 0.16x | Recursive list halving |
| triangl | 2.47s | 2.98s | 0.83x | Peg solitaire |
| cpstak | 2.60s | 5.66s | 0.46x | Takeuchi in CPS |
| ack | 1.41s | 1.83s | 0.77x | Ackermann function |
| primes | 0.56s | 2.65s | 0.21x | Sieve of Eratosthenes |
| mazefun | 2.60s | 6.56s | 0.40x | Functional maze generation |
| puzzle | 1.43s | 1.93s | 0.74x | 3D puzzle backtracking |
| array1 | 2.50s | 3.41s | 0.73x | Kernighan-Van Wyk vectors |
zscheme wins 14 of 16 benchmarks, geometric mean 0.39x (2.5x faster). zscheme excels on list-heavy code (7-11x faster on destruc, diviter, divrec, primes) thanks to dedicated pair arenas with headerless 16-byte cons cells.
AOT vs VM
The AOT compiler provides 13x geometric mean speedup over the bytecode interpreter (20 benchmarks), with individual speedups ranging from 4.3x (destruc) to 54.4x (cpstak).
See bench/HISTORY.md for full optimization history.
Embedding
zscheme is designed as an embeddable library. Add it as a Zig dependency, pick only the libraries you need, and optionally extend it with custom Scheme or Zig code.
Minimal setup
conststd=@import("std");constzs=@import("zscheme");// Construct an Io handle once and pass it to Memory.varthreaded:std.Io.Threaded=.init(allocator,.{});deferthreaded.deinit();constio=threaded.io();varmem=zs.Memory.init(allocator,io);defermem.deinit();varvm=zs.Vm.init(&mem);defervm.deinit();// Register only the libraries you need (sandboxed — no file I/O, no eval)tryvm.registerLibrary(zs.lib.scheme.base.library);tryvm.registerLibrary(zs.lib.scheme.write.library);// Or register everything: try zs.registerAll(&vm);constresult=tryvm.interpret("(+ 1 2 3)");std.debug.print("{}\n",.{result.asFixnum().?});// 6Native Zig primitives
Register Zig functions as Scheme primitives:
fnprim_timestamp(vm_ptr:*anyopaque,args:[]constzs.Value)anyerror!zs.Value{if(args.len!=0)returnerror.ArityError;constmem=zs.Vm.getMemory(vm_ptr);constts=std.Io.Clock.now(.real,mem.io);constns:i64=@intCast(ts.nanoseconds);returnzs.Value.makeFixnum(ns)orelseerror.Overflow;}constclosure=trymem.makeNativeClosure(&prim_timestamp);tryvm.defineGlobal("timestamp-ns",closure);Calling Scheme from Zig
_=tryvm.interpret("(define (double x) (* x 2))");constdouble=vm.globals.get("double").?;constresult=tryvm.callAndRun(double,&.{zs.Value.makeFixnum(21).?});std.debug.print("{}\n",.{result.asFixnum().?});// 42Native value types
Register custom value types that work seamlessly in both the VM interpreter and AOT compiler.
VM path — values are heap-allocated and GC-managed, accessed via nativeData():
constVec2=struct{x:f32,y:f32};fnprim_make_vec2(vm_ptr:*anyopaque,args:[]constzs.Value)anyerror!zs.Value{constx:f32=@floatCast(zs.runtime.toF64(args[0]));consty:f32=@floatCast(zs.runtime.toF64(args[1]));constv=Vec2{.x=x,.y=y};returnzs.Vm.getMemory(vm_ptr).makeNativeValue(0,std.mem.asBytes(&v));}fnprim_vec2_x(vm_ptr:*anyopaque,args:[]constzs.Value)anyerror!zs.Value{constdata=args[0].nativeData(Vec2,0)orelsereturnerror.TypeError;returnzs.Vm.getMemory(vm_ptr).makeFloat(@floatCast(data.x));}Operator overloading — register operators so +, -, *, / work with native types:
mem.registerNativeOps(0,.{.add=&prim_vec2_add,// (+ vec2 vec2) → vec2.mul=&prim_vec2_scale,// (* vec2 scalar) → vec2.format=&formatVec2,// (display vec2) → "#<vec2 3.0 4.0>"});(define v (+ (make-vec2 1.0 2.0) (make-vec2 3.0 4.0)))
(display v) ; → #<vec2 4.0 6.0>
(vec2-x (* v 2.0)) ; → 8.0
AOT path — the same Scheme code compiles to direct Zig function calls with zero boxing. The codegen reads a .zon descriptor file that lists each type's Zig name, size, and function signatures:
// engine.zon — passed to `zscheme --emit-zig --native-types=engine.zon`.{.module="engine",// generated code does `@import("engine")`.types=.{.{.name="vec2",.zig_type_name="Vec2",.size=8,.functions=.{.{.scheme_name="make-vec2",.zig_name="makeVec2",.params=.{.f64_t,.f64_t},.ret=.{.custom=0}},.{.scheme_name="vec2-x",.zig_name="vec2X",.params=.{.{.custom=0}},.ret=.f64_t},.{.scheme_name="vec2-add",.zig_name="vec2Add",.params=.{.{.custom=0},.{.custom=0}},.ret=.{.custom=0},.overloads=.add},},},},};; AOT: compiles to native.vec2X(native.vec2Add(a, b)) — zero boxing, zero GC
(define (sum-x a b) (vec2-x (+ a b)))
The companion engine.zig file provides the Zig types and functions, plus
mechanical pack_T / unpack_T boundary helpers (~12 lines per type).
See examples/native-types/ for a full runnable
example — zig build native-types-example builds and runs it.
Custom Scheme libraries
Embed Scheme source files as proper R7RS libraries:
_=tryvm.interpret(@embedFile("math.scm"));_=tryvm.interpret(@embedFile("app.scm"));;; math.scm
(define-library (example math)
(import (scheme base))
(export square factorial)
(begin
(define (square x) (* x x))
(define (factorial n)
(if (<= n 1) 1 (* n (factorial (- n 1)))))))
Available libraries
| Library | What it provides |
|---|---|
scheme.base |
Core language — arithmetic, lists, strings, control flow |
scheme.write |
display, write, newline |
scheme.read |
read |
scheme.char |
Unicode character predicates and case conversion |
scheme.inexact |
sqrt, sin, cos, exp, log, etc. |
scheme.file |
File I/O — open-input-file, open-output-file, etc. |
scheme.time |
current-jiffy, jiffies-per-second |
scheme.cxr |
caar, cadr, caddr, etc. |
scheme.eval |
eval, environment |
scheme.load |
load |
scheme.lazy |
delay, force, make-promise |
scheme.process-context |
command-line, exit |
scheme.case-lambda |
case-lambda |
scheme.repl |
interaction-environment |
zscheme.debug |
disassemble (inspect bytecode), debug-break (programmatic breakpoint for the source-level debugger) |
zscheme.meta |
library-list, library-exports, library-doc, symbol-doc, symbol-signature — runtime module introspection |
Docstrings with #doc
The #doc reader extension attaches documentation to exported functions. Docstrings are stored in the library table at compile time and queryable at runtime via (zscheme meta):
(define-library (example math)
(import (scheme base))
(export square)
(begin
(define (square x)
#doc "Return the square of x."
(* x x))))
(import (example math) (zscheme meta))
(symbol-doc '(example math) 'square) ; → "Return the square of x."
(symbol-doc '(example math) 'nonexistent) ; → #f
Docstrings are dropped from bytecode — functions behave identically at runtime.
Omitting a library means its functionality is unavailable — useful for sandboxing untrusted code. Only scheme.base is required for basic operation.
A full working example with VM, mixed, and pure-AOT build modes is in examples/callback/.
What's Implemented
R5RS (complete)
- All special forms:
lambda,if,set!,cond,case,and,or,let,let*,letrec,begin,do,delay,quasiquote syntax-rulesmacros with hygienecall/ccanddynamic-windevalwithscheme-report-environment- All standard procedures (~170 primitives)
R7RS-small (mostly complete)
define-libraryand module system with import modifiersguardexception handling withwith-exception-handler,raise,errordefine-record-typelet-values,let*-values,letrec*case-lambdalet-syntax,letrec-syntax- Bytevector operations and bytevector ports
- Full Unicode character classification and case conversion
- Vector quasiquote (
`#(,x ,@xs)) - Multiple return values (
values,call-with-values) - String and file ports
Not Yet Implemented
- Rational numbers (
1/2,10/2literals, exact division) - Bignums (integers limited to 61 bits)
- Complex numbers
make-parameter/parameterizedelay-force(iterative forcing)define-valuessyntax-case- Datum labels (
#0=,#0#)
License
MIT License. See LICENSE for details.