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
24 changes: 24 additions & 0 deletions changelog.d/20260728_190000_unisay_lift_length_foreigns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
### Changed

- The foreign lifter now translates Lua's unary `#` operator, so the one-line
`length` exports of the array and string forks —
`function(xs) return #xs end` — lift into the IR instead of staying opaque.
`Data.Array.length` and `Data.String.CodeUnits.length` join the lift
allowlist, and a saturated site collapses to a bare `#xs`: the VM's own
length opcode in place of a foreign-table read plus a call frame per use,
which matters most where a length read sits in a loop guard. Both lifted
rows also drop out of the emitted FFI tables, as the uncurried wrappers
already did. A length read over a manifest array now meets the existing fold
and becomes a constant. `Data.Array.ST.lengthImpl` stays off the allowlist
despite the identical body: its call is an effect statement whose thunk
codegen sheds only for a call body, so lifting it would buy a closure
allocation on top of the call it replaced (#247).

- The IR node for Lua's `#` is named `PrimLen`, sitting alongside `PrimNot` as
the second unary primop of `Note [IR primops]`, rather than `ArrayLength`.
One node per Lua operator is what lets the length reads the lifter produces
inherit every rewrite the array-pattern length test already had — the
literal-array fold, the push into `if` branches, the CSE candidate class, the
`Deref` inlining tier — instead of needing a twin of each. The new
`Note [PrimLen reads immutable values]` records the invariant those licences
rest on and why the mutable `STArray` length is kept out of the node.
2 changes: 1 addition & 1 deletion lib/Language/PureScript/Backend/IR.hs
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,7 @@ mkCaseClauses = mkClauses Map.empty
PatAny →
nextMatch history clause'
PatArrayLength (intCast → len) →
testFocus (literalInt len `eq` arrayLength expr)
testFocus (literalInt len `eq` primLen expr)
PatInteger i →
testFocus (literalInt i `eq` expr)
PatFloating d →
Expand Down
6 changes: 4 additions & 2 deletions lib/Language/PureScript/Backend/IR/CSE.hs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ none existed. The pass therefore only touches forms that are effect-free
arguments are themselves effect-free values — a partial application
is a runtime call of the curried wrapper and is left alone;
* single reads over never-nil bases ('ObjectProp', 'ArrayIndex',
'ArrayLength', 'ReflectCtor', 'DataArgumentByIndex' over a 'Ref' or
'PrimLen', 'ReflectCtor', 'DataArgumentByIndex' over a 'Ref' or
a record-read chain) — records and data values are write-once, so a
read is stable, and a never-nil base makes it non-throwing (see
'isRefProjection' for why a sum-variant slot is not such a base).
Sharing a length read is what obliges 'PrimLen' to keep its operand
immutable — see Note [PrimLen reads immutable values].

Never arbitrary applications: a call can do anything. GHC's CSE pass,
with its similar sharing restrictions, is the precedent. Sharing one
Expand Down Expand Up @@ -391,7 +393,7 @@ isRefProjection ∷ RawExp ann → Bool
isRefProjection = \case
ObjectProp _ base _ → isNilSafeBase base
ArrayIndex _ base _ → isNilSafeBase base
ArrayLength _ base → isNilSafeBase base
PrimLen _ base → isNilSafeBase base
ReflectCtor _ base → isNilSafeBase base
DataArgumentByIndex _ _ _ base → isNilSafeBase base
_ → False
Expand Down
18 changes: 10 additions & 8 deletions lib/Language/PureScript/Backend/IR/Optimizer.hs
Original file line number Diff line number Diff line change
Expand Up @@ -1114,7 +1114,9 @@ instance Monoid Complexity where
{- | Bottom-up cost classification. 'Trivial': a reference or a
scalar/empty literal.
'Deref': a chain of cheap reads (projection, index, length, tag) over a
Trivial base. 'KnownSize': an abstraction or a non-empty literal — a
Trivial base — pasted at any use count, which for the length read is what
obliges 'PrimLen' to keep its operand immutable (see Note [PrimLen reads
immutable values]). 'KnownSize': an abstraction or a non-empty literal — a
bounded allocation. Everything that computes is 'NonTrivial', and
unlisted constructors deliberately land there, so a new node kind is
conservative by default. A string literal above 128 characters counts
Expand All @@ -1139,7 +1141,7 @@ complexityOf = \case
| otherwise → KnownSize <> foldMap (complexityOf . snd) props
ObjectProp _ann base _prop → Deref <> complexityOf base
ArrayIndex _ann base _idx → Deref <> complexityOf base
ArrayLength _ann base → Deref <> complexityOf base
PrimLen _ann base → Deref <> complexityOf base
ReflectCtor _ann base → Deref <> complexityOf base
DataArgumentByIndex _ann _algTy _idx base → Deref <> complexityOf base
AbsN _ann _params body → KnownSize <> complexityOf body
Expand Down Expand Up @@ -1642,7 +1644,7 @@ spelled out on 'reduceObjectProp'.
reduceArrayRead ∷ Applicative m ⇒ RewriteRuleM m Ann
reduceArrayRead =
pure . \case
ArrayLength ann (LiteralArray _ elements) →
PrimLen ann (LiteralArray _ elements) →
Just $ LiteralInt ann (fromIntegral (length elements))
ArrayIndex ann (LiteralArray _ elements) index →
setAnn ann <$> elements !!? fromIntegral index
Expand Down Expand Up @@ -2009,9 +2011,9 @@ scrutinee is never in place for 'reduceArrayRead' to fold: the match on
@case [10, 20] of [a, b] → …@ lands on

> let v = [10, 20] in
> if 2 == arrayLength v then … v[0] … v[1] … else fallthrough
> if 2 == primLen v then … v[0] … v[1] … else fallthrough

and nothing folds: the rules see @ArrayLength (Ref v)@ and
and nothing folds: the rules see @PrimLen (Ref v)@ and
@ArrayIndex (Ref v) i@, never the literal.

This propagates the literal through a 'Standalone' Let binding into the
Expand Down Expand Up @@ -2106,7 +2108,7 @@ propagateKnownArrayThroughLet = \case
foldArrayReads name len freshElements = go
where
go = \case
ArrayLength alAnn (Ref _ (Local n))
PrimLen alAnn (Ref _ (Local n))
| n == name → LiteralInt alAnn (fromIntegral len)
ArrayIndex aiAnn (Ref _ (Local n)) i
| n == name
Expand Down Expand Up @@ -3104,8 +3106,8 @@ pushEliminatorIntoIfBranches =
ArrayIndex ann (IfThenElse ifAnn c t e) index →
Just $
IfThenElse ifAnn c (ArrayIndex ann t index) (ArrayIndex ann e index)
ArrayLength ann (IfThenElse ifAnn c t e) →
Just $ IfThenElse ifAnn c (ArrayLength ann t) (ArrayLength ann e)
PrimLen ann (IfThenElse ifAnn c t e) →
Just $ IfThenElse ifAnn c (PrimLen ann t) (PrimLen ann e)
ReflectCtor ann (IfThenElse ifAnn c t e) →
Just $ IfThenElse ifAnn c (ReflectCtor ann t) (ReflectCtor ann e)
DataArgumentByIndex ann algTy index (IfThenElse ifAnn c t e) →
Expand Down
2 changes: 1 addition & 1 deletion lib/Language/PureScript/Backend/IR/Query.hs
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ hasWholeValueArrayRead ∷ Name → Natural → Exp → Bool
hasWholeValueArrayRead name len = go
where
go = \case
ArrayLength _ (Ref _ (Local n)) | n == name → False
PrimLen _ (Ref _ (Local n)) | n == name → False
ArrayIndex _ (Ref _ (Local n)) i | n == name, i < len → False
Ref _ (Local n) | n == name → True
other → any go (toListOf subexpressions other)
Expand Down
60 changes: 48 additions & 12 deletions lib/Language/PureScript/Backend/IR/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ paramName (ParamNamed _ann name) = Just name

{- | A binary primitive operation, defined as the Lua operator of the same
name so that lowering is the identity (see Note [IR primops]). Every
operator here is binary; the sole unary primop, logical @not@, is its own
'PrimNot' node.
operator here is binary; the two unary operators — logical @not@ and
length @#@ — are the separate 'PrimNot' and 'PrimLen' nodes.
-}
data PrimOp
= -- | @+@
Expand Down Expand Up @@ -130,8 +130,8 @@ data PrimOp

{- Note [IR primops]
~~~~~~~~~~~~~~~~~~~~~
'PrimBinOp' and 'PrimNot' are the pure Lua scalar operators lifted into
the IR (issue #178). Their reason to exist is that hot polymorphic code
'PrimBinOp', 'PrimNot' and 'PrimLen' are the pure Lua operators lifted
into the IR (issue #178). Their reason to exist is that hot polymorphic code
bottoms out in opaque curried foreigns — @intAdd@, @ordIntImpl@,
@refEq@, @boolConj@ — that the optimizer cannot see through: to the IR a
foreign body is text. Lifting that text's pure return-tree subset to
Expand Down Expand Up @@ -160,6 +160,38 @@ the constant folding in
target's semantics (Lua 5.1: every number is an IEEE double), not the
host's. See the folding rules there for the per-operator caveats
(integer range, division by zero, the modulo sign, concat typing).

'PrimLen' carries one extra obligation, because it reads a value rather
than computing over scalars: see Note [PrimLen reads immutable values].
-}

{- Note [PrimLen reads immutable values]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Rules treat a 'PrimLen' as a /stable/ read — one that yields the same
number for the same operand however often it runs. Two of them say so
outright: it is a CSE candidate, so two occurrences can become one shared
binding ("Language.PureScript.Backend.IR.CSE"), and it sits in the
@Deref@ complexity tier, so a binding of one is pasted at its use sites
whatever the use count ("Language.PureScript.Backend.IR.Optimizer").
Neither move preserves meaning if the operand's length can change between
the occurrences.

Stability holds because every operand the compiler puts under the node is
immutable:

* a PureScript @Array@ — immutable by type, and the length test of an
array pattern ('Language.PureScript.Backend.IR.mkCase') is the
node's original producer;
* a PureScript @String@ — likewise immutable, and the operand when the
foreign lifter translates @Data.String.CodeUnits.length@.

A mutable operand is the case to keep out. With @arr@ an @STArray@,
@#arr@ read before a push and again after it are two different numbers,
so sharing the two reads, or pasting the earlier one forward past the
push, would change the program. The lift allowlist in
"Language.PureScript.Backend.Lua.ForeignLift" accordingly omits
@Data.Array.ST.lengthImpl@, whose body is the same @#xs@; a future
producer of a 'PrimLen' owes the same check on its operand.
-}

data RawExp ann
Expand All @@ -183,7 +215,11 @@ data RawExp ann
| -- | See Note [IR primops]
PrimNot ann (RawExp ann)
| DataArgumentByIndex ann AlgebraicType Natural (RawExp ann)
| ArrayLength ann (RawExp ann)
| {- | Lua's unary @#@: the element count of a table, or the byte count
of a string. See Note [IR primops] — and, for why the operand is
never a mutable array, Note [PrimLen reads immutable values].
-}
PrimLen ann (RawExp ann)
| ArrayIndex ann (RawExp ann) Natural
| ObjectProp ann (RawExp ann) PropName
| ObjectUpdate ann (RawExp ann) (NonEmpty (PropName, RawExp ann))
Expand Down Expand Up @@ -455,7 +491,7 @@ getAnn = \case
PrimBinOp ann _ _ _ → ann
PrimNot ann _ → ann
DataArgumentByIndex ann _ _ _ → ann
ArrayLength ann _ → ann
PrimLen ann _ → ann
ArrayIndex ann _ _ → ann
ObjectProp ann _ _ → ann
ObjectUpdate ann _ _ → ann
Expand Down Expand Up @@ -489,7 +525,7 @@ setAnn ann = \case
PrimBinOp _ op l r → PrimBinOp ann op l r
PrimNot _ e → PrimNot ann e
DataArgumentByIndex _ algTy i e → DataArgumentByIndex ann algTy i e
ArrayLength _ e → ArrayLength ann e
PrimLen _ e → PrimLen ann e
ArrayIndex _ e i → ArrayIndex ann e i
ObjectProp _ e prop → ObjectProp ann e prop
ObjectUpdate _ e patches → ObjectUpdate ann e patches
Expand Down Expand Up @@ -660,8 +696,8 @@ primBinOp = PrimBinOp noAnn
primNot ∷ Exp → Exp
primNot = PrimNot noAnn

arrayLength ∷ Exp → Exp
arrayLength = ArrayLength noAnn
primLen ∷ Exp → Exp
primLen = PrimLen noAnn

reflectCtor ∷ Exp → Exp
reflectCtor = ReflectCtor noAnn
Expand Down Expand Up @@ -737,8 +773,8 @@ subexpressions go = \case
PrimBinOp ann op <$> go a <*> go b
PrimNot ann a →
PrimNot ann <$> go a
ArrayLength ann a →
ArrayLength ann <$> go a
PrimLen ann a →
PrimLen ann <$> go a
ArrayIndex ann a idx →
ArrayIndex ann <$> go a <*> pure idx
ObjectProp ann a prp →
Expand Down Expand Up @@ -1091,7 +1127,7 @@ alphaEq = go 0 Map.empty Map.empty
annL == annR && go lvl scopeL scopeR aL aR
(DataArgumentByIndex annL tL iL aL, DataArgumentByIndex annR tR iR aR) →
annL == annR && tL == tR && iL == iR && go lvl scopeL scopeR aL aR
(ArrayLength annL aL, ArrayLength annR aR) →
(PrimLen annL aL, PrimLen annR aR) →
annL == annR && go lvl scopeL scopeR aL aR
(ArrayIndex annL aL iL, ArrayIndex annR aR iR) →
annL == annR && iL == iR && go lvl scopeL scopeR aL aR
Expand Down
4 changes: 2 additions & 2 deletions lib/Language/PureScript/Backend/Lua.hs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ fromIR foreigns unboxed topLevelNames modname ir = case ir of
Right <$> liftA2 (Lua.binOp (luaBinaryOp op)) (goExp l) (goExp r)
IR.PrimNot _ann e →
Right . Lua.logicalNot <$> goExp e
IR.PrimLen _ann e →
Right . Lua.hash <$> goExp e
IR.Ctor _ann algebraicTy ctorModName ctorTyName ctorName ctorArgs →
-- A constructor value is a positional table built directly from the
-- compiled field arguments (the node is saturated by construction — see
Expand Down Expand Up @@ -287,8 +289,6 @@ fromIR foreigns unboxed topLevelNames modname ir = case ir of
fieldRows = fmap Lua.tableRowV . parenLastMultiValued
ctorId = IR.ctorId ctorModName ctorTyName ctorName
ctorRow = Lua.tableRowV (Lua.String ctorId)
IR.ArrayLength _ann e →
Right . Lua.hash <$> goExp e
IR.ArrayIndex _ann expr index →
-- IR array indices are 0-based (like the source language), but Lua
-- tables are 1-based, so shift by one. This mirrors the arrays FFI
Expand Down
37 changes: 32 additions & 5 deletions lib/Language/PureScript/Backend/Lua/ForeignLift.hs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ use:
value (a branch that falls through to @nil@ does not lift);
* the binary operators of Note [IR primops] and @==@/@~=@ → 'PrimBinOp'
/ 'Eq' / 'PrimNot';
* the unary @#@ → 'PrimLen' (issue #247), which is how the one-line
@length@ exports of the array and string forks —
@function(xs) return #xs end@ — become the VM's own length opcode
instead of a foreign-table read plus a call frame per use;
* integer, float, and boolean literals;
* a reference to a parameter in scope, or to a @local@ in the file's
header (inlined) — this is how @ordIntImpl = (unsafeCoerceImpl)@ and
Expand Down Expand Up @@ -113,6 +117,7 @@ import Language.PureScript.Backend.IR.Types
, paramNamed
, paramUnused
, primBinOp
, primLen
, primNot
, refLocal
, setAnn
Expand Down Expand Up @@ -142,13 +147,28 @@ import Prelude hiding (show)
{- | The foreign exports lifted into the IR: the arithmetic, comparison,
boolean, and concatenation core of the prelude (issue #178), both
halves of the @*.Uncurried@ wrappers — @run@ (issue #198) and @mk@
(issue #227) — and the identity coercion @Unsafe.Coerce.unsafeCoerce@
(issue #227) — the identity coercion @Unsafe.Coerce.unsafeCoerce@
(issue #236), whose lifted @λx. x@ beta-reduces away at every applied
site. 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).
site, and the @#@-shaped @length@ exports (issue #247). Membership is a
hard contract (see the module header): a listed export that fails to
lift is a compile error. Broadening the list further is follow-up work.

A warning to that follow-up: do /not/ list the Effect\/ST core —
Two warnings to that follow-up. An export whose body translates is not
thereby worth lifting: @Data.Array.ST.lengthImpl@ has the same
@function(xs) return #xs end@ body as @Data.Array.length@ and is
deliberately absent, because it is an @STFn1@ — a call of it is an effect
statement, and code generation sheds the surrounding effect thunk only
when the thunk's body is a /call/ (the marker case in
"Language.PureScript.Backend.Lua"). A lifted 'PrimLen' body keeps the
thunk, so @lengthImpl(arr)@ would become
@(function() return #arr end)()@ — a closure allocation plus a call where
there was one call. Its operand is also a mutable @STArray@, which the
sharing and pasting the node is subject to elsewhere assume against
(Note [PrimLen reads immutable values]); the unshed thunk holds the node
out of those rules' reach today, and this exclusion does not rely on
that.

And do /not/ list the Effect\/ST core —
@Effect.bindE@\/@pureE@, @Control.Monad.ST.Internal.bind_@\/@pure_@ —
even though its thunk-shaped bodies are technically liftable now that
nullary calls translate. Those very names are the canonical heads
Expand Down Expand Up @@ -186,6 +206,12 @@ allowlist =
)
, ("Data.Semigroup", ["concatString"])
, ("Unsafe.Coerce", ["unsafeCoerce"])
, -- The @#@-shaped length exports (issue #247). Both take an
-- immutable operand, an array and a string; the identically-bodied
-- @Data.Array.ST.lengthImpl@ is absent for the two reasons the
-- Haddock above spells out.
("Data.Array", ["length"])
, ("Data.String.CodeUnits", ["length"])
, -- Both halves of the uncurried FFI wrappers: @run@ (issue #198)
-- and @mk@ (issue #227). @runFn0@/@mkFn0@ are absent by policy,
-- not liftability: their bodies force a /pure/ @Fn0@ with a
Expand Down Expand Up @@ -347,6 +373,7 @@ liftLuaExp env bound = \case
| Just e ← Map.lookup name env → liftLuaExp (Map.delete name env) bound e
| otherwise → Nothing
UnOp LogicalNot (_ann, a) → primNot <$> liftLuaExp env bound a
UnOp HashOp (_ann, a) → primLen <$> liftLuaExp env bound a
BinOp op (_ann, a) (_ann', b) → do
a' ← liftLuaExp env bound a
b' ← liftLuaExp env bound b
Expand Down
Loading