Fold Record.Unsafe surgery over statically-known records and erase unsafeCoerce - #304
Merged
Conversation
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
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 #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, butRecord.Unsafe's bodies are outside its translatable subset:unsafeGet/unsafeHasindex a table by a dynamic key (an IR field read needs a static property name), andunsafeSet/unsafeDeletecopy their record with apairsloop. 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.unsafeCoercejoins theForeignLiftallowlist. The issue grouped identity foreigns with the handwritten layer, butunsafeCoerce'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. xis 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 rrewrites to a direct field read for any record operand — the read is the call's entire body, so nothing aboutrneeds 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, andunsafeHas l {…}a boolean literal (sound because no PureScript value is represented asnil— 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.BuilderdeferredThe issue also names
Record.Builder.unsafeInsert/unsafeModify/unsafeDelete/copyRecordandRecord.Unsafe.Union.unsafeUnionFn(from therecordpackage). Those entries are deliberately not in this registry: the package set carries no Lua fork ofrecord, 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 unconditionalcopyRecord r → runsound if a future fork mirrors upstream (erasing the defensive copy would let builder steps mutate the caller's record). Extending the registry once a Luarecordfork 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 fromgolden.lua:Surgery on a record read out of a
Ref(opaque):unsafeGetstrength-reduces to a field read, the copying surgeries stay calls:The
unsafeGetrow also drops out of the emitted FFI table (every site folded), as does the wholeUnsafe_Coerce_foreigntable whereverunsafeCoercewas applied — e.g.Golden.LongReaderBind.Test: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 viaunsafeSeton a manifest two-field literal and reads it back across a function boundary. The committed LuaJIT counter censuses show the allocation drop — function-bodyTNEW+TDUP4 → 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-bodyFNEW14 → 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.