Add auto-compile, VM optimization, disassemble, call/cc compilation

- auto-compile! parameter: compile defines on the fly for zero-effort speedup
- VM optimization: local aliases, inlined truthiness checks (~15% faster)
- disassemble builtin: human-readable bytecode listing
- call/cc compiled natively in VM (escape-only, no longer falls back to eval)
- Makefile: add lint, bench-verbose, clean targets
- 518 tests green
This commit is contained in:
russell@unturf.com 2026-04-13 14:49:32 -04:00
parent a7ca8e9f89
commit f584ad76f2
4 changed files with 223 additions and 32 deletions

View file

@ -9,9 +9,18 @@ test-verbose:
bench:
python3 bench.py
bench-verbose:
python3 bench.py -v
repl:
python3 uncommonlisp.py
clean:
lint:
python3 -m py_compile uncommonlisp.py
python3 -m py_compile tests.py
python3 -m py_compile bench.py
.PHONY: all test test-verbose bench repl clean
clean:
rm -rf __pycache__ *.pyc
.PHONY: all test test-verbose bench bench-verbose repl lint clean

View file

@ -379,3 +379,71 @@ bench_compiled('mergesort (200 elements)', '''
print('=' * 75)
print('ratio = compiled time / python time (lower is better)')
# ═══════════════════════════════════════════════════════════════════════════
# Auto-compile mode (zero-effort: just define and go)
# ═══════════════════════════════════════════════════════════════════════════
print()
print('AUTO-COMPILE mode (auto-compile! #t)')
print('=' * 75)
from uncommonlisp import _auto_compile
def bench_auto(name, lisp_src, python_fn, iters=3):
"""Time auto-compiled lisp vs python. Defines + runs in one pass."""
exprs = read_all(lisp_src)
lisp_times = []
for _ in range(iters):
env = fresh()
_auto_compile[0] = True
for e in exprs[:-1]: leval(e, env)
t = time.perf_counter()
result = leval(exprs[-1], env)
lisp_times.append(time.perf_counter() - t)
_auto_compile[0] = False
lisp_best = min(lisp_times)
py_times = []
for _ in range(iters):
t = time.perf_counter()
py_result = python_fn()
py_times.append(time.perf_counter() - t)
py_best = min(py_times)
slowdown = lisp_best / py_best if py_best > 0 else float('inf')
status = '✓' if result == py_result or str(result) == str(py_result) else '✗'
print(f'{status} {name:<30} auto={lisp_best*1000:6.1f}ms py={py_best*1000:6.1f}ms '
f'ratio={slowdown:5.1f}x')
return lisp_best, py_best
bench_auto('fib(35) iterative', '''
(define (fib n)
(let loop ((a 0) (b 1) (i 0))
(if (= i n) a (loop b (+ a b) (+ i 1)))))
(fib 35)
''', lambda: py_fib(35))
bench_auto('fib(20) tree-recursive', '''
(define (fib n)
(if (<= n 1) n (+ (fib (- n 1)) (fib (- n 2)))))
(fib 20)
''', lambda: py_fib_rec(20))
bench_auto('sum-to(50000) tail-recursive', '''
(define (sum-to n)
(let loop ((i n) (acc 0))
(if (= i 0) acc (loop (- i 1) (+ acc i)))))
(sum-to 50000)
''', lambda: py_sum(50000))
bench_auto('ackermann(3,4)', '''
(define (ack m n)
(cond ((= m 0) (+ n 1))
((= n 0) (ack (- m 1) 1))
(else (ack (- m 1) (ack m (- n 1))))))
(ack 3 4)
''', lambda: py_ack(3, 4))
print('=' * 75)

View file

@ -2494,6 +2494,39 @@ class TestBytecodeCompiler(unittest.TestCase):
''', "(f '(1 2 3))")
assert r is NIL
def test_auto_compile(self):
"""auto-compile! should compile defines automatically."""
run('(auto-compile! #t)', self.g)
run('(define (sq x) (* x x))', self.g)
assert run('(compiled? sq)', self.g) == True
assert run('(sq 7)', self.g) == 49
run('(auto-compile! #f)', self.g)
run('(define (cube x) (* x x x))', self.g)
assert run('(compiled? cube)', self.g) == False
def test_auto_compile_lambda(self):
"""auto-compile! works for (define name (lambda ...))."""
run('(auto-compile! #t)', self.g)
run('(define double (lambda (x) (* x 2)))', self.g)
assert run('(compiled? double)', self.g) == True
assert run('(double 5)', self.g) == 10
run('(auto-compile! #f)', self.g)
def test_disassemble(self):
"""disassemble should return without error."""
run('(define (f x) (+ x 1))', self.g)
run('(set! f (compile f))', self.g)
run('(disassemble f)', self.g) # should not raise
def test_auto_compile_tco(self):
"""Auto-compiled named-let should have proper TCO."""
run('(auto-compile! #t)', self.g)
run('''(define (sum-to n)
(let loop ((i n) (acc 0))
(if (= i 0) acc (loop (- i 1) (+ acc i)))))''', self.g)
assert run('(sum-to 100000)', self.g) == 5000050000
run('(auto-compile! #f)', self.g)
###############################################################################
# Main

