Skip to content

Release a consumed tuple or Option box on the VM - #1436

Merged
jasisz merged 3 commits into
mainfrom
vm-answer-state-unbox
Sep 25, 2026
Merged

jasisz merged 3 commits into
mainfrom
vm-answer-state-unbox

Conversation

@jasisz

@jasisz jasisz commented Sep 25, 2026

Copy link
Copy Markdown
Owner

On the VM, the generated loop's Option.Some(state) box and the tuples around it kept no holder count. After a match had taken them apart, they still counted as holders of the state, so every answer copied the answer module's Maps. #1424 fixed this for Rust only, and VM time on run_owned_answer_state did not change.

What changes

I took the second option from the issue: Option boxes (and tuples) now take part in the holder count. The generated loop stays as it is, and any program that carries a record through Option, Result or a tuple benefits too.

  • Holder counts. ArenaEntry::Tuple and ArenaEntry::Boxed now carry a holder_count, like Map, Vector and Record. Every entry, global and constant that stores one registers itself, through a single predicate (NanValue::counts_holders).
  • Consuming destructure. A match whose subject nothing reads afterwards (a temporary, or a local at its last use) releases what it takes apart:
    • POP_CONSUMED after a tuple arm empties the tuple.
    • MATCH_UNWRAP with the consume bit empties the box.
    • ? does the same for an Ok box, via PROPAGATE_ERR_CONSUMED.
  • Guards at run time. Each release happens only when no off-stack holder is registered, no stack cell holds the tuple or box, and a Map or Vector inside is worth the walk (the same WALK_SLACK bound map writes use). Anything else leaves it holding its value.
  • Profiling. aver run --profile now also prints how many map entries were copied by writes that were not in place.

Measurements

Release build without LTO, best of 3:

main this branch
run_owned_answer_state as checked in (200 requests, 1k-entry Map) 0.060 s 0.056 s
same, scaled to 2000 requests and a 100k-entry Map 1.84 s 0.13 s

On the checked-in fixture, main copied 1802 map writes' worth of entries and this branch copies 1399. The remaining copies are the run's own small Maps (versions, slots), which the next PR addresses.

aver bench bench/scenarios/ (VM, p50, best of two rounds, machine under load from other jobs): most scenarios are within ±2%. fractal_seahorse and record come out 2–3.5% slower across three runs. The fractal hot loop runs none of the new code (no tuples, wrappers or matches on them in its opcode mix), so I read this as dispatch-loop layout. The release paths are already #[inline(never)] and outside the loop.

Tests

  • New tests/vm_consumed_destructure.rs:
    • 4 wins: an Option unwrapped at its last use, a tuple destructured at its last use, ? on a Result, and a box inside a tuple.
    • 9 refusals: the caller keeps the Option, the tuple or the Result; a record, a List or a Map holds the box; one box in two tuples; a tuple inside another tuple; a local read again after the match.
    • The generated loop fixture, asserted through --profile.
  • Fault injection, one guard at a time:
Guard removed Tests failing
Off-stack holder check 6
Stack-cell check 5
Tuples counted as holders 2
Boxes counted as holders 5
"Consume only temporaries / last uses", in match or ? 0
A tuple or box turning marking on 0

The consume-only rule is backed by the runtime guards: a local still live holds a stack cell, so the stack-cell check refuses. The marking switch only matters in a program whose only aggregates are tuples or boxes.

  • mir_vm_codegen: ? on a temporary emits PROPAGATE_ERR_CONSUMED, and on a live local it emits PROPAGATE_ERR.
  • Full cargo test -p aver-lang (native features) passes, run without lake on PATH so the Lean-backed tests skipped. cargo test --lib for the other workspace crates, run_all_spec and rust_work_spec also pass. cargo fmt and both non-wasm clippy commands are clean.
  • Generated Rust is unchanged, so no self-host regeneration was needed.

The generated loop hands an answer module's state out of the run in an
Option, inside a tuple, and takes the answer back in another tuple. On
the VM the Option box and the tuples kept no holder count, so after a
match had taken them apart they still counted as holders of the state,
and every answer copied the module's Maps. #1424 fixed this on the Rust
side only.

Tuples and the boxes of Option.Some, Result.Ok and Result.Err now count
their off-stack holders the way maps, vectors and records do: every
entry, global and constant that stores one registers itself. A match
whose subject nothing reads afterwards (a temporary, or a local at its
last use) now releases what it takes apart: POP_CONSUMED after a tuple
arm empties the tuple, and MATCH_UNWRAP with the consume bit empties the
box, each only when nothing off the stack and no stack cell holds the
tuple or box, and only when a Map or Vector inside makes it worth the
walk. `?` does the same for an Ok box through PROPAGATE_ERR_CONSUMED.

aver run --profile now also prints how many map entries the writes that
were not in place copied.

Release build without LTO, run_owned_answer_state scaled to 2000
requests against a 100k-entry Map: 1.84 s -> 0.13 s. The fixture as
checked in (200 requests, 1k entries) is 0.06 s either way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
jasisz and others added 2 commits September 25, 2026 09:21
# Conflicts:
#	CHANGELOG.md
#	src/vm/execute/slots.rs
#	src/vm/opcode.rs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jasisz
jasisz merged commit be8cac1 into main Sep 25, 2026
26 checks passed
jasisz added a commit that referenced this pull request Sep 25, 2026
__bump read the run whole (__versionOf(run, owner)) inside the update
that sets run.versions, and __park did the same with moved.now and
__versionOf(moved, owner) inside the update that sets moved.slots, so
on the VM neither record update could take its Map field out and every
answer copied both Maps. perf-shared-update reports both when it is
allowed to look at generated functions.

Both now read everything else first and update the run at its last use.
__take<Module> reads the state before it updates the run, so the update
moves the run's other fields instead of leaving the old run holding
them, and __seatFamily<P> lists the retired keys before it hands the
retired Map to __unretire<P>. With the generated-function filter lifted,
perf-shared-update reports nothing in the loop fixtures.

On the VM the gain shows together with the consumed-tuple release
(#1436), since the run reaches __serve<P><Kind> through the __take
tuple: on run_owned_answer_state the writes refused because something
else held the Map go from 403 to 4, and on its 2000-request version from
4003 to 4. The Maps are small, so wall time does not move.

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