perf: rebuild sharing for multi-use foreign accessor reads (#248) - #255
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates the PureScript-to-Lua backend’s handling of foreign accessor inlining/sharing so that multi-use foreign accessor reads are re-shared (to benefit from stage-2 Lua “promotion” into chunk locals) while preserving explicit @inline always/never semantics and ensuring ForeignLift-lifted bodies still inline for beta-reduction.
Changes:
- Stop forcing
@inline alwayson all foreign accessors emitted by the linker; keep only explicit pragmas. - Add a final IR pass (
shareForeignAccessors) to re-bind duplicated unannotated foreign accessor reads (2+ sites) back to their linker QName, rewriting reads to references. - Update ForeignLift to mark lifted bodies as
@inline always, and add/refresh golden + optimizer spec coverage for the new behavior.
Reviewed changes
Copilot reviewed 70 out of 71 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
lib/Language/PureScript/Backend/IR/Inliner.hs |
Updates documentation/semantics around foreign accessor pragmas and late sharing rebuild. |
lib/Language/PureScript/Backend/IR/Linker.hs |
Stops defaulting foreign accessors to Inline.Always; adds foreignAccessorQName helper. |
lib/Language/PureScript/Backend/IR/Optimizer.hs |
Adds shareForeignAccessors pass and wires it into the pipeline. |
lib/Language/PureScript/Backend/Lua/ForeignLift.hs |
Marks lifted foreign bodies as @inline always unless an explicit pragma exists. |
test/Language/PureScript/Backend/IR/Optimizer/Spec.hs |
Adds unit tests for multi-use accessor sharing behavior (issue #248). |
test/ps/src/Golden/ForeignAccessorDefault/Test.purs |
New golden source demonstrating multi-use vs single-use accessor behavior. |
test/ps/src/Golden/ForeignAccessorDefault/Test.lua |
New FFI module for the golden. |
test/ps/output/Golden.ForeignAccessorDefault.Test/corefn.json |
New compiled CoreFn for the new golden module. |
test/ps/output/Golden.ForeignAccessorDefault.Test/golden.ir |
New IR golden capturing the post-pass sharing shape. |
test/ps/output/Golden.ForeignAccessorDefault.Test/golden.lua |
New Lua golden showing the promoted-local sharing vs single-site field read. |
test/ps/output/Golden.ForeignAccessorDefault.Test/eval/golden.txt |
New eval oracle for the new golden. |
test/ps/output/Golden.ForeignAccessorDefault.Test/eval/.gitignore |
Ignores eval actual output for the new golden. |
changelog.d/20260712_150000_unisay_foreign_accessor_default.md |
Changelog entry describing the new default + pragma behavior. |
test/ps/output/Golden.Uncurry.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.Uncurry.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.UncurriedLift.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.UncurriedLift.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.TestReturnTableField/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.TailRecM2Shadow.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.TailRecM2Shadow.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.StringEscapes.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.StringCodePoints.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.StringCodePoints.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.RecGroupOrder.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.ProfunctorDictLens.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.ProfunctorDictLens.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.Primops.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.Primops.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.NumberIsNaN.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.NumberIsNaN.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.MaybeChain.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.MaybeChain.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.Loopification.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.Loopification.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.LongWriterBind.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.LongStateBind.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.LongStackBind.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.LongReaderBind.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.LongMaybeBind.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.LongExceptBind.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.LongEitherBind.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.LongDoBlock.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.LongCallbackChain.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.LongBindFlipped.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.LongApplyChain.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.Issue37.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.HelloPrelude.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.GenericEqTwoTypes.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.GenericEqTwoTypes.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.ForeignSharing.Token/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.ForeignSharing.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.ForeignSharing.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.Foreign.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.Foreign.Lib/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.FloatIn.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.FloatIn.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.FieldCaching.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.FieldCaching.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.Fibonacci.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.DerivedFunctor.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.DerivedFunctor.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.CharLiterals.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.CharLiterals.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.BugListGenericEq.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.BugListGenericEq.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.ArrayPatternMatch.Test/golden.ir |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.ArrayPatternMatch.Test/golden.lua |
Golden churn reflecting new accessor sharing/rebinding behavior. |
test/ps/output/Golden.ArrayOfUnits.Test/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.Annotations.M2/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
test/ps/output/Golden.Annotations.M1/golden.ir |
Golden churn: foreign accessor annotations/shape changes. |
Unisay
marked this pull request as ready for review
July 12, 2026 14:09
The linker annotated every foreign accessor @inline always, so each use site re-materialized a field read off the foreign table. That default bought wrapper transparency, but stage 2 of #174 made the shared form strictly cheaper for multi-use names: an accessor kept as a binding is promoted to a chunk local, so every use is a register or upvalue access with zero table reads. The linker now emits accessors carrying only their explicit pragma, and a final IR pass (shareForeignAccessors, after the specialize fixpoint) counts the accessor reads that survived optimization and re-binds every read occurring at two or more sites to its linker QName, rewriting the reads to references. Counting at the end of the pipeline also catches the copies minted by call-site inlining — a dictionary method resolved at several sites pastes one field read per site — so dict-projected primitives like Data.Show.showIntImpl share too. Single-use accessors keep the dissolved form: no binding, no local slot. Explicit pragmas keep their meaning in both directions: always opts a name out of the re-binding, never keeps the accessor a shared binding from the start. ForeignLift-lifted bodies are now marked always by the lift itself, since they exist to beta-reduce at saturated call sites. Eval goldens are unchanged; the structural golden churn is the shared accessors materializing as promoted locals.
A foreign used at two call sites materializes as a promoted chunk local and both sites reference it; a foreign used once keeps the dissolved form, a field read at its one use site. The eval oracle pins the runtime output either way.
…UIRKS.md .github/copilot-instructions.md still described the pre-#173 FFI convention — required parens around exported values and a ban on comments between table fields — both lifted once pslua started parsing foreign files with its own Lua 5.1 parser. docs/QUIRKS.md and CLAUDE.md were updated in #173; this file was added afterward and never synced, which is why Copilot flagged a compliant FFI export in PR #255 as invalid.
#254 (graduated @inline directives) merged to main after this branch was written and added Golden.DirectiveArity/DirectiveAccessor plus touched Golden.LongWriterBind's codegen. Rebasing this branch onto it exposes shareForeignAccessors to foreign accessors used at two or more sites in those goldens (showIntImpl, log, concatArray) for the first time, which now materialize as promoted shared locals instead of repeated field reads — the same effect already accepted for the other 40+9 goldens in the first commit, just triggered by the new base. eval/golden.txt is byte-identical for all three tests; only golden.ir/golden.lua move.
Unisay
force-pushed
the
issue-248/foreign-accessor-inline-default
branch
from
July 12, 2026 14:52
31f0ad7 to
759f50c
Compare
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 #248.
Problem
The linker annotated every foreign accessor
@inline always, so each use site re-materialized a field read off the foreign table:Data_Show_foreign.showIntImpl(x). The default bought wrapper transparency, but stage 2 of #174 (PR #246) made the shared form strictly cheaper for multi-use names: an accessor kept as a binding is promoted to a chunk local, so every use is a register or upvalue access with zero table reads (the ADR 0001 microbenchmark puts the per-read win at 2.4x on PUC 5.1).What changed
The linker now emits accessors carrying only their explicit pragma annotation (
Note [Foreign bindings structure emitted by the Linker]), and a new final IR passshareForeignAccessors(after the specialize fixpoint, beforeflattenDeepBinds) counts the accessor reads that survived optimization and re-binds every read occurring at two or more sites to its linker QName, rewriting the reads to references. The re-bound accessor is inserted right after its module'sForeignImportbinding, so module-init order stays valid, and stage-2 promotion then turns it into a chunk local through the existing budget accounting.Counting at the end of the pipeline catches duplication of any origin. The obvious alternative, keeping multi-use accessors at the
withBindingdecision, misses the copies minted later by call-site inlining (#180): a dictionary method resolved at several sites pastes one field read per site, and that is exactly howData.Show.showIntImplreaches its use sites. Measured after the last pass that can multiply reads,showIntImplnow lands as the shape the issue asks for:Constraints from the issue:
@inline <name> alwaysopts a name out of the re-binding and keeps per-site field reads at any use count, while@inline <name> neverkeeps the accessor a shared binding from the start. Fork-side pragmas remain the escape hatch.alwaysby the lift itself. Without the annotation a multi-use lifted body (an abstraction, not a cheap projection) would stay a shared binding and its call sites would never beta-reduce.magicDohas already consumed the Effect/ST chains. AllLong*eval goldens are unchanged.@inline unit alwaystonever— inlining unit duplicates the singleton allocation #176 becomes a special case: a multi-useunitaccessor now shares one promoted local holding the singleton, with no per-name pragma needed.Verification
Golden.ForeignAccessorDefault.Test(with eval oracle): a foreign used at two call sites materializes as a promoted local and both sites reference it; a foreign used once keeps the dissolved field read.Optimizer/Spec: a multi-use unannotated accessor is kept as a shared binding, a single-use one dissolves, an explicitalwaysdissolves a multi-use accessor, and the existingnever-veto test still passes.golden.irfiles (mostlyJust Alwaysturning intoNothingon dissolved reads) and 9golden.luafiles, all of them the shared form appearing (log,isNaN,unsafeCoerce,Ref.read,showIntImpl, ...). Everyeval/golden.txtis byte-identical, so the semantic oracle never moved.cabal test allis green on the default seed plus seeds 7, 1337 and 424242 (the optimizer was touched);hlintis clean;fourmoluapplied.