Skip to content

feat(ir): dissolve a single-use header-free foreign import - #344

Merged
Unisay merged 2 commits into
mainfrom
issue-251/inline-single-use-foreign
Jul 29, 2026
Merged

feat(ir): dissolve a single-use header-free foreign import#344
Unisay merged 2 commits into
mainfrom
issue-251/inline-single-use-foreign

Conversation

@Unisay

@Unisay Unisay commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

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 ForeignImport node and an ObjectProp (field projection) accessor per name. The inliner refuses to paste a ForeignImport into 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.ForeignSharing is 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:

local Golden_ForeignSharing_Token_foreign = { token = {} }
return { token = Golden_ForeignSharing_Token_foreign.token }

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 an if branch, never the right operand of Lua's short-circuiting and/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.Token becomes exactly what the issue predicted, one table allocation and one hash read lighter:

return { token = {} }

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:

-local Data_Show_foreign = { showIntImpl = function(n) return tostring(n) end }
-local Data_Show_showIntImpl = Data_Show_foreign.showIntImpl
-local Effect_Console_foreign = {
-  log = function(s) return function() print(s) end end
-}
-local Effect_Console_log = Effect_Console_foreign.log
+local Data_Show_showIntImpl = function(n) return tostring(n) end
+local Effect_Console_log = function(s) return function() print(s) end end

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.headerFreeForeigns parses each foreign source and returns the modules whose chunk is a bare return { … }. 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 ProgramFacts record 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. Its mempty asserts neither, so every existing unit-test call site stays optimizedUberModule mempty unchanged 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 new Golden.ForeignHeader.Test is the same shape as the Data.Show case 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:

local prefix = "tag"
return {
  tag = (prefix .. "ged")
}
local Effect_Console_log = function(s) return function() print(s) end end
local Golden_ForeignHeader_Test_foreign = (function()
  local prefix = "tag"
  return { tag = prefix .. "ged" }
end)()
local Golden_ForeignHeader_Test_tag = Golden_ForeignHeader_Test_foreign.tag

The header-free Effect.Console import next to it collapses; the one with the header stays wrapped and keeps its accessor. It carries an eval/golden.txt oracle (tagged twice), so the fixture is checked by running it, not only by shape.

Golden.Foreign.Test shows the same split within one file, the header-free Lib import folding to the literal 100 while the header-carrying one stays:

-local Golden_Foreign_Lib_foreign = { alive = 100 }
 local Golden_Foreign_Test_foreign = (function()
   local fooBar = 42
   return { foo = fooBar + 1, boo = fooBar + 2 }
 end)()
 return {
   foo = Golden_Foreign_Test_foreign.foo,
-  baz = {
-    [1] = Golden_Foreign_Test_foreign.boo,
-    [2] = Golden_Foreign_Lib_foreign.alive
-  }
+  baz = { [1] = Golden_Foreign_Test_foreign.boo, [2] = 100 }
 }

The identity oracle still holds

Golden.ForeignSharing.Test asks at runtime whether two reads of a foreign token are the same object and prints shared or fresh. Its import now folds — into the shared accessor, so the table is allocated exactly once and both reads see it:

local Golden_ForeignSharing_Token_token = {}
return (function(s) return function() print(s) end end)((function()
  if (function(a)
    return function(b) return rawequal(a, b) end
  end)(Golden_ForeignSharing_Token_token)(Golden_ForeignSharing_Token_token) then
    return "shared"
  else
    return "fresh"
  end
end)())()

eval/golden.txt still reads shared, 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:

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 oracles change by line number only, since dropping a local shifts everything after it up by one. The two other trace reports that move, trace_effect_step and trace_state_step, keep their abort, compiled, and blacklist counts exactly; only their line numbers shift.

trace_ref_loop is the one report whose counts change, and it needs a caveat rather than a victory lap:

-counts: aborts=1 compiled=3 blacklisted=0
+counts: aborts=1 compiled=4 blacklisted=0

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 one local from 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 workload reps (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/ci runs 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 inlineSingleUseForeignsPass swapped 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.

folds the import into the export that reads it [✘]
collapses the import into a kept accessor binding [✘]
keeps an import whose source has header statements [✔]
keeps an import read at two sites [✔]
keeps an import read from an if branch [✔]

The existing #175 regression 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.lua files, no file grew, and no eval/golden.txt moved.

One caveat found while stress-running the property tests: IR Optimizer / inlines expressions referenced once fails roughly once per 100 runs. It reproduces at the same rate on unmodified main (245 examples, same test), and it exercises only optimizedExpression, which this branch does not touch — pre-existing, and I will file it separately.

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
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.
@Unisay
Unisay merged commit 0e7eede into main Jul 29, 2026
4 of 6 checks passed
@Unisay
Unisay deleted the issue-251/inline-single-use-foreign branch July 29, 2026 07:37
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.

Inline a single-use, header-free ForeignImport into a once-evaluated use site

1 participant