Unpack let-bound records read only field-wise (#240) - #321
Merged
Conversation
A new Golden.ScalarReplacement.Test module covering the aggregate shapes issue #240 unpacks: a let-bound record read at two fields (fieldwise), the defaults pattern of a literal read at a field and used as an update base (defaults), a record-update chain read field-wise (chained), and a record flowing into a branch result as a whole value that must keep its allocation (wholeValue), the soundness guard. The goldens pin the current boxed form — every record a table allocation, every update a PSLUA_object_update copy — so the unpacking rewrite shows as a reviewable diff against them; the hand-written eval oracle pins the runtime outputs the rewrite must preserve.
Pins the contract of the scalar-replacement rules before their implementation: a let-bound record literal read only field-wise unpacks to per-field binders (bound once, never duplicated), an update use is reconstructed as one literal from the known field set, a let-bound record update folds its reads to the patch values and the base record and coalesces a chained update onto the base, and the in-place fold collapses an update over a manifest literal — the shape used-once inlining leaves behind. Declines pin the soundness boundary: a whole-value use, a read at a label the literal lacks, or an update patching a foreign label all keep the allocation. The reference-multiset property uses an allocation-count oracle because a countFreeRef check on a still-bound name is vacuous.
A let-bound record literal or record update whose binder never flows anywhere as a whole value exists only to be projected or updated again, yet it still allocates its table — and an update pays a runtime copy on top. Three rewrites remove the aggregates (issue #240), gated on the exact-use census Query.hasWholeValueObjectRead takes over every occurrence: * propagateKnownObjectThroughLet explodes a literal binding into per-field binders: reads become the binders, an update use is reconstructed as a single literal over the known field set, and the binding is dropped. Occurrences fold across trailing sibling groupings as well as the body, so the sequentially-scoped defaults pattern (let opts = {…}; chosen = opts { … }) dissolves whole. * propagateObjectUpdateThroughLet binds the update's own parts (base and read patches) instead: reads reach the patch values or the base directly, and a chained update coalesces onto the base with patch lists merged — two copies become one. * reduceObjectUpdate folds the in-place shapes used-once inlining leaves behind: an update over a manifest literal becomes the patched literal, an update over an update coalesces. A value isInlinableValue admits is pasted at its occurrences instead of bound, the substitution inlineLocalBinding would perform one step later. Whole-value uses, reads at labels a literal lacks, and updates patching such labels all decline, preserving sharing — the boundary Golden.ScalarReplacement.Test pins from both sides. Dictionary towers built as local records collapse the same way: Golden.LongWriterBind drops its Writer-transformer dictionaries entirely (895 → 838 Lua lines and no more module table). Closes #240
A fold whose step builds a record, updates it, and reads the result back field-wise — the shape issue #240 unpacks. The committed counter goldens pin the unpacked steady state: one function-body table allocation in the whole artifact (the range input array, once per run) where the boxed form paid three per element (the literal, the update's patch table, and the copy inside PSLUA_object_update). Against the pre-change compiler the linked artifact runs 4.6x faster under LuaJIT (median 0.0237s -> 0.0052s at n=100000) and 5.3x under PUC Lua 5.1 (0.0405s -> 0.0076s), with identical results.
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 #240.
A
let-bound record that is only ever read field-wise — projected at a label, or used as the base of a record update — never needs to exist as a table: the aggregate is built, read a few times, and dropped. On the Lua target that table is a real heap allocation, and a record update pays a second one plus apairscopy loop inside thePSLUA_object_updateruntime fixture (the injected helper that implements PureScript record update by copying the operand table and overwriting the patched keys). pslua already unpacked constructors (propagateKnownCtorThroughLet, #214) and array literals (#225) through alet; this PR adds the record siblings.Three rules in
Language.PureScript.Backend.IR.Optimizer, all gated on an exact-use census (Query.hasWholeValueObjectRead) that admits only field reads and update-base uses — one whole-value occurrence (returned, passed on, stored) vetoes the rewrite, preserving sharing. All before/after Lua below is quoted verbatim from the committed golden and bench artifacts; the first commit pins the boxed shapes precisely so these diffs are reviewable.1.
propagateKnownObjectThroughLet— unpack a literal bindingA binding whose right-hand side is a manifest record literal explodes into per-field bindings: each read becomes its field value and the record binding is dropped (a value that is binder-free and free to re-emit — a reference, a scalar, a cheap projection — is pasted at its occurrences directly instead of bound). The golden module's
compiled to an allocate-then-project table:
and now to plain arithmetic:
An update use does not veto the unpacking: the field set is statically known, so the use is reconstructed as a single literal — patched labels take their patch expressions, unpatched labels their field values. And unlike its constructor/array siblings the fold also reaches occurrences in trailing sibling
letbindings, becauseletgroupings scope sequentially and the common defaults pattern puts the update use in a sibling, not the body:compiled to two tables and a runtime copy:
and now to zero of either:
2.
propagateObjectUpdateThroughLet— unpack an update bindingA binding whose right-hand side is a record update has an unknown field set (the base is arbitrary), so nothing is reconstructed from scratch; instead the update's own parts are bound — the base record and the read patch values — and the copy the binding denoted never runs. In the shapes the new unit specs pin (surface syntax,
g 1a non-trivial call):3.
reduceObjectUpdate— fold the in-place shapesThe in-place sibling of
reduceObjectProp, for the shapes used-once inlining leaves behind once a record binding's single use is an update base:The rules compose
A record-update chain read field-wise dissolves link by link, and once no table is left the surrounding arithmetic constant-folds. The golden module's
compiled to three tables and two runtime copies per call:
after the change the function is gone entirely — its call site
logShow (chained 3 4)folds all the way to the answer:The same unpacking fires on locally-built dictionary records:
Golden.LongWriterBind.Testdrops its whole Writer-transformer dictionary tower (applyWriterT,applyIdentity,applicativeIdentity,semigroupArray— all local records read only at their method labels), shrinking from 895 to 838 lines of Lua and losing its module-scopeMtable; itsdiscardcollapses from a three-dictionary chain toThe soundness boundary
One whole-value use keeps the allocation. The golden module's
wholeValuelets its record flow into a branch result, and its compiled form is byte-identical before and after — the update atastays a realPSLUA_object_updatebecausermust remain a table:Conservatively, a read or a patch at a label the literal lacks also declines (such input is ill-typed): the runtime fixture only overwrites existing keys, so reconstructing a record with a foreign key would change its field set. Every eval golden (the hand-verified runtime-output oracles) is unchanged.
Measurement
New macrobenchmark
Bench.RecordFold(bench/macro/record_fold.lua) — a fold whose step builds a record, updates it, and reads the result back field-wise. The step compiled by the pre-change compiler pays three table allocations per element:and by this branch, none:
The static table-allocation census (
tnew_census) agrees: function-bodyTNEW+TDUPdrops from 4 sites (the literal, the update's patch table, the copy insidePSLUA_object_update, therangeinput array) to 1 (the input array alone, built once per run). Wall-clock at n=100000, same result value (15000350000), medians over the harness's samples:The committed counter goldens (
bench/goldens/*Bench.RecordFold*) pin the unpacked steady state; all pre-existing bench counters are unchanged (Bench.RecordSetdeliberately keeps its record live across a function boundary — a whole-value use the census declines, as designed).Commits
test(golden)pins the pre-change boxed shapes so the rewrite shows as a reviewable diff.test(optimizer)pins the rule contract (red at commit time, per TDD).feat(optimizer)the three rules plus the census; accepts the structural golden churn (LongReaderBind/StringCodePoints/TailRecM2Shadow.irdiffs are fresh-name renumbering only — their.luais untouched).bench(macro)the RecordFold spec and counter goldens.docs(changelog)scriv fragment.