wasm-gc: update maps in place and keep older versions valid - #1433
Merged
Merged
Conversation
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>
# Conflicts: # CHANGELOG.md
This was referenced Sep 25, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Porting btc-listener to process layer v2 turned this up. An answer module's state held a
Map<Int,Int>with 100k entries, and 2000Map.setrequests took 508 s on wasm-gc. The same run takes 0.01 s in Rust after #1424.Cause
On wasm-gc,
Map.sethad two helpers.set_in_placewrote into the map's arrays.setfirst copied all three arrays (capelements each), then wrote into the copy. The emitter pickedset_in_placeonly whenmir_arg_uniquely_ownedheld: a last-use local whose slot is not aliased, or a fresh collection. In the answer function the receiver isstate.counts, a field read from a record parameter. No ownership fact covers that, so every request copied the whole table. The WAT forOwner_bumpintests/fixtures/run_owned_answer_stateshows 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
Rccopy-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,Optionand 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.removeon wasm-gc wrote into the map it was given, with no ownership check at all.(Map.remove(m, 1), Map.set(m, 2, 20))withm = {1: 10, 3: 30}printed1 false 1 false 2 false true falseforlen/hasofm, the removed map and the set map. The VM prints2 true 1 false 3 true true false.setwas 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.$mapgets a sixth field,diff. A new per-Map<K,V>struct$diffholds(next, idx, key, value, hash).setandremovewrite buckets in place and return a new$mapover the same arrays. Before each write, the old bucket goes into a$diffhung on the version that was handed in. That version is therefore still the old map.diffis null owns the arrays' current contents.reroot(m)makesmthat version: it walks the chain to the current version and swaps the recorded buckets back, reversing the chain. It takes the helper slotset_in_placeused to have, so each map instantiation has the same number of helpers as before.get,get_or_default,get_pair,order_slots,keys,values,entries,hash,eq,set,remove) reroots first.lenreads only the version's own size.eqof two versions that share arrays walks a copy of one side. Otherwise eachgeton the other side would swap buckets under it.maps.rsthat reads buckets directly now reroots first: the wasip2 request and response header walkers, and theaver_http_handlewrapper. 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.setno longer depends onmir_arg_uniquely_owned.Vector.setstill does.Cost: a map used once, as in the generated loop and recursive builders, pays an inline null-diff test and one small
$diffallocation 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_statepattern scaled up: 100k entries and 2000Counter.bumprequests.aver run --wasm-gc, releaseaveraver run --wasm-gc, debugavertools/wasm-work/run.mjs)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:map_build, wasmtimemap_lookup, wasmtimemap_build, V8map_lookup, V8Certification
Only modules that use a
Mapget the new struct layout. The prelude every module shares is unchanged. None of the certification fixtures uses a map (projects/payment_opshas none). Locallycargo test -p aver-cert --libpasses. 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
tests/wasm_gc_map_versions_spec.rs:==between versions that share arrays;removecase above;Map.sethelper contains noarray.copyand allocates arrays only in its grow.set/remove/==operations each, over branching versions with colliding keys, gave identical output on the VM and on wasm-gc.--features wasmpass, as do the wasm-gc suites from CI (every suite except thecert_*ones),own_param_soundnessandrust_work_spec.--features wasm,wasip2:wasip2_codegen_regression,wasip2_coordinator_spec,wasip2_http,wasip2_http_handler,wasip2_poc,wasip2_tcp,wasip2_stress,wasip2_unicode_case,stdlib_specandwasm_work_spec.cargo clippy -p aver-lang --lib --bin aver --benches --features wasm,wasip2 -- -D warningsandcargo fmt --checkare clean.Not in this PR
Vector.sethas the same shape: it copies whenever the receiver is not provably unique, a record field included. The same versioning would apply to vectors.