Lift the #-shaped length foreigns to a unary length primop - #341
Merged
Conversation
The `length` exports of the array and string forks are one-line wrappers around Lua's unary `#` — `function(xs) return #xs end` — so to the IR they were opaque foreigns: a saturated `length xs` paid a foreign-table read plus a call frame for what the VM does with one length opcode. The lifter had no way to translate them, its only unary case being `not`. Teach the lifter `#`, and allowlist `Data.Array.length` and `Data.String.CodeUnits.length`. A saturated site now collapses to a bare `#xs` — including inside a loop guard, where the call frame was paid per iteration — and both lifted rows drop out of the emitted FFI tables, as the uncurried wrappers already did. A length read over a manifest array meets the existing fold and becomes a constant. `Data.Array.ST.lengthImpl` has the identical body and stays off the allowlist. It is an `STFn1`, so its call is an effect statement, and codegen sheds the surrounding effect thunk only when the thunk's body is a call; a lifted length body keeps the thunk, turning one call into `(function() return #arr end)()` — a closure allocation on top of the call it replaced. It also reads a mutable `STArray`, which the sharing and pasting licences the node enjoys elsewhere are not written for. The IR node for `#` is named `PrimLen`, joining `PrimNot` as the second unary primop of Note [IR primops], rather than adding a second node beside the existing `ArrayLength`. One node per Lua operator is what lets the lifted length reads 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 rule. The constructor keeps its position so the derived `Ord` on `RawExp` is unchanged. Note [PrimLen reads immutable values] records the invariant those licences rest on. Golden churn: `Golden.StringCodePoints` sheds both `length` foreign rows and reads `#(s)` directly; `Golden.ArrayPatternMatch` and `Golden.DirectivePack` carry the node rename only. Every eval oracle is unchanged. The new `Golden.LengthLift` covers the array, string and loop-guard sites, and pins `lengthImpl` as a direct call.
Unisay
force-pushed
the
issue-247/prim-len-lift
branch
from
July 28, 2026 17:54
a91dcd6 to
42cce26
Compare
Unisay
marked this pull request as ready for review
July 28, 2026 18:28
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 #247.
The problem
psluacompiles PureScript to Lua. A PureScript function implemented in Lua (an FFI import, "foreign" in PureScript's vocabulary) is opaque to the compiler's optimizer: the optimizer works on an intermediate representation (IR) where a foreign body is just text it cannot look inside. The foreign lifter (Language.PureScript.Backend.Lua.ForeignLift) exists to reopen a slice of that box — it parses the fork's real.luasource and, for an allowlisted export whose body falls in a small pure subset, translates the body into IR nodes so ordinary rewriting can proceed.The
lengthexports of the Lua forks are the tiniest possible foreigns. Verbatim frompurescript-lua-arrays'src/Data/Array.lua:#is Lua's unary length operator: one VM opcode.purescript-lua-strings'Data.String.CodeUnits.lengthis the same body over a string. So a saturatedlength xswas paying a table read off the foreign module plus a call frame to reach an opcode — and unlike ashowshim,lengthsits on hot paths: it appears in loop guards and bounds logic, once per iteration.The lifter could not translate these because its only unary case was
not:What changed
One case for
#, and two allowlist entries:The allowlist is a hard contract in this pass: a listed export that stops being liftable (a fork reshapes its body) is a compile error, not a silent regression. So these two entries also pin the fork sources.
The node is the existing
#node, renamedThe issue proposed adding a new
PrimLennode. The IR already had a node that lowers to exactly#—ArrayLength, minted by the array-pattern matcher for a fixed-length array match — so adding a second one would mean two IR nodes for one Lua operator, and every rewrite keyed on#would need writing twice or would silently miss whichever node it did not name. Instead the existing node is renamed toPrimLenand documented as the second unary primop ofNote [IR primops], besidePrimNot. Its position inRawExpis unchanged, so the derivedOrdinstance is unaffected.The payoff is that lifted length reads inherit, unchanged, every rule the array-pattern length test already had. All four fire in this PR's goldens: the fold over a manifest array, the push into
ifbranches, the CSE candidate class, and theDerefinlining tier that pastes a cheap read at its use sites.What the generated Lua looks like
New golden
Golden.LengthLift. A saturated array length and a string length become the bare operator:The loop-guard case — the shape the lift exists for, where the call frame was paid per iteration:
An existing golden,
Golden.StringCodePoints, shows the effect on real code. Both foreign tables shed theirlengthrow, and one of them vanishes outright (-is the previous golden,+the new one):and its call sites become the operator, with the repeat inside one body shared by CSE:
Every
eval/golden.txtexecution oracle in the suite is unchanged, which is the semantic check that these are the same programs.Data.Array.ST.lengthImplstays off the allowlistThe issue asked for three exports. The third,
Data.Array.ST.lengthImpl, has a byte-identical body:It is nonetheless excluded, and the exclusion is measured rather than assumed — I allowlisted it and looked at the output. Its PureScript type is
STFn1 (STArray h a) h Int, an effectful uncurried function, so a call of it is an effect statement that the compiler wraps in a thunk. Code generation sheds that thunk only when the thunk's body is a call; a lifted length body is not, so the thunk survives:That is a closure allocation on top of the call it was supposed to replace — a pessimization, so the exclusion costs nothing and saves something.
There is a second reason, stated as an invariant rather than a live bug.
PrimLenis treated as a stable read: CSE may collapse two occurrences into one binding, and theDereftier pastes a bound one at its use sites regardless of use count. Neither move is meaning-preserving if the operand's length can change in between, and anSTArray's can —#arrbefore apushand after it are different numbers. Today the unshed thunk keeps a liftedlengthImplout of those rules' reach (I verified this: withlengthImplallowlisted, CSE shares the thunk,local _S_cse0 = function() return #(arr) end, and both sites still call it, so the execution oracle stays correct). The exclusion does not lean on that accident.Note [PrimLen reads immutable values]records the invariant and is cross-referenced from the two rules that depend on it, so a future producer of the node has to check its operand.Golden.LengthLiftpins this:lengthsAroundPushbrackets apushwith twoData.Array.ST.lengthreads, and its Lua golden holds both at a directData_Array_ST_lengthImpl(arr), so allowlisting the export later trips the golden.Also in here
The allowlist Haddock claimed "a broader allowlist is follow-up work (issue #187)". #187 is the closing audit of the optimisation series, not an allowlist issue, and no issue tracks broadening the list. Rather than repoint it at #247 — which this PR closes, so it would be a fresh dead pointer — the sentence now states the follow-up without a number.
Verification
runFnones: the#body lifts toPrimLen, and a#body with a precedinglocaldeclines (the pure-return-tree restriction still bounds the new operator). Confirmed red before the lifter case —expected: Just (AbsN … (PrimLen …)) / but got: Nothing— and green after.cabal test all: 1221 examples, 0 failures. Green on a re-run with noPSLUA_GOLDEN_ACCEPT, so the accepted goldens are stable.IR Optimizer12 runs and all non-golden specs 12 runs, fresh seeds, no failures.fourmolu,hlint(no hints), and acabal cleanfull rebuild with zero warnings.luacheckon the new golden reports only the two classes every golden already carries (the unreadMtable, a long line).