Skip to content

Fold Record.Unsafe surgery over statically-known records and erase unsafeCoerce - #304

Merged
Unisay merged 2 commits into
mainfrom
issue-236/record-surgery-rewrites
Jul 26, 2026
Merged

Fold Record.Unsafe surgery over statically-known records and erase unsafeCoerce#304
Unisay merged 2 commits into
mainfrom
issue-236/record-surgery-rewrites

Conversation

@Unisay

@Unisay Unisay commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Closes #236.

What

Two complementary changes fold foreign record operations and identity coercions that the optimizer previously treated as opaque calls.

A handwritten semantic-rewrite layer for Record.Unsafe (Language.PureScript.Backend.IR.RecordSurgery). The source-derived foreign lift (ForeignLift, #178) translates pure FFI bodies into IR so the optimizer can see through them, but Record.Unsafe's bodies are outside its translatable subset: unsafeGet/unsafeHas index a table by a dynamic key (an IR field read needs a static property name), and unsafeSet/unsafeDelete copy their record with a pairs loop. Their semantics are instead restated by hand as a rewrite rule keyed by qualified name and application-spine shape, registered in the optimizer's rewrite fixpoints. Unlike the lift, a handwritten registry can drift from the package set, so it is kept deliberately tiny and the golden eval oracles pin the shipped prelude's behaviour.

Unsafe.Coerce.unsafeCoerce joins the ForeignLift allowlist. The issue grouped identity foreigns with the handwritten layer, but unsafeCoerce's body (function(x) return x end) is inside the liftable subset, and the lift is the strictly better mechanism: drift-proof by construction (it reads the fork source and errors if the shape changes), and the lifted λx. x is marked inline-always, so beta reduction erases the coercion at every applied site and dead-code elimination drops the module's FFI table from the output entirely.

The folds and their soundness conditions

With a static label, unsafeGet l r rewrites to a direct field read for any record operand — the read is the call's entire body, so nothing about r needs to be known — and the existing projection folds (reduceObjectProp) collapse it further when the operand is manifest. The copying surgeries fold only on a manifest record literal, where the fork FFI's fresh copy is observationally the rewritten literal itself: unsafeSet l v {…} becomes the literal with the field replaced in place or appended (one table allocation instead of two), unsafeDelete l {…} the literal without the field, and unsafeHas l {…} a boolean literal (sound because no PureScript value is represented as nil — the same ecosystem invariant that keeps values storable in Lua tables). A surgery on a record the optimizer cannot see through is left as the foreign call.

A label folds only when the Lua lowering keeps it verbatim: generated tables key record fields through makeSafe (which renames reserved words and non-identifiers), while the foreign call looks up the raw string at runtime, so folding a mangled label would read a different key than the call it replaces. Reserved words, non-identifiers, and non-ASCII labels decline.

Scope note: Record.Builder deferred

The issue also names Record.Builder.unsafeInsert/unsafeModify/unsafeDelete/copyRecord and Record.Unsafe.Union.unsafeUnionFn (from the record package). Those entries are deliberately not in this registry: the package set carries no Lua fork of record, so there is no FFI source to anchor the entries against and no way to exercise them end-to-end, and upstream's Builder primitives mutate their record in place, which makes the issue's proposed unconditional copyRecord r → r unsound if a future fork mirrors upstream (erasing the defensive copy would let builder steps mutate the caller's record). Extending the registry once a Lua record fork exists — with pure-copy FFI as a design constraint — is filed as a follow-up.

Before / after

Golden.RecordSurgery.Test (new golden, pinned pre-change in the first commit), surgery on manifest literals, verbatim from golden.lua:

-- before
local Golden_RecordSurgery_Test_inserted = Record_Unsafe_unsafeSet("b")(2)({
  a = 1
})
local Golden_RecordSurgery_Test_got = Record_Unsafe_unsafeGet("a")({ a = 42 })
local Golden_RecordSurgery_Test_coerced = Unsafe_Coerce_foreign.unsafeCoerce(7)

-- after
M.Golden_RecordSurgery_Test_inserted = { a = 1, b = 2 }
-- got/coerced fold to the constants 42 and 7 at their use sites

Surgery on a record read out of a Ref (opaque): unsafeGet strength-reduces to a field read, the copying surgeries stay calls:

-- before
logShow(Record_Unsafe_unsafeGet("k")(Record_Unsafe_unsafeSet("k")(Record_Unsafe_unsafeGet("k")(dyn_S_1) + 1)(dyn_S_1)))()

-- after
logShow((Record_Unsafe_foreign.unsafeSet("k")(dyn_S_1.k + 1)(dyn_S_1)).k)()

The unsafeGet row also drops out of the emitted FFI table (every site folded), as does the whole Unsafe_Coerce_foreign table wherever unsafeCoerce was applied — e.g. Golden.LongReaderBind.Test:

-- before
local Unsafe_Coerce_foreign = { unsafeCoerce = function(x) return x end }
local Golden_LongReaderBind_Test_compute = Unsafe_Coerce_foreign.unsafeCoerce(9)

-- after
local Golden_LongReaderBind_Test_compute = 9

26 golden modules move (net −95 lines of generated Lua); every hand-verified eval oracle is byte-identical.

Measurement

Bench.RecordSet (new macro benchmark, counters pinned pre-change in the first commit) builds a record per element via unsafeSet on a manifest two-field literal and reads it back across a function boundary. The committed LuaJIT counter censuses show the allocation drop — function-body TNEW+TDUP 4 → 3 (the copy loop's allocation is gone; the per-element build is now the single literal { a = i, b = i + 1, c = i + 2 }), function-body FNEW 14 → 11, prototypes 23 → 18. Wall-clock on the same spec (median of the harness runs, n=1e6): LuaJIT 0.374s → 0.089s, PUC Lua 5.1 0.552s → 0.169s, identical results.

Tests

The first commit pins the pre-change shapes: the new golden module with a hand-written eval oracle, and the benchmark with its pre-fold counters — so this PR's second commit shows the improvement as a reviewable diff. Twelve focused optimizer unit tests cover each fold, the dissolved foreign-accessor head shape, and the decline cases (unknown record, dynamic label, mangled label, partial application); they were confirmed red before the implementation (8 of 12 failing, the decline cases hold vacuously). The optimizer-touching spec groups were seed-stressed 22 extra runs, all green.

Unisay added 2 commits July 26, 2026 15:45
RecordSurgery is a new golden module exercising Record.Unsafe's
unsafeGet/unsafeSet/unsafeDelete/unsafeHas over manifest record
literals and over a record read from a Ref (opaque to the optimizer),
plus an applied Unsafe.Coerce.unsafeCoerce. Every surgery is pinned as
a curried foreign call against the emitted copy-loop FFI table; the
eval oracle pins the runtime semantics the folds must preserve.

Bench.RecordSet is a new macro benchmark building a record per element
through unsafeSet on a manifest two-field literal, read back across a
function boundary. Its committed counters pin the pre-fold allocation
cost: 4 function-body TNEW+TDUP sites and 14 function-body FNEW sites.
A handwritten semantic layer for foreign record operations
(Language.PureScript.Backend.IR.RecordSurgery), complementary to the
source-derived ForeignLift: Record.Unsafe's bodies are outside the
liftable subset (dynamic table indexing, pairs copy loops), so their
semantics are restated as a rewrite rule keyed by qualified name and
application-spine shape, registered in the optimize fixpoints.

With a static plain label, unsafeGet becomes a direct ObjectProp read
for any record operand — the read is the call's entire body — and the
existing projection folds collapse it further on manifest operands.
unsafeSet/unsafeDelete/unsafeHas fold only on a manifest record
literal, where the fork FFI's fresh copy is the rewritten literal
itself: one table allocation replaces two, and membership is decided
at compile time. A label the Lua lowering would mangle (reserved word,
non-identifier, non-ASCII) declines, since generated tables key by the
makeSafe spelling while the foreign call looks up the raw string.

Unsafe.Coerce.unsafeCoerce joins the ForeignLift allowlist: its body
lifts to an inline-always identity lambda, so beta reduction erases
the coercion at every applied site and the module's FFI table drops
out of the output entirely.

Bench.RecordSet counters: function-body TNEW+TDUP 4 -> 3, FNEW
14 -> 11, prototypes 23 -> 18. Wall-clock on the same spec (median,
n=1e6): LuaJIT 0.374s -> 0.089s, PUC Lua 5.1 0.552s -> 0.169s.

Closes #236
@Unisay Unisay self-assigned this Jul 26, 2026
@Unisay
Unisay marked this pull request as ready for review July 26, 2026 15:40
@Unisay
Unisay merged commit ff62acd into main Jul 26, 2026
2 checks passed
@Unisay
Unisay deleted the issue-236/record-surgery-rewrites branch July 26, 2026 16:02
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.

Handwritten semantic-rewrite layer for record surgery and identity foreigns

1 participant