SpecConstr: specialize recursive functions on known-constructor call patterns - #279
Merged
Conversation
…terns (#208) GHC's SpecConstr discipline on the IR: a recursive-group member whose body scrutinizes a parameter and whose recursion passes a known constructor at that position gets a specialized copy taking the constructor's fields as separate parameters; every qualifying call site is rewritten to it and the boxed original stays as the entry point. The pass mints reboxes instead of folding: the case-of-known-constructor folds in the same specialize+dce fixpoint collapse them, so the specialized loop carries raw values and allocates only where the box escapes. Specializations are pattern-keyed by name for idempotence and capped per binding. The known-constructor resolution cluster moves from the optimizer to IR.Query so the new pass can share it without an import cycle. Claude-Session: https://claude.ai/code/session_01FwfS3b4MkgCj54u47ruzU4
…zation (#208) A Tuple-accumulator fold loop: the counter goldens pin the specialized shape (JLOOP, no blacklisting, no table build in the loop body). PUC Lua 5.1 runs ~2.5x faster than the boxed baseline; LuaJIT is unchanged because its allocation sinking already elided the box inside the trace. Claude-Session: https://claude.ai/code/session_01FwfS3b4MkgCj54u47ruzU4
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds a new IR optimization pass (“SpecConstr”-style call-pattern specialization) to eliminate per-iteration constructor allocation in common recursive accumulator loops, and wires it into the existing optimizer fixpoint so that subsequent known-constructor folds can remove the resulting reboxes.
Changes:
- Introduces
Language.PureScript.Backend.IR.SpecConstr, specializing recursive-group members on known-constructor call patterns and rewriting qualifying call sites module-wide. - Moves/centralizes known-constructor application resolution into
IR.Query(newCtorShape), and updates optimizer folds to use the shared helper. - Adds new golden + eval oracle coverage and a new macro benchmark (
tuple_fold) with corresponding bench goldens.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| test/ps/src/Golden/SpecConstr/Test.purs | New PureScript golden module covering positive/negative specialization shapes (Tuple, Maybe, mutual recursion, dead accumulator). |
| test/ps/src/Bench/TupleFold.purs | New benchmark PureScript workload exercising the Tuple accumulator fold shape targeted by specialization. |
| test/ps/output/Golden.SpecConstr.Test/golden.lua | New generated Lua golden demonstrating the specialized worker loops and rewritten call sites. |
| test/ps/output/Golden.SpecConstr.Test/golden.ir | New generated IR golden pinning the specialized bindings and rewrite results. |
| test/ps/output/Golden.SpecConstr.Test/eval/golden.txt | New eval oracle ensuring runtime behavior is preserved for all covered shapes. |
| test/ps/output/Golden.SpecConstr.Test/eval/.gitignore | Ignores eval runner output artifact for the new golden. |
| test/ps/output/Golden.SpecConstr.Test/corefn.json | New committed CoreFn JSON for the golden module. |
| pslua.cabal | Registers the new IR.SpecConstr module in the library build. |
| lib/Language/PureScript/Backend/IR/SpecConstr.hs | Implements SpecConstr-style specialization + call-site rewriting for recursive groups (top-level and local). |
| lib/Language/PureScript/Backend/IR/Query.hs | Adds shared known-constructor resolution (CtorShape, resolveKnownCtorApp) for reuse across passes. |
| lib/Language/PureScript/Backend/IR/Optimizer.hs | Wires spec-constr into the specialize+dce fixpoint and adapts known-constructor folds to the new CtorShape API. |
| changelog.d/20260715_120000_unisay_spec_constr.md | Changelog fragment documenting the new optimization and benchmark results. |
| bench/macro/tuple_fold.lua | Adds the new macro benchmark driver for the Tuple fold workload. |
| bench/goldens/trace_tuple_fold.txt | New LuaJIT trace golden capturing compiled/blacklisted counters for the new benchmark. |
| bench/goldens/fnew_Bench.TupleFold.txt | New LuaJIT FNEW counter golden for the new benchmark artifact. |
Unisay
marked this pull request as ready for review
July 15, 2026 15:08
This was referenced Jul 15, 2026
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.
Closes #208.
A recursive function with a constructor accumulator builds the box on every iteration and takes it apart at the top of the next one. This PR brings GHC's SpecConstr discipline to the IR: when a recursive-group member scrutinizes a parameter and its recursion passes a known constructor at that position, the pass mints a specialized copy that takes the constructor's fields as separate parameters, then rewrites every qualifying call site to it. The boxed original survives as the entry point for callers that don't know the constructor.
The canonical fold now compiles to a loop with no allocation in it. Before:
After:
How it works
The pass itself folds nothing. The specialized body is a binder-freshened copy of the original with every read of the specialized parameter replaced by a rebox (
Tuple f1 f2over the new field parameters). It rides in the specialize+dce fixpoint, so the next optimize round's case-of-known-constructor folds (#177/#214) collapse the reboxes at eliminating reads into the tag string and the field references. A rebox at a whole-value read (the exit path above) stays, which is exactly right: it allocates where the box escapes, not per iteration.Guards follow GHC's: recursive bindings only, the scrutiny requirement (specializing an unscrutinized box would just move the allocation), the pattern must come from the group's own call sites, and a per-binding cap (
specConstrLimit = 3, the-fspec-constr-countanalogue). Instead of iterating to a fixpoint internally, one run mints one specialization layer and the enclosing specialize+dce fixpoint provides the bounded iteration. Specializations are pattern-keyed by name (<f>$sc<pos><Ctor>), so a rerun that meets a known pattern reuses the existing binding instead of minting a duplicate, and the pass reportsUnmodifiedonce no unhandled pattern remains.Rewriting applies module-wide, not just inside the group: the entry call
go (Tuple 0 0)qualifies too, which is what lets DCE drop the boxed entry entirely when nobody boxed is left calling it (visible in the golden:sumCountkeeps only the specialized loop).The known-constructor resolution cluster (
resolveKnownCtorAppand friends) moved from the optimizer intoIR.Queryso the new pass can share it without an import cycle. It now returns aCtorShaperecord instead of a tuple, since the specializer needs the constructor's identity to rebuild the rebox, not just its tag.Covered shapes
The golden test pins four cases: a product-type
Tupleaccumulator (fully unboxed, boxed entry DCE'd), a sum-typeMaybeaccumulator (only theJustpattern recurs, tag tests fold away, theNothingarm stays on the boxed entry), a mutually recursiveping/pongpair carrying the same pattern (both specialize, and the pair loops boxlessly), and a negative control whose boxed parameter is dead (left alone).Measurements
New
tuple_foldmacro benchmark,n = 5e6:PUC Lua gets ~2.5x from dropping the per-iteration table build. LuaJIT is unchanged, and that's expected: its allocation sinking already elided the box inside the trace. The win there is structural (the loop bytecode carries no
TNEW, so nothing depends on sinking succeeding) rather than a headline number. The remaining PUC gap to ideal is theadd$wworker calls the loop still makes instead of inline+, a pre-existing shape (the main-branch build has the same calls) unrelated to this pass.Counter goldens pin the specialized shape:
Bench.TupleFold.lua:8 JLOOP,blacklisted=0, and the loop body free of table construction.Tests
cabal test all: 928 examples, 0 failures, including the checked pipeline runner (per-pass invariant linting) over every golden module and the hand-verified eval oracles. Property specs re-run across several seeds.bench/cicounters match the committed goldens, and the twelve pre-existing counter files are byte-identical, so no other benchmark shape moved.https://claude.ai/code/session_01FwfS3b4MkgCj54u47ruzU4