Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions bench/goldens/fnew_Bench.ArrayFoldl.txt
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
chunk: Bench.ArrayFoldl.lua
runtime: LuaJIT 2.1.1741730670
main-chunk FNEW: 6
function-body FNEW: 11
total FNEW: 17
prototypes: 18
main-chunk FNEW: 5
function-body FNEW: 9
total FNEW: 14
prototypes: 15
function-body FNEW sites:
Bench.ArrayFoldl.lua:11
Bench.ArrayFoldl.lua:10
Bench.ArrayFoldl.lua:21
Bench.ArrayFoldl.lua:20
Bench.ArrayFoldl.lua:26
Bench.ArrayFoldl.lua:26
Bench.ArrayFoldl.lua:49
Bench.ArrayFoldl.lua:48
Bench.ArrayFoldl.lua:47
Bench.ArrayFoldl.lua:56
Bench.ArrayFoldl.lua:55
Bench.ArrayFoldl.lua:44
Bench.ArrayFoldl.lua:43
Bench.ArrayFoldl.lua:42
Bench.ArrayFoldl.lua:51
Bench.ArrayFoldl.lua:50
8 changes: 4 additions & 4 deletions bench/goldens/trace_array_foldl.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ spec: array_foldl
runtime: LuaJIT 2.1.1741730670
workload: n=5000000 reps=2 result=12500002500000
aborts (distinct site -- reason):
Bench.ArrayFoldl.lua:55 -- NYI: bytecode FNEW
Bench.ArrayFoldl.lua:50 -- NYI: bytecode FNEW
bytecode end state (J*=compiled, I*=blacklisted):
Bench.ArrayFoldl.lua:18 IFORL
Bench.ArrayFoldl.lua:34 JLOOP
Bench.ArrayFoldl.lua:54 IFUNCF
Bench.ArrayFoldl.lua:55 JFUNCF
Bench.ArrayFoldl.lua:29 JLOOP
Bench.ArrayFoldl.lua:49 IFUNCF
Bench.ArrayFoldl.lua:50 JFUNCF
array_foldl.lua:13 JFORI
array_foldl.lua:13 JFORL
array_foldl.lua:17 JFORI
Expand Down
21 changes: 21 additions & 0 deletions changelog.d/20260710_131814_unisay_lift_uncurried_wrappers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
### Changed

