Skip to content

check: derive perf-shared-update from the field moves generated code makes - #1440

Merged
jasisz merged 3 commits into
mainfrom
fix/shared-update-from-field-moves
Sep 25, 2026
Merged

jasisz merged 3 commits into
mainfrom
fix/shared-update-from-field-moves

Conversation

@jasisz

@jasisz jasisz commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Porting btc-listener found perf-shared-update (#1432) disagreeing with generated Rust in both directions.

Root cause

The check decided "the record still holds the field" with its own AST walk that mirrored the VM's field takes (ir::field_take), not the facts the Rust backend moves fields by (ir::mir::field_moves):

  • False positive. done = toppedUp(s.pool, s.book, s.nextKey)? then S.update(s, pool = done.pool, book = done.book, nextKey = done.nextKey): the check saw s read after the call and warned; Rust moves the Book out (nothing else reads it, the update replaces it).
  • False negative. match (s.pool, s.book) / (pool, book) -> f(s, g(book)): the update's argument is a bare local, which the check never looked at; Rust clones s.book into the match (because s is used whole afterwards), so g copies the Map. btc infra/chainstate.av absorbedFrom and infra/follow.av toppedUp at a835677 have this shape.

Fix

  • The check lowers the module to MIR the way it compiles (slot resolve, last use, HIR resolve against the program's symbol table, lower_program, optimize) and uses field_moves::movable_projections per function. A value handed to an update is reported when it is a field read that does not move, or a local bound to a field read by a let or a match (tuple, constructor and list patterns included) and handed on at its last use. A param that every self tail call hands on unchanged never gives up a field (Rust's loop-carried params). Which callees update in place is still read from the source summaries and followed into dependencies. The MIR is built only when some repeated function has an update site at all, so a check without one does no extra work; on btc (debug build) aver check takes 100 s against 98 s before.
  • types::checker::program_symbols exposes the symbol table the typechecker builds (from the loaded modules or the module root), so the lowering resolves dependency calls.
  • field_moves now understands the base of an update of a field chain, T.update(s.window, created = ...): when nothing outside the update reads that part of s again, the base moves what the update keeps (a fixpoint, since an inner base may depend on an outer one), and the replaced field may move out before it. Without this the check would have had to warn on the nested update that the VM already does in place, because Rust cloned setting.window and setting.window.created. Rust now emits Window { created: setting.window.created.insert_owned(..), ..setting.window }.
  • The repair text no longer suggests binding the parts with a match, which is the false-negative shape; it says to read the field where nothing reads that part of the record again, for example in the update that replaces it. docs/diagnostics-slugs.md is updated.

Mutual tail-call arms only get field moves with #1438, so the let form inside a mutual group (the btc tended shape) moves in Rust once #1438 is in; this check already treats it as moving.

Precision

  • examples/: 0 warnings (life.av's one warning is gone: editorLoop(st.grid, …) reads the grid in a let that the update replacing it follows, so field_moves moves it; the copy that remains there comes from editorLoop being a mutual tail-call member whose wrapper borrows and clones, which the check does not see).
  • self_hosted/: 0.
  • btc HEAD: 0. btc a835677: the 7 pool.* warnings stay; the withStanding ones (one.standing and popped.standing in orPump, greetingRead, greetingPopped, poppedFor) go, because those fields do move; the two false negatives above are now reported.

Tests

  • shared_update_spec: the old parts_moved_out_of_the_record_do_not_warn encoded the false negative and is now a_part_bound_by_a_match_while_the_record_is_passed_on_warns; new a_field_read_before_the_update_that_replaces_it_does_not_warn (the btc false positive, in let and match form). The nested-update test keeps expecting no warning, now true for Rust too.
  • field_moves unit tests: a chain base that moves, and one read again afterwards that does not.
  • rust_work_spec::a_nested_update_moves_the_rest_of_the_inner_record (fixture rust_nested_update_moves): checks the emitted moves and clones, builds, compares with the VM.
  • Every entry in examples/, tests/fixtures, self_hosted/ with a main compiled to Rust and cargo checked: 151 pass; the same 6 as on main fail for unrelated reasons (E0277/E0308). btc HEAD passes cargo check. Self-host regenerated with no change.
  • lib tests, rust_work_spec, own_param_*, rust_codegen_regression, compile_spec, verify_handle_params_spec, fmt, clippy.

jasisz and others added 2 commits September 25, 2026 09:58
The check decided whether a record still held a collection with its own
reading of the AST, which followed the VM's field takes. It disagreed with
what generated Rust does in both directions:

- done = toppedUp(s.pool, s.book, s.nextKey)? followed by
  S.update(s, pool = ..., book = ..., nextKey = ...) warned, although the
  Book moves out of s: nothing else reads it and the update replaces it.
- match (s.pool, s.book) with (pool, book) -> f(s, g(book)) did not warn,
  although s is still used whole, so the match copies the Book and g
  copies it again when it inserts.

The check now lowers the module to MIR the way it compiles, against the
program's symbol table, and asks field_moves, the analysis the Rust
backend moves fields by. A value handed to an update is reported when it
is a field read that does not move, or a local bound to one by a let or a
match and handed on at its last use; a record a loop hands on unchanged
never gives up a field. Which callees update in place is still read from
the source, followed into dependencies.

field_moves now also understands the base of an update of a field chain
(T.update(s.window, created = ...)): when nothing outside the update reads
that part of s again, the base moves what the update keeps and the
replaced field may move out before it. Generated Rust emits
Window { created: ..., ..setting.window } instead of cloning both, so the
nested update the VM already did in place no longer copies the Map in
Rust, and the check agrees with both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
field_moves counted a read in a match subject or if condition as running
together with the arms, so State.update(state, jobs = Map.set(state.jobs,
task, job)) under match Map.get(state.jobs, task) did not move the Map.
Generated Rust moved it anyway through its record-successor fast path, and
the check derived from field_moves then warned about a copy that does not
happen (the run guide example).

A read inside a call in the subject is over once the call returns: the
call's value holds nothing of it. Such a read is now apart from the arms,
so the field read in the arm moves. A subject that is the field read
itself, bound by the arm's pattern, still overlaps the arms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jasisz

jasisz commented Sep 25, 2026

Copy link
Copy Markdown
Owner Author

Pushed 7f387ea: the run guide example (tests/guide_example_spec) warned on State.update(state, jobs = Map.set(state.jobs, task, job)) under match Map.get(state.jobs, task). Rust already moved that Map through its record-successor fast path, but field_moves counted the read in the match subject as running with the arms. A read inside a call in a subject or condition is now over before the arms (the call's value holds nothing of it); a subject that is the field read itself still overlaps. Unit tests for both cases; the examples/self_hosted counts, the Rust sweep (152 ok, same 6 unrelated failures), the lib and Rust suites and the 13 check-output suites pass locally.

…-update-from-field-moves

# Conflicts:
#	CHANGELOG.md
#	tests/rust_work_spec.rs
@jasisz
jasisz merged commit 5a578a3 into main Sep 25, 2026
28 checks passed
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