Skip to content

Count free references incrementally in the IR optimizer#189

Merged
Unisay merged 1 commit into
mainfrom
issue-142/incremental-free-ref-counts
Jul 6, 2026
Merged

Count free references incrementally in the IR optimizer#189
Unisay merged 1 commit into
mainfrom
issue-142/incremental-free-ref-counts

Conversation

@Unisay

@Unisay Unisay commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

optimizeModule decides whether to inline each Standalone binding partly on whether it is referenced exactly once. It computed those reference counts by folding countFreeRefs over every expression in the module, and it redid that fold for every binding that reached the use-once check. That is O(bindings × module size) per optimizeModule run, and the run repeats on every iteration of the optimize+dce fixpoint (#142).

The same counts were also assembled from the original bindings plus the already-substituted exports, a mix of stale and fresh sources. Within a single run that misjudges use-once: a binding that has become referenced multiple times can still be inlined into all of them (code bloat), or a genuinely used-once binding can be skipped. idempotently papered over it across iterations at the cost of extra passes (#143).

What changed

A single free-reference map is threaded through the fold with a precise invariant: it always holds the free-reference counts over the current accumulator (the bindings kept so far plus the exports). foldrM visits bindings right-to-left, and a binding can only be referenced by material to its right, so by the time a binding is visited every one of its referencers is already accounted for and its count is final. The map is built once from the exports and updated by a known delta at each step: add a kept binding's own refs, or, when inlining, add one copy of the inlinee's refs per replaced occurrence and drop the binding's own entry. Note [Incremental free-reference counting] in Optimizer.hs records the invariant and the delta.

This removes the quadratic recomputation and, because the counts now track the live post-substitution module, fixes the stale use-once judgement in the same change.

Measurements

Allocation of a single optimizeModule run on a synthetic module of N non-inlinable bindings, via ghci :set +s:

N bindings before after
200 13.1 MB 7.7 MB
400 34.5 MB 12.8 MB
800 109.9 MB 23.1 MB

Doubling N multiplies allocation by about 2.6x to 3.2x before (trending toward the quadratic 4x) and about 1.7x to 1.8x after (linear). The gap widens with module size, as expected going from O(bindings × size) to O(size).

Goldens

Structural goldens (golden.ir / golden.lua) shift for 28 modules. Two kinds of change: the live count stops duplicating a multiply-used foreign accessor (for example Data.Ring.intSub is now shared rather than pasted into both arms of Golden.Fibonacci), and gensym suffixes renumber where a different number of substitutions runs. Every eval/golden.txt oracle is unchanged, so runtime behaviour is preserved.

Testing

Adds a single-run regression spec that inlines a binding which becomes used-once mid-run; it is red against the stale count and green with the fix. A single run is deliberate: the optimize+dce fixpoint masks the misjudgement by self-correcting on a later iteration. The existing optimizer property tests (free references preserved, GUC invariants, idempotence, bound-name set) still pass, and the full suite is green with the eval oracles untouched.

Fixes #142
Fixes #143

optimizeModule recomputed the whole-module free-reference map for every
Standalone binding that reached the use-once check, costing O(bindings ×
module size) per run and repeating inside the optimize+dce fixpoint
(#142). Those counts also mixed the original bindings with the
already-substituted exports — a stale view that misjudged use-once and
could duplicate a binding across several sites or skip a legitimate
inline (#143).

Thread a single free-reference map through the right-to-left fold,
initialised from the exports and updated by a known delta as each binding
is kept or inlined (see Note [Incremental free-reference counting]).
Because a binding is only referenced by material to its right, its count
is final by the time it is visited, so one incremental map replaces the
per-binding recomputation and reflects the live post-substitution module.

Adds a single-run regression spec that goes red on the stale count. The
structural goldens shift (fewer duplicated foreign accessors, e.g.
Data.Ring.intSub in Fibonacci; gensym renumbering elsewhere); the eval
goldens are unchanged, so runtime behaviour is preserved.
@Unisay Unisay requested a review from Copilot July 6, 2026 13:20
@Unisay Unisay self-assigned this Jul 6, 2026
@Unisay Unisay marked this pull request as ready for review July 6, 2026 13:27
@Unisay Unisay merged commit 0bcac06 into main Jul 6, 2026
2 checks passed
@Unisay Unisay deleted the issue-142/incremental-free-ref-counts branch July 6, 2026 13:27
@Unisay Unisay removed the request for review from Copilot July 6, 2026 13:51
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.

isUsedOnce counts references against stale bindings during inlining optimizeModule recomputes whole-module free-reference counts per binding

1 participant