- The `run` half of the `*.Uncurried` FFI wrappers now lifts to direct n-ary
calls (#198). Building on the foreign lifter (#178) and the `AppN` node
(#179), `runFn2`…`runFn10`, `runSTFn1`…`runSTFn10`, and
`runEffectFn1`…`runEffectFn10` are lifted from their curried runtime
fallbacks to inline-always IR: `runFn3` becomes `\fn a b c -> AppN fn [a, b,
c]`, and `runSTFn2` becomes `\fn a b -> Abs _ (AppN fn [a, b])` (the trailing
effect thunk is a unary lambda with an unused parameter). A saturated call
site then beta-reduces to a single n-ary Lua call, so `runFn3 impl x y z`
compiles to `impl(x, y, z)` rather than the two-closure curried onion; a
partial application keeps the wrapper's curried fallback. The `mk`
counterparts need an n-ary `AbsN` (#227) and stay opaque.
- An `Effect`/`ST` statement whose action is a lifted uncurried wrapper now
sheds its final closure at code generation: the effect run of a literal thunk,
`(\_ -> fn(a, …)) EffectRunArg`, lowers straight to the call `fn(a, …)`
instead of `(function() return fn(a, …) end)()`. The `EffectRunArg` marker is
kept through the IR pipeline so dead-code elimination still keeps the
result-unused effect statement; only the Lua backend drops the redundant
force. On the ST/Array boundary this turns `runSTFn2(pushImpl)(x)(arr)()` from
four calls and three closures into one `pushImpl(x, arr)`.
16 changes: 16 additions & 0 deletions lib/Language/PureScript/Backend/Lua.hs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,22 @@ fromIR foreigns topLevelNames modname ir = case ir of
pure . Right $ case body of
Left chunk → Lua.functionDef luaParams chunk
Right e → Lua.functionDef luaParams [Lua.return e]
-- Running the literal thunk that a saturated lifted @*.Uncurried@ effect
-- wrapper reduces to — @(\_ -> fn(a, …)) EffectRunArg@ — is just the call
-- @fn(a, …)@: the uncurried @fn@ runs once it has every argument, so no
-- thunk need be built and immediately forced (issue #198). This is the
-- effect-side payoff of the lift — @fn(a, …)@ instead of
-- @(function() return fn(a, …) end)()@. The 'IR.EffectRunArg' marker is
-- kept in the IR through the pipeline, so dead-code elimination still keeps
-- the (result-unused) effect statement (see 'IR.isEffectRun'); only codegen
-- drops the redundant force. The body is required to be an 'IR.AppN', which
-- always lowers to an expression — so this never inlines a 'Let' (magic-do
-- keeps such chunk boundaries thunked to bound locals per Lua function).
IR.AppN
_ann
(IR.AbsN _ (IR.ParamUnused _ :| []) body@IR.AppN {})
(IR.EffectRunArg _ :| []) →
Right <$> goExp body
Comment thread
Unisay marked this conversation as resolved.
IR.AppN _ann fn args → do
e ← goExp fn
-- See Note [Nullary functions and Prim.undefined]. PS inserts a
Expand Down
71 changes: 62 additions & 9 deletions lib/Language/PureScript/Backend/Lua/ForeignLift.hs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,29 @@ registry of qualified-name → IR mappings, is what makes registry drift
against the package set impossible by construction — the payoff of
Lua's tiny grammar (issue #178).

The same machinery lifts the @run@ half of the @*.Uncurried@ wrappers
(issue #198): @runFn3@ becomes @\\fn a b c -> AppN fn [a, b, c]@ and
@runSTFn2@ becomes @\\fn a b -> Abs _ (AppN fn [a, b])@ (the trailing
effect thunk is a unary lambda with an unused parameter). Marked
inline-always like every lifted accessor, a saturated call site collapses
to a single n-ary Lua call after beta reduction; the effect thunk — run
in statement position by magic-do's 'EffectRunArg' application — is then
shed at code generation ('Language.PureScript.Backend.Lua.fromIR'),
turning @runSTFn2(pushImpl)(x)(arr)()@ from four calls and three closures
into one @pushImpl(x, arr)@.

= What lifts

The translatable subset, mirroring the shapes the prelude forks actually
use:

* curried single-parameter function literals → nested 'Abs';
* a zero-parameter function literal (the @*.Uncurried@ effect thunk,
@function() … end@) → a unary 'Abs' with an unused parameter;
* a saturated call @fn(a, b, …)@ of one or more arguments → the n-ary
'AppN' node (issue #198); a nullary @fn()@ has no 'AppN' and does not
lift, and a literal-lambda head called at any arity other than its
own declines (Note [n-ary application]);
* @return@ / @if … then … else@ trees (an @elseif@ is a nested @if@ in
the else branch) → 'IfThenElse', provided every branch returns a
value (a branch that falls through to @nil@ does not lift);
Expand All @@ -38,9 +55,9 @@ use:
header (inlined) — this is how @ordIntImpl = (unsafeCoerceImpl)@ and
the @refEq@ aliases resolve.

Everything else — loops, mutation, varargs, table constructors, calls,
multi-parameter functions, string/char literals — leaves the export
opaque (correct for e.g. @foldlArray@).
Everything else — loops, mutation, varargs, table constructors,
multi-parameter function literals, string/char literals — leaves the
export opaque (correct for e.g. @foldlArray@).
-}
module Language.PureScript.Backend.Lua.ForeignLift
( liftForeigns
Expand All @@ -67,14 +84,16 @@ import Language.PureScript.Backend.IR.Types
( Exp
, Grouping (Standalone)
, PrimOp (..)
, RawExp (ForeignImport, ObjectProp)
, RawExp (AbsN, ForeignImport, ObjectProp)
, abstraction
, applicationN
, eq
, ifThenElse
, literalBool
, literalFloat
, literalInt
, paramNamed
, paramUnused
, primBinOp
, primNot
, refLocal
Expand All @@ -101,11 +120,11 @@ import Prelude hiding (show)
--------------------------------------------------------------------------------
-- Allowlist -------------------------------------------------------------------

{- | The foreign exports lifted into the IR the arithmetic, comparison,
boolean, and concatenation core of the prelude (issue #178). Membership
is a hard contract (see the module header): a listed export that fails to
lift is a compile error. The @*.Uncurried@ modules and a broader
allowlist are follow-up work (issues #179, #187).
{- | The foreign exports lifted into the IR: the arithmetic, comparison,
boolean, and concatenation core of the prelude (issue #178), plus the
@run@ half of the @*.Uncurried@ wrappers (issue #198). Membership is a
hard contract (see the module header): a listed export that fails to lift
is a compile error. A broader allowlist is follow-up work (issue #187).
-}
allowlist ∷ Set QName
allowlist =
Expand Down Expand Up @@ -133,8 +152,20 @@ allowlist =
]
)
, ("Data.Semigroup", ["concatString"])
, -- The @run@ half of the uncurried FFI wrappers (issue #198). Their
-- @mk@ counterparts need an n-ary 'AbsN' (issue #227) and stay opaque;
-- @runFn0@ is a nullary call with no 'AppN', @runFn1@ is PureScript
-- @id@ with no foreign — both absent below.
("Data.Function.Uncurried", runWrappers "runFn" [2 .. 10])
, ("Control.Monad.ST.Uncurried", runWrappers "runSTFn" [1 .. 10])
, ("Effect.Uncurried", runWrappers "runEffectFn" [1 .. 10])
]

-- @[prefix<n> | n <- arities]@, e.g. @runWrappers "runFn" [2, 3]@ is
-- @["runFn2", "runFn3"]@.
runWrappers ∷ Text → [Int] → [Text]
runWrappers prefix arities = [prefix <> toText (show n) | n ← arities]

--------------------------------------------------------------------------------
-- Orchestration ---------------------------------------------------------------

Expand Down Expand Up @@ -275,6 +306,28 @@ liftLuaExp env bound = \case
Function [(_ann, ParamNamed param)] body →
abstraction (paramNamed (irName param))
<$> liftBlock env (Set.insert param bound) body
-- A zero-parameter function literal is the effect thunk of the
-- @run{ST,Effect}FnN@ wrappers: @function() return fn(a, b) end@. It
-- lifts to a unary 'Abs' with an unused parameter — the shape
-- magic-do runs in statement position and codegen then sheds, so a
-- saturated site fuses the thunk away entirely (issue #198).
Function [] body →
abstraction paramUnused <$> liftBlock env bound body
-- A saturated call @fn(a, b, …)@ — the body of the @runFnN@ wrappers —
-- lifts to the n-ary 'AppN' node (issue #198). A nullary call @fn()@
-- has no 'AppN' representation, so 'nonEmpty' declines it (e.g.
-- @runFn0 = \\fn -> fn()@ stays opaque).
FunctionCall (_ann, fn) args → do
fn' ← liftLuaExp env bound fn
args' ← nonEmpty args >>= traverse (\(_ann', a) → liftLuaExp env bound a)
-- A literal-lambda head (an inlined header local, or a parenthesized
-- function literal) must be called at exactly its own arity: any
-- other count would build an ill-formed 'AppN' (Note [n-ary
-- application]). Splitting the spine instead would change the
-- program — Lua drops surplus arguments — so a mismatch declines.
case fn' of
AbsN _ params _ | length params /= length args' → Nothing
_ → Just (applicationN fn' args')
_ → Nothing

{- | Translate a block that must be a pure return tree: a single @return@
Expand Down
114 changes: 93 additions & 21 deletions test/Language/PureScript/Backend/Lua/ForeignLift/Spec.hs
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
module Language.PureScript.Backend.Lua.ForeignLift.Spec where

import Data.Set qualified as Set
import Language.PureScript.Backend.IR.Names
( Name (..)
, QName (..)
, moduleNameFromString
)
import Language.PureScript.Backend.IR.Names (Name (..))
import Language.PureScript.Backend.IR.Types
( PrimOp (..)
, abstraction
, applicationN
, eq
, ifThenElse
, paramNamed
, paramUnused
, primBinOp
, primNot
, refLocal
)
import Language.PureScript.Backend.Lua.ForeignLift (allowlist, liftExport)
import Language.PureScript.Backend.Lua.ForeignLift (liftExport)
import Language.PureScript.Backend.Lua.Linker.Foreign
( Source
, interpretForeignModule
Expand Down Expand Up @@ -98,11 +95,100 @@ spec = describe "Foreign lift (#178)" do
primBinOp PrimConcat (refLocal (Name "s1")) (refLocal (Name "s2"))
)

describe "lifts the *.Uncurried run wrappers (#198)" do
it "lifts runFn3 to a saturated n-ary call" do
-- The pure wrapper: `\fn a b c -> fn(a, b, c)`. The body is a single
-- Lua call of every parameter at once, which lifts to the n-ary AppN
-- node. Marked inline-always downstream, a saturated site beta-reduces
-- to a direct `AppN impl [x, y, z]`.
let src =
"return { runFn3 = function(fn) return function(a) "
<> "return function(b) return function(c) "
<> "return fn(a, b, c) end end end end }"
fn = Name "fn"
a = Name "a"
b = Name "b"
c = Name "c"
liftExport (source src) (Name "runFn3")
`shouldBe` Just
( abstraction (paramNamed fn) $
abstraction (paramNamed a) $
abstraction (paramNamed b) $
abstraction (paramNamed c) $
applicationN
(refLocal fn)
(refLocal a :| [refLocal b, refLocal c])
)

it "lifts runSTFn2 to a thunk over an n-ary call" do
-- The effectful wrapper: `\fn a b -> \() -> fn(a, b)`. The trailing
-- `function()` thunk becomes a unary lambda with an unused parameter
-- (Abs paramUnused) — the exact shape magic-do runs in statement
-- position and codegen then sheds, so the thunk and its three
-- closures fuse away.
let src =
"return { runSTFn2 = function(fn) return function(a) "
<> "return function(b) return function() "
<> "return fn(a, b) end end end end }"
fn = Name "fn"
a = Name "a"
b = Name "b"
liftExport (source src) (Name "runSTFn2")
`shouldBe` Just
( abstraction (paramNamed fn) $
abstraction (paramNamed a) $
abstraction (paramNamed b) $
abstraction paramUnused $
applicationN (refLocal fn) (refLocal a :| [refLocal b])
)

it "lifts runEffectFn1 (single-argument n-ary call under a thunk)" do
let src =
"return { runEffectFn1 = function(fn) return function(a) "
<> "return function() return fn(a) end end end }"
fn = Name "fn"
a = Name "a"
liftExport (source src) (Name "runEffectFn1")
`shouldBe` Just
( abstraction (paramNamed fn) $
abstraction (paramNamed a) $
abstraction paramUnused $
applicationN (refLocal fn) (refLocal a :| [])
)

it "declines the mk* wrappers (their inner function is n-ary, #227)" do
-- `mkFn2 = \fn -> function(a, b) return fn(a)(b) end`: the inner
-- multi-parameter function needs an n-ary AbsN (issue #227), so the
-- wrapper stays an opaque foreign, not on this allowlist.
let src =
"return { mkFn2 = function(fn) "
<> "return function(a, b) return fn(a)(b) end end }"
liftExport (source src) (Name "mkFn2") `shouldSatisfy` isNothing

describe "declines everything outside the subset" do
it "declines a multi-parameter function (would misapply when curried)" do
liftExport (source "return { f = function(x, y) return x + y end }") (Name "f")
`shouldSatisfy` isNothing

it "declines a nullary call (AppN cannot express a zero-argument call)" do
-- `runFn0 = \fn -> fn()`. An n-ary AppN needs at least one argument;
-- the zero-argument call falls outside the subset and stays opaque.
liftExport
(source "return { runFn0 = function(fn) return fn() end }")
(Name "runFn0")
`shouldSatisfy` isNothing

it "declines a literal-lambda call at a mismatched arity" do
-- `local k = function(x) return x end; f = \a -> k(a, a)`: legal
-- Lua (the surplus argument is dropped), but the header local
-- inlines to a literal unary 'Abs', and applying it to two
-- arguments would build an ill-formed 'AppN' (Note [n-ary
-- application]) — so the export declines instead.
let src =
"local k = function(x) return x end\n"
<> "return { f = function(a) return k(a, a) end }"
liftExport (source src) (Name "f") `shouldSatisfy` isNothing

it "declines a body with a table index" do
liftExport (source "return { f = function(xs) return xs[1] end }") (Name "f")
`shouldSatisfy` isNothing
Expand All @@ -122,20 +208,6 @@ spec = describe "Foreign lift (#178)" do
liftExport (source "return { a = function(x) return x end }") (Name "b")
`shouldSatisfy` isNothing

describe "allowlist" do
it "lists the arithmetic/comparison/boolean/concat core" do
Set.member (qname "Data.Semiring" "intAdd") allowlist `shouldBe` True
Set.member (qname "Data.Ord" "ordIntImpl") allowlist `shouldBe` True
Set.member (qname "Data.Eq" "refEq") allowlist `shouldBe` True
Set.member (qname "Data.Semigroup" "concatString") allowlist `shouldBe` True

it "does not list opaque foreigns" do
Set.member (qname "Data.Ord" "ordArrayImpl") allowlist `shouldBe` False
Set.member (qname "Data.Semiring" "numAdd") allowlist `shouldBe` False

qname ∷ Text → Text → QName
qname m n = QName (moduleNameFromString m) (Name n)

{- | Parse a foreign-module source into a 'Source', failing the test with a
clear message if it does not parse or interpret.
-}
Expand Down
Loading