whitepaper §6.6.3: collaborative adaptive meta-GC results

Adds §6.6.3 "Collaborative Meta-GC: From Greedy to Adaptive" with
the three-workload benchmark (friendly / hostile / mixed × greedy
/ adaptive). Honest read of the numbers:

  friendly  greedy    732 ms  1000 resets, 0 escapes
  friendly  adaptive  691 ms  1000 resets, 0 escapes       (-6%)
  hostile   greedy    568 ms     0 resets, 1000 escapes
  hostile   adaptive  607 ms     0 resets, 1000 escapes, 11 skipped (+7%)
  mixed     greedy   1981 ms    17 resets, 1983 escapes
  mixed     adaptive 1694 ms    14 resets, 1986 escapes, 2 skipped  (-17%)

Adaptive wins on friendly (-6%) and mixed (-17%, the policy's
design target). On fully hostile workloads implicit GC fires 982
of 1000 arenas before the dispatcher sees them, so the signal is
drowned and greedy happens to edge adaptive by ~7%. Section
explicitly calls out the collaborative-but-local structure
(shared state on arena_active + EMA + countdown, decisions made
locally by each component) and credits the benchmark work with
surfacing two real correctness bugs in the conservative stack
scan — 24-byte strings misread as env nodes, 40-byte strings
misread as 25-element vectors — both now fixed.

Also:
  - meta-gc-policy.dot rewritten to show the adaptive gate
    (rate > 50% + probe countdown) before the greedy verify path;
    new skip branch, new EMA annotations on edges.
  - §6 reproducibility list + Makefile bench-gc-adaptive target.
  - PDF rebuilt at 2.64 MB.

137 asm no-GC + 137 asm GC + 189 shared functional tests pass
against the new asm.
This commit is contained in:
russell@unturf.com 2026-04-18 11:35:27 -04:00
parent 3d55092037
commit 3f1b1bc0ab
6 changed files with 2109 additions and 1308 deletions

View file

@ -132,6 +132,9 @@ bench-gc: asm-build
bench-gc-arena: asm-build
@bash tests/bench-gc-arena.sh
bench-gc-adaptive: asm-build
@bash tests/bench-gc-adaptive.sh
bench-all: bench c-bench bench-3way bench-portal bench-portal-cross bench-web bench-rpc-chain bench-proof
@echo "═══════════════════════════════════════════════════════════"
@echo "All benchmarks complete. Numbers in the whitepaper §6.4,"

View file

