Skip to content

Move record fields out at the record's last use in generated Rust - #1428

Merged
jasisz merged 2 commits into
mainfrom
perf/move-record-fields-at-last-use
Sep 25, 2026
Merged

jasisz merged 2 commits into
mainfrom
perf/move-record-fields-at-last-use

Conversation

@jasisz

@jasisz jasisz commented Sep 25, 2026

Copy link
Copy Markdown
Owner

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, withoutSetting and withoutWindow helpers to get unique ownership.

  • 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 (build: add release-fast profile for local development #227).
  • T.update(s, window = v) at the last use of s was emitted as T { window: v, ..s }, which keeps the old window in the partially moved s until 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 another match arm or if branch, finish in an earlier let value, or read a disjoint part of the record. The base of T.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.
  • Rust backend (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 of Map.set, Map.remove or Vector.set.
  • RecordUpdate has three cases:
    • The base is the last use and no earlier read moved any of its fields. It emits { let mut __updated = s; __updated.window = v; __updated }, so the old field drops at once.
    • The new values move replaced fields out of the base. The base moves after them (..s) instead of being cloned.
    • Otherwise the output is unchanged.

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.

case before after
(b) 1,000 rounds of absorbed(setting.window.created, setting.window.spent, key), 100k-entry Maps 0.77 s, 22 MiB 0.01 s, 15 MiB
(a) 100 restarts via Setting.update(setting, window = empty) then refill, 100k entries 0.61–0.65 s, 25–27 MiB peak 0.58–0.64 s, 16–17 MiB peak
(a) 10 restarts, 1M entries 1.27 s, 334 MiB peak 1.25–1.28 s, 202–229 MiB peak

tests/rust_work_spec.rs::a_record_gives_up_its_fields_at_its_last_use pins the generated shape: absorbed takes owned Maps, step takes Setting by value and moves both fields, and restarts uses 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

  • At 149623a, absorbedInto(setting, this, absorbed(setting.window.created, ...)) still clones the Maps. setting is passed whole in the same call and still holds the window. That sharing is in the program, so the compiler cannot remove it.
  • With the helper calls at HEAD replaced by the inline updates they wrap (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 into absorbed. The generated crate passes cargo check. The helper functions are no longer needed; the update that empties the field still is.

Tests

  • cargo fmt --check passes.
  • Both CI clippy commands pass for aver-lang, with and without wasm,wasip2.
  • Lib tests: 1611 passed.
  • rust_work_spec, own_param_graduation, own_param_soundness, rust_codegen_regression and compile_spec all pass.
  • AVER_SELF_HOST_REGEN=1 rust_self_host_regen --include-ignored passes.

jasisz and others added 2 commits September 25, 2026 03:32
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
jasisz merged commit e6bd1a4 into main Sep 25, 2026
28 checks passed
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>
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.

1 participant