wasm-gc: set vectors in place and keep older versions valid - #1444
Merged
Merged
Conversation
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>
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>
jasisz
force-pushed
the
wasm-gc/vector-versions
branch
from
September 25, 2026 20:26
14dd7ac to
8434b8c
Compare
This was referenced Sep 26, 2026
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.
Follow-up to #1443, which fixed the VM and generated Rust. The same shape, a record holding a 100,000-cell Vector with each step doing
match Vector.set(s.cells, i, x), copied the whole array on wasm-gc as well.Cause
On wasm-gc,
Vector.setwrote the array in place only whenmir_arg_uniquely_ownedheld: a last-use local whose slot is not aliased, or a fresh collection. Otherwise it allocated a new array andarray.copyd every cell. A vector read from a record field is covered by neither, so every step copied. This is the same thing #1433 found forMap.set, and wasm-gc has no reference counts to decide it at run time.Fix: versions over one array, as #1433 did for Map
The details are in the new
src/codegen/wasm_gc/vectors.rs.Vector<T>value is now a version struct(arr, diff, held)over the existing(array (mut T)), and there is a diff struct(next, idx, value)for eachVector<T>. The registry allocates both for everyVector<T>invector_order.vector_type_idxstill names the array, which the internalVector<String>of the string concat helper keeps using as it did.aver_to_wasm("Vector<T>")now names the version.set(v, i, x, owned)makesvcurrent, builds a new versionwover the same array, records the old cell in a diff onvpointing atw, and writes the cell. Out-of-range handling stays at the call sites, unchanged.current(v) -> arraymakesvthe version that owns the array's contents. It walks the diff chain to the current version and swaps the recorded cells back, reversing the chain, the same algorithm as the mapreroot. It returns at once whenvis already current. Every read of the elements goes through it:get, the fused get-or,List.fromVector,eq,hash, and the capability ABIget/sethelpers the Rust and JavaScript hosts use.lenreadsarray.lendirectly, because every version has the same length.heldis set on a version once another version's diff points at it. When the compiler proves the receiver unique and it is current and not held, the cell is written with nothing recorded, so a builder loop allocates nothing.eqof two versions that share an array copies one side first.Vector.new,__vector_new,Vector.fromListand the hostnewhelper wrap their fresh array in a version with no diff.==on two vectors looked the vector helpers up by theVector<T>spelling, but they are registered under theirList<T>pair. Before this change the module failed validation (expected i64, found (ref null $type)); now it compiles.Cost: each non-owned set allocates two small structs. Reading an older version again costs one swap per set made since. A read of a current version pays one call to
currentand a null test.Measurements
These are release builds,
aver run --wasm-gcon wasmtime, best of 3, fortests/fixtures/vector_field_set/main.av(the same fixture as #1443, added here too so the branch stands alone):What remains is startup and building the 100,000-cell vector.
aver bench bench/scenarios/vector_ops.toml(the onlyvector_*scenario), p50:vector_opsfills a 5000-cell vector through a parameter that was not proven unique, so before this change every set copied the array.A read-only probe with 200,000 fused
Vector.getcalls on a vector no set touches measured 9.37 ms before and 9.24 ms after, so thecurrentcall on reads costs nothing measurable.Certification
The wall models
Vector<T>as the plain array of its elements (Grammar.lean:vec tis "a wasm array of the element representation"), withVector.getadmitted fused. That is no longer the value representation, and a read may reroot. Soplan_from_mirdeclines any function whose plan mentions aVectortype, with the reasontypeVector: a Vector is a versioned struct on wasm-gc, and the wall models it as a plain array. The wall andaver-certare unchanged. A package that claimed a vector plan anyway would be checked against the old array lowering, which no longer matches the emitted bytes.tools/cert_ratchet.pyreports one drop:tools/certkit/fixtures/cell_at.av: cellAt. The baseline is updated with--update --allow-drop. Every other program keeps all its certified functions, bridges and law-claims (197 certified functions).cert_verify_accepts_fused_vector_read_and_declines_three_tampersbecomescert_declines_a_function_that_reads_a_vector, which asserts the decline and its reason.Vectorbuiltin orList.fromVector. The registry still keeps aVector<T>array for everyList<T>helper pair and for the string concatenation helper. In a program without a Vector value those arrays stay plain, and thefrom_list/to_list/eq/hashhelpers are emitted exactly as on main, so the module is byte-identical. The concatenation helper'sVector<String>argument is always the bare array, in both cases.examples/,tools/certkit/fixtures,bench/scenariosandtests/fixturesthat compiles on both: 525 of 558 are byte-identical. The 33 that differ all have a Vector value. Some get it through a dependency (checkers/render), through the generated wait helpers ofrun_wait_own_key(Vector.fromList(keys)), or through a capability type (capability_manifest).greet/shout) keeps main's bytes: 2496-byte core module, same sha256.a_program_without_a_vector_value_is_unchanged_by_versionspins the size and sha256 of a list, interpolation and+program to main's.5698c8e(main.avcompiled with--certify) has the same results on main and on this branch: 632 certified exports, 119 law-claims, 494 bridges, 138 declined bridges. Every function that touches a Vector there was already declined on main for another reason, such asCall Builtin(Vector.fromList). Its module grows 768,493 → 799,495 bytes (+4.0%), because a program with a Vector value versions everyVector<T>, including the ones itsList<T>helpers register.docs/certification.mddrops the fused vector read from the admitted grammar and says why.Tests
tests/wasm_gc_vector_versions_spec.rs, added to the wasm-gc CI lane. Each program runs on the VM and on wasm-gc against a hand-checked answer:==between versions that share an array;Vector<String>and a set past the end;sethelper neither copies nor allocates an array.wasm_gc_container_read_ownership:fresh_local_set_stays_in_place_while_container_read_copiesasserted that the container-read variant clones. Neither variant clones now, so the test is renamedneither_a_fresh_nor_a_container_read_set_copies. The provenance × operation matrix in that file still checks the values on the VM, wasm-gc and the self-host.setthroughmatch, the fusedwithDefaultspelling, an owned fill loop, a set through a record field, and reads, lengths,==and full dumps of arbitrary earlier versions. Every program gave identical output.eq's shared-array copy removed,equality_between_versions_that_share_an_arrayfails.heldcheck removed from the owned write, every test still passes. I could not build a program where the ownership analysis grants a unique receiver to a version that another version is described against: a set result bound by amatch, awithDefaultof a set with a fresh default, and an argument to a function whose parameter would graduate all stayed non-owned. The check is kept because the write is unsound without it the moment the analysis grants such a receiver.cargo test -p aver-lang --lib --features wasm,wasip2;cert_verify_specandcert_certify_spec, which ran separately (next item);cert_certify_spec(40 tests, run again after the byte fix),cert_decode_specandcert_one_build_spec, plus the decline test incert_verify_spec;cargo test -p aver-cert, exceptphase_timeout::check_fails_closed_when_a_lean_step_exceeds_the_time_limit, which looks for leftover build dirs under/tmpwhile this machine's temp dir is elsewhere (this PR does not touchaver-cert);cargo fmtand the three CI clippy commands.cross_int_euclidean_divmod_vm_vs_wasm_gcoverflows the compiler's stack in the local debug build. It does so with a debug build of main too, and it passes in CI.cert_verify_speclocally. The Certification lanes on this PR cover it.What the wall has to learn (follow-up, not in this PR)
Merge this PR only after the wall supports the new representation. Until then the producer declines any function whose plan mentions a
Vector.Types. These are per
Vector<T>, in the one rec group, only in a program with a Vector value.$arr_T = (array (mut T)). This is main's type, and it is stillvecStruct tfor the concatenation helper'sVector<String>.$vec_T = (struct (field (mut (ref null $arr_T))) (field (mut (ref null $diff_T))) (field (mut i32))), with fieldsarr,diffandheld. This is the value type of aVector<T>.$diff_T = (struct (field (mut (ref null $vec_T))) (field (mut i32)) (field (mut T))), with fieldsnext,idxandvalue.For
Vector<Int>in a bignum module these are, for example,$arr = 3,$vec = 11and$diff = 12, andTis(ref null $aint).Helper bytes for
Vector<Int>. Sizes are body sizes including the size prefix; "main" is the same helper on main.current(v) -> $arr(new; the reroot)struct.set/array.setset(v, i, x, owned) -> $vec(new)current; allocates two structs;struct.setandarray.setfrom_listto_listcurrentfirsteqcurrenton both; copies one side when they share an arrayhashcurrentfirstnew/len/get/set(forVector<Unit>)newwraps;lenadds astruct.get;get/setcallcurrentInline code at the call sites also changes:
Vector.lenadds onestruct.get $vec 0beforearray.len.Option.withDefault(Vector.get(v, i), lit)(the wall'svecGetOrB) readsarray.lenthroughstruct.get, and callscurrentbeforearray.get.currentandset.Vector.new,__vector_newandVector.fromListend inref.null $diff; i32.const 0; struct.new $vec.What can be pinned byte for byte. Every helper is a fixed template per
T, parameterized only by the three type indices, the element value type and thecurrentfunction index. So each can be pinned the way the wall pins__aint_to_index.setand the four ABI helpers are straight-line.current,eq,hash,from_listandto_listcontain loops. The wall'sWInstrhas noloop/br/struct.set/array.set/array.new_default/array.copy/ref.eq, so it cannot execute these today. It also modelsWValas trees (structv/arrvalues), with no store or identity, so it cannot express two versions sharing one mutable array. Either the machine learns a store with references and those instructions, orcurrentandsetenter as host-style contracts with the properties below, each proved once against its pinned template.Properties that keep the plan's
vec tmodel (every version reads its own contents) true. They are stated over a storeH.contents_H(v)isH.arr(v)whenH.diff(v)is null, and otherwisecontents_H(H.diff(v).next)with cellidxreplaced byvalue.SRepr (.vec t vs) viffvis a$vec_Treference andcontents_H(v) = wswithSReprL vs ws. The length bound stays.current(v)awitha = H'.arr(v),H'.diff(v) = nullandH'.array(a) = contents_H(v).$vec_Treferenceu,contents_H'(u) = contents_H(u). Only the representation of the chain changes.set(v, i, x, 0), fori < |contents_H(v)|:wwithcontents_H'(w) = contents_H(v)[i := x].contents_H'(u) = contents_H(u)for allu ≠ w), includingvitself.H'.held(w) = 1.set(v, i, x, 1)writes in place and returnsv. This is sound only whenheld(v) = 0and nothing readsvagain. The first half holds whenheld(u) = 1for everyuthat some diff'snextpoints at:setsetsheldon the version it describes against, andcurrentsets it on each version it restores. That invariant must be proved. The second half is the compiler's staticmir_arg_uniquely_ownedclaim, which the wall cannot check. So a certified plan should either be lowered withowned = 0everywhere, or cite the last-use fact as a checked premise.len, the fused get,to_list,hash, the ABIget) observe exactlycontents_H(v).lenreads the array directly, which is sound because every version of a lineage shares one array andsetnever resizes it.eq(a, b)equals elementwise equality ofcontents_H(a)andcontents_H(b). When both share an array, it compares a copy ofcontents_H(a)taken beforecurrent(b)reroots.