Move record fields out at the record's last use in generated Rust - #1428
Merged
Merged
Conversation
A field of a record local was always cloned, even when nothing read that
part of the record again. Two costs followed:
- f(s.window.created, s.window.spent) at the last use of s left a second
reference to each Map in s, so every in-place insert inside f copied
the whole Map.
- T.update(s, window = v) at the last use of s was emitted as
T { window: v, ..s }, which leaves the old window in the partially
moved s until the function returns.
A new MIR analysis (field_moves) names the field reads that may move:
every other read of the same local either runs in another branch,
finished in an earlier let, or reads a disjoint part of the record, and
one of them is the local's last use. The Rust backend moves such a read
when its root is an owned value. A Map or Vector param that receives
such a read graduates to the owned ABI when the callee updates it in
place, and a record param that gives a field to such a param, or to the
target of Map.set, Map.remove or Vector.set, is taken by value.
An update whose base is the record's last use, and whose fields were not
moved out earlier, now moves the record and assigns the new fields, so
the replaced value drops at once. When the new field values move fields
the update replaces, the base moves after them instead of being cloned.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
jasisz
added a commit
that referenced
this pull request
Sep 25, 2026
Brings in nested literal and list patterns (#1434), Run.fail (#1430), #1428, #1429 and #1432. A module with no process of its own used to go down one of two exclusive branches of the front pipeline: compile its nested patterns, or carry its waits keyed by a type other than Int. A dependency can need both, so the branch now does both in order: check the module as written, so errors name the patterns the user wrote; compile the nested patterns and check again; carry the waits with the key types read off that check of the lowered module; and check what the carrying wrote once more. The entry already did both: the yield lowering carries its waits over the module as written, and the nested patterns are compiled after it. Nested patterns inside a yield function are still refused. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This was referenced Sep 25, 2026
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.
A field of a record local was always cloned in generated Rust, even at the record's last use. This came up porting btc-listener to process layer v2, which needed
withoutCatching,withoutSettingandwithoutWindowhelpers to get unique ownership.f(s.window.created, s.window.spent)at the last use ofsleft a second reference to each Map ins, so every in-place insert insidefcopied the whole Map (build: add release-fast profile for local development #227).T.update(s, window = v)at the last use ofswas emitted asT { window: v, ..s }, which keeps the oldwindowin the partially movedsuntil the function returns.Approach
src/ir/mir/field_moves.rs(new, backend-neutral) names the field reads that may move. Every other read of the same local must either run in anothermatcharm orifbranch, finish in an earlierletvalue, or read a disjoint part of the record. The base ofT.update(s, ...)counts as every field except the replaced ones. One of these reads must also be the local's last use. Reads inside an independent product never move.ownership.rs): such a projection moves when its root is an owned value, meaning not a borrowed param, not a TCO wrapper, and not carried unchanged into the next loop iteration.own_param(Rust model only): a movable field read supplies an owned carrier, but only for a Map or Vector param the callee updates in place (field_moves::in_place_collection_params). A read-only param gains nothing from owning the field.compute_owned_record_params: a record param is taken by value when a field moves out of it into a by-value record param, into an in-place collection param, or into the target ofMap.set,Map.removeorVector.set.RecordUpdatehas three cases:{ let mut __updated = s; __updated.window = v; __updated }, so the old field drops at once...s) instead of being cloned.Measurements
The fixture is
tests/fixtures/rust_record_field_moves:Setting { window: Window { created, spent: Map<Int, Int> }, rounds }. The generated Rust was built in release and timed with/usr/bin/time -l, 3 runs each.absorbed(setting.window.created, setting.window.spent, key), 100k-entry MapsSetting.update(setting, window = empty)then refill, 100k entriestests/rust_work_spec.rs::a_record_gives_up_its_fields_at_its_last_usepins the generated shape:absorbedtakes owned Maps,steptakesSettingby value and moves both fields, andrestartsuses the drop-early update. It also checks that the output matches the VM.Self-host
The self-host was regenerated. The diff drops 22
.clone()s of fields at last use and adds none.btc-listener
absorbedInto(setting, this, absorbed(setting.window.created, ...))still clones the Maps.settingis passed whole in the same call and still holds the window. That sharing is in the program, so the compiler cannot remove it.Setting.update(setting, window = emptyWindow()),Catching.update(catching, setting = Option.None),State.update(state, catching = Option.None)), each one now emits the drop-early form or moves the window Maps straight intoabsorbed. The generated crate passescargo check. The helper functions are no longer needed; the update that empties the field still is.Tests
cargo fmt --checkpasses.wasm,wasip2.rust_work_spec,own_param_graduation,own_param_soundness,rust_codegen_regressionandcompile_specall pass.AVER_SELF_HOST_REGEN=1 rust_self_host_regen --include-ignoredpasses.