Skip to content

feat(codegen): renumber compiler-minted locals at emission - #308

Merged
Unisay merged 3 commits into
mainfrom
issue-306/emission-renumber-locals
Jul 26, 2026
Merged

feat(codegen): renumber compiler-minted locals at emission#308
Unisay merged 3 commits into
mainfrom
issue-306/emission-renumber-locals

Conversation

@Unisay

@Unisay Unisay commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Closes #306.

Problem

Every fresh name the compiler mints carries an index drawn from a monotone supply threaded through the whole pipeline: suffix-minted names like x$223 (uniquification, inline-paste freshening) and prefix-minted names like $cse1413 or the $sel/$a dispatch locals of codegen, all mangled $_S_ on the way into the Lua AST. That index is pipeline history, not artifact structure — any upstream change that shifts how much supply earlier code consumes renames every later binder, so semantically identity changes churn golden files in nothing but indices (#304 review: of 26 moved modules, several changed only like v_S_1437 → v_S_1440).

The new differential spec demonstrates the leak at the level it manifests, before the fix (test/Language/PureScript/Backend/Lua/Renumber/Spec.hs, compiling the same IR twice with a burned supply offset of 100):

expected: "return {
            value = (function()
              local v_S_0 = 1
              local x_S_2
              x_S_2 = v_S_0
              return x_S_2
            end)()
          }"
 but got: "return {
            value = (function()
              local v_S_100 = 1
              local x_S_102
              x_S_102 = v_S_100
              return x_S_102
            end)()
          }"

Fix

A late Chunk → Chunk pass, Language.PureScript.Backend.Lua.Renumber, wired at the tail of optimizeChunk (the shared seam both Backend.compileModules and the golden harness print through), after every name-minting Lua-level transform and immediately before the printer. The supply must stay global — the pipeline's unique-binders invariant depends on it — so nothing changes in the IR; only the emitted spelling is normalized.

The pass renumbers every supply-drawn digit run occurring in a local binder in first-occurrence order — a fresh index per base name, counted from 0 — and rewrites the binder's references scope-consistently. A digit run is supply-drawn in exactly two shapes, mirroring the two minting grammars (see Note [Supply-drawn digit runs] in the new module): a whole _S_-delimited segment (x_S_223, and the same run embedded mid-name in a derived dispatcher b_S_5_S_loop), or the run terminating the tag of a _S_-prefixed name (_S_cse1413, _S_a1). Digit runs anywhere else are spelling, not supply: add3 keeps its 3, SpecConstr's positional …_S_sc1Tuple keeps its 1.

Two design points worth review attention:

  • Derived names renumber in step with the binder they embed. Loopification derives a dispatcher's name from its leader member (b$5b$5$loop), so a suffix-only rule would stabilize the member yet leave the dispatcher churning. Assignments are keyed by (piece-prefix, original run), so b_S_5 and b_S_5_S_loop both map through the same entry — the second differential test pins exactly this on a mutually tail-recursive group.
  • The rewrite is capture-free alpha-renaming by construction. Binders rename together with exactly their in-scope references (the walk models Lua scoping, including the repeat-until condition seeing body locals); an unbound reference is a global — an FFI file's stdlib or host-API read — and is never rewritten, and allocation skips any index whose spelling occurs free in the chunk, so a renamed binder cannot capture such a global either.

Sample of the resulting normalization, quoted from the accepted Golden.NativeLoopsGuard.Test/golden.lua:

-      local bind_S_1433 = (Effect_monadEffect.Bind1()).bind
-      return function(f_S_1434)
-        return function(a_S_1435)
-          return bind_S_1433(f_S_1434)(function(fPrime_S_1436)
-            return bind_S_1433(a_S_1435)(function(aPrime_S_1437)
+      local bind_S_0 = (Effect_monadEffect.Bind1()).bind
+      return function(f_S_1)
+        return function(a_S_1)
+          return bind_S_0(f_S_1)(function(fPrime_S_0)
+            return bind_S_0(a_S_1)(function(aPrime_S_0)

Verification

  • Differential (red before the pass, green after): compiling the same IR with supply indices offset by 100 must emit byte-identical text — one test for plain suffix-minted locals, one for a local recursive group whose lowering derives a dispatcher name and mints $sel/$a locals.
  • Unit fixtures (parse → renumber → print): first-occurrence allocation per base, prefix-minted names, dispatcher/member pairing, sibling-scope consistency, repeat-until scoping, the free-spelling skip, and the untouched classes (unsuffixed names, positional digits, field names, table keys, global references).
  • Properties (Hedgehog, 300 tests each, seed-stressed 12 runs): the pass is idempotent on chunks generated over supply-minted name pools, and is the identity on chunks without supply-drawn names.
  • Corpus: the one-time whole-corpus renumbering touches only golden.lua files (52 of them); every golden.ir and every hand-verified eval/golden.txt oracle is byte-identical and green, which pins the renaming as alpha-equivalence on the corpus. Luacheck stays clean; cabal test all green on the accepted state.

Notes

  • The insertion/deletion counts of the golden diff differ slightly (1485/−1512) because shorter names let the pretty-printer re-join previously wrapped lines; the change is still purely name-driven.
  • A hand-written FFI local that happens to spell a minted shape (x_S_1 is a legal Lua and PureScript identifier) is renumbered too — consistently with its references, so semantics are unchanged; only the spelling in the output moves.
  • The issue names a possible follow-up (dropping the suffix entirely where the base name is unambiguous in scope); that needs real shadowing analysis and stays out of scope here.

Fresh names are drawn as base$N or $tagN from monotone supplies
threaded through the whole pipeline, so any change that shifts supply
consumption early in a module renames every later binder — alpha-only
churn that inflates golden diffs of semantically identity changes
(observed at scale in the #304 review: 26 modules moved, several in
nothing but indices).

A late Chunk → Chunk pass (Language.PureScript.Backend.Lua.Renumber)
runs at the tail of optimizeChunk, after every name-minting transform
and immediately before the printer. It renumbers every supply-drawn
digit run occurring in a local binder in first-occurrence order (per
base name, counted from 0) and rewrites the binder's references
scope-consistently. Keying the assignment by the run's original
spelling keeps a derived name in step with the binder it embeds: a
recursive-group member b$5 and its dispatcher b$5$loop renumber
together. Unbound (global) references are never rewritten, and
allocation skips any spelling occurring free in the chunk, so the
rewrite is capture-free alpha-renaming by construction.

The differential spec pins the contract at the pipeline level:
emission is byte-identical under a burned supply offset, for plain
suffix-minted locals and for recursive groups with derived dispatcher
names. Property specs pin idempotence and that chunks without
supply-drawn names pass through untouched.

The whole-corpus golden.lua renumbering is the intended one-time
effect. golden.ir files and every hand-verified eval oracle are
byte-identical, pinning the alpha-equivalence on the corpus.

Closes #306
@Unisay
Unisay marked this pull request as ready for review July 26, 2026 19:08
Unisay added 2 commits July 26, 2026 21:14
The FNEW/TNEW censuses and trace reports key sites by line number in
the linked bench artifacts; the emission-time renumbering shortens
minted names, re-wrapping pretty-printed lines and shifting those line
numbers. Site counts and shapes are unchanged.

The record_set trace report additionally gains one compiled site
(record_set.lua:19 JFUNCF, compiled 8 -> 9, aborts and blacklists
unchanged): the renamed artifact's layout shifts LuaJIT's layout-random
hot-counter aliasing, letting a site that previously aliased away reach
its hot threshold. Regenerated with ./bench/ci --accept; the local
censuses match the CI-computed ones byte-for-byte.
The trace report walked the spec harness's own bytecode alongside the
artifact's, so the goldens also pinned the compile states of each spec's
drive/ideal wrappers. Whether such a wrapper's entry compiles is an
order race against its inner loop's counter — a pure function of
per-process address layout — and for curried_step the emission
renumbering shifted that layout into the marginal zone: raw trials
flip between JFUNCF-present (aborts=1 compiled=4) and JFUNCF-absent
(aborts=2 compiled=3) at p far enough from 0 and 1 that the majority
vote itself flips per invocation, on CI and locally alike.

Those spec-side states carry no information about emitted code, which
is what the oracles exist to pin, so the report now records abort sites
and bytecode end states of the artifact chunk only. With the filter,
8/8 raw curried_step trials are byte-identical and five consecutive
./bench/ci verification runs pass; the golden diff is purely
subtractive (spec-side lines and recomputed counts).

The artifact-side majority vote stays: export wrappers measured at
p ~ 0.9 (the Bench.BindChain example in the header) still need it.
@Unisay Unisay self-assigned this Jul 26, 2026
@Unisay
Unisay merged commit 0c62368 into main Jul 26, 2026
2 checks passed
@Unisay
Unisay deleted the issue-306/emission-renumber-locals branch July 26, 2026 21:09
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.

Emission-time renumbering of compiler-minted locals: keep supply history out of artifacts

1 participant