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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ PureScript Source → CoreFn → IR → Lua → Optimized Lua
- Case-of-case transformation
- **Inliner** (`IR.Inliner`): Marks expressions for inlining
- **Dead Code Elimination** (`IR.DCE`): Removes unused bindings
- **Uncurrying** (`IR.Uncurry`): Splits curried bindings into n-ary
workers plus curried wrappers and rewrites saturated call sites to
direct worker calls

4. **Linking** (`Language.PureScript.Backend.IR.Linker`)
- Creates an "UberModule" containing all reachable code
Expand Down
11 changes: 5 additions & 6 deletions bench/goldens/fnew_Bench.BindChain.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
chunk: Bench.BindChain.lua
runtime: LuaJIT 2.1.1741730670
main-chunk FNEW: 4
function-body FNEW: 4
total FNEW: 8
prototypes: 9
function-body FNEW: 3
total FNEW: 7
prototypes: 8
function-body FNEW sites:
Bench.BindChain.lua:3
Bench.BindChain.lua:17
Bench.BindChain.lua:25
Bench.BindChain.lua:24
Bench.BindChain.lua:23
Bench.BindChain.lua:22
12 changes: 12 additions & 0 deletions bench/goldens/fnew_Bench.CurriedStep.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
chunk: Bench.CurriedStep.lua
runtime: LuaJIT 2.1.1741730670
main-chunk FNEW: 4
function-body FNEW: 5
total FNEW: 9
prototypes: 10
function-body FNEW sites:
Bench.CurriedStep.lua:3
Bench.CurriedStep.lua:3
Bench.CurriedStep.lua:7
Bench.CurriedStep.lua:10
Bench.CurriedStep.lua:21
8 changes: 3 additions & 5 deletions bench/goldens/trace_bind_chain.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,16 @@ spec: bind_chain
runtime: LuaJIT 2.1.1741730670
workload: n=1000000 reps=2 result=3000000
aborts (distinct site -- reason):
Bench.BindChain.lua:17 -- NYI: bytecode FNEW
Bench.BindChain.lua:3 -- NYI: bytecode FNEW
bytecode end state (J*=compiled, I*=blacklisted):
Bench.BindChain.lua:18 IFUNCF
Bench.BindChain.lua:19 IFUNCF
Bench.BindChain.lua:20 IFUNCF
Bench.BindChain.lua:21 IFUNCF
Bench.BindChain.lua:22 IFUNCF
Bench.BindChain.lua:3 IFUNCF
Bench.BindChain.lua:3 JFUNCF
Bench.BindChain.lua:5 JFUNCF
Bench.BindChain.lua:8 IFUNCF
Bench.BindChain.lua:9 IFUNCF
bind_chain.lua:10 IFORL
bind_chain.lua:17 JFORI
bind_chain.lua:17 JFORL
counts: aborts=2 compiled=4 blacklisted=7
counts: aborts=1 compiled=4 blacklisted=6
18 changes: 18 additions & 0 deletions bench/goldens/trace_curried_step.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
spec: curried_step
runtime: LuaJIT 2.1.1741730670
workload: n=100000 reps=2 result=5000050000
aborts (distinct site -- reason):
Bench.CurriedStep.lua:10 -- NYI: bytecode FNEW
Bench.CurriedStep.lua:3 -- NYI: bytecode FNEW
Bench.CurriedStep.lua:7 -- NYI: bytecode FNEW
bytecode end state (J*=compiled, I*=blacklisted):
Bench.CurriedStep.lua:10 IFUNCF
Bench.CurriedStep.lua:10 JFUNCF
Bench.CurriedStep.lua:15 IFUNCF
Bench.CurriedStep.lua:3 IFUNCF
Bench.CurriedStep.lua:3 JFUNCF
Bench.CurriedStep.lua:7 IFUNCF
Bench.CurriedStep.lua:7 JFUNCF
curried_step.lua:12 JFORI
curried_step.lua:12 JFORL
counts: aborts=3 compiled=5 blacklisted=4
15 changes: 15 additions & 0 deletions bench/macro/curried_step.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- A hot two-argument loop, always fully applied: uncurried into a direct
-- n-ary worker call, it is the canonical trace-compilable loop; curried,
-- every iteration allocates closures and the loop gets blacklisted.
return {
artifact = "Bench.CurriedStep",
n = 100000,
drive = function(mod, n)
return mod.run(n)
end,
ideal = function(n)
local acc = 0
for i = n, 1, -1 do acc = acc + i end
return acc
end,
}
14 changes: 14 additions & 0 deletions changelog.d/20260707_190000_unisay_uncurry_worker_wrapper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
### Added

