Skip to content

lazy: bound the collection budget by the arena - #68

Merged
olydis merged 1 commit into
mainfrom
claude/tc-performance-optimizations-rqtyzl-lazy-budget-ceiling
Oct 6, 2026
Merged

olydis merged 1 commit into
mainfrom
claude/tc-performance-optimizations-rqtyzl-lazy-budget-ceiling

Conversation

@olydis

@olydis olydis commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Bug

In the lazy evaluator (lazy-graph-nil-mmap-32.hpp, the runner's default), _head can go past ARENA_NODES = 2^30: a node is written past the 8 GiB reservation, and its index (≥ 2^30) has the MARK bit set. Three ways there:

path trigger
while (_live * 2 > _budget) _budget *= 2; has no ceiling a live set over 2^29 nodes, at any budget
set_budget accepts any size RUNNER_RSS_THRESHOLD_MB > 8192
a step allocates 2 nodes, the check before it ensures room for 1 (_free || _head < _budget) a budget of the whole arena: RUNNER_RSS_THRESHOLD_MB=0 or 8192, or one that doubled up to it

Repro: real 2^30 arena, default budget, a term whose live set grows without bound (grow grow △, grow = \v \acc v v (△ acc acc)), sent to the lazy runner followed by a small request:

  • before: SIGSEGV after 77 s (exit 139, peak 8.7 GB). Through lambada: no output, hangs on the dead runner until timeout (rc=124).
  • after: err arena exhausted: the live set does not fit after 74 s, arena at 1,073,741,822 nodes (2^30 − 2), and the next request is answered. Through lambada: arena.lamb:3: runner: arena exhausted: the live set does not fit, rc=1, after 73 s.

Fix

  • Ceiling, as in the eager evaluator: the budget doubles only up to the arena; a live set that needs more throws arena exhausted: the live set does not fit.
  • Room for two: collect_if_over_budget returns early only if two nodes fit ((_free && _arena[_free].v) || _head + 2 <= _budget), so a step never takes _head past the budget.
  • Clamp: set_budget clamps to the arena.
  • Reset: whnf restores the spine and the root stack when an exception passes (eager's _stack.resize(base)); otherwise the failed request's redexes stay roots and every later request fails too. The handler sits in whnf, around the recursion (now the private force), not in every recursive frame: there it took clang's frame from 72 to 104 bytes, i.e. 29% less forcing depth on the same stack (gcc: 160 either way).
  • Test hook: the class becomes BasicLazyGraphNilMmap32<ARENA_BITS = 30>; LazyGraphNilMmap32 is an alias, so callers do not change.
  • runner.md: one sentence on the ceiling.

Test

dag-machine/test-lazy.cpp (built and run by test.sh, next to test-jets.cpp) runs the evaluator on a 2^12-node arena: a budget past the arena still collects at its end; a live set that does not fit throws, with _head inside the arena; afterwards it reduces correctly again.

header result
main (template parameter only) SIGSEGV
fix without the ceiling SIGSEGV
fix without room for two FAIL (_head past the arena; SIGSEGV instead when nothing is mapped right after it)
fix without the clamp SIGSEGV
fix without the spine reset FAIL: "after which it reduces as before"
fix without the roots reset FAIL: "after which it reduces as before"
fix 3 PASS (g++ and clang++)

Validation

  • dag-machine/test.sh passes under LC_ALL=C and LC_ALL=C.UTF-8, test-runner.sh 21/21. test-lazy.cpp and runner.cpp build with g++ 13 and clang++ under -Wall -Wextra with no new warnings. test.cpp's lazy sanity and collection checks pass.
  • Deepest forcing in an 8 MiB stack (whnf of (K y) △ nested n deep): g++ 51.7k before and after; clang++ 104k before and after (74k with the handler in the recursion).
  • Speed: arboretum's expect tests under the lazy runner (--jobs 4, cold cache, RUNNER_WORKER_STACK_MB=4096), main → this branch: 561.2 s → 569.6 s (+1.5%, one run each), src unchanged by either. The new check is +1.4% instructions on recursive fib (cachegrind, g++); timing on that micro-benchmark is within ±5% either way across compilers.

Repro: the runner gets load <one-line module>, reduce dag <grow>, then a small reduce. lambada: TREE_CALCULUS_RUNNER=1 LAMBADA_TREE_CALCULUS=<this checkout> node submodules/lambada/bin/lambada.js expect-test bundle.dag --root src, with src/arena.lamb holding the two lines above.

Caveats

🤖 Generated with Claude Code

https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr

Three paths let the lazy evaluator's _head pass ARENA_NODES = 2^30, so a
node is written past the 8 GiB reservation and an index gets the MARK bit:
the budget doubled with no ceiling (a live set over 2^29 nodes), set_budget
took any size (RUNNER_RSS_THRESHOLD_MB over 8192), and a step allocates two
nodes after a check that left room for one (a budget of the whole arena:
RUNNER_RSS_THRESHOLD_MB=0 or 8192).

As the eager evaluator does: the budget stops at the arena, and a live set
that needs more throws "arena exhausted: the live set does not fit". On top,
for the lazy step's second node, the check leaves room for two; set_budget
clamps; and whnf drops what a failed reduction left on the spine and the
roots, so its redexes stop being roots. It does that around the recursion,
now force(), rather than in it: a handler in every frame took clang's from
72 to 104 bytes, 29% less depth on the same stack.

`grow grow △` with grow = \v \acc v v (△ acc acc), lazy runner, default
budget: SIGSEGV after 77 s (lambada blocks on the dead runner) before;
"arena exhausted: the live set does not fit" after 74 s, arena 2^30 - 2
nodes, and the next request answered, after.

test-lazy.cpp runs the evaluator on a 2^12-node arena (ARENA_BITS, a
template parameter for that): it fails on the old code, and with any one of
the ceiling, the room for two, the clamp, or either reset taken out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr
@olydis
olydis force-pushed the claude/tc-performance-optimizations-rqtyzl-lazy-budget-ceiling branch from 325d90e to 5658332 Compare October 4, 2026 22:38
@olydis
olydis merged commit 127992a into main Oct 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants