Skip to content

Compile KL to a bytecode VM (~15× faster tak, ~60× faster tail loops) - #50

Merged
tiancaiamao merged 3 commits into
tiancaiamao:masterfrom
pyrex41:claude/shen-go-compiler-Stnpj
Jun 8, 2026
Merged

Compile KL to a bytecode VM (~15× faster tak, ~60× faster tail loops)#50
tiancaiamao merged 3 commits into
tiancaiamao:masterfrom
pyrex41:claude/shen-go-compiler-Stnpj

Conversation

@pyrex41

@pyrex41 pyrex41 commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Summary

This adds a stack-based bytecode VM for KL, replacing the tree-walking
interpreter as the execution path for every defun (both REPL/KL defun
and the Shen-level define, which lowers to KL defun). The tree-walker
remains in place and is still used for top-level forms and as a fallback.

Each function is compiled once, at define time, to a flat []Instr sequence
with integer-indexed local slots:

  • Variable access envGet (O(depth) alist scan) → array index O(1).
  • Calls no longer allocate 2×arity cons cells per call to extend the
    environment.
  • Special-form dispatch happens at compile time; the VM loop is a single
    switch over uint8 opcodes.
  • Closures (lambda/freeze) capture upvalues by value at creation time.
  • TCO: tail calls reuse the existing trampoline; self-tail calls loop in
    place without growing the Go stack.
  • Arithmetic fast paths: + - * < <= > >= = not compile to dedicated
    opcodes with a fixnum fast path, avoiding boxing and the trampoline.

A correctness fix is also included (Phase 0): <, <=, >, >= used
mustInteger (which truncated floats) instead of mustNumber.

Performance (S39.2 kernel)

Benchmark Baseline (tree-walker) This PR Speedup
tak(18,12,6) 0.088s 0.006s ~15×
(sum 0 5000000) tail loop 2.99s 0.05s ~60×
fib(30) 0.29s

See bench/RESULTS.md and thoughts/shen-go-compiler-design.md for the full
design write-up and methodology.

Semantics preserved

The VM matches the interpreter's evalFunction/eval semantics, including
the Lisp-2 detail that a global function binding takes precedence over a
same-named local in call position
. Other preserved behaviors: strict
boolean if (non-boolean condition errors), nested-defun lexical capture,
partial application, over-application, and trap-error (lowered to
try-catch over a freezed body so panic/recover still has a real frame).

Testing

  • go test ./... passes, including new TestBytecodeVM cases covering
    closures (incl. multi-level upvalue chains), let shadowing, trap-error,
    tail recursion, currying, over-application, nested-defun capture, strict
    boolean if, global-vs-local precedence, and float comparisons.
  • go vet ./kl/ clean.
  • The full S39.2 kernel compiles and loads (every defun goes through
    the compiler), and a suite of real Shen programs — pattern-matched
    defines with where guards, higher-order functions, Ackermann, deep
    (200k-iteration) tail recursion, string/float ops — all produce correct
    results.

Notes for reviewers

  • Arithmetic intrinsics inline +/-/etc., which means redefining those
    primitives globally would not be picked up by already-compiled code. This
    matches typical Lisp compiler behavior and the kernel never redefines them.
  • thoughts/shen-go-compiler-design.md is included as design rationale —
    happy to drop it if you'd prefer it not live in the repo.

🤖 Generated with Claude Code

claude added 3 commits May 5, 2026 18:24
Phase 0 — Correctness fix:
- Fix <, <=, >, >= to use mustNumber() instead of mustInteger(), so float
  comparisons like (< 1.5 1.8) return true correctly.  Regression test added.

Phase 2 — Bytecode VM:
- kl/compiler.go: KL → bytecode compiler.  Handles defun, lambda, freeze,
  let, if, and/or, cond, do, type, trap-error, function calls (tail + non-tail).
  Closures capture upvalues by value at closure-creation time.