View file

@ -654,12 +654,18 @@ def leval(expr, env):
if isinstance(a[0], Pair): # (define (f x) body...)
fname = a[0].car; ps, rest = _formals(a[0].cdr)
p = Proc(ps, rest, a[1:], env, name=str(fname))
if _auto_compile[0]:
try: p = bc_compile_proc(p, env)
except Exception: pass
env.define(fname, p)
else:
name = a[0]
if not isinstance(name, Symbol): raise LispErr(f'define: name must be symbol, got {show(name)}')
val = leval(a[1], env) if len(a) > 1 else VOID
if isinstance(val, Proc) and not val.name: val.name = str(name)
if _auto_compile[0] and isinstance(val, Proc):
try: val = bc_compile_proc(val, env)
except Exception: pass
env.define(name, val)
return VOID
@ -993,6 +999,7 @@ OP_CALL = 20; OP_TAIL_CALL = 21; OP_RETURN = 22
OP_MAKE_CLOSURE = 30
OP_PUSH_ENV = 40; OP_POP_ENV = 41; OP_BIND = 42
OP_EVAL = 50 # fallback to tree-walker
OP_CALL_CC = 51 # escape-only call/cc
class CodeObj:
"""Compiled bytecode chunk."""
@ -1019,8 +1026,7 @@ _BC_FALLBACK = frozenset(map(S, [
'define-values', 'let-values', 'let*-values',
'define-record-type', 'module', 'import', 'include', 'load',
'parameterize', 'dynamic-wind', 'with-exception-handler',
'guard', 'call/cc', 'call-with-current-continuation',
'call-with-values', 'values', 'apply', 'eval', 'error',
'guard', 'call-with-values', 'values', 'eval', 'error',
'case',
]))
@ -1250,6 +1256,18 @@ def _bc(expr, code, env, tail=False):
if not tail: code.emit(OP_POP_ENV)
return
# --- call/cc ---
if head is S('call/cc') or head is S('call-with-current-continuation'):
a = _L(args)
_bc(a[0], code, env)
code.emit(OP_CALL_CC); return
# --- apply ---
if head is S('apply'):
a = _L(args)
# Compile all args, emit OP_EVAL as fallback for TCO correctness
code.emit(OP_EVAL, expr); return
# --- Macro expansion at compile time ---
if isinstance(head, Symbol):
try:
@ -1302,49 +1320,51 @@ def _bc_lambda(body, params, rest, env, name=None):
def vm_exec(code, env):
"""Execute compiled bytecode."""
instrs = code.instrs; ip = 0; stack = []
"""Execute compiled bytecode. Optimized with local aliases."""
instrs = code.instrs; ip = 0; stack = []; _ap = stack.append; _po = stack.pop
_isinstance = isinstance; _CP = CompiledProc; _Pr = Proc
while ip < len(instrs):
op, arg = instrs[ip]; ip += 1
if op == OP_CONST: stack.append(arg)
elif op == OP_LOOKUP: stack.append(env.lookup(arg))
elif op == OP_SET: env.set(arg, stack.pop())
elif op == OP_DEFINE: env.define(arg, stack.pop())
elif op == OP_POP: stack.pop()
elif op == OP_DUP: stack.append(stack[-1])
elif op == OP_VOID: stack.append(VOID)
if op == OP_CONST: _ap(arg)
elif op == OP_LOOKUP: _ap(env.lookup(arg))
elif op == OP_SET: env.set(arg, _po())
elif op == OP_DEFINE: env.define(arg, _po())
elif op == OP_POP: _po()
elif op == OP_DUP: _ap(stack[-1])
elif op == OP_VOID: _ap(VOID)
elif op == OP_JUMP: ip = arg
elif op == OP_JUMP_IF_FALSE:
if not _truthy(stack.pop()): ip = arg
if _po() is False: ip = arg
elif op == OP_JUMP_IF_FALSE_KEEP:
if not _truthy(stack[-1]): ip = arg
else: stack.pop()
if stack[-1] is False: ip = arg
else: _po()
elif op == OP_JUMP_IF_TRUE_KEEP:
if _truthy(stack[-1]): ip = arg
else: stack.pop()
if stack[-1] is not False: ip = arg
else: _po()
elif op == OP_CALL:
n = arg
if n: args_ = stack[-n:]; del stack[-n:]
else: args_ = []
func = stack.pop()
if isinstance(func, CompiledProc):
stack.append(vm_exec(func.code,
func = _po()
if _isinstance(func, _CP):
_ap(vm_exec(func.code,
func.env.child(func.params, func.rest, args_)))
elif isinstance(func, Proc):
stack.append(_call(func, args_, env))
elif _isinstance(func, _Pr):
_ap(_call(func, args_, env))
elif callable(func):
stack.append(func(args_, env))
_ap(func(args_, env))
else: raise LispErr(f'not callable: {show(func)}')
elif op == OP_TAIL_CALL:
n = arg
if n: args_ = stack[-n:]; del stack[-n:]
else: args_ = []
func = stack.pop()
if isinstance(func, CompiledProc):
func = _po()
if _isinstance(func, _CP):
env = func.env.child(func.params, func.rest, args_)
instrs = func.code.instrs; ip = 0; stack.clear()
_ap = stack.append; _po = stack.pop
continue
elif isinstance(func, Proc):
elif _isinstance(func, _Pr):
c = func.env.child(func.params, func.rest, args_)
body = _body_env(func.body, c) if func.has_defs else func.body
for e in body[:-1]: leval(e, c)
@ -1352,18 +1372,37 @@ def vm_exec(code, env):
elif callable(func):
return func(args_, env)
else: raise LispErr(f'not callable: {show(func)}')
elif op == OP_RETURN: return stack.pop() if stack else VOID
elif op == OP_RETURN: return _po() if stack else VOID
elif op == OP_MAKE_CLOSURE:
inner_code, params, rest = arg
stack.append(CompiledProc(inner_code, params, rest, env,
inner_code.name))
_ap(_CP(inner_code, params, rest, env, inner_code.name))
elif op == OP_PUSH_ENV: env = Env(env)
elif op == OP_POP_ENV: env = env.p
elif op == OP_BIND: env.define(arg, stack.pop())
elif op == OP_EVAL: stack.append(leval(arg, env))
elif op == OP_BIND: env.define(arg, _po())
elif op == OP_EVAL: _ap(leval(arg, env))
elif op == OP_CALL_CC:
proc = _po()
class _Esc(Exception):
__slots__ = ('v',)
def __init__(self, v): self.v = v
def _kont(a, _e): raise _Esc(a[0] if a else VOID)
try:
if _isinstance(proc, _CP):
_ap(vm_exec(proc.code,
proc.env.child(proc.params, proc.rest, [_kont])))
elif _isinstance(proc, _Pr):
_ap(_call(proc, [_kont], env))
elif callable(proc):
_ap(proc([_kont], env))
else: raise LispErr(f'call/cc: not callable: {show(proc)}')
except _Esc as e: _ap(e.v)
return stack[-1] if stack else VOID
# Auto-compile flag
_auto_compile = [False]
def bc_compile_proc(proc, env):
"""Compile a Proc into a CompiledProc."""
if isinstance(proc, CompiledProc): return proc
@ -1372,6 +1411,43 @@ def bc_compile_proc(proc, env):
return CompiledProc(code, proc.params, proc.rest, proc.env, proc.name)
def _disassemble(proc):
"""Return human-readable bytecode listing."""
if isinstance(proc, Proc):
return f'#<interpreted {proc.name or "λ"}> — not compiled'
if not isinstance(proc, CompiledProc):
return f'not a procedure: {show(proc)}'
_OP_NAMES = {
OP_CONST: 'CONST', OP_LOOKUP: 'LOOKUP', OP_SET: 'SET',
OP_DEFINE: 'DEFINE', OP_POP: 'POP', OP_DUP: 'DUP', OP_VOID: 'VOID',
OP_JUMP: 'JUMP', OP_JUMP_IF_FALSE: 'JUMP_IF_FALSE',
OP_JUMP_IF_FALSE_KEEP: 'JUMP_IF_FALSE_KEEP',
OP_JUMP_IF_TRUE_KEEP: 'JUMP_IF_TRUE_KEEP',
OP_CALL: 'CALL', OP_TAIL_CALL: 'TAIL_CALL', OP_RETURN: 'RETURN',
OP_MAKE_CLOSURE: 'MAKE_CLOSURE',
OP_PUSH_ENV: 'PUSH_ENV', OP_POP_ENV: 'POP_ENV', OP_BIND: 'BIND',
OP_EVAL: 'EVAL', OP_CALL_CC: 'CALL_CC',
}
lines = [f'--- {proc.name or "λ"} '
f'({" ".join(str(p) for p in proc.params)}'
f'{"" if not proc.rest else " . " + str(proc.rest)}) ---']
for i, (op, arg) in enumerate(proc.code.instrs):
name = _OP_NAMES.get(op, f'OP_{op}')
if op == OP_MAKE_CLOSURE:
inner_code, params, rest = arg
arg_str = f'({inner_code.name or "λ"} {" ".join(str(p) for p in params)})'
elif op == OP_EVAL:
arg_str = show(arg)[:50]
elif arg is not None:
arg_str = show(arg) if not isinstance(arg, int) or op in (
OP_CALL, OP_TAIL_CALL, OP_JUMP, OP_JUMP_IF_FALSE,
OP_JUMP_IF_FALSE_KEEP, OP_JUMP_IF_TRUE_KEEP) else show(arg)
else:
arg_str = ''
lines.append(f' {i:4d} {name:<22s} {arg_str}')
return '\n'.join(lines)
###############################################################################
# Built-ins
###############################################################################
@ -2114,6 +2190,11 @@ def make_global_env():
# ── Bytecode compiler ────────────────────────────────────────────────────
d(S('compile'), lambda a, e: bc_compile_proc(a[0], e))
d(S('compiled?'), lambda a, _: isinstance(a[0], CompiledProc))
d(S('disassemble'),lambda a, _: (print(_disassemble(a[0])) or VOID))
def _auto_compile_fn(a, _):
if not a: return _auto_compile[0]
_auto_compile[0] = _truthy(a[0]); return VOID
d(S('auto-compile!'), _auto_compile_fn)
# Constants
d(S('pi'), math.pi)