- Functions are uncurried through a worker/wrapper split (#24): every binding
with a manifest arity of two or more and at least one saturated call site
becomes an n-ary Lua worker plus a curried wrapper under the original name,
and the saturated sites call the worker directly — `add(x)(y)` becomes
`add$w(x, y)`. Partial applications and functions passed as values keep
going through the wrapper, so behavior is unchanged; unreferenced wrappers
are removed by dead-code elimination. This removes per-application closure
allocation on saturated calls — the main LuaJIT trace-compilation unlock,
since closure creation aborts trace recording. The definition-side `AbsN`
node mirrors `AppN` (`Abs` is now its singleton pattern synonym), beta
reduction handles exact-arity n-ary redexes, and the deep-bind flattening
recognises n-ary chains and spines.
53 changes: 41 additions & 12 deletions lib/Language/PureScript/Backend/IR/DCE.hs
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,16 @@ eliminateDeadCode uber@UberModule {..} =
first deannotateExp . rewriteExpBottomUp \case
-- Under GUC a dead binder is unreferenced by definition, so
-- blanking its name touches no reference elsewhere (the hazard
-- behind issue #56). Requiring 'ParamNamed' keeps the rule
-- precise: an already-blank parameter is left alone.
Abs ann (ParamNamed pann@(paramId, _) _name) b
| not (paramId `member` reachableIds) →
Just (Abs ann (ParamUnused pann) b)
-- behind issue #56). Only a dead /suffix/ of the parameter list
-- is blanked: 'ParamUnused' must stay a trailing run
-- (Note [n-ary abstraction]), so a dead parameter followed by a
-- live one keeps its name. Firing only when a name was actually
-- blanked keeps the rule precise: an already-blank parameter is
-- left alone.
AbsN ann params b
| let params' = blankDeadSuffix params
, params' /= params →
Just (AbsN ann params' b)
Let ann binds body
-- Under GUC dropping a dead binder touches no reference
-- elsewhere in the Let (later grouping RHSs or the body): a
Expand Down Expand Up @@ -191,6 +196,26 @@ eliminateDeadCode uber@UberModule {..} =
members ∷ [Grouping ((Id, Ann), Name, AExp)] → Int
members = length . (listGrouping =<<)

-- Right-to-left: blank dead named parameters until the first
-- parameter that must stay named (a live one, or an interior one
-- once the suffix is broken).
blankDeadSuffix
∷ NonEmpty (Parameter (Id, Ann))
→ NonEmpty (Parameter (Id, Ann))
blankDeadSuffix = NE.fromList . snd . foldr step (True, []) . toList
where
step
∷ Parameter (Id, Ann)
→ (Bool, [Parameter (Id, Ann)])
→ (Bool, [Parameter (Id, Ann)])
step p (inSuffix, ps) = case p of
ParamNamed pann@(paramId, _ann) _name
| inSuffix && not (paramId `member` reachableIds) →
(True, ParamUnused pann : ps)
ParamUnused _pann
| inSuffix → (True, p : ps)
_ → (False, p : ps)

reachableIds ∷ Set Id =
Set.fromList
[ node
Expand Down Expand Up @@ -297,13 +322,17 @@ eliminateDeadCode uber@UberModule {..} =
adjacencyListForExpr scope expr =
mkNode (nodeId expr) (expressionDependsOnIds scope expr)
`DL.cons` case expr of
Abs _ann param b →
case param of
ParamUnused _ann' → adjacencyListForExpr scope b
ParamNamed (paramId, _ann) name →
DL.cons
(mkNode paramId [])
(adjacencyListForExpr (addLocalToScope paramId name scope) b)
AbsN _ann params b →
paramNodes <> adjacencyListForExpr scopeWithParams b
where
(scopeWithParams, paramNodes) =
foldl' bindParam (scope, DL.empty) (toList params)
bindParam (sc, nodes) = \case
ParamUnused _ann' → (sc, nodes)
ParamNamed (paramId, _ann') name →
( addLocalToScope paramId name sc
, DL.snoc nodes (mkNode paramId [])
)
Let _ann groupings body →
adjacencyListForExpr bodyScope body <> groupingsAdjacency
where
Expand Down
106 changes: 71 additions & 35 deletions lib/Language/PureScript/Backend/IR/FlattenDeepBinds.hs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ module Language.PureScript.Backend.IR.FlattenDeepBinds
) where

import Data.List qualified as List
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Language.PureScript.Backend.IR.Linker (UberModule (..))
Expand All @@ -156,6 +157,7 @@ import Language.PureScript.Backend.IR.Types
, refLocal
, rewriteExpTopDownM
, substituteCopyM
, pattern Abs
, pattern App
)

Expand Down Expand Up @@ -202,11 +204,13 @@ flattenRule expr
--------------------------------------------------------------------------------
-- Strategy A: continuation lambda-lifting -------------------------------------

{- | One step of a continuation chain: @f action (\param -> …)@. Fields: the
head, the continuation parameter, and the action — all kept verbatim (only the
continuation /structure/ is rewritten).
{- | One step of a continuation chain: @f action (\param -> …)@ or its n-ary
call shape @f(action, \param -> …)@. Fields: a rebuild closure that
reconstructs the step node verbatim around a rewritten continuation body —
preserving the call shape, which distinguishes programs
(Note [n-ary application]) — and the continuation parameter.
-}
data Step = Step Exp (Parameter Ann) Exp
data Step = Step (Exp → Exp) (Parameter Ann)

{- | Peel a maximal prefix of @f action (\param -> rest)@ steps, returning them
together with the first expression that is not such a step (the chain's final
Expand All @@ -221,12 +225,25 @@ peelChain = go
Just (step, rest) → first (step :) (go rest)
Nothing → ([], expr)

-- | Recognise @f action (\param -> rest)@ as a step plus its continuation body.
{- | Recognise @f action (\param -> rest)@ — through either call shape, the
curried @(f action) (\param -> rest)@ or the n-ary @f(action, \param ->
rest)@ of an uncurried worker — as a step plus its continuation body.
Recognition is purely structural: any head applied to exactly two operands
of which the trailing one is a lambda is a step, regardless of the monad or
combinator.
-}
asStep ∷ Exp → Maybe (Step, Exp)
asStep expr = case spine expr of
(hd, [action, k])
| Abs _ann param rest ← k →
Just (Step hd param action, rest)
asStep expr = case expr of
App ann fa (Abs annK param rest)
| (_hd, [_action]) ← spine fa →
Just (Step (App ann fa . Abs annK param) param, rest)
AppN ann hd (action :| [Abs annK param rest]) →
Just
( Step
(\rest' → AppN ann hd (action :| [Abs annK param rest']))
param
, rest
)
_ → Nothing

{- | Lambda-lift a recognised chain into a flat @let@ of @$kontN@ helpers.
Expand Down Expand Up @@ -259,7 +276,7 @@ lambdaLift steps finalAction =
bindOrder =
Map.fromList
[ (name, i)
| (i, Step _ (ParamNamed _ name) _) ← zip [0 ..] steps
| (i, Step _ (ParamNamed _ name)) ← zip [0 ..] steps
]
chainBound ∷ Set Name
chainBound = Map.keysSet bindOrder
Expand Down Expand Up @@ -307,14 +324,15 @@ lambdaLift steps finalAction =
b' ← substituteCopyM (Local name) (refLocal name') b
pure (freshNames <> [name'], b')

-- | Rebuild a segment's nested @f action (\param -> …)@ wrapping a tail.
{- | Rebuild a segment's nested steps wrapping a tail, each step through its
own rebuild closure so the original call shapes survive verbatim.
-}
buildSteps ∷ [Step] → Exp → Exp
buildSteps steps tailExp =
foldr step tailExp steps
where
step ∷ Step → Exp → Exp
step (Step hd param action) rest =
App noAnn (App noAnn hd action) (Abs noAnn param rest)
step (Step rebuild _param) = rebuild

-- | @\\p1 -> \\p2 -> … -> body@ (p1 outermost).
curryAbs ∷ [Name] → Exp → Exp
Expand All @@ -328,40 +346,54 @@ applyToVars = foldl' \f p → App noAnn f (refLocal p)
--------------------------------------------------------------------------------
-- Strategy B: application-spine sequentialisation -----------------------------

{- | Length of the longest contiguous chain of strict 'App' nodes reachable from
this expression — the parse nesting Strategy B can flatten. Counts both the
callee and the argument side of each 'App' (Lua nests both @f(…)@ and its
argument) and stops at every non-'App' node: 'Abs' bodies and branch positions
are deferred (and handled by Strategy A or a later descent), and other
constructs carry their own depth that 'sequentialiseSpine' leaves in place.
{- | Length of the longest contiguous chain of strict 'AppN' nodes reachable
from this expression — the parse nesting Strategy B can flatten. Counts the
callee and every argument side of each call (Lua nests @f(…)@ and each of
its arguments) and stops at every non-application node: 'AbsN' bodies and
branch positions are deferred (and handled by Strategy A or a later
descent), and other constructs carry their own depth that
'sequentialiseSpine' leaves in place.
-}
spineDepth ∷ RawExp ann → Int
spineDepth = \case
App _ann f a → 1 + max (spineDepth f) (spineDepth a)
AppN _ann f args →
1 + foldl' max (spineDepth f) (spineDepth <$> args)
_ → 0

{- | One 'App' node on a spine's deepest path, holding the off-path operand
verbatim. 'rebuildFrame' reattaches it to the deep child.
{- | One 'AppN' node on a spine's deepest path, holding the off-path operands
verbatim. 'rebuildFrame' reattaches them around the deep child.
-}
data Frame
= -- | @App deep sibling@ — the deep child is the callee.
OnCallee Ann Exp
| -- | @App sibling deep@ — the deep child is the argument.
OnArg Ann Exp
= -- | @deep(args…)@ — the deep child is the callee.
OnCallee Ann (NonEmpty Exp)
| -- | @callee(before…, deep, after…)@ — the deep child is one argument.
OnArg Ann Exp [Exp] [Exp]

rebuildFrame ∷ Frame → Exp → Exp
rebuildFrame (OnCallee ann sibling) deep = App ann deep sibling
rebuildFrame (OnArg ann sibling) deep = App ann sibling deep
rebuildFrame (OnCallee ann args) deep = AppN ann deep args
rebuildFrame (OnArg ann callee before after) deep =
AppN ann callee (NE.prependList before (deep :| after))

{- | Peel the deepest contiguous application path, outermost frame first, down
to the innermost non-'App' base. Following the deeper child at each node makes
@length (fst (decompose e)) == 'spineDepth' e@.
to the innermost non-application base. Following the deepest child at each
node makes @length (fst (decompose e)) == 'spineDepth' e@.
-}
decompose ∷ Exp → ([Frame], Exp)
decompose = \case
App ann f a
| spineDepth f >= spineDepth a → first (OnCallee ann a :) (decompose f)
| otherwise → first (OnArg ann f :) (decompose a)
AppN ann f args
| fDepth >= foldl' max 0 argDepths →
first (OnCallee ann args :) (decompose f)
| otherwise →
first (OnArg ann f before after :) (decompose deep)
where
fDepth = spineDepth f
argDepths = spineDepth <$> toList args
deepIndex =
fromMaybe 0 (List.elemIndex (foldl' max 0 argDepths) argDepths)
(before, deep, after) = case splitAt deepIndex (toList args) of
(bs, d : as) → (bs, d, as)
-- Unreachable: 'deepIndex' points into 'args'.
(bs, []) → (bs, error "decompose: empty deep argument", [])
base → ([], base)

{- | Sequentialise a deep strict-application spine: rebuild its deepest path
Expand Down Expand Up @@ -404,12 +436,16 @@ letHelpers ∷ [Grouping (Ann, Name, Exp)] → Exp → Exp
letHelpers [] body = body
letHelpers (h : hs) body = Let noAnn (h :| hs) body

-- | Unwind an application into its head and arguments (left to right).
{- | Unwind an application into its head and operands (left to right),
through both call shapes. The result deliberately flattens the shape
distinction of Note [n-ary application], so it is only for /counting/
operands ('asStep'), never for rebuilding.
-}
spine ∷ Exp → (Exp, [Exp])
spine = go []
where
go ∷ [Exp] → Exp → (Exp, [Exp])
go acc (App _ann f a) = go (a : acc) f
go acc (AppN _ann f args) = go (toList args <> acc) f
go acc h = (h, acc)

freshKontName ∷ SupplyM Name
Expand Down
11 changes: 6 additions & 5 deletions lib/Language/PureScript/Backend/IR/Linker.hs
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,14 @@ qualifyTopRefs moduleName = go
Ref ann (Local refName)
| isTopLevel refName →
Ref ann (Imported moduleName refName)
Abs ann parameter body →
Abs ann parameter (go topNames' body)
AbsN ann parameters body →
AbsN ann parameters (go topNames' body)
where
topNames' ∷ Set Name =
case parameter of
ParamNamed _ann argName → Set.delete argName topNames
ParamUnused _ann → topNames
foldl' shadowParam topNames parameters
shadowParam names = \case
ParamNamed _ann argName → Set.delete argName names
ParamUnused _ann → names
-- See Note [Sequential scoping of Let bindings]
Let ann groupings body →
Let ann groupings' (go topNamesAfterBinds body)
Expand Down
Loading