Lift the *.Uncurried run wrappers to direct n-ary calls (#198) - #226
Merged
Conversation
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.
Contributor
There was a problem hiding this comment.
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, andrunEffectFn1..10, and add lift support for (a) saturated calls with 1+ arguments →AppNand (b) zero-arg Lua thunks → unaryAbswithParamUnused. - Add a Lua backend codegen fast-path to lower
(\_ -> AppN ...) EffectRunArgdirectly to the innerAppN ...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. |
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
marked this pull request as ready for review
July 10, 2026 15:49
Unisay
commented
Jul 10, 2026
…#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.
This was referenced Jul 11, 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 #198.
The forks declare uncurried FFI throughout (
zipWithImplisFn3,pushImplisSTFn2,indexImplisFn4), but until now a saturatedrunSTFn2(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
runhalf of the*.Uncurriedwrappers through the foreign lifter (#178) into theAppNnode (#179), so a saturated call site collapses to a single direct Lua call.Changes
1. Lift the run wrappers (
ForeignLift).runFn2…runFn10,runSTFn1…runSTFn10, andrunEffectFn1…runEffectFn10join the allowlist. Two shapes were added to the lifter: a saturated callfn(a, b, …)lifts toAppN, and the zero-parameter effect thunkfunction() … endlifts to anAbswith an unused parameter. SorunFn3becomes\fn a b c -> AppN fn [a, b, c]andrunSTFn2becomes\fn a b -> Abs _ (AppN fn [a, b]). These are inline-always like every lifted accessor, so a saturated site beta-reduces to a directAppN impl [x, y, z], and a partial application keeps the wrapper's curried fallback. Themk*counterparts need an n-aryAbsN(#227) and stay opaque;runFn0(a nullary call, noAppN) andrunFn1(PureScriptid, 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-formedAppN(the WellApplied invariant), keeping the designed failure mode — decline orNotLiftable, never malformed IR.2. Fuse the effect run at codegen (
Luabackend). 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 callfn(a, …).This has to happen at code generation, not in the IR: the
EffectRunArgmarker 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 anAppNbody, which always lowers to an expression, so it never inlines aLetchunk (which would merge locals past magic-do's chunk boundaries).Before / after
For an
Effect/STstatement (Golden.UncurriedLift):For a pure
runFn3site, saturated and partial:The
runFn*/runEffectFn*accessors disappear from the emitted FFI tables entirely.Testing
ForeignLift.Spec) for the new lift shapes (runFn3,runSTFn2,runEffectFn1), the declines (mkFn2, nullaryrunFn0, a literal-lambda call at a mismatched arity), and allowlist membership.Golden.UncurriedLift) exercising purerunFn2/runFn3, a partial application, an effectfulrunEffectFn2, and arunSTFn2site that links the realpurescript-lua-stfork FFI through the lifter — so a fork release reshaping itsUncurried.luatrips the allowlist hard contract in this repo's CI instead of only downstream. Theevaloracle checks the runtime output, andgolden.luapins the collapsed calls.hlint/fourmoluclean.Golden.StringCodePointsshifts by name index only (the structural diff is empty and itsevaloracle is unchanged): its dependency closure references a wrapper before DCE, so the lifter now moves it to a binding with named parameters,uniquifyrenumbers, and DCE then drops the dead wrapper. Same benign churn the #178 lifter produces.Notes
The
#172STFn-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.