feat(ir): dissolve a single-use header-free foreign import - #344
Merged
Conversation
The IR inliner refused to paste a `ForeignImport` — the table of one FFI
module's exports — into its use sites however few they were, because an
export value can be a Lua table constructor with identity (`unit = {}`)
that a copy under a lambda would re-allocate per call.
Add `inlineSingleUseForeignImports`, a late pass admitting the shapes
where re-evaluation provably cannot happen: exactly one reference to the
import, a header-free FFI source, and a path from the enclosing top-level
right-hand side down to that reference crossing only positions evaluated
exactly once. The walk declines at a lambda body, an `if` branch, and the
right operand of Lua's short-circuiting `and`/`or`.
Header-freeness is a fact about the FFI file, not the IR, so it is
supplied by the caller: `ForeignLift.headerFreeForeigns` parses each
foreign source best-effort and reports the modules whose chunk is a bare
`return { … }`. It travels with the CoreFn data-type table in a new
`ProgramFacts` record, whose `mempty` asserts neither fact.
The pass runs directly after `shareForeignAccessors`, the last pass to
change an import's reference count in either direction, which is what
lets it catch both shapes: the read dissolved into an export expression
and the shared accessor binding whose right-hand side is the import's
one reference.
Every golden shrank or stayed put (180 insertions against 409 deletions
over 60 files) and no `eval/golden.txt` oracle moved.
Unisay
marked this pull request as ready for review
July 28, 2026 20:24
…#251) The fold removes a hoisted foreign table from three bench artifacts, so their TNEW/TDUP census drops and every line number after the dropped `local` shifts up by one. Bench.EffectStep total TNEW+TDUP 5 -> 4 Bench.RefLoop total TNEW+TDUP 6 -> 5 Bench.StateStep total TNEW+TDUP 2 -> 1 No FNEW count moves — those goldens change by line number only. Among the trace reports, only `trace_ref_loop` changes its counts, and in the right direction: `compiled` 3 -> 4, one more function reaching a compiled trace now that the accessor read is gone from its prologue. `trace_effect_step` and `trace_state_step` keep their abort, compiled, and blacklist counts exactly. Verified stable: five consecutive `./bench/ci` runs match the accepted oracles, so the majority-vote trace report is not straddling a boundary.
This was referenced Jul 29, 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 #251.
The veto and the gap
A PureScript module with FFI compiles to one Lua table holding that module's foreign exports, bound to a generated name, plus one field read per export name. In the compiler's intermediate representation those are a
ForeignImportnode and anObjectProp(field projection) accessor per name. The inliner refuses to paste aForeignImportinto its reader however few readers there are, and the reason is identity: an export value can be a Lua table constructor,unit = {}in the prelude being the canonical one, and a copy of the import landing under a lambda would rebuild that table on every call where every reader is supposed to share one allocation.Golden.ForeignSharingis the oracle that pins it.The blanket form is blind to how often the reader actually runs. When the only reader sits in a position that module initialisation evaluates exactly once, folding the import there keeps the allocation count at one, and the hoisted table is pure overhead:
What this adds
A new pass,
inlineSingleUseForeignImports, folds an import into its reader when three conditions hold together.One, exactly one reference to the import survives the pipeline. Two, the FFI source is header-free: its Lua chunk is a bare
return { … }with no statements before it, so it lowers to a plain table constructor rather than a call of a function wrapping those statements. Three, the path from the top-level right-hand side (or export) holding the reference down to the reference itself crosses only positions evaluated exactly once per evaluation of that root — never a lambda body, never anifbranch, never the right operand of Lua's short-circuitingand/or.The soundness argument is one line: identity is minted per evaluation of the constructor, so conditions one and three together say the table is still built exactly once. Condition two is about when it is built — header statements can carry side effects whose order against the other module-init statements the fold would move, while a header-free constructor commutes with them.
Golden.ForeignSharing.Tokenbecomes exactly what the issue predicted, one table allocation and one hash read lighter:Where the fold sits, and why it is a pass rather than a relaxed veto
The issue proposes relaxing the inliner's veto in place. That would work for the shape above but would miss the other one, because it runs inside the optimizer's fixpoints, where an import's reference count is not yet final. An unannotated accessor dissolves into each of its use sites, so a foreign name read at three sites gives the import three references; the accessor-sharing pass (#248) then re-binds the repeated read to a single shared name, and the import drops back to one reference — the accessor binding's right-hand side, which is itself a once-evaluated root. No optimizer pass runs after that one, so the shape was unreachable from the veto.
Running the fold as its own pass directly after accessor sharing catches both.
Golden.ForeignAccessorDefault.Test, which pins the accessor-sharing behaviour, is where the second shape shows up — this is the collapse-in-place that the issue's follow-up comment asked for a golden of:Nothing folds it further at the IR level. The payoff is in the Lua backend, where the pasted import lowers to a field access into a table constructor and the existing constructor-projection fold (#140) plus the scope-call fold (#159) take the field. Worth noting: before this change that fold had zero live sites across the whole golden corpus despite its documentation naming foreign projection as its trigger; it now fires at 99.
Getting header-freeness to a pure pass
Whether an FFI file has a header is a fact about the file, not about the IR, so the caller supplies it.
ForeignLift.headerFreeForeignsparses each foreign source and returns the modules whose chunk is a barereturn { … }. It is best-effort by design: the scan runs before dead-code elimination, so it sees FFI files belonging to imports the pipeline later drops and never compiles, and reporting one of those as merely "not header-free" keeps a broken-but-unused file from becoming a build error. Files the build does need are parsed again at lowering, which is where a real parse failure is reported.It reaches the optimizer in a new
ProgramFactsrecord alongside the CoreFn data-type table that was already threaded through — the two are the same kind of thing, facts the IR does not carry. Itsmemptyasserts neither, so every existing unit-test call site staysoptimizedUberModule memptyunchanged and the rules that read the facts simply decline.The header condition, pinned
No existing golden isolated condition two:
Golden.Foreign.Test's header-carrying import is also read twice, so the reference count alone would hold it. The newGolden.ForeignHeader.Testis the same shape as theData.Showcase above — one import, read once, from a shared accessor's right-hand side — over a source that has a header, so the header is the only thing declining the fold:The header-free
Effect.Consoleimport next to it collapses; the one with the header stays wrapped and keeps its accessor. It carries aneval/golden.txtoracle (taggedtwice), so the fixture is checked by running it, not only by shape.Golden.Foreign.Testshows the same split within one file, the header-freeLibimport folding to the literal100while the header-carrying one stays:The identity oracle still holds
Golden.ForeignSharing.Testasks at runtime whether two reads of a foreign token are the same object and printssharedorfresh. Its import now folds — into the shared accessor, so the table is allocated exactly once and both reads see it:eval/golden.txtstill readsshared, and it is hand-maintained — the golden harness never auto-accepts it — so that is a real check, not a re-recording.Bench counters
The repo's bench suite counts LuaJIT table allocations (
TNEW/TDUP) and closure creations (FNEW) statically per linked artifact, plus a trace abort/blacklist report per macro spec, all diffed against committed oracles. Three artifacts lose a hoisted foreign table:No
FNEWcount moves — those oracles change by line number only, since dropping alocalshifts everything after it up by one. The two other trace reports that move,trace_effect_stepandtrace_state_step, keep their abort, compiled, and blacklist counts exactly; only their line numbers shift.trace_ref_loopis the one report whose counts change, and it needs a caveat rather than a victory lap:The new line is
Bench.RefLoop.lua:32 JFUNCF, the entry of the ST thunk wrapping the hot loop. This is not a codegen improvement — it is hot-counter aliasing. LuaJIT's hot counters live in a small hashed table keyed by bytecode address, so removing onelocalfrom the artifact reshuffles which counters collide, and this entry lands on the trace-formation boundary. Measured over raw single-process trials it forms with probability roughly 0.6 to 0.85, drifting between batches; raising the workloadreps(4, 8, 16, 32) does not move it, which rules out threshold slack and confirms aliasing. The report's majority vote over nine fresh processes resolves it to "present" consistently here (ten consecutive./bench/ciruns match), and CI has produced both sides — one red, then green on re-run.So this golden line is marginal, and pinning "present" is the better of two imperfect options: it is what this machine produces every time and what CI produces at least sometimes, whereas pinning "absent" would fail locally on every run. The underlying fragility is the oracle's, not this change's — a line-shift in any artifact can push a borderline entry across — and it deserves its own issue rather than a fix smuggled into this one.
Verification
The pass was demonstrated red before green: with
RunPass inlineSingleUseForeignsPassswapped for a no-op, the two positive unit tests fail and the three guards stay green, which is the right split since the guards assert the veto.The existing
#175regression test now passes a header-free fact for its module, so the lambda under which its single reference sits is the only thing left declining — previously the empty fact set would have masked the check.Full suite green (1230 examples), zero warnings on a clean build, HLint clean. Every golden shrank or stayed put: 180 insertions against 409 deletions over 60
golden.luafiles, no file grew, and noeval/golden.txtmoved.One caveat found while stress-running the property tests:
IR Optimizer / inlines expressions referenced oncefails roughly once per 100 runs. It reproduces at the same rate on unmodifiedmain(245 examples, same test), and it exercises onlyoptimizedExpression, which this branch does not touch — pre-existing, and I will file it separately.