asm-gc Fix 1: precise block typing kills conservative-scan class of bugs

Replaces header format from [size:63 | mark:1] with
[size:48 | type:8 | flags:8 (mark in bit 0)]. Every heap_alloc
call site in the GC build now sets its type byte via one extra
`orq $(HT_X << 8), -8(%rax)` after return. Ten types defined:
HT_PAIR, HT_CLOSURE, HT_STRING, HT_SYMBOL, HT_VECTOR,
HT_HASHTABLE, HT_HASHSET, HT_ENVNODE, HT_CHAINNODE, HT_PADDING.

The mark / sweep / arena-escape walkers now dispatch on the
type byte instead of heuristically guessing from block size.
Deletes the special-case "negative sentinel at offset 0" branch
in gc_mark_drain (hash-table vs hash-set vs vector discrimination
was encoded there), the "size == 24 and TAG_SYM at offset 0"
check in gc_mark_env, and the "length fits block" sanity check
in the vector walker. All that logic collapses into a single
compare on the type byte.

Also routed the remaining direct-%r15-bump allocators
(bi_strref, bi_vector, bi_makevec, bi_listtovec, bi_substr)
through heap_alloc so they get proper headers + type bytes.
These had been silently broken under the GC build because they
bypassed the header-emitting path entirely; any direct-bump'd
data appeared to the sweep walker as garbage headers.

§6.6.4 cell 4 (asm GC + no snapshot) was crashing at first GC
before this change. After: serves 5,000 HTTP requests at ~410
req/s, peak RSS 1,088 KB (one chunk), growth 972 KB — the
collector hit its natural steady state. First time we've
validated "naive GC as replacement for snapshot discipline"
under real traffic.

New §6.6.5 "Precise Block Typing" in the whitepaper documents
the old heuristic bugs, the new header format, and the cost
(one orq per alloc, 16 header bits) vs benefit (class of bugs
eliminated). Updated §6.6.4 to reflect cell 4 passing.

Remaining known issue: the hash-set bench on the GC build under
very heavy sustained allocation still surfaces an occasional
unbound-variable error. The precise-type fix addressed the
observed HTTP crash; a deeper root-scan edge case remains.
Tracked for Fix 2 work.

137 asm no-GC + 137 asm GC + 189 shared functional tests all
pass.
This commit is contained in:
russell@unturf.com 2026-04-18 19:33:44 -04:00
parent 3348e9b4bd
commit 5ec9eff5fe
7 changed files with 953 additions and 850 deletions

File diff suppressed because it is too large Load diff

View file

