From d4ddb9664a25fd3df46b65d23f3481039b194578 Mon Sep 17 00:00:00 2001 From: jasisz Date: Fri, 25 Sep 2026 09:11:09 +0200 Subject: [PATCH] Hand the generated loop's own Maps on at the run's last use __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 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

lists the retired keys before it hands the retired Map to __unretire

. 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

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) --- CHANGELOG.md | 1 + src/yield_lowering/coordinator.rs | 10 +++++----- tests/run_all_spec.rs | 7 ++++++- tests/rust_work_spec.rs | 2 +- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07568de6a..c07e93120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ The generated loop is now written from the program's source alone, and the manif - **A `main` that answers `Err` exits non-zero on wasm-gc and wasip2**, with the error on stderr on wasm-gc, as it already did on the VM and in generated Rust. Both wasm targets used to exit zero. - **`aver replay` of a run whose `main` answered `Err` matches.** The VM replay compared a runtime error against the recorded `Err` value and always reported a mismatch. - **The VM updates a Map in a field of a record in place when the record is consumed.** `State.update(state, counts = Map.set(state.counts, k, v), served = state.served + 1)` used to copy the whole Map on every call, even with `state` held by nothing else: the base of the update and the local, still needed for `served`, both held the record while the Map was set. A field that a record update or a record literal reads once, while every other read of the local in it is of another field and the local is not read afterwards, is now taken out of the record first, and the runtime does it only when exactly the holders the compiler accounted for hold the record. An update whose base nothing else holds moves the fields it keeps into the new record, so the dead base no longer keeps the next update of another field copying. In a release build, two thousand requests against a hundred-thousand-key Map now run in 0.06 s instead of 2.3 s, and ten thousand in 0.06 s instead of 9.6 s. A field two levels down (`setting.window.created`) is still copied; take the inner record out first. +- **The generated loop hands its own Maps on at the run's last use.** `__bump` (the answer versions), `__park` (the slot table), `__take` and the family seating read everything else of the run first and update the run last, so `Map.set` on `versions`, `slots` and a family's retired keys finds them held by nothing else instead of copying them once per answer on the VM. - **A wait over sockets and jobs keeps watching its sockets after a job outside its set settles.** The wake from that job used to end the socket poll, and the wait then slept out the rest of its timeout on the job engine alone, missing sockets that became ready meanwhile and never reporting them. The VM, generated Rust and the wasm-gc native host now share one wait loop that polls the whole set again. - **A handle whose slot the engine has forgotten answers `work: unknown job` on the VM**, as it already did elsewhere, instead of claiming another job kind started it. Which kind began a job is now kept in the job's own slot, so nothing a job kind keeps grows with the number of jobs it starts. - **The VM runs a function whose bytecode is larger than 32 KiB.** Jump offsets were sixteen bits and a longer forward jump wrapped into a backward one, which crashed `aver verify` on large generated trace laws. diff --git a/src/yield_lowering/coordinator.rs b/src/yield_lowering/coordinator.rs index 99d5a9d1a..cb9436b1d 100644 --- a/src/yield_lowering/coordinator.rs +++ b/src/yield_lowering/coordinator.rs @@ -594,7 +594,7 @@ fn write_loop( // ── Handing a state out ──────────────────────────────────────── for answer in answers { out.push_str(&format!( - "\nfn __take{0}(run: __Run) -> Tuple<__Run, Option<{1}>>\n ? \"Hands the state of '{2}' out of the run and leaves none behind, so the answer function it goes to holds the only reference to it and can update it in place. The state comes back with the answer.\"\n (__Run.update(run, {3} = Option.None), run.{3})\n", + "\nfn __take{0}(run: __Run) -> Tuple, __Run>\n ? \"Hands the state of '{2}' out of the run and leaves none behind, so the answer function it goes to holds the only reference to it and can update it in place. The state is read first and the run is updated at its last use, so the run it came from gives up its other fields instead of still holding them. The state comes back with the answer.\"\n (run.{3}, __Run.update(run, {3} = Option.None))\n", super::build::capitalize(&answer.field), answer.state, answer.module, @@ -670,12 +670,12 @@ fn write_loop( out.push_str("\nfn __current(run: __Run, id: Int) -> Int\n ? \"The instance number of the request one process is waiting on, or -1 when nothing is seated under that id.\"\n match Map.get(run.slots, id)\n Option.None -> 0 - 1\n Option.Some(slot) -> slot.seq\n"); out.push_str("\nfn __nextInstance(seq: Int) -> Int\n ? \"The instance number an answer for the current one leaves behind. It rises, so the instance just answered can never be current again.\"\n seq + 1\n"); out.push_str("\nfn __versionOf(run: __Run, owner: Int) -> Int\n ? \"How many times this answer module has answered other than Settled. A request parked on Settled is asked again once this has moved past the number it was parked at.\"\n match Map.get(run.versions, owner)\n Option.None -> 0\n Option.Some(version) -> version\n"); - out.push_str("\nfn __bump(run: __Run, owner: Int) -> __Run\n ? \"One answer of this module that was not Settled: its state may have moved, so every request parked on Settled with it may be worth asking again.\"\n __Run.update(run, versions = Map.set(run.versions, owner, __versionOf(run, owner) + 1))\n"); + out.push_str("\nfn __bump(run: __Run, owner: Int) -> __Run\n ? \"One answer of this module that was not Settled: its state may have moved, so every request parked on Settled with it may be worth asking again. The version is read first, so the versions Map is handed to Map.set at the run's last use.\"\n version = __versionOf(run, owner)\n __Run.update(run, versions = Map.set(run.versions, owner, version + 1))\n"); out.push_str("\nfn __deadlineOf(wake: Run.Wake) -> Option\n ? \"The deadline half of a wake, if it has one.\"\n match wake\n Run.Wake.Until(_, deadline) -> deadline\n Run.Wake.Settled(deadline) -> deadline\n"); out.push_str("\nfn __dueOf(deadline: Option, now: Int) -> Int\n ? \"The clock reading a deadline falls due at. A negative deadline is due now; no deadline carries none.\"\n match deadline\n Option.None -> 0\n Option.Some(ms) -> now + Int.max(ms, 0)\n"); out.push_str("\nfn __msOf(deadline: Option) -> Int\n ? \"How long the request asked to be left alone for, never less than nothing.\"\n match deadline\n Option.None -> 0\n Option.Some(ms) -> Int.max(ms, 0)\n"); out.push_str("\nfn __parked(slot: __Slot, wake: Run.Wake, now: Int, owner: Int, version: Int) -> __Slot\n ? \"The slot an Err leaves behind: the same instance and the same request, now remembering what would make asking again worth it. A deadline is turned into the clock reading it falls due at, and the ms that was asked for is kept beside it, so a clock that steps backwards cannot strand the request.\"\n __Slot(seq = slot.seq, pending = slot.pending, waiting = wake, due = __dueOf(__deadlineOf(wake), now), ms = __msOf(__deadlineOf(wake)), owner = owner, version = version)\n"); - out.push_str("\nfn __park(run: __Run, id: Int, wake: Run.Wake, owner: Int) -> __Run\n ? \"An Err: the request stays where it is with the same instance number. The state the answer module returned was already written back. An answer that is not Settled moves the module's version first; a Settled one does not, so a request cannot wake itself.\"\n moved = __moved(run, wake, owner)\n match Map.get(moved.slots, id)\n Option.None -> moved\n Option.Some(slot) -> __Run.update(moved, slots = Map.set(moved.slots, id, __parked(slot, wake, moved.now, owner, __versionOf(moved, owner))))\n"); + out.push_str("\nfn __park(run: __Run, id: Int, wake: Run.Wake, owner: Int) -> __Run\n ? \"An Err: the request stays where it is with the same instance number. The state the answer module returned was already written back. An answer that is not Settled moves the module's version first; a Settled one does not, so a request cannot wake itself. The clock and the version are read first, so the slots Map is handed to Map.set at the last use of the run.\"\n moved = __moved(run, wake, owner)\n now = moved.now\n version = __versionOf(moved, owner)\n match Map.get(moved.slots, id)\n Option.None -> moved\n Option.Some(slot) -> __Run.update(moved, slots = Map.set(moved.slots, id, __parked(slot, wake, now, owner, version)))\n"); out.push_str("\nfn __moved(run: __Run, wake: Run.Wake, owner: Int) -> __Run\n ? \"The versions after one Err: Until moves its module's version, Settled leaves it.\"\n match wake\n Run.Wake.Until(_, _) -> __bump(run, owner)\n Run.Wake.Settled(_) -> run\n"); out.push_str("\nfn __staleInstance(run: __Run, id: Int, seq: Int) -> Bool\n ? \"Why an answer carrying this instance number changes nothing: it is not the number the slot under this id is waiting on.\"\n seq != __current(run, id)\n"); @@ -858,7 +858,7 @@ fn write_seating(out: &mut String, proc: &Proc<'_>, performs: &ProcessEffects) { keyed.by, protocol.fn_name, keyed.field )); out.push_str(&format!( - "\nfn __seatFamily{upper}(run: __Run, keys: List<{key}>) -> __Run\n ? \"The '{0}' family at the turn boundary: instances whose key has left the list are dropped, retired keys that have left it may come back later, and every listed key that is neither seated nor retired is seated, in list order.\"\n{seat} present = __keySet{upper}(keys, {{}})\n kept = __dropLeft{upper}(run, Map.keys(run.seated{upper}), present)\n back = __Run.update(kept, retired{upper} = __unretire{upper}(kept.retired{upper}, Map.keys(kept.retired{upper}), present))\n __seatKeys{upper}(back, keys)\n", + "\nfn __seatFamily{upper}(run: __Run, keys: List<{key}>) -> __Run\n ? \"The '{0}' family at the turn boundary: instances whose key has left the list are dropped, retired keys that have left it may come back later, and every listed key that is neither seated nor retired is seated, in list order. The retired keys are listed first, so the retired Map is handed on at the last use of the run.\"\n{seat} present = __keySet{upper}(keys, {{}})\n kept = __dropLeft{upper}(run, Map.keys(run.seated{upper}), present)\n retiredKeys = Map.keys(kept.retired{upper})\n back = __Run.update(kept, retired{upper} = __unretire{upper}(kept.retired{upper}, retiredKeys, present))\n __seatKeys{upper}(back, keys)\n", protocol.fn_name )); out.push_str(&format!( @@ -957,7 +957,7 @@ fn write_serve(proc: &Proc<'_>, answers: &[Answer], performs: &ProcessEffects) - let mut pattern = binders.clone(); pattern.push("state".to_string()); out.push_str(&format!( - " {}.{}({}) -> match __take{}(run)\n (__rest, __held) -> match __held\n Option.Some(__taken) -> __serve{upper}{}(__rest, id, seq{key_arg}, state, {}.{op}({}))\n Option.None -> __rest\n", + " {}.{}({}) -> match __take{}(run)\n (__held, __rest) -> match __held\n Option.Some(__taken) -> __serve{upper}{}(__rest, id, seq{key_arg}, state, {}.{op}({}))\n Option.None -> __rest\n", protocol.request, kind.name, pattern.join(", "), diff --git a/tests/run_all_spec.rs b/tests/run_all_spec.rs index 7467372f5..afedb64c0 100644 --- a/tests/run_all_spec.rs +++ b/tests/run_all_spec.rs @@ -448,10 +448,15 @@ fn the_dump_shows_the_loop_that_was_generated() { "fn __servePeer(run: __Run, id: Int, seq: Int, request: __PeerRequest) -> __Run", // The answer module's state is handed out of the run for the one // answer, so the answer function holds the only reference to it. - "fn __takeLedger(run: __Run) -> Tuple<__Run, Option>", + "fn __takeLedger(run: __Run) -> Tuple, __Run>", "Ledger.claim(__taken)", // An Ok settles the request, an Err parks it on the wake it named. "Result.Err(__wake) -> __park(", + // The run's own Maps are handed to Map.set at the run's last use, + // with everything else read first, so no answer copies them. + " version = __versionOf(run, owner)\n __Run.update(run, versions = Map.set(run.versions, owner, version + 1))", + "__Run.update(moved, slots = Map.set(moved.slots, id, __parked(slot, wake, now, owner, version)))", + "(run.ledger, __Run.update(run, ledger = Option.None))", "Run.Wake.Settled(deadline) -> Bool.or(__versionOf(run, slot.owner) > slot.version", // The entry's own policies are called by name. "match admit(__view(run, ready), id)", diff --git a/tests/rust_work_spec.rs b/tests/rust_work_spec.rs index 3ccdb4801..6a1b95dff 100644 --- a/tests/rust_work_spec.rs +++ b/tests/rust_work_spec.rs @@ -446,7 +446,7 @@ fn an_answer_modules_state_reaches_its_answer_function_uniquely_owned() { "pub fn __serve(mut run @ _: __Run,", "pub fn __serveTicker(mut run @ _: __Run,", "pub fn __takeOwner(mut run @ _: __Run)", - "let (__rest, __held) = __takeOwner(run);", + "let (__held, __rest) = __takeOwner(run);", "crate::aver_generated::owner::bump(__taken, __a0)", ] { if !entry.contains(by_value) {