Skip to content

Unpack let-bound records read only field-wise (#240) - #321

Merged
Unisay merged 5 commits into
mainfrom
issue-240/scalar-replacement
Jul 27, 2026
Merged

Unpack let-bound records read only field-wise (#240)#321
Unisay merged 5 commits into
mainfrom
issue-240/scalar-replacement

Conversation

@Unisay

@Unisay Unisay commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

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 a pairs copy loop inside the PSLUA_object_update runtime 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 a let; 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 binding

A 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

fieldwise n =
  let r = { width: n + 1, height: n * 2 }
  in r.width + r.height

compiled to an allocate-then-project table:

local Golden_ScalarReplacement_Test_fieldwise = function(n)
  local r = { width = n + 1, height = n * 2 }
  return r.width + r.height
end

and now to plain arithmetic:

M.Golden_ScalarReplacement_Test_fieldwise = function(n) return n + 1 + n * 2 end

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 let bindings, because let groupings scope sequentially and the common defaults pattern puts the update use in a sibling, not the body:

defaults n =
  let opts = { verbose: 1, level: n }
      chosen = opts { verbose = 2 }
  in opts.level + chosen.verbose + chosen.level

compiled to two tables and a runtime copy:

local Golden_ScalarReplacement_Test_defaults = function(n)
  local opts = { verbose = 1, level = n }
  local chosen = PSLUA_object_update(opts, { verbose = 2 })
  return opts.level + chosen.verbose + chosen.level
end

and now to zero of either:

M.Golden_ScalarReplacement_Test_defaults = function(n) return n + 2 + n end

2. propagateObjectUpdateThroughLet — unpack an update binding

A 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 1 a non-trivial call):

-- a read at a patched label becomes that patch's value, bound once;
-- a read at any other label reads the base record directly:
let x = y { a = g 1 } in x.a + (x.a + x.b)
  ⟶  let f = g 1 in f + (f + y.b)

-- an update use coalesces onto the base with the patch lists merged,
-- the later update winning a contested label — two copies become one:
let x = y { a = 1 } in use (x { b = 2 })
  ⟶  use (y { a = 1, b = 2 })

3. reduceObjectUpdate — fold the in-place shapes

The in-place sibling of reduceObjectProp, for the shapes used-once inlining leaves behind once a record binding's single use is an update base:

-- over a manifest literal the copy is the patched literal; the
-- patched-over field's value is dropped, never evaluated:
{ a: g 1, b: 2 } { a = 5 }   ⟶   { a: 5, b: 2 }

-- over another update the two copies coalesce into one:
(y { a = 1 }) { b = 2 }      ⟶   y { a = 1, b = 2 }

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

chained a b =
  let r0 = { x: a, y: 0, z: 0 }
      r1 = r0 { y = b }
      r2 = r1 { z = a + b }
  in r2.x + r2.y + r2.z

compiled to three tables and two runtime copies per call:

local Golden_ScalarReplacement_Test_chained_S_w = function(a, b)
  local r0 = { x = a, y = 0, z = 0 }
  local r1 = PSLUA_object_update(r0, { y = b })
  local r2 = PSLUA_object_update(r1, { z = a + b })
  return r2.x + r2.y + r2.z
end

after the change the function is gone entirely — its call site logShow (chained 3 4) folds all the way to the answer:

local _ = Effect_Console_log(Data_Show_showIntImpl(14))()

The same unpacking fires on locally-built dictionary records: Golden.LongWriterBind.Test drops 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-scope M table; its discard collapses from a three-dictionary chain to

local Golden_LongWriterBind_Test_discard_S_w = function(v_S_0, k_S_0)
  local m_S_0 = k_S_0(v_S_0[1])
  return Data_Tuple_Tuple_S_w(m_S_0[1], Data_Semigroup_foreign.concatArray(v_S_0[2])(m_S_0[2]))
end

The soundness boundary

One whole-value use keeps the allocation. The golden module's wholeValue lets its record flow into a branch result, and its compiled form is byte-identical before and after — the update at a stays a real PSLUA_object_update because r must remain a table:

local Golden_ScalarReplacement_Test_wholeValue = function(n)
  local r = { a = n, b = n + 1 }
  local s = (function()
    if r.a >= 0 and r.a ~= 0 then
      return r
    else
      return PSLUA_object_update(r, { a = 0 - r.a })
    end
  end)()
  return s.a + s.b
end

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:

local r_S_0 = { lo = i_S_0, hi = i_S_0 + 1 }
local s_S_0 = PSLUA_object_update(r_S_0, { hi = r_S_0.hi * 2 })
return acc_S_1 + s_S_0.lo + s_S_0.hi

and by this branch, none:

return acc_S_1 + i_S_0 + (i_S_0 + 1) * 2

The static table-allocation census (tnew_census) agrees: function-body TNEW+TDUP drops from 4 sites (the literal, the update's patch table, the copy inside PSLUA_object_update, the range input 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:

runtime before after speedup
LuaJIT 0.0237s 0.0052s 4.6×
PUC Lua 5.1 0.0405s 0.0076s 5.3×

The committed counter goldens (bench/goldens/*Bench.RecordFold*) pin the unpacked steady state; all pre-existing bench counters are unchanged (Bench.RecordSet deliberately keeps its record live across a function boundary — a whole-value use the census declines, as designed).

Commits

  1. test(golden) pins the pre-change boxed shapes so the rewrite shows as a reviewable diff.
  2. test(optimizer) pins the rule contract (red at commit time, per TDD).
  3. feat(optimizer) the three rules plus the census; accepts the structural golden churn (LongReaderBind/StringCodePoints/TailRecM2Shadow .ir diffs are fresh-name renumbering only — their .lua is untouched).
  4. bench(macro) the RecordFold spec and counter goldens.
  5. docs(changelog) scriv fragment.

Unisay added 5 commits July 27, 2026 14:45
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.
@Unisay
Unisay marked this pull request as ready for review July 27, 2026 14:01
@Unisay Unisay self-assigned this Jul 27, 2026
@Unisay
Unisay merged commit e7473b8 into main Jul 27, 2026
2 checks passed
@Unisay
Unisay deleted the issue-240/scalar-replacement branch July 27, 2026 17:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scalar replacement: unpack records/arrays/updates read only field-wise

1 participant