- kl/vm.go: stack-based VM with per-call flat locals[] frame.  Opcodes:
  LOAD_CONST, LOAD_LOCAL, STORE_LOCAL, LOAD_GLOBAL, LOAD_UPVAL, CALL,
  TAIL_CALL, RETURN, JUMP, JUMP_FALSE, MAKE_CLOSURE, POP.
- eval.go: defun special form now compiles to bytecode (CompileFunc).
  apply() has a fast path for scmBytecodeFunc.  Try() no longer requires
  scmNative, accepting any callable (enables trap-error in compiled code).
  scmHeadBytecodeFunc is self-evaluating.
- primitives.go: primDefun compiles scmProcedure → bytecode on binding.
  PrimStr handles scmBytecodeFunc.
- library.go: equal() handles scmBytecodeFunc (pointer equality).
- types.go: ObjString handles scmBytecodeFunc.

Benchmark (tak 18 12 6):
  Baseline (tree-walker): 0.088s
  Phase 2 (VM):           0.013s  → 6.6× speedup

Tail-call loop (sum 0 5000000):
  Baseline: ~3.0s
  Phase 2:  ~0.24s  → 12.5× speedup

All kl unit tests pass (29/29).

https://claude.ai/code/session_01RFe4YsuWLhZMdG5XDUewiq
Phase 3 — Arithmetic intrinsics:
- Compiler detects calls to +, -, *, <, <=, >, >=, =, not and emits
  specialized opcodes (OP_ADD, OP_SUB, OP_MUL, OP_LT, OP_LE, OP_GT,
  OP_GE, OP_EQ, OP_NOT) that bypass the trampoline entirely.
- Fixnum fast paths in numAdd/numSub/numMul/numCmp/numCmpLE avoid
  float64 boxing for the common integer case.
- Symbols for each intrinsic are interned at startup (symAdd, symSub, …).

Phase 5 — Self-tail-call loop:
- OP_SELF_TAIL_CALL: when the compiler detects a recursive call to the
  same function in tail position (with the same arity), it emits args
  onto the stack then OP_SELF_TAIL_CALL N.  The VM copies the new args
  into locals[0..N-1] and resets pc=0 — no trampoline round-trip, no
  new locals allocation.

Benchmark update:
  tak(18,12,6):          0.006s  (was 0.088s baseline → 14.7×)
  sum(0, 5M) loop:       0.05s   (was 2.99s baseline  → 60×)
  fib(30):               0.29s

All 29 unit tests pass.

https://claude.ai/code/session_01RFe4YsuWLhZMdG5XDUewiq
P1: compileDefun now threads the outer compiler so nested defuns can
close over lexical variables from the enclosing scope.

P1: OP_JUMP_FALSE panics on non-boolean values instead of treating
anything non-False as truthy, matching KL strict boolean semantics.

P2: In call position, a symbol with a global function binding now takes
precedence over a same-named local variable, matching the interpreter's
evalFunction lookup order.

Cleanup: remove unused isBytecodeFunc, drop unused upvals param from
vmPartialApply, replace numCmp(x,y,-1) with a dedicated numCmpLT,
fix OP_EQ comment ("numeric only" → "structural equality").

Tests: add 6 new TestBytecodeVM cases covering each regression and
multi-level closure chains, over-application, and float comparisons
through compiled defuns.

https://claude.ai/code/session_01RFe4YsuWLhZMdG5XDUewiq
@tiancaiamao

Copy link
Copy Markdown
Owner

Once upon a time, there was a bytecode VM implementation
c662b8a#diff-598381d56429ba9a942db62bada59264d71bf239dc610c1162d531d7a7eff72b
And it had even been implemented several times in different ways
#34

The latest implementation use ast-interpreter + compile to Go, just because the ast interpreter performance does not really matter, it is only used for bootstrap.

Any way, in the AI era, code is cheap now. I think it's acceptable to get it back, as long as it does not break anything

@tiancaiamao
tiancaiamao merged commit a52bdc8 into tiancaiamao:master Jun 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants