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).
176 lines
8.0 KiB
Markdown
176 lines
8.0 KiB
Markdown
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.ww` interpreter module: types, lexer, parser, env,
|
|
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
|
|
standalone binary, exec'd directly — `ww test`
|
|
drops `-I` in single-file mode, so the Makefile
|
|
runs the binary itself.
|
|
- `test_*.lisp` demo 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.
|
|
|
|
1. **Top-level `[N]T` arrays mis-address inside functions.**
|
|
`globalarr[i] = …` lowers to `LEAQ (BP), BX` instead of
|
|
`LEAQ globalarr(SB), BX` — the global address gets treated as a
|
|
stack frame, corrupting both. Heap-allocate via `os.alloc` into a
|
|
`*T` and alias to a local at the top of each function before
|
|
indexing. See `sym_off` / `sym_blob` / `obuf`.
|
|
|
|
2. **`global_ptr[i]` mis-addresses too.** Even after switching to a
|
|
pointer global, `MOVQ global_ptr(SB), BX` is replaced with
|
|
`MOVQ (BP), BX`. Alias `let p = global_ptr;` at function entry,
|
|
then `p[i]`. See every helper that touches the symbol interner.
|
|
|
|
3. **Two-level field write through a non-pointer sub-struct.**
|
|
`L.cur.kind = k` or `L.src.ptr = buf.ptr` (where `cur`/`src` is 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 (see `lexer.curkind`/`curival`/…) or build
|
|
the whole sub-struct as a local and do a single whole-struct
|
|
assign (`let s: str = …; L.src = s;`).
|
|
|
|
4. **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 = v` through a `*T`: same — stores AX.
|
|
- `*p = v` for `*f64`: stores AX.
|
|
- `func(f64_arg)` where the source is a struct field: cgen does
|
|
`MOVQ off(BX), AX` (integer load) and never puts the bits in X0.
|
|
|
|
What does work: `MOVSD X0, global(SB)` (a top-level f64 global)
|
|
and `MOVSD X0, off(BP)` (a local f64 slot). The interpreter
|
|
threads f64 through a scratch global (`fbuf`) and byte-copies
|
|
8 bytes wherever a `*f64` would normally suffice. See `vfloat`,
|
|
`to_f64`, `copybytes`.
|
|
|
|
5. **`alloc(value{ text = s })` writes only `s.ptr`.** The cgen sets
|
|
up `s.ptr` in AX, sets the new-pointer in BX, then needs `s.len`
|
|
in another reg — but the same BX gets clobbered by the new-pointer
|
|
reload, so the `.len` store never happens. Manual init via
|
|
`p.text = s` writes both halves correctly.
|
|
|
|
6. **`(slice | E)` tagged-union returns drop `slice.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 intermediate `BX = s.len` is loaded but
|
|
never moved into a return register. Inline the slice-building
|
|
loop into the caller instead of factoring it into a helper. See
|
|
`eval`'s argument-eval inline.
|
|
|
|
7. **`xs[i].kind` drops 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.
|
|
|
|
8. **f64 compound assigns are mis-lowered to `acc = d` (no OP).**
|
|
`acc += f` / `acc /= f` etc. on f64 locals drop the operator.
|
|
Write the explicit form: `acc = acc + f` / `acc = acc / f`.
|
|
Integer compound assigns work fine, so `acc += i` on `i64`
|
|
stays 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 `continue`s
|
|
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 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`.
|
|
Strconv has no `ftos` yet.
|
|
- **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)` assumes `parse_expr`
|
|
always primes exactly one token of lookahead after returning success.
|
|
A future parser change that skips that prime silently eats input.
|
|
- The `define` tie-back prepends a self-binding frame onto
|
|
`bv.envp`. It assumes constructors always initialize `envp`. They
|
|
do today.
|
|
- Cross-module type names (`lispcore.value`) don't resolve; we
|
|
side-step by using bare names. If `ww`'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`.
|