Skip to content

wasm-gc: update maps in place and keep older versions valid - #1433

Merged
jasisz merged 5 commits into
mainfrom
wasm-gc-map-set-in-place
Sep 25, 2026
Merged

jasisz merged 5 commits into
mainfrom
wasm-gc-map-set-in-place

Conversation

@jasisz

@jasisz jasisz commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Porting btc-listener to process layer v2 turned this up. An answer module's state held a Map<Int,Int> with 100k entries, and 2000 Map.set requests took 508 s on wasm-gc. The same run takes 0.01 s in Rust after #1424.

Cause

On wasm-gc, Map.set had two helpers. set_in_place wrote into the map's arrays. set first copied all three arrays (cap elements each), then wrote into the copy. The emitter picked set_in_place only when mir_arg_uniquely_owned held: a last-use local whose slot is not aliased, or a fresh collection. In the answer function the receiver is state.counts, a field read from a record parameter. No ownership fact covers that, so every request copied the whole table. The WAT for Owner_bump in tests/fixtures/run_owned_answer_state shows the call going to the copying helper.

Rust fixed the same shape in #1424 by moving the state down the serve chain, because its runtime Rc copy-on-write then sees a count of one. wasm-gc has no reference counts. For a static proof, uniqueness would have to be tracked through record fields, the run record, Option and the generated loop. Any record construction elsewhere that shares a map would then make an in-place write unsound.

Two more findings along the way:

  • Map.remove on wasm-gc wrote into the map it was given, with no ownership check at all. (Map.remove(m, 1), Map.set(m, 2, 20)) with m = {1: 10, 3: 30} printed 1 false 1 false 2 false true false for len/has of m, the removed map and the set map. The VM prints 2 true 1 false 3 true true false.
  • The in-place set was sound only because of that uniqueness check, so any shape the check could not see paid a full copy.

Fix: versions over shared arrays

The map representation now keeps several versions of one map on the same arrays. This is the persistent-array technique from Baker's "shallow binding", also used in Conchon and Filliâtre's persistent union-find. Details are in src/codegen/wasm_gc/maps/versions.rs.

  • $map gets a sixth field, diff. A new per-Map<K,V> struct $diff holds (next, idx, key, value, hash).
  • set and remove write buckets in place and return a new $map over the same arrays. Before each write, the old bucket goes into a $diff hung on the version that was handed in. That version is therefore still the old map.
  • The version whose diff is null owns the arrays' current contents. reroot(m) makes m that version: it walks the chain to the current version and swaps the recorded buckets back, reversing the chain. It takes the helper slot set_in_place used to have, so each map instantiation has the same number of helpers as before.
  • Every helper that reads the arrays (get, get_or_default, get_pair, order_slots, keys, values, entries, hash, eq, set, remove) reroots first. len reads only the version's own size.
  • A grow rehashes into new arrays. The old version keeps its arrays, so no diff crosses a grow.
  • eq of two versions that share arrays walks a copy of one side. Otherwise each get on the other side would swap buckets under it.
  • Code outside maps.rs that reads buckets directly now reroots first: the wasip2 request and response header walkers, and the aver_http_handle wrapper. Map arguments of a host import (Tcp.poll, Wait.poll) and of the wasip2 poll helper are rerooted before the call, because the native host reads the buckets of that argument directly.
  • Map.set no longer depends on mir_arg_uniquely_owned. Vector.set still does.

Cost: a map used once, as in the generated loop and recursive builders, pays an inline null-diff test and one small $diff allocation per write. Reading an older version again costs one bucket swap per write made since.

Numbers

The measurement uses the tests/fixtures/run_owned_answer_state pattern scaled up: 100k entries and 2000 Counter.bump requests.

before after
aver run --wasm-gc, release aver 11.3 s 0.39 s
aver run --wasm-gc, debug aver 558 s 7.2 s (6.2 s with 20 requests, so nearly all of it is startup and building the map)
Node 26 (tools/wasm-work/run.mjs) 1.87 s 0.30 s
Node 26, 20 000 requests ~20 s (extrapolated from 8000: 7.9 s) 1.18 s

