Repository navigation
Memory oracle has blind spots: freelists, arena, NaN-boxed slots and JIT code are invisible to ASan #1665
Description
Activity
- addedarea:memorySubsystem: memorySubsystem: memorykind:gate-defectA gate, check or claim that measures less than it saysA gate, check or claim that measures less than it says
on Oct 7, 2026 InauguralPhysicist commented
on Oct 7, 2026 CollaboratorAuthorMore actionsHistory from the owner (2026-10-07): the hybrid allocator was not a deliberate design choice. An older model implemented it to stop iLambdaAi from freezing the dev box (memory growth). Per our rule that inherited design is a hypothesis, the question comes first: should the pools exist at all?
New first checklist item (it comes before the poisoning work):
- With the pool-off build from this issue, measure pooled against pool-off on iLambdaAi's real workload (peak RSS: the freeze was a peak-memory cliff), DMG, the suite and the CodSpeed targets. Report peak RSS, wall time and instruction counts, as paired runs.
- Check whether pool-off freezes iLambdaAi on today's runtime at all. If it does not, the original cause is gone or was misattributed (unbounded growth or retention, with pooling only shrinking it).
- Decide on the numbers:
- if pooling pays little, use plain
malloc/free, the standard choice, and most blind spots disappear by construction; - if it pays a lot, keep it fully poisoned and checked (the modes below).
- if pooling pays little, use plain
InauguralPhysicist commented
on Oct 7, 2026 CollaboratorAuthorMore actionsArchaeology results (original history at
InauguralPhysicist/EigenScript, iLambdaAi, and the surviving Python prototype v0.4.1). This corrects the framing above: only the arena was the iLambdaAi patch.piece introduced stated reason isolated measurement? verdict bump arena iLambdaAi@4d465c2 (2026-03-25) training OOM on the 3.8 GB laptop: the first C runtime (Replit Agent, iLambdaAi@a5b62a3) freed nothing during eval "40GB reclaimed, memory flat" (against no reclamation at all) redundant since refcounting (aafcd0e, 2026-04-16); still produces escape bugs (#873, #1184, #1588). Remove it, or move it to a package. Value freelist fca1b6f (2026-04-28) DMG growing 45 MB/s none isolated: the same commit fixed real refcount bugs, which is what bounded the growth pool-off A/B env freelist c478bdd (2026-05-14) no rationale (empty commit body) none pool-off A/B call-env recycling 8946bae (2026-06-10) DMG per-call cost microbench -17 to -26%; DMG itself ~1.7% the code path behind #1661. Drop it if <2% on DMG, EMS and Tidepool; otherwise poison it. The standard baseline (refcounting plus a cycle collector over plain malloc) is what the original Python prototype had, and what we have today underneath the pools. One
EIGS_POOL_OFFbuild settles all four rows. It is the first checklist item above.InauguralPhysicist commented
on Oct 7, 2026 CollaboratorAuthorMore actionsTape impact (owner, 2026-10-07): the deterministic trace tape came much later in the project, but it is now a core product (replay contract, profiler, cross-device bridge). Pre-v1 decisions that change what a tape record holds or means are decided as a SET and land in ONE tape-format bump: #1644 (value model), #1667 (observer signal), the device/stream id, and #1665 (a stale read is also a replay-determinism bug). The tape is the most irreversible pre-v1 commitment, because old tapes must keep replaying after the freeze.
InauguralPhysicist commented
on Oct 8, 2026 CollaboratorAuthorMore actionsDesign note (owner discussion, 2026-10-07): the pools patch costs that other languages remove at the source.
The pool-off measurement (#1665, branch
pool-off-1665, results are being posted with its PR) shows the pools are a speed lever with no peak-memory benefit. Each layer patches a cost created by an earlier design choice:layer cost it patches how mature runtimes solve it Value freelist (NUM) numbers become heap Values on some paths immediate/unboxed numbers in the value word: Lua, LuaJIT, V8 small ints, SpiderMonkey/JavaScriptCore NaN-boxing. CPython's float freelist is the same patch we have. Env freelist + call-env recycling every call heap-allocates an environment locals in registers on a contiguous VM stack; only captured variables go to the heap (Lua upvalues, JS context allocation, Scheme closure conversion) bump arena (mark/reset) the first C runtime freed nothing (the iLambdaAi freeze) already fixed by refcounting (aafcd0e). Generational nurseries (Java, .NET, OCaml) are the general solution. The overall shape (refcount + cycle collector + freelists) is CPython's model, inherited, not chosen (#1670 trust boundary).
Root-cause candidates, for the value-model release (#1644):
- Immediate numbers everywhere. This removes the Value freelist.
- Stack-slot frames with upvalues for captured variables. This removes both env layers AND the Heap use-after-free: under-arity call leaves an unbound param slot that a GC cull frees (v0.45.0, minimal repro) #1661/JIT mis-resolves an in-scope loop variable under EIGS_POOL_OFF (stale env read, #1661 family, invisible to ASan) #1674 class: with no recycled env slots, there is nothing to read stale.
Keep or decide, not copy:
- Refcounting stays a deliberate candidate. Its deterministic reclamation points suit deterministic replay (principle 0) better than tracing-GC timing.
- The observer is the EigenScript-specific question. Observer state is keyed by binding identity (
Env::obs), so stack-slot frames must say where per-variable observer data lives. Measure the options; don't assume one. - The EigenLua rung (a register-VM reference) is the oracle for frame-layout cost.
The per-layer toggle measurement (four sub-flags instead of one
EIGS_POOL_OFF) is still useful to price each patch. The decision this feeds is whether to remove the causes, not whether to tune the patches.InauguralPhysicist commented
on Oct 8, 2026 CollaboratorAuthorMore actionsItem 1 (pool-off measurement) is up as #1675, with the measurement table and oracle findings in its body. Headline: the pools buy speed only (DMG dispatch is +120% Ir with pools off, with no change in peak RSS). The iLambdaAi arena workload does not OOM without the pools. Pool-off is a negative control for the #1661 class, not a detector, so items 2 and 4 are the real oracle. New bug found: #1674.
InauguralPhysicist commented
on Oct 8, 2026 CollaboratorAuthorMore actionsPre-registered decision rule (2026-10-07). Owner: "I don't have a decision, that should be a measurement that decides." This rule is fixed BEFORE the numbers exist. Whatever the matrix says is the outcome.
Arms
arm build A pooled (today) B all pools off ( EIGS_POOL_OFF, #1675)C1–C4 ONE layer off at a time: Value freelist / Env freelist / call-env recycling / arena D root-cause prototype: immediate NUMs + stack-slot locals for non-captured variables (upvalues for captured), with pools off. Must pass the full suite and observer_corpus unchanged. Metrics
- cachegrind Ir on the 10 workloads in Add EIGS_POOL_OFF build and measure the pools in isolation (#1665 item 1) #1675.
- DMG
cpu_instrswall and peak RSS. - iLambdaAi
token_trainwall and peak RSS. - Stale-read class: does the Heap use-after-free: under-arity call leaves an unbound param slot that a GC cull frees (v0.45.0, minimal repro) #1661 revert / JIT mis-resolves an in-scope loop variable under EIGS_POOL_OFF (stale env read, #1661 family, invisible to ASan) #1674 repro still exist in the arm (caught by a detector, masked, or impossible by construction)?
Rules
- A layer whose solo removal (C) costs < 2% Ir on every workload is removed.* Simpler and standard wins ties.
- D replaces the env layers if: D's DMG Ir is within 5% of A, D is no worse than B on any workload, and the Heap use-after-free: under-arity call leaves an unbound param slot that a GC cull frees (v0.45.0, minimal repro) #1661/JIT mis-resolves an in-scope loop variable under EIGS_POOL_OFF (stale env read, #1661 family, invisible to ASan) #1674 class is impossible by construction in D.
- Otherwise the surviving layers stay, and Memory oracle has blind spots: freelists, arena, NaN-boxed slots and JIT code are invisible to ASan #1665 items 2 and 4 (parked-slot poisoning,
EIGS_JIT_CHECKED) are mandatory before v1. Required regardless for whatever pooling survives. - Observer: D must say where per-variable observer state lives. Its observer overhead (
observed_loopvsunobserved_loopdelta) may not exceed A's by more than 2%.
Order
C1–C4 first (cheap: four sub-flags under the #1675 flag). D second (a prototype branch, not a merge candidate until the rule says so).
InauguralPhysicist commented
on Oct 8, 2026 CollaboratorAuthorMore actionsArms C1–C4 results (branch
pool-layers-1665, commit 4d05654). Four sub-flags. The default build andmake pool-offare byte-identical to before (29/29.ofiles, checked withcmp).layer max solo ΔIr over the 10 workloads DMG wall (median of 3) own-workload check rule 1 C1 Value NUM freelist +12.41% (alloc_heavy) +1.4% n/a KEEP C2 Env freelist +1.10% noise n/a REMOVE, blocked on #1674 (see below) C3 call-env recycling +13.79% (bench_dmg_shape) +13.5% n/a KEEP C4 bump arena +0.00% noise iLambdaAi token_train: +57.5% wall, +1.9 MB RSS, no OOMKEEP (see below) How rule 1 was applied to the arena. None of the 10 Ir workloads touches the arena, so its +0.00% means it was never exercised there, not that it costs nothing. The rule's metric list includes
token_train, which is the arena's only consumer. On that workload removing the arena costs +57.5% wall, far above the 2% threshold. Verdict: KEEP. Its original purpose (preventing the iLambdaAi freeze) is gone, and today it is purely a speed lever. To close the gap in the procedure, every future arm must be judged on at least one workload that exercises its layer. A short cachegrind run oftoken_train(A vs C4) will confirm this in Ir.Interaction. On 9 of the 10 workloads the solo deltas add up exactly to the all-off total. On
bench_dmg_shapethe effect is super-additive: the solo deltas sum to +14.2%, but all-off costs +120.4%. That synergy runs through call-env recycling, which stays, so removing C2 alone stays in the additive regime. The removal PR must still re-measure C2 together with any other change.#1674 is the env-freelist layer. It reproduces with only C2 off and nowhere else. So the env-freelist removal and the #1674 fix land together, fix first.
Next: arm D, the root-cause prototype (immediate NUMs and stack-slot locals), against the same matrix.
InauguralPhysicist commented
on Oct 8, 2026 CollaboratorAuthorMore actionsArena scope (2026-10-08).
arena_mark/arena_resetare general builtins, but their doc comment says "call before a training step". The only working consumer across the ecosystem is iLambdaAi's legacy model-freetoken_train.eigs; the current transformer trainer uses the native model builtins and no arena. The other hits (EigenScript ×5, EigenGauntlet ×1, ouroboros ×1) test the builtin itself. Its costs are borne by the whole language: the no-yield-inside-arena refusals in the task layer, the per-valuearenaflag on hot paths, and the escape-bug class (#873, #1184, #1588). As a user-visible manual memory region in a refcounted language it is a principle-0 deviation, so it is not a language feature to keep. The measurement only decides whether its speed is recovered elsewhere. Arm D must includetoken_train(A vs D, wall and Ir). If D recovers the speed (immediate NUMs remove the per-number malloc/free), the arena is removed by rule. If not, the residual is measured and explained.InauguralPhysicist commented
on Oct 8, 2026 CollaboratorAuthorMore actionsCorrection to the arena verdict (2026-10-08).
token_train.eigsis iLambdaAi's frozen reference baseline (README: "2-token trigram training (frozen reference baseline)"; still called byrun_token_loop.sh). It is not an active trainer, and a fresh clone cannot run it today (iLambdaAi#49). A frozen baseline's requirement is that its results stay the same, not its speed. The C4 arm already showed that without the arena it runs to completion, bounded (21 MB), with no OOM and no freeze. So the +57% wall is not a cost rule 1 should weigh. Arena verdict: REMOVE, on the same evidence class as C2 (no exercised workload that needs its speed), with this acceptance check:token_trainwith the arena removed reproduces the pooled run's loss trajectory and checkpoint bytes on a fixed corpus (after iLambdaAi#49 gives it a committed corpus). Arm D no longer gates this removal. Removing it also deletes the task-layer no-yield-in-arena refusals and the per-valuearenaflag from the hot paths.InauguralPhysicist commented
on Oct 8, 2026 CollaboratorAuthorMore actionsCorrection to the correction.
token_trainis not iLambdaAi's current baseline either; the README label is stale. The baseline is the 4-gram on the slot corpus (held-out 1.766), which the transformer beats. So the arena serves a script that is neither the current trainer nor the current baseline, and cannot run from a fresh clone. Arena: REMOVE, with no equivalence gate. iLambdaAi#49 now retires the script. Removal order: iLambdaAi#49 lands first (no consumer calls the builtins), then the EigenScript removal ofarena_mark/arena_reset, the arena allocator, the per-valuearenaflag and the task-layer no-yield-in-arena refusals, with their tests.InauguralPhysicist commented
on Oct 8, 2026 CollaboratorAuthorMore actionsEnv-freelist removal is unblocked. #1674 merged (#1689): the inline-cache ABA on env address reuse is fixed with a uint64 process-wide atomic version counter plus a high-water on free, so correctness no longer depends on the env freelist keeping envs at stable addresses.
[0fe]runstest_bool_fuzz.shwithEIGS_ENV_FREELIST_OFF=1on the default build, andmake pool-off-envpasses it. Next for this issue, per the rule: remove the env freelist (C2) and consolidate the runtimeEIGS_ENV_FREELIST_OFFknob with the compile-timeEIGS_POOL_OFF_ENVarm. The arena removal stays gated on iLambdaAi#49.15 remaining items
- added 14 commits that reference this issue
on Oct 11, 2026 InauguralPhysicist commented
on Oct 12, 2026 CollaboratorAuthorMore actionsArm D step 2 landed (2026-10-11). List items and dict values are
EigsSlotstorage as of #1726. Numbers are stored immediate, withnum_guardon every store.before after 1M-element numeric list 103.9 B/element 8.0 B/element 100k four-key numeric dicts 1095 B/dict 713 B/dict DMG cpu_instrsIr (real emulator)−1.2% (JIT tier) / −1.3% (VM tier) bench_perf Ir −13.5% Rule 2 (DMG within 5%) is met with margin.
Path:
- step 1 (Route container element access through src/container.h (#1665 arm D step 1) #1701);
- B0–B3 (Slot-access API, rehearsal build and migration gate (#1665 arm D B0) #1702, Core runtime onto the slot API (#1665 arm D B1) #1706, VM onto the slot API (#1665 arm D B2) #1707, builtins.c onto the slot API (#1665 arm D B3) #1712);
- B5–B9 (Tensor and model files onto the slot API (#1665 arm D B5) #1713, Buffer and host builtins onto the slot API (#1665 arm D B6) #1718, Graphics extension onto the slot API (#1665 arm D B7) #1720, HTTP, net, store and db extensions onto the slot API (#1665 arm D B8) #1721, Embedding API and lint_host onto the slot API; retire the borrow API (#1665 arm D B9) #1724);
- the flip (List items and dict values become EigsSlot storage (#1665 arm D B10, the flip) #1726).
Every batch was gated locally (release suite, full ASan with leak tally 0, rehearsal suite, Ir, tape identity, jit_diff), and three blind reviews ran along the way. They caught:
- the
get_atuse-after-free and thedispatchdead-frame return (B3); - the ragged-tensor SIGSEGV (B5);
- the raw-storage gate gap (B10);
- a sourced
set -ein a section fragment that silently killed the macOS merge-queue suite. It is fixed, andsuite_label_check.shnow rejects errexit/nounset toggles in fragments.
Remaining on this issue:
- B11: number producers that still box a value only to store it;
- the NaN-box trap tag;
- stack-slot locals (arm D phase 2).
Follow-ups filed along the way: #1709, #1710, #1711, #1719, #1722, #1723, #1725.
Owner observation (2026-10-07, after #1661): "our use after free/heap after use is hybrid. so I'm not even sure how oracled it is."
He is right. EigenScript's memory is a hybrid of refcounting, a cycle collector, a bump arena, per-thread Value/Env freelists, and NaN-boxed slots, and our memory oracles see only the system-allocator boundary. #1661 was caught by ASan only by luck: the stale pointer happened to point at a list that had really been
free()d. Had the stale slot pointed at a recycled freelist object or an arena slice, ASan would have stayed silent.Blind spots, measured from the code
EIGS_VALGRINDbuild (eigenscript.c:14-30). A parked env'svalues[]slots are not poisoned at all, and that is the field Heap use-after-free: under-arity call leaves an unbound param slot that a GC cull frees (v0.45.0, minimal repro) #1661 read.values[slot]unchecked, where the interpreter bounds-checks. Stale reads therefore land only in JIT tiers (Heap use-after-free: under-arity call leaves an unbound param slot that a GC cull frees (v0.45.0, minimal repro) #1661 needed the JIT to crash).Done when (each with a planted fault that the new mode catches and today's ASan misses)
make asan-strict, flagEIGS_POOL_OFF) that bypasses the freelists and the arena, so every Value, Env and string is a realmalloc/free. ASan then sees every object's lifetime. Run the suite under it in CI (sharded like the ASan lane).ASAN_POISON_MEMORY_REGION) of WHOLE parked freelist objects, including an env'svalues[], and of arena slices on reset, for the normal ASan build. Unpoison on reuse.EIGS_JIT_CHECKEDbuild flag where emitted GET_LOCAL and SET_LOCAL bounds- and binding-check like the interpreter. CI runs the JIT-tier suite under it.Related: #1661 (the bug that exposed this), and the pre-v1 security gate.