diff --git a/examples/lisp/CLAUDE.md b/examples/lisp/CLAUDE.md index 3e8995a8..a71736d4 100644 --- a/examples/lisp/CLAUDE.md +++ b/examples/lisp/CLAUDE.md @@ -115,14 +115,17 @@ env where `od?` is unbound; the tie-back only adds the self-binding. ## Interpreter limitations (design, not bug) -- **No GC.** Every cons / value / env frame is `mmap`'d via - `rt_alloc` and never reclaimed. A long REPL session leaks until - the process exits. The test_huge demo peaks at ~595 MB under - `--pages-as-heap=yes`. Per-top-level-form arena reset would cut - this by ~100× — see the closing note in `repl()` for the hook - point. Note: `rt_alloc` is one mmap syscall per call returning a - whole 4KB page, so even tail-recursive loops are bottlenecked on - allocation, not on Lisp work — `(spin 100000 0)` runs in ~4s. +- **No GC.** Cons / value / env cells come from a single bump- + pointer arena (`arena_alloc`, 64 KiB chunks chained via `os.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 is `append(xs, av)` in eval's arg + loop: every apply allocates a fresh `[]*value` through `rt_ensure`, + which still hits `rt_alloc` page-per-call. Per-top-level-form + arena reset (perm/trans split + Cheney promote on define/set!) is + the next step — see the closing note in `repl()` for the hook + point. - **No bigints.** `i64` wraps silently on overflow. `(fact 21)` rolls over. - **Float printing is fixed `%.6f`.** `1.0` prints as `1.000000`. diff --git a/examples/lisp/lispcore.ww b/examples/lisp/lispcore.ww index 88807a06..491ae1ed 100644 --- a/examples/lisp/lispcore.ww +++ b/examples/lisp/lispcore.ww @@ -10,8 +10,9 @@ // (*value | parserr | eof) from the parser // - `match` with `yield` so the REPL dispatch is one expression // - `?` to propagate the error variant up the call chain -// - struct types + struct literals + the `alloc(structlit{...})` -// constructor sugar (subject to the f64/str traps below) +// - struct types with manual field-store init through *T (the +// `alloc(structlit{...})` sugar trips f64/str cgen traps; we +// hand-roll instead — see arena_alloc and the v* constructors) // - enum types (valkind, sform, btin, tkind) + `switch` dispatch // - tagged-union-typed function parameters + bool-return helpers // - `[]*value` slices with `append(s, v)` growth @@ -19,6 +20,8 @@ // - classic `for (cond)` + `break` // - `defer` to release the slurp buffer on every exit edge // - runes ('a'), str literals, f64 arithmetic, mixed i64/f64 promo +// - a chunked bump-pointer arena (arena_alloc) over os.alloc, so +// every cell isn't paying a 4 KiB-page-per-allocation tax // - module imports (use os/fmt/ascii/strconv/strings) // // Build: make @@ -104,6 +107,62 @@ export type env = struct { }; def ENV_SZ: u64 = 32u64; +// ---- arena allocator -------------------------------------------------- +// +// Bump-pointer arena over 64 KiB chunks from os.alloc. Replaces the +// page-per-cell waste of routing every value/env through os.alloc +// directly — rt_alloc rounds the request up to a 4 KiB page, so each +// 96-byte value used to cost 4032 bytes of slack. test_huge.lisp +// peaked at ~595 MB under massif --pages-as-heap=yes; the same +// allocation pattern now packs into chained 64 KiB chunks. +// +// Same lifetime semantics as the old direct-mmap path: never reclaimed +// inside a single process. A follow-up (the closing note in repl()) +// will split this into perm/trans and reset the trans arena between +// top-level forms. + +def ARENA_CHUNK: u64 = 65536u64; // 64 KiB per mmap +def CHUNK_HEADER: u64 = 32u64; // header bytes, 8-aligned + +type chunk = struct { + next: *chunk, + cur: u64, +}; + +// Chained list of mmap'd chunks. `arena_head` is the active chunk; +// older chunks reachable via .next stay live for the values that +// landed in them. arena_init() seeds the first chunk. +let arena_head: *chunk = nil; + +fn arena_new_chunk(prev: *chunk) *chunk = { + let raw: *u8 = os.alloc(ARENA_CHUNK): *u8; + let c: *chunk = raw: *chunk; + c.next = prev; + c.cur = 0u64; + return c; +}; + +export fn arena_init() void = { + arena_head = arena_new_chunk(nil); +}; + +// arena_alloc — bump `n` bytes off the head chunk; mmap a new chunk if +// the head can't fit the request. Result is zeroed memory: fresh mmap +// pages start zero, and we don't recycle in this pass. +fn arena_alloc(n: u64) *void = { + let n8: u64 = (n + 7u64) & ~7u64; + let h: *chunk = arena_head; + if (h.cur + n8 + CHUNK_HEADER > ARENA_CHUNK) { + let c: *chunk = arena_new_chunk(h); + arena_head = c; + h = c; + }; + let raw: *u8 = h: *u8; + let p: *u8 = raw + CHUNK_HEADER + h.cur; + h.cur += n8; + return p: *void; +}; + // ---- error variants --------------------------------------------------- export type rterror = !str; // runtime: division by zero, unbound symbol, … @@ -112,23 +171,32 @@ export type eof = !void; // parser: end of input — distinct from a parse // ---- value constructors ---------------------------------------------- // -// alloc(structlit{...}) sugar: cgen rewrites this to rt_alloc(VALUE_SZ) -// + per-field stores, returning a freshly-init'd *value. Mirrors the -// `new T(...)` ergonomics other languages have without ww needing -// constructors or generics. +// Every v* constructor allocates one VALUE_SZ slab from the arena and +// initialises the fields the kind needs. The cgen's +// `alloc(value{...})` struct-literal sugar lowers to rt_alloc directly +// (one mmap per cell), so the constructors hand-roll the alloc + per- +// field stores instead. Same shape as `new T(...)` in other languages. export fn vnil() *value = { - return alloc(value{ kind = valkind.NIL }); + let p: *value = arena_alloc(VALUE_SZ): *value; + p.kind = valkind.NIL; + return p; }; export fn vbool(b: bool) *value = { let n: i32 = 0; if (b) { n = 1; }; - return alloc(value{ kind = valkind.BOOL, bval = n }); + let p: *value = arena_alloc(VALUE_SZ): *value; + p.kind = valkind.BOOL; + p.bval = n; + return p; }; export fn vint(v: i64) *value = { - return alloc(value{ kind = valkind.INT, ival = v }); + let p: *value = arena_alloc(VALUE_SZ): *value; + p.kind = valkind.INT; + p.ival = v; + return p; }; // f64 → struct-field via a *T pointer is mis-lowered (stores AX instead @@ -139,7 +207,7 @@ let fbuf: f64 = 0.0; fn vfloat(v: f64) *value = { fbuf = v; - let p: *value = os.alloc(VALUE_SZ): *value; + let p: *value = arena_alloc(VALUE_SZ): *value; p.kind = valkind.FLOAT; let raw: *u8 = p: *u8; let dst: *u8 = raw + FVAL_OFF; @@ -151,34 +219,44 @@ fn vfloat(v: f64) *value = { }; export fn vsym(id: i32) *value = { - return alloc(value{ kind = valkind.SYM, sid = id }); + let p: *value = arena_alloc(VALUE_SZ): *value; + p.kind = valkind.SYM; + p.sid = id; + return p; }; -// alloc(value{ text = s }) writes only s.ptr — the BX register holding -// s.len is clobbered by the new-ptr reload before the .len store. -// Manual init writes both halves correctly. +// Manual init via `p.text = s` writes both halves of the str slice; +// the cgen's struct-literal path drops s.len because BX gets reloaded +// with the new-pointer before the .len store. fn vstr(s: str) *value = { - let p: *value = os.alloc(VALUE_SZ): *value; + let p: *value = arena_alloc(VALUE_SZ): *value; p.kind = valkind.STR; p.text = s; return p; }; export fn vcons(a: *value, d: *value) *value = { - return alloc(value{ kind = valkind.CONS, car = a, cdr = d }); + let p: *value = arena_alloc(VALUE_SZ): *value; + p.kind = valkind.CONS; + p.car = a; + p.cdr = d; + return p; }; fn vbuiltin(id: i32) *value = { - return alloc(value{ kind = valkind.BUILTIN, ival = id: i64 }); + let p: *value = arena_alloc(VALUE_SZ): *value; + p.kind = valkind.BUILTIN; + p.ival = id: i64; + return p; }; fn vlambda(params: *value, body: *value, e: *env) *value = { - return alloc(value{ - kind = valkind.LAMBDA, - car = params, - cdr = body, - envp = e, - }); + let p: *value = arena_alloc(VALUE_SZ): *value; + p.kind = valkind.LAMBDA; + p.car = params; + p.cdr = body; + p.envp = e; + return p; }; // ---- symbol interner -------------------------------------------------- @@ -626,7 +704,10 @@ fn parse_expr(L: *lexer) (*value | parserr | eof) = { // ---- env -------------------------------------------------------------- fn env_define(e: **env, sid: i32, v: *value) void = { - let f: *env = alloc(env{ sid = sid, val = v, next = *e }); + let f: *env = arena_alloc(ENV_SZ): *env; + f.sid = sid; + f.val = v; + f.next = *e; *e = f; }; @@ -1072,11 +1153,10 @@ export fn eval(v0: *value, ein: **env) (*value | rterror) = { // env that closures (e.g. `(adder 5)` returning // `(lambda (x) (+ x k))`) had captured. if (bv.kind == valkind.LAMBDA) { - let frame: *env = alloc(env{ - sid = nameval.sid, - val = bv, - next = bv.envp, - }); + let frame: *env = arena_alloc(ENV_SZ): *env; + frame.sid = nameval.sid; + frame.val = bv; + frame.next = bv.envp; bv.envp = frame; }; return vnil(); @@ -1122,9 +1202,10 @@ export fn eval(v0: *value, ein: **env) (*value | rterror) = { return "let: name must be sym": rterror; }; let val = eval(pair.cdr.car, &cure)?; - let f: *env = alloc(env{ - sid = nm.sid, val = val, next = inner, - }); + let f: *env = arena_alloc(ENV_SZ): *env; + f.sid = nm.sid; + f.val = val; + f.next = inner; inner = f; bcur = bcur.cdr; }; @@ -1186,9 +1267,10 @@ export fn eval(v0: *value, ein: **env) (*value | rterror) = { if (nm.kind != valkind.SYM) { return "lambda: bad param": rterror; }; - let f: *env = alloc(env{ - sid = nm.sid, val = xs[i], next = inner, - }); + let f: *env = arena_alloc(ENV_SZ): *env; + f.sid = nm.sid; + f.val = xs[i]; + f.next = inner; inner = f; p = p.cdr; i += 1; @@ -1318,6 +1400,7 @@ fn bind_one(e: **env, name: str, id: i32) void = { }; export fn initsyms() void = { + arena_init(); sym_off = os.alloc((SYM_CAP: u64) * 4u64): *i32; sym_len = os.alloc((SYM_CAP: u64) * 4u64): *i32; sym_blob = os.alloc(SYM_BLOBSZ: u64): *u8;