A map that is already current pays a field read and a branch on each helper call, because the null-diff test is inline. Each write also allocates one $diff. On the bench scenarios, comparing release binaries of main and this branch by p50:

main this PR
map_build, wasmtime 0.99 ms 1.03 ms
map_lookup, wasmtime 1.48 ms 1.52 ms
map_build, V8 0.56 ms 0.58 ms
map_lookup, V8 0.96 ms 0.96 ms

Certification

Only modules that use a Map get the new struct layout. The prelude every module shares is unchanged. None of the certification fixtures uses a map (projects/payment_ops has none). Locally cargo test -p aver-cert --lib passes. I did not run the Lean-backed certification suites on this machine, because another Lean job was running there; the Certification smoke lanes on this PR cover them.

Tests

  • New tests/wasm_gc_map_versions_spec.rs:
    • every older version stays readable across updates, inserts, removes that shift a probe run, growth, a 30-write chain and two branches from one base;
    • == between versions that share arrays;
    • the remove case above;
    • the Map.set helper contains no array.copy and allocates arrays only in its grow.
  • Locally, about 1 900 random programs of 120 to 220 set/remove/== operations each, over branching versions with colliding keys, gave identical output on the VM and on wasm-gc.
  • The lib tests with --features wasm pass, as do the wasm-gc suites from CI (every suite except the cert_* ones), own_param_soundness and rust_work_spec.
  • The wasip2 suites pass with --features wasm,wasip2: wasip2_codegen_regression, wasip2_coordinator_spec, wasip2_http, wasip2_http_handler, wasip2_poc, wasip2_tcp, wasip2_stress, wasip2_unicode_case, stdlib_spec and wasm_work_spec.
  • cargo clippy -p aver-lang --lib --bin aver --benches --features wasm,wasip2 -- -D warnings and cargo fmt --check are clean.

Not in this PR

  • Vector.set has the same shape: it copies whenever the receiver is not provably unique, a record field included. The same versioning would apply to vectors.
  • A program that keeps a very old version alive also keeps every diff between it and the current version. Memory is O(writes) rather than a copy per write, but it is not freed until that old version is dropped.

jasisz and others added 5 commits September 25, 2026 03:58
Map.set copied the whole table whenever the compiler could not prove the
map had no other holder, which it cannot for a map held in a record
field. An answer module's state map of 100 000 entries took 558 s to
serve 2000 Map.set requests under `aver run --wasm-gc`. Map.remove wrote
into the map it was given with no check at all, so a caller that kept
that map saw the key disappear.

A map now keeps versions over shared arrays. set and remove write the
bucket in place and return a new map struct. Before each write, the old
contents of the bucket are recorded in a diff struct on the map they were
given. The version with no diff owns the arrays' contents. A new
per-map reroot helper, in the slot set_in_place used to have, makes any
version the current one by swapping recorded buckets back. Every helper
that reads the arrays reroots first. So does the code outside maps.rs
that walks buckets: the wasip2 header walkers, the http handler wrapper,
and the map arguments of host imports and of the wasip2 poll. eq copies
one side when both maps share arrays. A grow uses new arrays, so no diff
crosses one.

The same run now takes 7 s, nearly all of it startup and building the
map, and 0.3 s on Node 26 instead of 1.9 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The spec is gated on the wasm feature, so the native lanes build it with
no tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every map helper called reroot on entry, and wasmtime does not inline
the call, so map_lookup ran about 10% slower than before. Testing the
diff field at the call site brings it within a few percent.

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 7ddcdb1 into main Sep 25, 2026
26 checks passed
jasisz added a commit that referenced this pull request Sep 25, 2026
Vector.set copied the whole array whenever the compiler could not prove
its receiver unique, which it cannot for a vector held in a record field.
2000 sets of a 100,000-cell Vector in a record took 0.21 s under
`aver run --wasm-gc`, and 20,000 took 1.8 s.

