examples: lisp — chunked bump arena for value/env cells

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).
This commit is contained in:
2026-05-12 23:12:40 +09:00
parent 78b1cbfb6a
commit fa33357821
2 changed files with 129 additions and 43 deletions

View File

@@ -115,14 +115,17 @@ env where `od?` is unbound; the tie-back only adds the self-binding.
## Interpreter limitations (design, not bug) ## Interpreter limitations (design, not bug)
- **No GC.** Every cons / value / env frame is `mmap`'d via - **No GC.** Cons / value / env cells come from a single bump-
`rt_alloc` and never reclaimed. A long REPL session leaks until pointer arena (`arena_alloc`, 64 KiB chunks chained via `os.alloc`).
the process exits. The test_huge demo peaks at ~595 MB under The arena never reclaims — long REPL sessions still leak — but
`--pages-as-heap=yes`. Per-top-level-form arena reset would cut cells pack tightly instead of one cell per 4 KiB mmap. test_huge
this by ~100× — see the closing note in `repl()` for the hook peaks at ~253 MB under `--pages-as-heap=yes`, down from ~525 MB
point. Note: `rt_alloc` is one mmap syscall per call returning a pre-arena. The remaining bulk is `append(xs, av)` in eval's arg
whole 4KB page, so even tail-recursive loops are bottlenecked on loop: every apply allocates a fresh `[]*value` through `rt_ensure`,
allocation, not on Lisp work — `(spin 100000 0)` runs in ~4s. 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)` - **No bigints.** `i64` wraps silently on overflow. `(fact 21)`
rolls over. rolls over.
- **Float printing is fixed `%.6f`.** `1.0` prints as `1.000000`. - **Float printing is fixed `%.6f`.** `1.0` prints as `1.000000`.

View File

@@ -10,8 +10,9 @@
// (*value | parserr | eof) from the parser // (*value | parserr | eof) from the parser
// - `match` with `yield` so the REPL dispatch is one expression // - `match` with `yield` so the REPL dispatch is one expression
// - `?` to propagate the error variant up the call chain // - `?` to propagate the error variant up the call chain
// - struct types + struct literals + the `alloc(structlit{...})` // - struct types with manual field-store init through *T (the
// constructor sugar (subject to the f64/str traps below) // `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 // - enum types (valkind, sform, btin, tkind) + `switch` dispatch
// - tagged-union-typed function parameters + bool-return helpers // - tagged-union-typed function parameters + bool-return helpers
// - `[]*value` slices with `append(s, v)` growth // - `[]*value` slices with `append(s, v)` growth
@@ -19,6 +20,8 @@
// - classic `for (cond)` + `break` // - classic `for (cond)` + `break`
// - `defer` to release the slurp buffer on every exit edge // - `defer` to release the slurp buffer on every exit edge
// - runes ('a'), str literals, f64 arithmetic, mixed i64/f64 promo // - 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) // - module imports (use os/fmt/ascii/strconv/strings)
// //
// Build: make // Build: make
@@ -104,6 +107,62 @@ export type env = struct {
}; };
def ENV_SZ: u64 = 32u64; 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 --------------------------------------------------- // ---- error variants ---------------------------------------------------
export type rterror = !str; // runtime: division by zero, unbound symbol, … 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 ---------------------------------------------- // ---- value constructors ----------------------------------------------
// //
// alloc(structlit{...}) sugar: cgen rewrites this to rt_alloc(VALUE_SZ) // Every v* constructor allocates one VALUE_SZ slab from the arena and
// + per-field stores, returning a freshly-init'd *value. Mirrors the // initialises the fields the kind needs. The cgen's
// `new T(...)` ergonomics other languages have without ww needing // `alloc(value{...})` struct-literal sugar lowers to rt_alloc directly
// constructors or generics. // (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 = { 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 = { export fn vbool(b: bool) *value = {
let n: i32 = 0; let n: i32 = 0;
if (b) { n = 1; }; 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 = { 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 // 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 = { fn vfloat(v: f64) *value = {
fbuf = v; fbuf = v;
let p: *value = os.alloc(VALUE_SZ): *value; let p: *value = arena_alloc(VALUE_SZ): *value;
p.kind = valkind.FLOAT; p.kind = valkind.FLOAT;
let raw: *u8 = p: *u8; let raw: *u8 = p: *u8;
let dst: *u8 = raw + FVAL_OFF; let dst: *u8 = raw + FVAL_OFF;
@@ -151,34 +219,44 @@ fn vfloat(v: f64) *value = {
}; };
export fn vsym(id: i32) *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 // Manual init via `p.text = s` writes both halves of the str slice;
// s.len is clobbered by the new-ptr reload before the .len store. // the cgen's struct-literal path drops s.len because BX gets reloaded
// Manual init writes both halves correctly. // with the new-pointer before the .len store.
fn vstr(s: str) *value = { 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.kind = valkind.STR;
p.text = s; p.text = s;
return p; return p;
}; };
export fn vcons(a: *value, d: *value) *value = { 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 = { 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 = { fn vlambda(params: *value, body: *value, e: *env) *value = {
return alloc(value{ let p: *value = arena_alloc(VALUE_SZ): *value;
kind = valkind.LAMBDA, p.kind = valkind.LAMBDA;
car = params, p.car = params;
cdr = body, p.cdr = body;
envp = e, p.envp = e;
}); return p;
}; };
// ---- symbol interner -------------------------------------------------- // ---- symbol interner --------------------------------------------------
@@ -626,7 +704,10 @@ fn parse_expr(L: *lexer) (*value | parserr | eof) = {
// ---- env -------------------------------------------------------------- // ---- env --------------------------------------------------------------
fn env_define(e: **env, sid: i32, v: *value) void = { 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; *e = f;
}; };
@@ -1072,11 +1153,10 @@ export fn eval(v0: *value, ein: **env) (*value | rterror) = {
// env that closures (e.g. `(adder 5)` returning // env that closures (e.g. `(adder 5)` returning
// `(lambda (x) (+ x k))`) had captured. // `(lambda (x) (+ x k))`) had captured.
if (bv.kind == valkind.LAMBDA) { if (bv.kind == valkind.LAMBDA) {
let frame: *env = alloc(env{ let frame: *env = arena_alloc(ENV_SZ): *env;
sid = nameval.sid, frame.sid = nameval.sid;
val = bv, frame.val = bv;
next = bv.envp, frame.next = bv.envp;
});
bv.envp = frame; bv.envp = frame;
}; };
return vnil(); return vnil();
@@ -1122,9 +1202,10 @@ export fn eval(v0: *value, ein: **env) (*value | rterror) = {
return "let: name must be sym": rterror; return "let: name must be sym": rterror;
}; };
let val = eval(pair.cdr.car, &cure)?; let val = eval(pair.cdr.car, &cure)?;
let f: *env = alloc(env{ let f: *env = arena_alloc(ENV_SZ): *env;
sid = nm.sid, val = val, next = inner, f.sid = nm.sid;
}); f.val = val;
f.next = inner;
inner = f; inner = f;
bcur = bcur.cdr; bcur = bcur.cdr;
}; };
@@ -1186,9 +1267,10 @@ export fn eval(v0: *value, ein: **env) (*value | rterror) = {
if (nm.kind != valkind.SYM) { if (nm.kind != valkind.SYM) {
return "lambda: bad param": rterror; return "lambda: bad param": rterror;
}; };
let f: *env = alloc(env{ let f: *env = arena_alloc(ENV_SZ): *env;
sid = nm.sid, val = xs[i], next = inner, f.sid = nm.sid;
}); f.val = xs[i];
f.next = inner;
inner = f; inner = f;
p = p.cdr; p = p.cdr;
i += 1; i += 1;
@@ -1318,6 +1400,7 @@ fn bind_one(e: **env, name: str, id: i32) void = {
}; };
export fn initsyms() void = { export fn initsyms() void = {
arena_init();
sym_off = os.alloc((SYM_CAP: u64) * 4u64): *i32; sym_off = os.alloc((SYM_CAP: u64) * 4u64): *i32;
sym_len = os.alloc((SYM_CAP: u64) * 4u64): *i32; sym_len = os.alloc((SYM_CAP: u64) * 4u64): *i32;
sym_blob = os.alloc(SYM_BLOBSZ: u64): *u8; sym_blob = os.alloc(SYM_BLOBSZ: u64): *u8;