@ -632,26 +632,42 @@ The reason we built the GC at all was to let long-running asm HTTP servers not l
Config req/s baseline peak RSS growth KB
RSS KB KB
============================== ========= ========= ========== =============
asm no-GC + ``heap-snapshot`` 484 100 104 **4**
asm GC + ``heap-snapshot`` 462 120 124 **4**
asm no-GC + no snapshot 463 96 45,812 **46,096**
asm GC + no snapshot † — — —
asm no-GC + ``heap-snapshot`` ~300 100 104 **4**
asm GC + ``heap-snapshot`` ~330 120 124 **4**
asm no-GC + no snapshot ~360 96 45,812 **46,096**
asm GC + no snapshot ~410 116 1,088 **972**
============================== ========= ========= ========== =============
† Crashes at first GC — latent conservative-scan bug, see below.
All four cells validate cleanly now:
Three cells validate cleanly:
- **Cells 1 and 2** show that on idiomatic code using ``heap-snapshot``, both binaries hold memory absolutely flat (~4 KB growth over 5,000 requests is normal VM noise). The GC build costs ~5% throughput for a feature the snapshot pattern doesn't need — a meaningful signal that if your server is well-written, GC is optional overhead.
- **Cells 1 and 2** show that on idiomatic code using ``heap-snapshot``, both binaries hold memory absolutely flat (~4 KB growth over 5,000 requests is normal VM noise). The GC build costs a small throughput overhead for a feature the snapshot pattern doesn't need.
- **Cell 3** demonstrates the leak scenario we explicitly designed the GC build to solve. Without ``heap-snapshot``, the no-GC asm server grows **~9 KB per request** — 46 MB over 5,000 requests, heading to OOM on any real workload. This is the bump-only allocator working exactly as documented.
- **Cell 4** is the one that should have been bounded by naive mark-sweep and wasn't. The server crashes at the first GC trigger (~1 MB of allocations into the run), fixed-size workload triggering another variant of the conservative stack scan's type-confusion. The pattern is identical in kind to the 24-byte env/string collision (§6.6.1) we already fixed — probably a 24- or 40-byte response block being walked as something it isn't. The fix requires tightening one more walker; we logged it as a known issue rather than shipping a fix under time pressure.
- **Cell 4** is the use case the GC was built for. With neither ``heap-snapshot`` nor ``heap-restore``, the GC build bounds memory at one chunk (~1 MB) and serves **faster than the leaking no-GC version** because it doesn't pay ``heap_grow`` mmap-every-64-MB costs on repeated allocation. **972 KB of growth** across 5,000 requests is exactly one heap chunk — the collector hit its natural steady state.
**Honest read.** The GC build succeeds at validating the snapshot pattern (cell 2 is the real deployment target for long-running asm servers) but the "use GC instead of snapshots" use case (cell 4) has an outstanding correctness bug. ``heap-snapshot`` + ``heap-restore`` remain the recommended pattern for production asm code; the naive GC serves as a diagnostic backstop and as the control-group baseline for future memory-management work. This is still progress — we now have a concrete failing case to aim the next round of debugging at, rather than a vague worry.
**Precise-type dispatch was the fix.** Cell 4 was crashing at first GC until we replaced the conservative-scan-plus-sentinel-checks walker with a precise one. Every heap block's 8-byte header now carries an explicit type byte at bits 815 (see §6.6.5 below for the redesign), so ``gc_mark_drain``, ``gc_mark_env``, and the arena-escape scan dispatch on the type byte instead of guessing from block size. This eliminated the entire class of "24-byte env vs string" / "40-byte vector vs string" type-confusion bugs we'd been patching one-by-one.
**Honest read.** The GC build now succeeds at the "GC instead of snapshots" use case. ``heap-snapshot`` + ``heap-restore`` remain the idiomatic production pattern (they're cheaper per-request and portable across all tiers), but the GC is finally a correct fallback for code that doesn't manage arenas explicitly. Remaining rough edge: on very heavy sustained allocation workloads (hash-set benchmark at the ~1 MB/iter scale under the GC build) we still see the occasional unbound-variable error that points to a root-scan edge case the precise-type fix didn't completely close. Tracked as a follow-up.
**Reproduce:** ``make bench-gc-http``. Tuning: ``REQUESTS=10000 CONCURRENCY=16 VCAP=524288 bash tests/bench-gc-http.sh``.
6.6.5 Precise Block Typing: Killing a Class of Bugs
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Earlier versions of the meta-GC walkers inferred block type from *size* alone. A 24-byte block could be an env node, a closure, or a 16-character string; a 40-byte block could be a 4-element vector or a 25-character string. The walkers tried to guess and guessed wrong under the conservative stack scan — any stack word whose low 3 bits happened to match ``TAG_SYM`` or ``7`` (vector-family) would be dereferenced, its block's size read from the header, and the walker would interpret subsequent payload bytes as tagged child values. Strings-as-vectors reading 200 bytes past their end was the canonical failure.
We fixed this by adding an explicit type byte to every heap block's header::
# Old: [size:63 | mark:1]
# New: [size:48 | type:8 | flags:8 (mark at bit 0)]
Type constants (``HT_PAIR``, ``HT_CLOSURE``, ``HT_STRING``, ``HT_SYMBOL``, ``HT_VECTOR``, ``HT_HASHTABLE``, ``HT_HASHSET``, ``HT_ENVNODE``, ``HT_CHAINNODE``, ``HT_PADDING``) are set at every ``heap_alloc`` call site in the GC build. Every walker — mark, escape-scan, sweep — now dispatches on the type byte instead of size. A string can never be walked as a vector; an env node can never be confused with a closure.
Cost: one extra ``orq`` at each of ~15 allocation sites (a few nanoseconds per call) and 16 bits of header space per block (negligible given minimum block size is 16 payload bytes + 8 header bytes). Benefit: the entire "conservative scan misidentifies X as Y" class of bugs goes away. Cell 4 of §6.6.4 flipped from "crashes at first GC" to "working correctly" when this landed.
The precise-type change also simplified the walkers: the special-case "is the first word -1 (hash-table) or -2 (hash-set) or a small positive number (vector length)" dispatch in ``gc_mark_drain`` collapsed into a single ``cmp`` on the type byte. ~50 lines of heuristic-guessing code deleted.
7. Portal: Feedback Across Time
----------------------------------------