Skip to content

Stop two compile-time code-size blowups that exhaust the SBCL heap - #12

Merged
pyrex41 merged 2 commits into
masterfrom
fix/factorise-fallthrough-blowup
Jul 30, 2026
Merged

pyrex41 merged 2 commits into
masterfrom
fix/factorise-fallthrough-blowup

Conversation

@pyrex41

@pyrex41 pyrex41 commented Jul 30, 2026

Copy link
Copy Markdown
Owner

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/tl spine per pattern element: a 7-key record pattern
becomes 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-chains in src/primitives.lsp, applied
only in |eval-kl|. Each CAR/CDR step is computed once via inline SETQ
at 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/*.lsp byte-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-cases spliced factored-remaining into both the inner
true fallthrough and the outer clause list. That is shared structure in
memory, but compile-expression walks the tree recursively, so each
factorisation 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-label join that both fallthrough paths %%goto-label to (compiles to
TAGBODY/GO) — the same mechanism the kernel's original factorise-defun
extension used, whose machinery src/compiler.shen had retained unused. Clause
order, 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-walk skipped TAGBODY wholesale, which would have
silently disabled fix 1 inside factored dispatch code. It now walks compound
tagbody statements with the tagbody's entry env.

Verification

  • Kernel suite 134/134 before and after. Port runtime suite 131/131 before,
    136/136 after (+5 regression assertions).
  • Dispatch semantics cross-checked byte-identical to shen-go and shen-rust:
    exact hits, group-miss fallthrough, total miss.
  • Both passes cooperate: 16 wide-record clauses across 8 factored groups
    compile in 1.4s / 392MB, on par with an unfactored control.
  • No heap bump was needed or taken. Note boot.lsp saves with
    :save-runtime-options t, which would block a runtime override anyway.
  • Downstream: all eleven four-port conformance suites pass exact-golden.

🤖 Generated with Claude Code

Reuben Brooks and others added 2 commits July 30, 2026 16:23
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
pyrex41 merged commit e48776d into master Jul 30, 2026
1 check failed
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>
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