A Vector<T> value is now a version struct over the (array (mut T)): arr,
diff and held, beside a diff struct (next, idx, value). This is the scheme
Map got in #1433. `set` writes the cell in place and returns a new version
over the same array, after hanging the old cell on the version it was
given. A per-vector `current` helper makes a version the one that owns
the array's contents, swapping recorded cells back along the chain, and
every read of the elements goes through it; `len` reads the array length
directly. A set whose receiver the compiler proves unique, and that no
other version is described against (`held`), writes with nothing
recorded. `eq` copies one side when two versions share an array. The
capability ABI helpers the hosts use go through `current` too.

The same runs now take 0.04 s, and bench/scenarios/vector_ops takes
0.37 ms instead of 18 ms on wasmtime and 0.29 ms instead of 13 ms on V8.

`==` on two vectors looked up the vector helpers by the Vector canonical
while they are registered by the List pair, so the module failed
validation; it now compiles.

The certificate wall models a Vector as the plain array of its elements,
so a function that touches a Vector is declined with that reason.
tools/certkit/fixtures/cell_at.av loses its certificate, the fused
vector-read verify test becomes a decline test, and the ratchet baseline
drops cellAt. Modules that register a Vector (every List<T> registers
one) gain the two structs and two helpers, so the wasip2 component
snapshot's module length and hash move.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
jasisz added a commit that referenced this pull request Sep 25, 2026
* wasm-gc: set vectors in place and keep older versions valid

Vector.set copied the whole array whenever the compiler could not prove
its receiver unique, which it cannot for a vector held in a record field.
2000 sets of a 100,000-cell Vector in a record took 0.21 s under
`aver run --wasm-gc`, and 20,000 took 1.8 s.

A Vector<T> value is now a version struct over the (array (mut T)): arr,
diff and held, beside a diff struct (next, idx, value). This is the scheme
Map got in #1433. `set` writes the cell in place and returns a new version
over the same array, after hanging the old cell on the version it was
given. A per-vector `current` helper makes a version the one that owns
the array's contents, swapping recorded cells back along the chain, and
every read of the elements goes through it; `len` reads the array length
directly. A set whose receiver the compiler proves unique, and that no
other version is described against (`held`), writes with nothing
recorded. `eq` copies one side when two versions share an array. The
capability ABI helpers the hosts use go through `current` too.

The same runs now take 0.04 s, and bench/scenarios/vector_ops takes
0.37 ms instead of 18 ms on wasmtime and 0.29 ms instead of 13 ms on V8.

`==` on two vectors looked up the vector helpers by the Vector canonical
while they are registered by the List pair, so the module failed
validation; it now compiles.

The certificate wall models a Vector as the plain array of its elements,
so a function that touches a Vector is declined with that reason.
tools/certkit/fixtures/cell_at.av loses its certificate, the fused
vector-read verify test becomes a decline test, and the ratchet baseline
drops cellAt. Modules that register a Vector (every List<T> registers
one) gain the two structs and two helpers, so the wasip2 component
snapshot's module length and hash move.

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

* wasm-gc: give vectors versions only in programs that have a Vector value

The registry keeps a Vector<T> array for every List<T> helper pair and
for the string concatenation helper, and versions were added to all of
them. A program with lists or interpolation but no Vector value grew:
the wasip2 carrierless component went from 2496 to 2931 bytes.

Versions, their structs and the current / set helpers now exist only
when the program has a Vector value: a type that names one in a
signature, binding annotation, record or variant field or capability
boundary type, or a call to a Vector builtin or List.fromVector.
Otherwise the Vector<T> arrays stay plain and the from_list, to_list,
eq and hash helpers are emitted as before, so the module is byte for
byte what it was. Of 558 programs under examples/, tools/certkit,
bench/scenarios and tests/fixtures that compile on both, 525 are
identical to main; the 33 that differ all have a Vector value, some
through a dependency or the generated wait helpers.

The wasip2 component snapshot is back to main's bytes, and a new test
pins the hash of a list-and-interpolation program.

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

---------

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