Replaces rt_alloc-per-cell (one 4 KiB mmap each) with arena_alloc over 64 KiB chunks. test_huge peak under massif --pages-as-heap=yes drops from ~525 MB to ~253 MB. Same lifetime semantics; remaining bulk is per-call append() in eval's arg slice (rt_ensure still mmaps page-per-call).
8.0 KiB
examples/lisp — tiny Lisp interpreter in pure ww. Demo program; not a production interpreter. Treat the files below as a worked example of "what does and doesn't lower cleanly through the wwstage cgen today", not as a reference Lisp implementation.
Layout
lispcore.wwinterpreter module: types, lexer, parser, env, eval, apply, printer, REPL. Everything the entry point and the test driver consume isexport-ed.lisp.wwentry point;use lispcore;+main().lisp_test.wwin-process test driver (66 probes). Built as a standalone binary, exec'd directly —ww testdrops-Iin single-file mode, so the Makefile runs the binary itself.test_*.lispdemo source files (cat test_X.lisp | ./lisp).
The lispcore.ww + lisp.ww split exists so the tests can use the
interpreter functions. Both files combine under the same module name
(the directory's basename, lisp), because ww's module resolver
takes the containing-directory basename when finding a sibling.
Cross-module type prefixes like lispcore.value don't resolve in the
test — use the bare names.
wwstage cgen workarounds at play
Every workaround below is documented inline at its use site. The shape of the bug is what matters, not the specific symptom — the same bug class shows up in any new code that hits the same pattern.
-
Top-level
[N]Tarrays mis-address inside functions.globalarr[i] = …lowers toLEAQ (BP), BXinstead ofLEAQ globalarr(SB), BX— the global address gets treated as a stack frame, corrupting both. Heap-allocate viaos.allocinto a*Tand alias to a local at the top of each function before indexing. Seesym_off/sym_blob/obuf. -
global_ptr[i]mis-addresses too. Even after switching to a pointer global,MOVQ global_ptr(SB), BXis replaced withMOVQ (BP), BX. Aliaslet p = global_ptr;at function entry, thenp[i]. See every helper that touches the symbol interner. -
Two-level field write through a non-pointer sub-struct.
L.cur.kind = korL.src.ptr = buf.ptr(wherecur/srcis a non-pointer struct field of a struct reached through a pointer) is silently dropped — the function body emits no store. Either flatten the sub-struct (seelexer.curkind/curival/…) or build the whole sub-struct as a local and do a single whole-struct assign (let s: str = …; L.src = s;). -
f64 through every boundary is unreliable. The cgen routes f64 stores via integer registers; in most paths AX gets stored where X0 should have been written.
alloc(value{ fval = v }): writes the kind enum (AX still holds it) instead of the f64.p.fval = vthrough a*T: same — stores AX.*p = vfor*f64: stores AX.func(f64_arg)where the source is a struct field: cgen doesMOVQ off(BX), AX(integer load) and never puts the bits in X0.
What does work:
MOVSD X0, global(SB)(a top-level f64 global) andMOVSD X0, off(BP)(a local f64 slot). The interpreter threads f64 through a scratch global (fbuf) and byte-copies 8 bytes wherever a*f64would normally suffice. Seevfloat,to_f64,copybytes. -
alloc(value{ text = s })writes onlys.ptr. The cgen sets ups.ptrin AX, sets the new-pointer in BX, then needss.lenin another reg — but the same BX gets clobbered by the new-pointer reload, so the.lenstore never happens. Manual init viap.text = swrites both halves correctly. -
(slice | E)tagged-union returns dropslice.len. The wwstage return convention is AX=tag, DX=payload1, CX=payload2. A slice header is 24 bytes (ptr/len/cap); only ptr and cap come through in DX/CX. The intermediateBX = s.lenis loaded but never moved into a return register. Inline the slice-building loop into the caller instead of factoring it into a helper. Seeeval's argument-eval inline. -
xs[i].kinddrops the trailing field load. Field access on a slice element gives back only the bytes at&xs[i]— the cgen doesn't chain the dereference. Bind to a local first:let p = xs[i]; if (p.kind …). See every builtin. -
f64 compound assigns are mis-lowered to
acc = d(no OP).acc += f/acc /= fetc. on f64 locals drop the operator. Write the explicit form:acc = acc + f/acc = acc / f. Integer compound assigns work fine, soacc += ioni64stays as-is.
If a new function "should work but acts weird", the bug is almost always one of the above and shows up under valgrind/gdb the same way it did the first time: silently dropped store, missing field read, garbage payload after a tagged-union return.
Tail-call optimization
eval is a single for(true) trampoline; a tail position rewrites
v (current expr) and cure (current env) in place and continues
instead of recursing. Tail positions are:
- the chosen branch of
if - the last form of
begin/let/ a lambda body - a direct call in any of the above
A tailed flag flips on the first jump into a fresh scope (LET
binding, lambda body) so subsequent define/set! mutate the local
cure chain rather than the caller's ein slot — same scoping the
old recursive run_body(_, &pe) path gave.
Mutual recursion still doesn't work — that's a define tie-back
limitation, not a TCO one. Defining evn? before od? captures an
env where od? is unbound; the tie-back only adds the self-binding.
Interpreter limitations (design, not bug)
- No GC. Cons / value / env cells come from a single bump-
pointer arena (
arena_alloc, 64 KiB chunks chained viaos.alloc). The arena never reclaims — long REPL sessions still leak — but cells pack tightly instead of one cell per 4 KiB mmap. test_huge peaks at ~253 MB under--pages-as-heap=yes, down from ~525 MB pre-arena. The remaining bulk isappend(xs, av)in eval's arg loop: every apply allocates a fresh[]*valuethroughrt_ensure, which still hitsrt_allocpage-per-call. Per-top-level-form arena reset (perm/trans split + Cheney promote on define/set!) is the next step — see the closing note inrepl()for the hook point. - No bigints.
i64wraps silently on overflow.(fact 21)rolls over. - Float printing is fixed
%.6f.1.0prints as1.000000. Strconv has noftosyet. - String escapes are accepted but not translated.
"\n"in source lands as the two bytes\\+n, not a newline. - No
(load)/ file I/O builtins. Programs come in via stdin.
Latent issues — not observed, but the surface to watch
- Other i64 compound assigns in the cgen aren't audited site-by-site. Workaround #8 covers f64; if you add new arithmetic on integers inside a hot loop, scan the asm.
- The interactive REPL's
shift_buf(L.curstart)assumesparse_expralways primes exactly one token of lookahead after returning success. A future parser change that skips that prime silently eats input. - The
definetie-back prepends a self-binding frame ontobv.envp. It assumes constructors always initializeenvp. They do today. - Cross-module type names (
lispcore.value) don't resolve; we side-step by using bare names. Ifww's module resolver starts honoring the qualified form, the tests still work — but if it starts rejecting unqualified cross-module references, both files break.
Build / test / profile
make # build ./lisp
make test # build + run lisp_test (66 in-process probes)
make demo # cat each test_*.lisp through ./lisp
make clean
Demo files are pipe-driven: cat test_arith.lisp | ./lisp.
Valgrind:
valgrind --error-exitcode=2 ./lisp < test_huge.lisp
valgrind --tool=massif --pages-as-heap=yes ./lisp < test_huge.lisp
The memcheck leak counter reads "0 allocs / 0 frees" because
rt_alloc is mmap, not malloc. Memcheck still catches uninit
reads, bad accesses, and stack issues. For real heap-shape analysis
use massif with --pages-as-heap=yes.