Skip to content

SpecConstr: specialize recursive functions on known-constructor call patterns - #279

Merged
Unisay merged 3 commits into
mainfrom
issue-208/spec-constr
Jul 15, 2026
Merged

SpecConstr: specialize recursive functions on known-constructor call patterns#279
Unisay merged 3 commits into
mainfrom
issue-208/spec-constr

Conversation

@Unisay

@Unisay Unisay commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

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:

go = function(acc)
  while true do
    local i = acc[2]
    if i < n then
      acc = Data_Tuple_Tuple_S_w(acc[1] + i, i + 1)  -- a table per iteration
    else
      return acc
    end
  end
end

After:

go_S_sc1Tuple = function(s, i)
  while true do
    if i < n then
      s, i = s + i, i + 1
    else
      return { s, i }  -- the box exists only where it escapes
    end
  end
end

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 f2 over 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-count analogue). 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 reports Unmodified once 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: sumCount keeps only the specialized loop).

The known-constructor resolution cluster (resolveKnownCtorApp and friends) moved from the optimizer into IR.Query so the new pass can share it without an import cycle. It now returns a CtorShape record 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 Tuple accumulator (fully unboxed, boxed entry DCE'd), a sum-type Maybe accumulator (only the Just pattern recurs, tag tests fold away, the Nothing arm stays on the boxed entry), a mutually recursive ping/pong pair 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_fold macro benchmark, n = 5e6:

runtime boxed (main) specialized ideal
PUC Lua 5.1 0.425s 0.169s 0.057s
LuaJIT 0.0021s 0.0022s 0.0020s

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 the add$w worker 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/ci counters 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

Unisay added 3 commits July 15, 2026 11:56
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (new CtorShape), 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 Unisay self-assigned this Jul 15, 2026
@Unisay
Unisay marked this pull request as ready for review July 15, 2026 15:08
@Unisay
Unisay merged commit 745a7f4 into main Jul 15, 2026
3 checks passed
@Unisay
Unisay deleted the issue-208/spec-constr branch July 15, 2026 15:12
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.

SpecConstr: specialize recursive functions on known-constructor call patterns

2 participants