Skip to content

Set a Vector field in place when the match on Vector.set hands the record back - #1443

Merged
jasisz merged 1 commit into
mainfrom
perf/vector-set-in-place
Sep 25, 2026
Merged

jasisz merged 1 commit into
mainfrom
perf/vector-set-in-place

Conversation

@jasisz

@jasisz jasisz commented Sep 25, 2026

Copy link
Copy Markdown
Owner

A record holds a 100,000-cell Vector, and each step of a tail-recursive loop does

match Vector.set(s.cells, i mod 100000, i)
    Option.Some(updated) -> State.update(s, cells = updated, count = s.count + 1)
    Option.None -> s

Every backend copied the whole Vector on every set. This PR fixes the VM and generated Rust. wasm-gc follows in a separate PR (Vector versions, as #1433 did for Map).

Why the Map fixes did not cover it

The fix

The None arm runs only when the index is out of range, and then the set changes nothing. So a backend that evaluates the index and the value, checks the index, and reads the target only in the Some arm never needs the target in the None arm.

  • field_moves::vector_set_match names the shape: a two-arm Some/None match (either order, or _ after Some) on Vector.set(path, index, value) where path is a field path of a local. movable_projections places the target read in the Some arm, so the usual rules decide whether it moves. It now takes the builtin table.
  • Rust: when the target moves, emit_mir_match_with emits let idx = index.to_usize(); let value = …; match idx.filter(|i| *i < s.cells.len()) { Some(i) => { let updated = s.cells.set_unchecked(i, value); … } None => { … } }.
  • VM: the subject compiles to the new VECTOR_SET_FIELD field holders over [record, index, value]. An index out of range answers None and leaves the record alone. In range, the Vector leaves the record only when the record is not held elsewhere and exactly holders + 1 stack cells hold it, counted while the index and value are still on the stack. It is then written in place only when confirm_vector_grant agrees, the same fence as VECTOR_SET_OR_KEEP. The in-place write moved into store_vector_element_in_place, shared by both opcodes. Anything else copies, as VECTOR_SET does. Only local.field is handled on the VM; a deeper path compiles as before.
  • perf-shared-update reads the same movable_projections, so the shape no longer warns. A Some arm that reads the old Vector still warns.
  • Arena::vector_elements_copied counts elements that Vector.set copies, the vector counterpart of map_entries_copied.

Measurements

tests/fixtures/vector_field_set/main.av (the measured program; the step count is the first argument). Release builds, best of 3, wall time:

backend sets before after
VM 2000 0.472 s 0.037 s
VM 20000 5.026 s 0.043 s
Rust release 2000 0.204 s 0.027 s
Rust release 20000 1.794 s 0.028 s

bench/scenarios/vector_ops.toml on the VM: p50 819 µs before, 850 µs after, which is within noise.

Tests

  • tests/vm_record_field_take.rs: the fixture copies no element. A loop copies nothing, and an index past the end leaves the Vector in the record. The None-first arm order also copies nothing. Refusals that must copy and answer correctly: the caller keeps the record; another local names the record; another record holds it; two records share the Vector; a local bound to the old Vector; the Some arm reads the old Vector; the record is kept whole beside the new Vector; the update keeps the base's Vector; the written value holds the record (Vector<Option<Node>> in Node).
  • Fault injection on each new guard, one at a time, reverted after:
    • record held off the stack: …held_by_another_record… fails;
    • stack count: …caller_still_holds… and …another_local_holds… fail;
    • take before the range check: the loop and None-first tests fail;
    • skipping the vector fence after the take: …two_records_share… and …another_local_holds_is_not_written… fail;
    • the static movable_projections check in the compiler: the old-Vector, kept-whole and update-keeps tests fail.
  • tests/rust_work_spec.rs: the fixture's generated step moves the field in the Some arm and prints what the VM prints. tests/fixtures/vector_field_set_shapes covers the hostile shapes plus a self-TCO loop, a Vector two records down, and a recursive node. It builds as Rust and prints the same as the VM and as main did before this change.
  • field_moves unit tests for the shape and for a Some arm that reads the target again.
  • tests/shared_update_spec.rs: the shape does not warn, and the read-back variant still warns.
  • cargo test --workspace passes locally, including own_param_soundness and vm_record_field_take. cargo fmt and all three CI clippy commands pass. python3 tools/regenerate_self_host.py --check reports the self-host is fresh.

Not covered: a Vector two records down (o.inner.cells) is still copied on both backends, because the Rust move needs the inner update's base to be final and the VM opcode handles one field.

…cord back

`match Vector.set(s.cells, i, x)` with `Some(updated) -> State.update(s,
cells = updated, ...)` and `None -> s` copied the whole Vector on every
set, on the VM and in generated Rust, even with `s` held by nothing else.
The field-take work for Map did not reach it: the Vector is read in the
match subject, not inside the update, and the None arm reads `s` whole,
so every analysis saw a later read of the field.

The None arm runs only when the index is out of range, and then the set
changes nothing. `field_moves::vector_set_match` names this shape, and
`movable_projections` places the target read in the Some arm, so it moves
when nothing in the Some arm or after the match needs the field.

- Generated Rust evaluates the index and the value, checks the index
  against the length, and moves the field into `set_unchecked` in the
  Some arm.
- The VM compiles the subject as the new `VECTOR_SET_FIELD` opcode. It
  answers None for an index out of range without touching the record;
  otherwise it takes the Vector out of the record only when nothing off
  the stack holds the record and exactly the expected stack cells do,
  counted while the index and value are still on the stack, and writes in
  place only under the same fence as every other in-place vector write.
  Anything else copies, as `VECTOR_SET` does.
- `perf-shared-update` reads the same facts, so it no longer reports this
  shape.

The arena counts the vector elements a `Vector.set` copies, so the tests
can assert that a set copied nothing. The new fixture's 2000 and 20,000
sets of a 100,000-cell Vector take 0.04 s instead of 0.47 s and 5.0 s on
the VM, and 0.03 s instead of 0.20 s and 1.8 s in generated Rust, in
release builds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jasisz
jasisz merged commit 9d89579 into main Sep 25, 2026
32 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