Unbox non-escaping Ref/STRef cells to mutable Lua locals - #315
Merged
Conversation
A new Golden.RefUnbox.Test module covering the cell shapes issue #239 lowers: a loop accumulator (sumTo), modify' with distinct state/value components (splitModify), ST write returning the written value (writeBack), a cell stored inside another cell (nested), and an Effect Ref with direct read/write (main). The goldens pin the current boxed form — every cell a {value = ...} table — so the unboxing change shows as a reviewable diff against them; the hand-written eval oracle pins the runtime outputs the rewrite must preserve.
A Ref/STRef cell compiles to a one-field heap table ({value = ...})
with every read/write/modify going through its field. A Lua local
captured by inner closures is itself a shared mutable slot (an
upvalue), so when the cell never flows anywhere as a whole value the
table buys nothing: a Let-bound run of new whose every use is a
recognised operation now lowers to a plain local, reads to the local
itself, writes and modifies to assignments. A literal modify function
beta-reduces at emission, so its {state, value} record is never
allocated either.
Recognition is by qualified name over the Effect.Ref and
Control.Monad.ST.Internal primitives plus the ST functor's foreign
map_ (the void (modify f r) shape), in the two codegen-time head forms
the loop matcher already uses. Only run positions lower; a cell used
as a first-class value anywhere keeps its boxed form, so a miss is a
missed optimisation, never a miscompile.
NativeLoop.runStatements statementizes chunk-lowered runs (an
unboxed-cell run returns its value in a tail return, which a
run-for-effect must discard), and dropValue drops discarded reads of
module-scope table fields, which the void wrapper's unit value
otherwise leaves as a dead local per loop iteration.
Closes #239
A hot ST loop accumulating through a non-escaping local STRef, with the hand-written Lua for-loop as its ideal. The committed counter oracles pin the unboxed shape: one steady-state TNEW/TDUP site in the whole artifact (the dead foreign new closure) and the hot loop trace-compiled (JFORL) instead of aborted on the per-iteration closures. Wall-clock on this machine: PUC Lua 5.1 0.0347s -> 0.0009s, LuaJIT 0.024s -> 0.0001s at n=100000.
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 #239.
A
Ref/STRefcell compiles to a one-field heap table: the foreignnewallocates{value = v}, and everyread/write/modifypays a table field access. This PR adds a codegen-time pass (Language.PureScript.Backend.Lua.RefUnbox) that lowers a non-escaping cell to a plain mutable Lua local:new vbecomeslocal r = v, areadrun becomesritself, andwrite/modifyruns become assignments. The load-bearing observation is that a Lua local captured by inner closures is an upvalue — a shared, mutable heap slot with exactly the aliasing behaviour of the{value = …}table — so occurrences under lambdas (loop bodies, nested magic-do chunks) need no special treatment, and the only thing the table provides that a local cannot is first-class identity. The analysis therefore asks one question per cell: is every occurrence the reference argument of a recognised operation? Any other occurrence (stored, returned, passed to an unknown function) keeps the cell boxed, so a miss is a missed optimisation, never a miscompile.What lowers. Recognition is by qualified name — the identity that survives linking, the same scheme magic-do and the native-loop matcher use — over the cell primitives of
Effect.RefandControl.Monad.ST.Internal(_new/new,read,write,modifyImpl) plus the ST functor's foreignmap_as a wrapper, becausevoid (STRef.modify f r)(the ubiquitousmodify_shape) inlines tomap_ (\_ → unit) (modifyImpl f r). Only run positions lower (the application carries magic-do'sEffectRunArgmarker, or is the body of a literal loop lambda thatNativeLoop.runStatementsruns per iteration); an operation thunk used as a first-class value keeps the cell boxed. A literalmodifyfunction whose body ends in a manifest{state, value}record is beta-reduced at emission — parameter bound to the local,Letspine emitted as statements, state field assigned, value field passed on — so the per-iteration record is never allocated either.Before/after, the ST accumulator loop from
Golden.NativeLoopsST.Test(quoted verbatim from the committed goldens):Soundness guard. The new
Golden.RefUnbox.Testpins a cell stored inside another cell (outer <- STRef.new inner): the storedinnerkeeps its{value = …}table whileouterstill unboxes, and boxed operations on an aliased cell keep going through the foreign implementation.Golden.NativeLoopsST.Test'scountDownpins the per-cell granularity of the value-position restriction: itsstepscell (used only in run positions) unboxes while itsvaluecell (read undermap_inside thewhilecondition — a first-class thunk) stays boxed. Alleval/golden.txtoracles — which are never auto-accepted — are byte-identical, so runtime behaviour is unchanged across the whole golden corpus, including the write-returns-written-value ST semantics and Effectwrite's nil result.Measurement (issue's verification asks for the allocation-counter drop). A new macro bench
Bench.RefLoop— a hot ST loop accumulating through a localSTRef— with committed counter oracles: the static table-allocation census (tnew_census) drops from 2 steady-state sites to 1 (the survivor is the dead foreignnewclosure body, never called), and the trace report shows the hot loop trace-compiled (JFORL) instead of aborted on per-iteration closures. Wall-clock at n=100000 on this machine: PUC Lua 5.10.0347s → 0.0009s, LuaJIT0.024s → 0.0001s(medians ofbench/tools/run_macro.lua). All pre-existing counter oracles are byte-identical, i.e. no other benchmark's shape moved.Mechanics.
fromIRcarries a new set of unboxed cell names: theLetcase decides unboxability per allocation binding (scanning the remaining bindings and body), lowers operation-run statements in place (scoped indo … endblocks whenever the lowering declares locals, keeping magic-do's ~150-statement chunks under Lua's 200-active-locals cap), and threads the extended set through everything downstream; an expression-position run compiles to a chunk ending in a return of the run's value. TwoNativeLoopadjustments:runStatementsnow statementizes chunk-lowered runs (an unboxed-cell run returns its value in a tail return, which a run-for-effect must rewrite into evaluation statements — previously only return-free loop chunks reached that path), anddropValueadditionally drops a discarded field read off the module-scope table (built by codegen as a plain table, so no metamethod can fire), which thevoidwrapper'sunitvalue otherwise leaves behind as a dead local per loop iteration.The two pre-existing
-Wname-shadowingwarnings inLua.hs(both\grouplambdas shadowing Relude'sgroup) surfaced during the rebuild and are fixed by renaming todispatchGroup, per the repo's no-tolerated-warnings rule.