whitepaper §6.6: naive GC + meta-GC; GNU assembler called out
Adds §6.6 "Memory Management and the Meta-GC", covering:
- Bump-only default: why asm leaks, when that's fine, when
it isn't (two-crash anecdote links to the CLAUDE.md safety
envelope).
- Naive mark-sweep control group: GC_NAIVE assemble flag,
per-block header, stop-the-world mark + first-fit free list.
Control-group numbers: bump 134 MB / 1097 ms vs naive 1.1 MB /
1431 ms → 124x less memory at ~30% throughput cost.
- Meta-GC layer (§6.6.1): (with-arena thunk) fast path with
three-way policy — implicit-GC-fired / no-mark-in-range /
mark-in-range → skip / bulk-reset / sweep-fallback.
Bench: 2000 arena calls on truly-transient workload: 2000
resets / 0 escapes / 205 MB reclaimed, full GCs drop from
200 (Phase A) to 1 (Phase B). Escape case tested: 20/20 caught,
data remains live.
- §6.6.2 What the control group tells us: three co-resident
strategies, two stats surfaces (gc-stats, arena-stats), a
concrete floor (124x memory, 30% time, 100% arena hit on
scoped code) that any future proposal must beat.
Also calls out GNU assembler (GAS, AT&T syntax) + as + ld + GNU
binutils explicitly in the tier list (§intro) and the §11 asm tier
summary, and updates the stale 4,968 LOC to the current 6,645
across three locations (intro list, §6.6 narrative, §11 table,
§11 narrative). Reproducibility list in §6 now references
make bench-gc and make bench-gc-arena.
PDF rebuilt; all test suites (Python + C + asm no-GC + asm GC +
shared functional) still green against this revision.
This commit is contained in:
parent
a8eddd492e
commit
ef77a8023f
2 changed files with 2265 additions and 1541 deletions
File diff suppressed because one or more lines are too long
|
|
@ -63,7 +63,7 @@ Lumbda ships in **four implementation tiers** — each independently built, each
|
|||
- **Python bytecode VM** — 3,743 lines, full first-class continuations, JSON portal, reference implementation
|
||||
- **C interpreter** — tree-walker + bytecode VM, 9,164 lines of runtime C, JSON portal
|
||||
- **C + x86_64 JIT** — pattern-matched native code emission via ``mmap(PROT_EXEC)``, 7--10× faster than CPython on recursive workloads
|
||||
- **Pure x86_64 assembly** — 4,968 lines, 22 KB stripped binary, zero external dependencies, **91 builtins including a full TCP stack, `eval`, and `read-from-string`**, binary heap-dump portal
|
||||
- **Pure x86_64 assembly** — 6,645 lines of GNU assembler (GAS, AT&T syntax), assembled with ``as`` and linked with ``ld`` (both from GNU binutils), ~22 KB stripped binary (bump-only build) / ~25 KB stripped (with optional naive mark-sweep GC + meta-GC arena under the ``GC_NAIVE`` assemble flag), zero external dependencies, **95+ builtins including a full TCP stack, `eval`, `read-from-string`, and native hash-table / hash-set primitives**, binary heap-dump portal
|
||||
|
||||
All three share one interchange format: **Scheme source itself**. An S-expression portal (``(define x 42)``) written by any implementation loads in any other — a 3×3 producer×consumer matrix, 9/9 cells green. The language *is* the wire protocol. This is not a property we added; it is what a parser has always made possible. We report it because most systems forget.
|
||||
|
||||
|
|
@ -351,6 +351,8 @@ Folding only applies when all operands are compile-time constants & the function
|
|||
- §6.1-§6.3 Python internal (tree-walker vs bytecode): ``make bench``
|
||||
- §6.4 Three impls head-to-head: ``make bench-3way``
|
||||
- §6.5 asm native hash-set vs portable Scheme hash-set: ``make bench-hashset``
|
||||
- §6.6 asm naive GC (bump vs mark-sweep memory): ``make bench-gc``
|
||||
- §6.6 asm meta-GC (arena fast path vs naive sweep): ``make bench-gc-arena``
|
||||
- §7.2 Cross-impl portal matrix: ``make bench-portal-cross``
|
||||
- §7.5 Portal save+load timings: ``make bench-portal``
|
||||
- §11.3 HTTP server vs busybox / python http.server: ``make bench-web``
|
||||
|
|
@ -469,6 +471,80 @@ The layout reuses vector tag 7 with a negative sentinel at offset 0 (hash-table:
|
|||
|
||||
**Reproduce:** ``make bench-hashset`` (source: ``tests/bench-hashset.sh``). Asm-only: C and Python tiers supply ``hash-table`` but not ``hash-set`` — the speedup shown here is intra-asm, native primitive vs Scheme-level implementation, not cross-tier.
|
||||
|
||||
6.6 Memory Management and the Meta-GC
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The asm tier's default allocator is a bump pointer: every allocation advances ``%r15`` monotonically, and nothing is ever freed. Python and C tiers have garbage collection (Python's host GC, Boehm ``GC_MALLOC`` in C); asm doesn't, by design, to keep the 6,645-line auditable surface small. Short programs finish without paying. Long-running asm servers leak until the kernel caps them — a real hazard that crashed this laptop twice during HTTP benchmarks before we wrote the six-layer safety envelope into ``CLAUDE.md``.
|
||||
|
||||
To measure what a collector would actually cost, we built a second asm binary behind an assemble-time flag:
|
||||
|
||||
.. code-block::
|
||||
|
||||
as --64 --defsym GC_NAIVE=1 uncommonlisp.s # collecting build
|
||||
as --64 uncommonlisp.s # default, bump-only
|
||||
|
||||
Same source, same tests (137/137 pass on both). The GC build adds an 8-byte ``(size << 1 | mark)`` header on every heap block, a stop-the-world mark-sweep triggered by bump overflow, and a free-list allocator that first-fits reclaimed space.
|
||||
|
||||
**Control-group numbers** (workload: 2,000 iterations of build-sum-discard over 200-element lists, i5-8350U, 512 MB ``ulimit -v``):
|
||||
|
||||
.. table::
|
||||
:widths: 34 18 18 30
|
||||
|
||||
================================== ========== ========== ==============
|
||||
asm build Wall time Peak RSS GC firings
|
||||
================================== ========== ========== ==============
|
||||
bump only (no GC) 1,097 ms 133.9 MB none
|
||||
naive mark-sweep (``GC_NAIVE=1``) 1,431 ms 1.1 MB ~200
|
||||
================================== ========== ========== ==============
|
||||
|
||||
**124× less memory at a ~30% throughput cost.** That is the honest GC tax at the naive end of the spectrum — the number we had been guessing at before building the control group. Every future memory-management proposal (generational, incremental, region-based) now has a concrete floor to beat.
|
||||
|
||||
6.6.1 Meta-GC: Arena Fast Path with Mark-Phase Verifier
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
On top of the naive sweep, the GC build exposes a Lumbda primitive ``(with-arena thunk)`` that takes a zero-argument procedure, runs it, and attempts an **O(1) bulk reclaim** of every allocation the thunk made. The decision logic is a three-way policy:
|
||||
|
||||
1. **If an implicit GC fired inside the thunk** (heap overflow forced a full sweep mid-execution), the snapshot is stale — skip the reset attempt and return.
|
||||
2. **Otherwise, run the existing mark phase** with the thunk's return value added as an extra root, then walk the arena range ``[snapshot_r15, %r15)`` block by block. If *no* header in that range has the mark bit set, nothing survives — bulk-reset ``%r15`` to the snapshot (one store) and return.
|
||||
3. **If any block in the arena range is marked**, the thunk's return value or some other root reaches into the arena. Abort the reset, fall through to the naive sweep on the full heap, and return.
|
||||
|
||||
Free-list reuse is disabled while an arena is active so the chain stays pristine for a verbatim restore; ``heap_alloc`` enforces this via a global ``arena_active`` flag. One critical correctness detail: after ``apply_proc_raw`` returns from the thunk, volatile registers contain stale tagged pointers into the arena. The conservative stack scan would otherwise treat those residuals as live roots and trigger a false escape on every call. Zero-ing ``%rax, %rcx, %rdx, %rsi, %rdi, %rbp, %r8-%r12`` before the verifier runs removes this hazard.
|
||||
|
||||
**Meta-GC benchmark** (same workload, same binary, both phases in one process):
|
||||
|
||||
.. table::
|
||||
:widths: 36 14 18 14 20
|
||||
|
||||
============================== ========= ========== ========== =================
|
||||
Strategy Wall time Peak RSS Full GCs Arena outcomes
|
||||
============================== ========= ========== ========== =================
|
||||
Phase A: naive sweep only 932 ms 1.2 MB 200 (unused)
|
||||
Phase B: arena-wrapped body 945 ms 1.2 MB 1 **2,000 resets /
|
||||
0 escapes**
|
||||
============================== ========= ========== ========== =================
|
||||
|
||||
Phase B reclaims **205 MB** via O(1) bulk resets — the work that would otherwise drive the mark-sweep path. Only one full GC fires (initial warm-up); the other 199 pressure events are replaced by arena exits. Wall time is within 1.4% of naive-only on this workload because the arena verifier's mark cost is comparable to the sweep cost it replaces. The advantage is not raw throughput but **bounded per-iteration latency** (no pressure-driven jitter) and the observability to prove which path actually ran.
|
||||
|
||||
**Escape detection**. When the thunk returns a heap-allocated value that the outer scope captures:
|
||||
|
||||
.. code-block:: scheme
|
||||
|
||||
(set! escaped (with-arena (lambda () (build-list 50 '()))))
|
||||
|
||||
…the verifier finds the returned pair's chain marked inside the arena range, correctly aborts, and the naive sweep keeps the data live. Tested at 20 consecutive escapes: 20 escapes reported, 0 resets, ``(length escaped)`` = 50 after completion. The fall-through path is correct under load.
|
||||
|
||||
6.6.2 What the Control Group Tells Us
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The meta-GC makes three memory strategies co-resident in one binary, each with its own stats counter:
|
||||
|
||||
- ``gc-stats`` → ``(collections . live-bytes)`` — pressure-driven sweeps
|
||||
- ``arena-stats`` → ``(calls resets escapes bytes-reclaimed)`` — arena outcomes
|
||||
|
||||
On an arena-friendly workload (transient allocation, scoped lifetime) the arena hit rate is 100% and the full-sweep rate falls to 1. On an arena-hostile workload (every iteration escapes) the arena-stats show 100% escapes and the naive sweep carries the load, with correctness preserved. The *policy* — decide which to run at each exit — is ~370 lines of GNU assembler; the *mechanism* it decides between (bump allocator, mark-sweep, bulk reset) adds another ~300 lines. Together they form a concrete floor for any future memory-management proposal: if it costs more than 30% throughput, or buys less than 124× memory reduction, or hits less than 100% on arena-friendly code, the naive baseline is already better.
|
||||
|
||||
**Reproduce:** ``make bench-gc`` (bump vs naive sweep) and ``make bench-gc-arena`` (naive sweep vs arena fast path). Sources: ``tests/bench-gc-memory.sh``, ``tests/bench-gc-arena.sh``.
|
||||
|
||||
7. Portal: Feedback Across Time
|
||||
----------------------------------------
|
||||
|
||||
|
|
@ -835,14 +911,14 @@ Lumbda implements R7RS Scheme in three implementations sharing the same ``.lsp``
|
|||
CPython (reference) — 1.3 ms 0.006 ms 5.5 ms — Python 3
|
||||
C interpreter ~9k 20 ms 0.06 ms 109 ms 205 KB libc
|
||||
Python bytecode VM 3,678 149 ms 0.75 ms 437 ms — Python 3
|
||||
x86_64 assembly (†) 4,968 8 ms 0.6 ms 43 ms 22 KB none
|
||||
x86_64 assembly (†) 6,645 8 ms 0.6 ms 43 ms 22 KB none
|
||||
===================== ====== ========== ========= =========== ========= ==============
|
||||
|
||||
All C & Python benchmarks measured in-process (no startup overhead). Assembly times (†) include full process lifetime: startup + tokenizer + parser + eval. "—" = not applicable / interpreted.
|
||||
|
||||
**The JIT runs Scheme faster than CPython runs Python.** ``ack(3,4)`` completes in 0.19 ms (JIT) vs 1.3 ms (CPython) — 7× faster. ``sum-to(50000)`` completes in 0.55 ms (JIT) vs 5.5 ms (CPython) — 10× faster. The JIT compiles Scheme AST directly to x86_64 machine code via ``mmap(PROT_EXEC)`` & raw byte emission. It handles ``if``, ``cond``, ``and``, ``or``, ``let``, named-let loops (native ``jmp`` — zero call overhead), ``car``/``cdr``/``cons``, arithmetic, comparisons, & self-recursive calls. Functions that use ``call/cc``, macros, or complex forms fall back to the interpreter.
|
||||
|
||||
**The assembly implementation proves the language runs on bare metal.** 4,968 lines of **GNU assembler (GAS, AT&T syntax)**, **22 KB stripped binary**, zero external dependencies. Fourteen Linux syscalls (``read``, ``write``, ``open``, ``close``, ``lseek``, ``mmap``, ``munmap``, ``socket``, ``connect``, ``accept``, ``bind``, ``listen``, ``clock_gettime``, ``exit``) — no libc, no stdlib. A bump allocator with ``heap-snapshot``/``heap-restore`` arena primitives, tag-in-low-3-bits values, **91 builtins** (including ``load``, ports, ``write-file``, ``file->string``, ``portal-save``, ``portal-resume``, the six ``tcp-*`` socket primitives, ``read-from-string``, ``eval``, ``symbol->string``, ``current-time-ms``), & TCO via ``jmp``. It runs ``(ack 3 4) = 125`` & ``(fib 35) = 9227465`` correctly, serves HTTP at **2,994 req/s**, and survives indefinitely with flat O(1) memory when the programmer uses the snapshot/restore arena in a per-request loop.
|
||||
**The assembly implementation proves the language runs on bare metal.** 6,645 lines of **GNU assembler (GAS, AT&T syntax)**, assembled with ``as`` and linked with ``ld`` from GNU binutils; ~22 KB stripped bump-only binary, ~25 KB stripped under the optional ``GC_NAIVE`` assemble flag. Zero external dependencies. Fourteen Linux syscalls (``read``, ``write``, ``open``, ``close``, ``lseek``, ``mmap``, ``munmap``, ``socket``, ``connect``, ``accept``, ``bind``, ``listen``, ``clock_gettime``, ``exit``) — no libc, no stdlib. A bump allocator with ``heap-snapshot``/``heap-restore`` arena primitives, tag-in-low-3-bits values, **95+ builtins** (including ``load``, ports, ``write-file``, ``file->string``, ``portal-save``, ``portal-resume``, the six ``tcp-*`` socket primitives, ``read-from-string``, ``eval``, ``symbol->string``, ``current-time-ms``, native ``hash-table-*`` and ``hash-set-*``, plus ``with-arena`` / ``gc-collect`` / ``gc-stats`` / ``arena-stats`` in the GC build), & TCO via ``jmp``. It runs ``(ack 3 4) = 125`` & ``(fib 35) = 9227465`` correctly, serves HTTP at **2,994 req/s**, and survives indefinitely with flat O(1) memory via either the snapshot/restore arena in a per-request loop *or* ``(with-arena thunk)`` under the collecting build (see §6.6).
|
||||
|
||||
**The bytecode VM delivers 7--19× speedup over tree-walking.** The Python implementation compiles Scheme to 40 opcodes (plus 20 specialized & 5 superinstructions), executed on an explicit frame stack with inline caching, constant folding, & peephole optimization. Full first-class continuations (multi-shot, upward) enable generators, coroutines, & machine state migration via portal.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue