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:
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user