@ -1,33 +1,44 @@
// Meta-GC decision policy — 2026-04-18
// Meta-GC decision policy — 2026-04-18 (adaptive)
// "A diagram is worth 10,000 words." — russell@unturf.com
digraph meta_gc {
rankdir=LR
node [shape=box, style="filled,rounded", fontname="Helvetica", fontsize=10]
edge [fontname="Helvetica", fontsize=9]
label="Meta-GC decision at (with-arena thunk) exit"
label="Meta-GC decision at (with-arena thunk) exit — adaptive + greedy layers"
labelloc=t
fontsize=13
fontname="Helvetica Bold"
enter [label="Arena enter\nsnapshot %r15\nactive ← 1\nfreelist OFF" fillcolor="#fff3cd"]
thunk [label="Run thunk\n(apply_proc_raw)\n+ zero volatile regs" fillcolor="#d4edda"]
thunk [label="Run thunk\n(apply_proc_raw)\nzero volatile regs" fillcolor="#d4edda"]
gate1 [label="arena_active\nstill 1?" shape=diamond fillcolor="#fab1a0"]
verify [label="Mark phase\n(globals, syms,\nstack, + result)" fillcolor="#81ecec"]
abort [label="SKIP reset\n(implicit GC ran)\nEMA += escape" fillcolor="#fab1a0"]
gate_rate [label="adaptive mode\n& rate > 50%?" shape=diamond fillcolor="#ffd6a5"]
gate_probe [label="probe\ncountdown > 0?" shape=diamond fillcolor="#ffd6a5"]
skip [label="SKIP verify\nlet heap grow\nnext overflow → full GC\nEMA += escape" fillcolor="#fd9644"]
verify [label="Verify\nmark phase\n(globals, syms,\nstack, + result)" fillcolor="#81ecec"]
gate2 [label="Mark in\n[snap, r15)?" shape=diamond fillcolor="#81ecec"]
reset [label="BULK RESET O(1)\n%r15 ← snap\nresets++" fillcolor="#00b894" fontcolor=white]
escape [label="SWEEP FALLBACK\ngc_sweep full\nescapes++" fillcolor="#e17055" fontcolor=white]
abort [label="SKIP reset\n(implicit GC ran)\nescapes++" fillcolor="#fab1a0"]
reset [label="BULK RESET O(1)\n%r15 ← snap\nresets++\nEMA += 0" fillcolor="#00b894" fontcolor=white]
escape [label="SWEEP FALLBACK\ngc_sweep full\nescapes++\nEMA += 1" fillcolor="#e17055" fontcolor=white]
ret [label="Return result\n(arena-stats)" fillcolor="#dfe6e9"]
enter -> thunk -> gate1
gate1 -> verify [label="yes"]
gate1 -> gate_rate [label="yes"]
gate1 -> abort [label="no"]
gate_rate -> verify [label="no (rate low\nor greedy)"]
gate_rate -> gate_probe [label="yes"]
gate_probe -> skip [label="yes\n(-- countdown)"]
gate_probe -> verify [label="no (probe,\nreset countdown)"]
verify -> gate2
gate2 -> reset [label="no"]
gate2 -> escape [label="yes"]
reset -> ret
escape -> ret
skip -> ret
abort -> ret
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Before After
Before After

File diff suppressed because one or more lines are too long

View file

@ -353,6 +353,7 @@ Folding only applies when all operands are compile-time constants & the function
- §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``
- §6.6.3 asm adaptive meta-GC (greedy vs EMA-driven across 3 workloads): ``make bench-gc-adaptive``
- §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``
@ -557,6 +558,67 @@ On an arena-friendly workload (transient allocation, scoped lifetime) the arena
**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``.
6.6.3 Collaborative Meta-GC: From Greedy to Adaptive
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The policy described in §6.6.1 is **greedy** — at every ``(with-arena)`` exit, unconditionally run the verifier, then decide reset-vs-sweep based on what it found. That works, but it pays the mark-phase cost even for arenas that are almost certainly going to escape. On a workload where most arenas escape, the verifier's work is wasted: the answer was going to be "escape" regardless.
The next layer replaces that with an **adaptive** policy driven by a scaled exponential moving average of recent escape rate::
rate = (rate * 7 + sample * 256) / 8 # sample = 0 (reset) | 1 (escape)
When ``rate > 128`` (50% escape rate) the dispatcher **skips the verifier entirely** and lets the arena's allocations survive in place. The heap keeps growing until a natural bump-overflow triggers ``gc_collect``, which reclaims anything truly dead with a proper mark pass. Every 16 skipped arenas, a probe forces one verify so the policy can re-evaluate and recover if the workload shifts back to arena-friendly.
Four co-operating pieces — allocator, mark phase, arena verifier, and the policy dispatcher — share exactly one piece of state (the EMA plus a probe countdown) and make local decisions:
.. code-block::
allocator: reads arena_active to skip free-list reuse
gc_collect: clears arena_active on implicit trigger (snapshot stale)
verifier: returns 0 (reset) / 1 (escape) for EMA update
dispatcher: reads rate+countdown, decides verify|skip|probe
No component queries another's internals. This is "collaborative" in the structural sense — shared state, no direct coupling — while each decision remains local.
**Control toggles:**
.. code-block:: scheme
(arena-set-mode 0) ; greedy baseline (always verify)
(arena-set-mode 1) ; adaptive (default)
**Stats** now include skipped-verifier count and the current EMA, for observability::
(arena-stats) -> (calls resets escapes skipped bytes-reclaimed rate)
**Three-workload benchmark** (``make bench-gc-adaptive``, i5-8350U, ``N=1000`` iterations per phase, each phase in a fresh asm-gc process to isolate state):
.. table::
:widths: 20 16 12 12 12 14 14
========== ========== ========== ======== ========= ========== ===========
Workload Mode Time (ms) Resets Escapes Skipped Full GCs
========== ========== ========== ======== ========= ========== ===========
friendly greedy 732 1000 0 0 0
friendly adaptive 691 1000 0 0 0
hostile greedy 568 0 1000 0 982
hostile adaptive 607 0 1000 11 982
mixed greedy 1981 17 1983 0 1966
mixed adaptive 1694 14 1986 2 1970
========== ========== ========== ======== ========= ========== ===========
Reading the numbers:
- **Friendly** workload: adaptive is ~6% faster. EMA stays at 0 (every arena resets), so the skip path never fires — the small win is measurement noise plus a slightly shorter decision path for the common case.
- **Hostile** workload: greedy happens to edge adaptive by ~7%. Both modes trigger implicit ``gc_collect`` 982 times out of 1000 arenas — heap overflow during the thunk clears ``arena_active`` before the dispatcher sees it, so the adaptive skip path only activated 11 times. The policy's signal is drowned out by pressure-driven GC.
- **Mixed** workload: adaptive is ~**17% faster** (1694 ms vs 1981 ms). This is the shape the policy was designed for — escape rate is moderate, arena entries interleave friendly and hostile phases, and the probe lets the EMA recover when the workload shifts. The number of resets stays comparable (14 vs 17), but the verifier runs less often on the doomed-to-escape arenas, and the overall mark-phase budget goes down.
**Honest read.** Adaptive is a clear win when the policy's signal is visible (mixed, friendly) and a wash when it isn't (hostile, where implicit GC dominates). The 137-test asm-gc suite passes identically under both modes, so choosing adaptive costs nothing on workloads where it doesn't help. The benchmark also surfaced two real bugs in the conservative stack scan that the greedy policy had been hiding — a 24-byte string misread as an env node, and a 40-byte string misread as a 25-element vector. Both are fixed (offset-0 tag check for env; length-fits-block check for vector/hash walkers), and the four strategies (arena reset / arena skip / full sweep / sweep fallback) now co-exist safely.
**Reproduce:** ``make bench-gc-adaptive`` (source: ``tests/bench-gc-adaptive.sh``, ``examples/bench-gc-adaptive.lsp``). Each (workload × mode) pair runs in a fresh asm-gc process so results don't contaminate each other across phases.
7. Portal: Feedback Across Time
----------------------------------------