examples: lisp — perm/trans split, promote-on-define, slice free

Two bump arenas. arena_reset_trans() runs between top-level forms;
top-level define / set! deep-copy the bound value graph into perm
via Cheney-style forwarding (pin = -1 + stashed fwd pointer in
.car/.val) so no perm cell ever points into trans. Args slice in
eval's apply path also gets explicit os.free per dispatch — without
that the rt_ensure page-per-call leak dominated and masked the
reset. test_huge peaks at ~2.6 MB under massif --pages-as-heap=yes,
down from ~525 MB pre-arena (~200x).
This commit is contained in:
2026-05-12 23:27:04 +09:00
parent fa33357821
commit 1c184ee6aa
2 changed files with 274 additions and 47 deletions

View File

@@ -9,7 +9,7 @@ not as a reference Lisp implementation.
eval, apply, printer, REPL. Everything the entry
point and the test driver consume is `export`-ed.
- `lisp.ww` entry point; `use lispcore;` + `main()`.
- `lisp_test.ww` in-process test driver (66 probes). Built as a
- `lisp_test.ww` in-process test driver (72 probes). Built as a
standalone binary, exec'd directly — `ww test`
drops `-I` in single-file mode, so the Makefile
runs the binary itself.
@@ -89,6 +89,17 @@ same bug class shows up in any new code that hits the same pattern.
Integer compound assigns work fine, so `acc += i` on `i64`
stays as-is.
9. **`let r = call(); foreign_call(); return r?;` corrupts `r` when
the call returned a tagged union.** The (`tag`, `payload1`,
`payload2`) triple sits in AX/DX/CX after the call, and the
foreign call between capture and `?`-unwrap clobbers at least one
register before the cgen has spilled it to the local slot.
Symptom: a `(*value | rterror)` whose `rterror` carries a string
literal prints with a `str.len` of tens of thousands. Workaround:
`match` the union inline before the foreign call and let each arm
return its own typed result. See eval's BUILTIN apply path
(where we free the args slice after `apply_builtin`).
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
@@ -115,17 +126,22 @@ 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 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.
- **Arena, not GC.** Cells live in two bump-pointer arenas: `perm`
(top-level definitions and the value graph each one pins) and
`trans` (parse cells, intermediate evals, the form's printed
result). `repl()` calls `arena_reset_trans()` between top-level
forms; a Cheney-style `promote_v` deep-copies the value graph at
every top-level `define`/`set!` boundary so no perm cell ever
points into trans. Forwarding markers (`pin = -1` plus the new
perm ptr stashed in `.car`/`.val`) break cycles. Detached trans
chunks go onto a per-arena free list, so peak virtual address
space is bounded by the largest form's working set. Builtin
args-slice headers in eval's apply path are released with
`os.free` per dispatch — without that, `rt_ensure`'s page-per-
call allocation dominates the profile. test_huge peaks at ~2.6 MB
under `--pages-as-heap=yes`, down from ~525 MB pre-arena (~200×).
No real GC inside a single form, so a pathological one-shot like
`(fib 25)` would still grow trans linearly until the form returns.
- **No bigints.** `i64` wraps silently on overflow. `(fact 21)`
rolls over.
- **Float printing is fixed `%.6f`.** `1.0` prints as `1.000000`.
@@ -155,7 +171,7 @@ env where `od?` is unbound; the tie-back only adds the self-binding.
```
make # build ./lisp
make test # build + run lisp_test (66 in-process probes)
make test # build + run lisp_test (72 in-process probes)
make demo # cat each test_*.lisp through ./lisp
make clean
```