Skip to content

Lift the *.Uncurried run wrappers to direct n-ary calls (#198) - #226

Merged
Unisay merged 6 commits into
mainfrom
issue-198/lift-uncurried-wrappers
Jul 11, 2026
Merged

Lift the *.Uncurried run wrappers to direct n-ary calls (#198)#226
Unisay merged 6 commits into
mainfrom
issue-198/lift-uncurried-wrappers

Conversation

@Unisay

@Unisay Unisay commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #198.

The forks declare uncurried FFI throughout (zipWithImpl is Fn3, pushImpl is STFn2, indexImpl is Fn4), but until now a saturated runSTFn2(pushImpl)(x)(arr)() still compiled to the curried runtime fallback: two closure allocations plus a thunk per call, exactly what curried FFI costs. On the Lua target the ecosystem's uncurried discipline bought nothing.

This lifts the run half of the *.Uncurried wrappers through the foreign lifter (#178) into the AppN node (#179), so a saturated call site collapses to a single direct Lua call.

Changes

1. Lift the run wrappers (ForeignLift). runFn2runFn10, runSTFn1runSTFn10, and runEffectFn1runEffectFn10 join the allowlist. Two shapes were added to the lifter: a saturated call fn(a, b, …) lifts to AppN, and the zero-parameter effect thunk function() … end lifts to an Abs with an unused parameter. So runFn3 becomes \fn a b c -> AppN fn [a, b, c] and runSTFn2 becomes \fn a b -> Abs _ (AppN fn [a, b]). These are inline-always like every lifted accessor, so a saturated site beta-reduces to a direct AppN impl [x, y, z], and a partial application keeps the wrapper's curried fallback. The mk* counterparts need an n-ary AbsN (#227) and stay opaque; runFn0 (a nullary call, no AppN) and runFn1 (PureScript id, no FFI) are not lifted. A literal-lambda call head (an inlined header local, or a parenthesized function literal — Lua legally calls either at any argument count) must be applied at exactly its own arity: a mismatch declines rather than build an ill-formed AppN (the WellApplied invariant), keeping the designed failure mode — decline or NotLiftable, never malformed IR.

2. Fuse the effect run at codegen (Lua backend). The issue expected magic-do to fuse the lifted effect thunk "for free", but it did not: a lifted effect action is a literal thunk \_ -> fn(a, …), and running it ((\_ -> fn(a, …)) EffectRunArg) was lowered to (function() return fn(a, …) end)(), leaving one residual closure. The fix lowers that shape straight to the call fn(a, …).

This has to happen at code generation, not in the IR: the EffectRunArg marker is what tells dead-code elimination to keep a result-unused effect statement (local _ = m()), so stripping it in the optimizer drops the effect entirely. The marker stays in the IR through the whole pipeline; only the Lua backend, downstream of DCE, drops the redundant force. The fusion is restricted to an AppN body, which always lowers to an expression, so it never inlines a Let chunk (which would merge locals past magic-do's chunk boundaries).

Before / after

For an Effect/ST statement (Golden.UncurriedLift):

-- before: runEffectFn2(logTwice)("hello")("world")()  (3 closures, 4 calls)
-- after:
local _ = M.Golden_UncurriedLift_Test_logTwice("hello", "world")

For a pure runFn3 site, saturated and partial:

-- runFn3 add3 1 2 3        ->  add3(1, 2, 3)
-- runFn3 add3 1 2 (partial) -> function(c) return add3(1, 2, c) end

The runFn* / runEffectFn* accessors disappear from the emitted FFI tables entirely.

Testing

  • Unit tests (ForeignLift.Spec) for the new lift shapes (runFn3, runSTFn2, runEffectFn1), the declines (mkFn2, nullary runFn0, a literal-lambda call at a mismatched arity), and allowlist membership.
  • A runnable golden (Golden.UncurriedLift) exercising pure runFn2/runFn3, a partial application, an effectful runEffectFn2, and a runSTFn2 site that links the real purescript-lua-st fork FFI through the lifter — so a fork release reshaping its Uncurried.lua trips the allowlist hard contract in this repo's CI instead of only downstream. The eval oracle checks the runtime output, and golden.lua pins the collapsed calls.
  • Full suite green; property specs stable across seeds; hlint/fourmolu clean.

Golden.StringCodePoints shifts by name index only (the structural diff is empty and its eval oracle is unchanged): its dependency closure references a wrapper before DCE, so the lifter now moves it to a binding with named parameters, uniquify renumbers, and DCE then drops the dead wrapper. Same benign churn the #178 lifter produces.

Notes

The #172 STFn-call microbenchmark and the foldl-driver currying (#24) are out of scope here, as the issue notes. The codegen fusion applies in every statement position, tail included, so a lifted effect wrapper leaves no residual closure wherever it appears in a do-block.

Lift runFn2-10, runSTFn1-10, runEffectFn1-10 from their curried runtime fallbacks into inline-always IR through the foreign lifter (#178) and the AppN node (#179): runFn3 becomes `\fn a b c -> AppN fn [a, b, c]` and runSTFn2 becomes `\fn a b -> Abs _ (AppN fn [a, b])`. A saturated call site beta-reduces to a single n-ary Lua call; a partial application keeps the curried fallback. The mk* wrappers need an n-ary AbsN (#24) and stay opaque.

An Effect/ST statement whose action is such a lifted wrapper sheds its last closure at codegen: the effect run of a literal thunk `(\_ -> fn(a...)) EffectRunArg` lowers straight to `fn(a...)` rather than `(function() return fn(a...) end)()`. The EffectRunArg marker stays in the IR so DCE still keeps the result-unused effect statement; only the Lua backend drops the redundant force.

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 improves performance of uncurried FFI calls on the Lua backend by lifting the *.Uncurried run* wrappers into IR as direct AppN calls (so saturated call sites compile to a single Lua call) and by fusing away the final “run effect thunk” at Lua code generation time.

Changes:

  • Extend the Lua foreign lifter allowlist to include runFn2..10, runSTFn1..10, and runEffectFn1..10, and add lift support for (a) saturated calls with 1+ arguments → AppN and (b) zero-arg Lua thunks → unary Abs with ParamUnused.
  • Add a Lua backend codegen fast-path to lower (\_ -> AppN ...) EffectRunArg directly to the inner AppN ... call.
  • Add unit tests for the new lift shapes and a runnable golden (Golden.UncurriedLift) pinning the collapsed Lua calls.

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
lib/Language/PureScript/Backend/Lua/ForeignLift.hs Adds allowlist entries + lifting support for run* wrappers (saturated calls → AppN, effect thunk → Abs with unused param).
lib/Language/PureScript/Backend/Lua.hs Adds codegen fusion for running a lifted effect thunk directly to an AppN call.
test/Language/PureScript/Backend/Lua/ForeignLift/Spec.hs Adds unit tests covering new run* lift shapes and allowlist membership/declines.
test/ps/src/Golden/UncurriedLift/Test.purs New PureScript golden source exercising saturated + partial runFn* and statement-position runEffectFn*.
test/ps/spago.yaml Adds functions package dependency for the new golden source.
test/ps/spago.lock Updates direct dependency list to include functions.
test/ps/output/Golden.UncurriedLift.Test/corefn.json New CoreFn artifact for the added golden module.
test/ps/output/Golden.UncurriedLift.Test/golden.ir New IR golden pinning lifted AppN/thunk shapes.
test/ps/output/Golden.UncurriedLift.Test/golden.lua New Lua golden verifying direct n-ary calls and fused effect statement shape.
test/ps/output/Golden.UncurriedLift.Test/eval/golden.txt New eval oracle for runnable golden output.
test/ps/output/Golden.UncurriedLift.Test/eval/.gitignore Ignores eval actual output file.
test/ps/output/Golden.StringCodePoints.Test/golden.ir Mechanical name/index churn from new lifting behavior (no semantic change intended).
test/ps/output/Golden.StringCodePoints.Test/golden.lua Mechanical name/index churn from new lifting behavior (no semantic change intended).
changelog.d/20260710_131814_unisay_lift_uncurried_wrappers.md Changelog fragment describing the lifting + codegen fusion behavior.

Comment thread lib/Language/PureScript/Backend/Lua.hs
Lifting the uncurried Array/ST wrappers to direct n-ary calls removes three closure allocations from the ArrayFoldl macro bench (total FNEW 17 -> 14; main-chunk 6 -> 5, function-body 11 -> 9). The trace report shifts by line number only. The other bench artifacts are unchanged.
@Unisay Unisay self-assigned this Jul 10, 2026
@Unisay
Unisay marked this pull request as ready for review July 10, 2026 15:49
Comment thread test/Language/PureScript/Backend/Lua/ForeignLift/Spec.hs Outdated
Unisay added 2 commits July 11, 2026 11:20
…#198)

The FunctionCall lift accepted any argument count on a lifted head, so an allowlisted export calling an inlined header-local lambda (or a parenthesized function literal) at the wrong arity would build an ill-formed AppN — violating the WellApplied invariant instead of falling back to an opaque foreign. Guard the case: a literal-AbsN head must be applied at exactly its own parameter count; a mismatch declines.

Also correct the surrounding docs found in review: the effect thunk is run by magic-do but shed at code generation (not "fused via magicDo"), fix an unparseable comment sentence in the fromIR special case, and wrap the new spec lines to the 80-column limit.
runSTFn1..10 sit on the allowlist hard contract, but no CI path compiled the actual purescript-lua-st fork's Uncurried.lua through liftForeigns — a fork release reshaping it would break every downstream project while this repo stayed green. Add a runSTFn2 site to the UncurriedLift golden (st joins the test project's dependencies): the golden now pins the lifted direct n-ary call sumST(40, 2) and the eval oracle checks its result.
The comments pointing at the mk wrappers' missing n-ary AbsN lift cited #24, a closed issue that never covered mk-lifting. That work is now tracked by #227, so the allowlist comment, the spec test names, and the changelog fragment point there instead. Legitimate #24 references (the worker/wrapper uncurrying split itself) are untouched.
The membership tests restated the allowlist literal item by item, so any edit to the set required the same edit in the spec without any invariant being checked. The contract that matters (every listed export lifts against the real fork FFI) is exercised by the golden and bench links, which run liftForeigns over the actual package-set sources.
@Unisay
Unisay merged commit 90cd173 into main Jul 11, 2026
2 checks passed
@Unisay
Unisay deleted the issue-198/lift-uncurried-wrappers branch July 11, 2026 09:44
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.

Lift the *.Uncurried wrappers to direct AppN calls

2 participants