Stop two compile-time code-size blowups that exhaust the SBCL heap - #12
Merged
Merged
Conversation
Compiling a function that destructures a moderately large literal pattern (a ~100-leaf record) exhausted SBCL's 1GB dynamic space inside COMPILE and killed the process. The kernel's pattern compiler re-derives the full hd/tl accessor chain for every pattern element, so kl->lisp emitted a single clause test of ~220 nested ANDs in which each deeper test repeats the whole (CAR (CDR ... V)) spine and the clause body repeats it again per bound variable: generated code size grows with pattern-size x pattern-depth (12,441 KL nodes for a 725-node source form), and SBCL's compile cost grows far faster than the code does (~800MB consed for that 12k-node form; a 2x larger pattern consed 5.6GB; 4x larger exhausted the control stack even with a 16GB heap). eval-kl now runs kl->lisp's output through an accessor-chain binding pass (shen-cl.bind-accessor-chains, src/primitives.lsp): each CAR/CDR step is computed once, assigned to a function-local variable with an inline SETQ at its first occurrence, and referenced thereafter. SETQ returns the assigned value, so evaluation order and results are identical to the untransformed code; a chain is only reused at a point the original could reach after evaluating the identical expression. Reuse is scoped by control flow (visible along an AND spine and into the guarded clause body; never across COND clauses, IF branches, OR alternatives or trap-error bodies), restricted to chains rooted at lexical variables (never KL [value X] special refs), and closure bodies (LAMBDA / |lambda| / |freeze|) are left untouched since a shared function-local cache would be unsound for re-entrant or concurrent closures. Forms the compiler does not itself emit (lisp. escapes) pass through byte-identical. Variable names are deterministic (shen-cl.accN, counter seeded past any existing occurrences), and only eval-kl applies the pass, so precompiled kernel sources under compiled/ are unchanged. Measurements (SBCL 2.6.5, macOS arm64): the repro record pattern went from heap-exhaustion death (841MB peak RSS at default 1GB) to loading in 60MB peak RSS; compiling its defun went from 802MB consed / 0.9s to 10.5MB / 0.01s; a 64-field record (8-16x past the old death point) now compiles in the default image at 158MB peak RSS. Verification: kernel certification suite 134/134 before and after; compiler golden tests pass (kl->lisp output itself is unchanged); port runtime suite grows 126 -> 131 with new tests/pattern-tests.shen (wide 16-field record + 40-deep spine, match and fallthrough pinned); CLI parity 16/16; sha256 7/7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng it twice shen-cl.factorise-cases groups consecutive cond clauses that share the same first AND test. The remaining clauses are needed on two control paths -- when the shared test fails, and when it matches but no sub-test does -- and were spliced into both, so each factorisation level embedded the next level's two splices: generated code grew 2^groups. The shape that triggers it is any plain dispatch table (define f c1 a -> ... c1 b -> ... c2 a -> ...): measured on SBCL 2.6.5, a 33-clause function with 16 two-clause groups compiled its 394 KL nodes to 2.1M Lisp nodes and died of heap exhaustion after 446s / 938MB inside COMPILE; 12 groups already cost 4.1s / 148MB. The remaining clauses are now emitted exactly once, as the label body of a %%let-label join that both paths %%goto-label to -- the same tagbody/go join the kernel's original factorise-defun extension used, whose %%let-label / %%goto-label / %%return support src/compiler.shen kept all along (add-block already wraps every factorised defun in the (BLOCK NIL ...) the returns escape through). From the first join onward every clause body is wrapped in %%return; clause order and test/body evaluation order are exactly those of the nested-cond emission this replaces, and functions where no group forms still return the input form untouched. Growth is now linear (~48 Lisp nodes per group): the fatal 16-group function compiles in 0.03s / 47MB, and a 401-clause, 200-group function in 0.04s. The accessor-chain binding pass (shen-cl.bind-accessor-chains) used to skip TAGBODY forms entirely, which would have silently disabled it inside factored dispatch code; it now walks each compound tagbody statement with the tagbody's entry env (GO joins mean bindings made in one statement are never guaranteed on entry to another, and nothing escapes), so large patterns inside factored groups keep both fixes: 16 wide-record clauses across 8 factored groups compile in 1.4s / 392MB, on par with the same 16 clauses unfactored (1.6s / 354MB), where each such clause alone consed ~5.6GB before the accessor pass. Verification (SBCL 2.6.5, macOS arm64): kernel certification suite 134/134 before and after; compiler golden tests pass; port runtime suite grows 131 -> 136 with the new grouped-dispatch tests in tests/pattern-tests.shen (24 groups compiling is itself the primary assertion, plus dispatch order and both fallthrough paths pinned); CLI parity 16/16; the accessor-pass repro (64-field record) still compiles at 138MB peak RSS. Label symbols are deterministic (shen-cl.labelN, counter reset per defun); compiled/*.lsp are unaffected (build.shen stubs the factoriser to identity when generating them). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pyrex41
pushed a commit
to pyrex41/urdr
that referenced
this pull request
Jul 30, 2026
Both ports carried pre-existing code-generation defects that made them reject code the other two accept, and this PR is the first change deep enough to trip them: shen-lua 4e3b43a (pyrex41/shen-lua#53) - pattern-match codegen exceeded LuaJIT's parser nesting limit - the reader overflowed the Lua stack on block comments over ~7.2KB (shen/run/run.shen's header is 8092 bytes) shen-cl e48776d (pyrex41/shen-cl#12) - accessor chains rebuilt per conditional level: 802MB of compiler consing for one defun, fatal in a 1GB image - factorise-cases spliced its fallthrough twice, so generated code grew 2^groups; a 33-clause dispatch function was fatal Neither fix changes semantics: all four ports agree exact-golden on every suite, and each port's own kernel certification is unchanged (134/134 both, byte-identical report lines). Pins move in both places that carry them -- build/locks/shen-ports.lock.json and scripts/bifrost-gate EXPECTED_PINS -- and the launchers were rebuilt and re-stamped from the new commits with `make ports` before the gate ran. `make conformance` PASS, four ports, twelve cases, zero skips. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two independent, pre-existing defects that made SBCL exhaust its 1GB dynamic
space inside the compiler on ordinary Shen programs. Both were found while
running a downstream four-port conformance suite.
1. Accessor chains rebuilt at every conditional level (
ffceeec)The kernel's pattern compiler (
shen.shendef->kldef, shared by all four ports)re-derives the full
hd/tlspine per pattern element: a 7-key record patternbecomes a 12,441-node KL form, 222 levels deep. shen-cl's KL→CL translation is
linear and not at fault — the death was SBCL compiling the result, consing
802MB for one defun.
New CL→CL pass
shen-cl.bind-accessor-chainsinsrc/primitives.lsp, appliedonly in
|eval-kl|. EachCAR/CDRstep is computed once via inlineSETQat first occurrence (SETQ returns the value, so evaluation order and results
are unchanged). Reuse only along guaranteed-evaluated paths — never across
COND clauses, IF branches, OR alternatives, or trap-error; only chains rooted
at lexical variables; closure bodies untouched.
compiled/*.lspbyte-identical.Repro compile: 802MB / 0.9s → 10.5MB / 0.01s. A 64-field record, 8-16x past
the old death point, now compiles in the default 1GB image.
2. The factoriser spliced its fallthrough twice — exponentially (
8be0b2d)shen-cl.factorise-casessplicedfactored-remaininginto both the innertruefallthrough and the outer clause list. That is shared structure inmemory, but
compile-expressionwalks the tree recursively, so eachfactorisation level expanded the next level's two splices: generated code
grew 2^groups.
Measured (groups = consecutive 2-clause runs sharing a first test — what any
plain dispatch table produces): 8 groups 8,173 CL nodes; 12 → 131,053; 16 →
2,097,133; 18 → 8,388,589, from KL input growing a flat 24 nodes per group.
End to end, a 33-clause function was fatal: heap exhaustion after 446s and
938MB peak.
The remaining clauses are now emitted once, as the label body of a
%%let-labeljoin that both fallthrough paths%%goto-labelto (compiles toTAGBODY/GO) — the same mechanism the kernel's originalfactorise-defunextension used, whose machinery
src/compiler.shenhad retained unused. Clauseorder, evaluation order, and fallthrough are unchanged; zero runtime cost.
Growth is now linear (~48 nodes/group): the fatal 16-group case compiles in
0.03s / 47MB, and a 401-clause function in 0.04s.
Also fixed:
shen-cl.acc-walkskippedTAGBODYwholesale, which would havesilently disabled fix 1 inside factored dispatch code. It now walks compound
tagbody statements with the tagbody's entry env.
Verification
136/136 after (+5 regression assertions).
exact hits, group-miss fallthrough, total miss.
compile in 1.4s / 392MB, on par with an unfactored control.
boot.lspsaves with:save-runtime-options t, which would block a runtime override anyway.🤖 Generated with Claude Code