Skip to content

perf: rebuild sharing for multi-use foreign accessor reads (#248) - #255

Merged
Unisay merged 4 commits into
mainfrom
issue-248/foreign-accessor-inline-default
Jul 12, 2026
Merged

perf: rebuild sharing for multi-use foreign accessor reads (#248)#255
Unisay merged 4 commits into
mainfrom
issue-248/foreign-accessor-inline-default

Conversation

@Unisay

@Unisay Unisay commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

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 pass shareForeignAccessors (after the specialize fixpoint, before flattenDeepBinds) 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's ForeignImport binding, 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 withBinding decision, 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 how Data.Show.showIntImpl reaches its use sites. Measured after the last pass that can multiply reads, showIntImpl now lands as the shape the issue asks for:

local Data_Show_showIntImpl = Data_Show_foreign.showIntImpl

Constraints from the issue:

  • Explicit pragmas keep their meaning in both directions: @inline <name> always opts a name out of the re-binding and keeps per-site field reads at any use count, while @inline <name> never keeps the accessor a shared binding from the start. Fork-side pragmas remain the escape hatch.
  • ForeignLift-lifted bodies are now marked always by 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.
  • DCE is unaffected: the pass runs on the post-DCE module and only re-binds reads that are live by construction.
  • Magic-do is unaffected: mid-pipeline shapes are identical to before, since accessors still dissolve like any cheap projection, and the sharing is rebuilt after magicDo has already consumed the Effect/ST chains. All Long* eval goldens are unchanged.
  • [fork-ffi] prelude: flip @inline unit always to never — inlining unit duplicates the singleton allocation #176 becomes a special case: a multi-use unit accessor now shares one promoted local holding the singleton, with no per-name pragma needed.

Verification

  • New golden 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.
  • Unit tests in Optimizer/Spec: a multi-use unannotated accessor is kept as a shared binding, a single-use one dissolves, an explicit always dissolves a multi-use accessor, and the existing never-veto test still passes.
  • Golden churn: 40 golden.ir files (mostly Just Always turning into Nothing on dissolved reads) and 9 golden.lua files, all of them the shared form appearing (log, isNaN, unsafeCoerce, Ref.read, showIntImpl, ...). Every eval/golden.txt is byte-identical, so the semantic oracle never moved.
  • cabal test all is green on the default seed plus seeds 7, 1337 and 424242 (the optimizer was touched); hlint is clean; fourmolu applied.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 always on 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.

Comment thread test/ps/src/Golden/ForeignAccessorDefault/Test.lua
Comment thread test/Language/PureScript/Backend/IR/Optimizer/Spec.hs
@Unisay
Unisay marked this pull request as ready for review July 12, 2026 14:09
Unisay added 4 commits July 12, 2026 16:47
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
Unisay force-pushed the issue-248/foreign-accessor-inline-default branch from 31f0ad7 to 759f50c Compare July 12, 2026 14:52
@Unisay
Unisay merged commit 74ccc53 into main Jul 12, 2026
2 checks passed
@Unisay
Unisay deleted the issue-248/foreign-accessor-inline-default branch July 12, 2026 15:01
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.

Revisit the foreign-accessor @inline always default: stage-2 promotion made the shared form free

2 participants