Skip to content

Compile captured closures' own bodies, not just their creation - #53

Merged
revarbat merged 1 commit into
mainfrom
compile-captured-closure-bodies
Jul 30, 2026
Merged

Compile captured closures' own bodies, not just their creation#53
revarbat merged 1 commit into
mainfrom
compile-captured-closure-bodies

Conversation

@revarbat

Copy link
Copy Markdown
Member

Summary

  • map()/filter() already compiled fully (no closure of their own). reduce()/accumulate()/while()'s own self-recursive helper closure, and any curried closure (e.g. fnliterals.scad's f_1arg()/f_2arg()/f_3arg()), always ran fully interpreted when invoked even after Op::MakeClosure let the creating function compile — the closure's own compiled chunk was discarded outright.
  • Registers a captures-having closure's chunk for compiled invocation too. Op::LoadUpvalue/Op::MakeClosure fall back to ctx.let_ (rooted at the closure's own capturedLet snapshot) whenever the live call-stack walk misses — the same mechanism an escaped closure already relied on. A new CompiledChunk::selfDecl field lets Op::MakeClosure distinguish a genuinely-local capture from one bubbled up by a nested literal, needed now that a captures-having chunk's own nested MakeClosure sites can actually execute.
  • Fixes a pre-existing, previously-unreachable bug this exposed: isolatedCallCtxFor (the tail-call trampoline's hop-context builder) used the caller's ctx.scope instead of the callee's own declNode.scope(), unlike every other call-resolution function in the file.
  • Deliberately excludes self-referential recursive closures (the reduce()/accumulate()/while() idiom, where the closure calls itself by name) from registration: the self-reference never resolves as a compile-time upvalue (LetOp's declareLocal for it runs only after compiling its own RHS), so it falls through to Op::LoadFree on every call. Registering it anyway turned an O(n) reduce() into O(n²) — confirmed empirically against a real list, and confirmed to be a pre-existing issue already present in the published v0.7.0, unrelated to this change. Closing that gap needs the self-reference to resolve as a real upvalue at compile time; left open as follow-up work.

Test plan

  • Full test suite (670 tests, up from 668) passes locally.
  • New tests: MultiLevelCurriedClosureInvocationRunsCompiled (f_1arg-style two-level nested closure correctness), SelfReferentialRecursiveClosureStillRunsInterpretedAndCorrectly (guard keeps reduce()'s own idiom correct and interpreted).
  • Manually verified via the CLI: reduce()/f_1arg() correctness at small and large scale; confirmed the O(n²) case is unchanged from the published v0.7.0 baseline (not a new regression), and that the containsLoadFree guard doesn't accidentally affect map()/filter()/curry-adapter cases.

🤖 Generated with Claude Code

Op::MakeClosure (previous release) let a function that CREATES a
closure compile even when that closure has captures, but always
discarded the closure's own compiled body, forcing every invocation
back through the interpreter regardless of list size -- reduce()'s
own self-recursive accumulator, and any curried closure returned from
functions like fnliterals.scad's f_1arg()/f_2arg()/f_3arg(), never
benefited from compilation at all.

Registers a captures-having closure's chunk for compiled invocation
too, with Op::LoadUpvalue/Op::MakeClosure falling back to ctx.let_
(rooted at the closure's own capturedLet snapshot) whenever the live
call-stack walk misses -- the same mechanism a genuinely escaped
closure already needed. A new CompiledChunk::selfDecl field lets
Op::MakeClosure tell a genuinely-local capture from one bubbled up by
a nested literal (needed once a captures-having chunk's own nested
MakeClosure sites can actually run, not just get discarded).

Also fixes a pre-existing scope bug this exposed: isolatedCallCtxFor
(the tail-call trampoline's hop-context builder) used the caller's own
ctx.scope instead of the callee's declNode.scope(), unlike every other
call-resolution function -- invisible until a captures-having closure
could ever actually be trampolined into.

Deliberately excludes a self-referential recursive closure (the
reduce()/accumulate()/while() idiom, where the closure calls itself by
name) from registration: its own name never resolves as a compile-time
upvalue (LetOp's declareLocal for it runs only after compiling its own
RHS), so it falls through to Op::LoadFree on every call -- registering
it for compiled invocation turned an O(n) reduce() into O(n^2)
(confirmed empirically against a real list), since each recursive
call's fallback re-derivation nests its own capturedLet one level
deeper than the last. This is a pre-existing gap (confirmed present in
the already-published v0.7.0 too, unrelated to this change) that stays
open for now -- closing it needs the self-reference to resolve as a
real upvalue at compile time, not a follow-on to bundle in here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@revarbat
revarbat merged commit e2ee38e into main Jul 30, 2026
3 checks passed
@revarbat
revarbat deleted the compile-captured-closure-bodies branch July 30, 2026 21:45
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.

1 participant