From 4c88a2495d20d3ed0665eb2ace6633c7dc201455 Mon Sep 17 00:00:00 2001 From: Milky2018 <842376130@qq.com> Date: Fri, 7 Aug 2026 14:37:24 +0800 Subject: [PATCH 1/2] Record the constant rematerialization gap as ISS-403 GitHub #486 asks why 8,632 MilkIR instructions become 225,372 VCode instructions. Instrumenting that turned up a reproducible factor of two that is independent of the branch-range problem ISS-402 fixed. Emitted code doubles once a function reuses integer constants. Sweeping function size, the marginal cost per wasm op is a flat 16 bytes up to 250 ops and a flat 32 bytes from 300 on. The cliff is not about size: it sits where the fixture starts reusing address constants, and making every address distinct removes it entirely, holding 16 B/op through 800 ops. CSE merges the repeated constants, which replaces many short live ranges with one spanning the whole function. With 256 of those live at once the allocator gives each a stack slot and reloads it at every use: 64.5% of the emitted bytes are spill and reload traffic across 244 slots. The reported module shows the same signature, 829 spills against 48,896 reloads. The fix is rematerialization, and the codebase already has the idea. EnvironmentField values are rematerialized during AArch64 lowering, and GVN deliberately does not merge them, which is one coherent policy. Constants get the opposite treatment on both halves. The inconsistency is the defect. Two disproved hypotheses are recorded in the notes so they are not retried: removing the GVN skip for Stable globals, and raising the fixed 300-instruction GVN budget. Both are real inefficiencies; both change the emitted code by exactly zero bytes. --- issues/ISS-403.md | 162 ++++++++++++++++++++++++++++++++++++++++++++++ issues/README.md | 3 + 2 files changed, 165 insertions(+) create mode 100644 issues/ISS-403.md diff --git a/issues/ISS-403.md b/issues/ISS-403.md new file mode 100644 index 00000000..0287c073 --- /dev/null +++ b/issues/ISS-403.md @@ -0,0 +1,162 @@ +# ISS-403: CSE merges repeated constants into long live ranges that nothing rematerializes + +## Metadata +- Type: bug +- Status: open +- Priority: 2 +- Labels: regalloc, codegen, optimization, code-size, agent +- Assignee: unassigned +- Created: 2026-08-07 +- Updated: 2026-08-07 +- External ref: https://github.com/Milky2018/wasmoon/issues/486 + +## Description + +Emitted code doubles in size once a function reuses integer constants. +The extra bytes are entirely spill and reload traffic. + +Marginal cost per wasm op, sweeping function size with a fixture that +cycles through 256 distinct addresses: + +| ops | marginal B/op | +| ---: | ---: | +| 25–250 | 16.0 | +| 280 | 16.4 | +| 300 | 32.0 | +| 350 | 32.0 | +| 20,000 | 33.9 | + +A sharp doubling between 280 and 300 ops, then flat. 16 B/op is the +correct lowering for `mem[a] = mem[a] + k`: a load, an add, a store, and +one address instruction. + +The cliff is not about size. It sits exactly where the fixture starts +*reusing* address constants. Rerunning with every address distinct: + +| ops | B/op | +| ---: | ---: | +| 400, 256 distinct addresses | 32.0 | +| 400, all distinct | 16.1 | +| 800, all distinct | 16.0 | + +No cliff at all. The variable is whether constants repeat, not how many +there are. + +## Root cause + +CSE merges the repeated `iconst`s, and nothing afterward undoes it. + +At 350 ops with 256 distinct addresses there are 700 address uses and +264 `iconst` instructions after O2 — merged. At 400 ops all-distinct +there are 408, with nothing to merge. + +Deduplicating a repeated constant replaces N short live ranges with one +live range spanning first use to last use, here the whole function. With +256 such constants live at once against roughly 30 allocatable registers, +the allocator gives each its own stack slot and reloads it at every use. + +Decoding the 678,316 bytes emitted for a 20,000-op function: + +| | share | +| --- | ---: | +| reload from stack | 42.9% | +| spill to stack | 21.6% | +| real memory ops | 23.6% | +| arithmetic | 11.8% | + +**64.5% of the emitted code is stack traffic**, spread over 244 distinct +stack slots at about 159 accesses each. + +The same signature appears in the module reported in GitHub #486: 1,027 +spill slots, 829 spills, 48,896 reloads. A 59:1 reload-to-spill ratio is +what "spilled once, reloaded everywhere" looks like; genuine pressure +from short-lived intermediates does not produce it. + +So CSE is doing exactly what it is designed to do, and for constants that +is a pessimization. + +## Design + +The missing piece is rematerialization: when a spill candidate is defined +by a cheap, pure, operand-free instruction, recompute it at the use +instead of allocating a slot and reloading. For an integer constant that +is one `movz`, no stack slot and no memory traffic. + +The codebase already holds this idea and applies it to exactly one +operand class. `EnvironmentField` values are rematerialized during +AArch64 lowering — see the `AArch64 lowering rematerializes stable fields +under integer pressure` test — and `opt_passes_cse_gvn.mbt` deliberately +skips `GlobalValue(ContextField(_, Stable, _))` so the IR does *not* merge +them into one long-lived value. Those two decisions are one coherent +policy. + +Constants get the opposite treatment on both halves: the IR merges them, +and nothing remats them. That is the worst of the two combinations, and +the inconsistency is the actual defect. + +Two ways to close it: + +1. Extend the existing lowering-level remat from `EnvironmentField` to + integer constants. Smallest change, reuses a tested mechanism, but + keeps the policy in target lowering where each target must repeat it. +2. Give VCode values a `rematerializable` property that the allocator + consults when choosing spill candidates, and have lowering mark both + context fields and constants. This puts the decision in the allocator, + which is the component that knows the pressure, and makes it apply to + every target and every cheap-to-recompute value. + +Option 2 is the right layer. Option 1 is a special case of it, so 1 is +worth doing first only if it is used as the first client of 2 rather than +as a substitute. + +Address folding is a smaller adjacent win worth doing in the same pass +over this code: `ScalarLoad`/`ScalarStore` carry a displacement field that +is left at 0 while `IntBinaryImmediate(W64, Add, 0)` and +`IntBinaryImmediate(W64, Add, 4)` compute the address into a register. +Folding the constant offset into the addressing mode removes those +instructions and the intermediate values that go with them. + +## Acceptance Criteria + +- [ ] Values defined by cheap, pure, operand-free instructions are + rematerialized instead of spilled. +- [ ] Integer constants and `EnvironmentField` values go through one + mechanism rather than two. +- [ ] The 256-distinct-address fixture stays at 16 B/op at 400 ops and + beyond, with no cliff. +- [ ] Constant offsets fold into load/store displacements. +- [ ] Workspace tests and the WAST corpus pass in both modes. + +## Relationships +- Depends on: none +- Parent: none +- Related: ISS-402, ISS-371 +- Discovered from: GitHub #486 + +## Notes + +- 2026-08-07: Found while instrumenting the MilkIR-to-VCode expansion + that GitHub #486 reports as 8,632 instructions becoming 225,372. + + Two hypotheses were tested and disproved before this one, both worth + recording so they are not retried. First, that GVN skipping `Stable` + globals was an inverted condition: removing the skip does cut redundant + `global_value` in the IR, 7 down to 2 on a small function, but changes + the emitted machine code for 5,000-op and 20,000-op functions by zero + bytes — 678,316 before and after, byte for byte. It changes nothing + because backend lowering already remats those values, which is what + makes the skip deliberate rather than a bug. Second, that the fixed + 300-instruction GVN work budget starves large functions: raising it to + the full function size also changed the output by zero bytes. + + Both are real inefficiencies. Neither drives code size, and the measured + cause turned out to be the reverse of the first hypothesis — not too + little CSE, but CSE without rematerialization. + +- 2026-08-07: Scope not established. This is verified for integer + constants in a synthetic fixture, where it accounts for a factor of 2. + The function in GitHub #486 also contains `call_indirect` and + `struct.new`, measured separately at 7.7x and 5.4x expansion, so remat + should not be assumed to account for the full 26x reported there. + +## Close Notes diff --git a/issues/README.md b/issues/README.md index 736efece..9e9c7d01 100644 --- a/issues/README.md +++ b/issues/README.md @@ -8,6 +8,7 @@ Generated by derive-tracker.wasm | --- | ---: | --- | --- | --- | --- | | [ISS-402](ISS-402.md) | 1 | bug | unassigned | The AArch64 branch-range fallback is not on the path the JIT compiles through | aarch64, emission, jit, robustness, agent | | [ISS-371](ISS-371.md) | 2 | task | unassigned | Reduce backtracking-allocator compile time on large functions | regalloc, compile-time, performance, agent | +| [ISS-403](ISS-403.md) | 2 | bug | unassigned | CSE merges repeated constants into long live ranges that nothing rematerializes | regalloc, codegen, optimization, code-size, agent | | [ISS-390](ISS-390.md) | 3 | task | unassigned | Rebuild sanitizer coverage as a dedicated package, not a compiler shim | ci, sanitizers, build-config, agent | | [ISS-399](ISS-399.md) | 3 | refactor | unassigned | `regalloc/planning` is a 2129-line package that only its tests use | regalloc, dead-code, agent | | [ISS-389](ISS-389.md) | 4 | chore | unassigned | `wasmoon/sanitizer_testsuite` is named after a gate that no longer exists | cleanup, testing, agent | @@ -19,6 +20,7 @@ Generated by derive-tracker.wasm | [ISS-402](ISS-402.md) | open | 1 | bug | unassigned | none | none | The AArch64 branch-range fallback is not on the path the JIT compiles through | | [ISS-367](ISS-367.md) | deferred | 2 | bug | unassigned | none | none | macOS UBSan aborts on a negative signal number from moonbitlang/async | | [ISS-371](ISS-371.md) | open | 2 | task | unassigned | none | none | Reduce backtracking-allocator compile time on large functions | +| [ISS-403](ISS-403.md) | open | 2 | bug | unassigned | none | none | CSE merges repeated constants into long live ranges that nothing rematerializes | | [ISS-390](ISS-390.md) | open | 3 | task | unassigned | none | none | Rebuild sanitizer coverage as a dedicated package, not a compiler shim | | [ISS-399](ISS-399.md) | open | 3 | refactor | unassigned | none | none | `regalloc/planning` is a 2129-line package that only its tests use | | [ISS-177](ISS-177.md) | deferred | 4 | bug | unassigned | none | none | Implement a Windows fd readiness backend for poll_oneoff | @@ -425,6 +427,7 @@ graph TD ISS_400["ISS-400: A v128 exception payload makes the JIT refuse the whole module"] ISS_401["ISS-401: Graph walks over input-sized structures still recurse on the native stack"] ISS_402["ISS-402: The AArch64 branch-range fallback is not on the path the JIT compiles through"] + ISS_403["ISS-403: CSE merges repeated constants into long live ranges that nothing rematerializes"] ISS_002 --> ISS_003 ISS_002 --> ISS_004 ISS_003 --> ISS_005 From 124ed6e216508223b7bc3691fea5bd807ed37b23 Mon Sep 17 00:00:00 2001 From: Milky2018 <842376130@qq.com> Date: Fri, 7 Aug 2026 14:44:31 +0800 Subject: [PATCH 2/2] Correct ISS-403's design after reading Cranelift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue claimed regalloc2 rematerializes. It does not — the concept does not appear anywhere in its source. The register allocator is not where Cranelift solves this. Cranelift does it during e-graph elaboration, splitting policy from mechanism. Policy is a small set of rewrite rules in opts/remat.isle covering iconst, the float consts, bnot and the ALU-with-one-constant forms, on the stated criterion that these are neutral or positive for register pressure and very cheap. Mechanism is maybe_remat_arg in egraph/elaborate.rs: when an argument's defining block differs from the using block and the value is marked, it clones the defining instruction in before the use. Two details worth keeping. The clone is memoised on (block, value), so the granularity is once per block rather than once per use — inside a block one register still serves every use. And placement is unified with LICM in the same pass, where pure no-argument values are deliberately allowed to hoist only one loop level rather than to the function entry, for the explicit reason of not putting pressure on the whole function. That is this issue's failure mode, named and guarded in their design. This also shrinks the work. wasmoon's class_to_value is already walked over the dominator tree with entries undone on exit, which is the same scoped-reuse structure Cranelift gets from ScopedHashMap. What is missing is the marked set and the per-block rebuild, not the machinery around it. Doing this in elaboration rather than in the allocator is not merely following Cranelift: an allocator that rematerializes has to emit target instructions for every value it declines to spill, which pushes the policy back into each target's lowering — the duplication that made EnvironmentField a special case to begin with. --- issues/ISS-403.md | 111 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 91 insertions(+), 20 deletions(-) diff --git a/issues/ISS-403.md b/issues/ISS-403.md index 0287c073..08d9815b 100644 --- a/issues/ISS-403.md +++ b/issues/ISS-403.md @@ -77,10 +77,10 @@ is a pessimization. ## Design -The missing piece is rematerialization: when a spill candidate is defined -by a cheap, pure, operand-free instruction, recompute it at the use -instead of allocating a slot and reloading. For an integer constant that -is one `movz`, no stack slot and no memory traffic. +The missing piece is rematerialization: rather than keep one definition +alive across the whole function and spill it, rebuild a cheap pure value +where it is used. For an integer constant that is one `movz`, with no +stack slot and no memory traffic. The codebase already holds this idea and applies it to exactly one operand class. `EnvironmentField` values are rematerialized during @@ -94,20 +94,78 @@ Constants get the opposite treatment on both halves: the IR merges them, and nothing remats them. That is the worst of the two combinations, and the inconsistency is the actual defect. -Two ways to close it: +### What Cranelift does + +Worth stating up front, because it corrects an assumption this issue was +first written with: **regalloc2 has no rematerialization at all.** There +are zero occurrences of the concept in its source. The register allocator +is not where this is solved. + +Cranelift solves it during **e-graph elaboration**, and splits policy from +mechanism: + +- Policy lives in rewrite rules, `codegen/src/opts/remat.isle`. The set is + small and deliberate: `iconst`, `f32const`, `f64const`, `bnot`, and the + ALU-with-one-constant-operand forms of `iadd`/`isub`/`band`/`bor`/`bxor`. + Its own comment gives the criterion — these are "neutral (add-with-imm) + or positive (iconst) for register pressure, and these ops are very + cheap". A rule marks a value with `(remat x)`, which adds it to a set. +- Mechanism is `maybe_remat_arg` in `codegen/src/egraph/elaborate.rs`. + When an argument's defining block differs from the block it is being + used in and the value is marked, elaboration clones the defining + instruction in before the use and rewrites the argument to the clone. + +Two details of that mechanism matter: + +- The copy is memoised on `(insert_block, value)`, so the granularity is + **once per block, not once per use**. Inside a block one register serves + every use; across blocks the live range would otherwise stretch. That is + the middle ground between full CSE and full duplication. +- It does not recurse into the rematerialized instruction's own arguments, + explicitly to avoid needing a second fixpoint loop (their TODO #7313). + +The placement decision is unified with LICM in the same pass: a pure value +is hoisted out of a loop when all its arguments are loop-invariant, and +kept at the use otherwise. There is a pointed exception for pure values +with no arguments, which are allowed to hoist at most one loop level +rather than to the function entry, and the stated reason is avoiding +"too much register pressure on the entire function". That is this issue's +failure mode, named and guarded against in their design. + +### What that means here + +Wasmoon already has the structure this needs. `class_to_value` is keyed by +e-class and walked over the dominator tree with entries undone on exit, so +reuse is already dominance-scoped exactly as Cranelift's `ScopedHashMap` +is. What is missing is only the marked set and the clone-into-the-use-block +step. + +So the design is: + +1. Mark cheap pure values during rewriting — constants first, then the + ALU-with-immediate forms — into a remat set. +2. In `collect_elaboration_steps`, where a dominating value is currently + returned as `Existing(value)` for free, return it only when the value + is defined in the block being elaborated or is not marked. Otherwise + plan a fresh build, memoised per block so repeated uses in one block + still share. +3. Extend the same treatment to `EnvironmentField`, which is currently + rematerialized separately in AArch64 lowering, so the two collapse into + one target-independent mechanism. + +Doing this in elaboration rather than in the register allocator is not +just following Cranelift. For the allocator to rematerialize, it would +have to be able to emit target instructions for each value it declines to +spill, which forces the policy back down into every target's lowering — +which is exactly the duplication that made `EnvironmentField` a special +case in the first place. -1. Extend the existing lowering-level remat from `EnvironmentField` to - integer constants. Smallest change, reuses a tested mechanism, but - keeps the policy in target lowering where each target must repeat it. -2. Give VCode values a `rematerializable` property that the allocator - consults when choosing spill candidates, and have lowering mark both - context fields and constants. This puts the decision in the allocator, - which is the component that knows the pressure, and makes it apply to - every target and every cheap-to-recompute value. - -Option 2 is the right layer. Option 1 is a special case of it, so 1 is -worth doing first only if it is used as the first client of 2 rather than -as a substitute. +Address folding is a smaller adjacent win worth doing in the same pass +over this code: `ScalarLoad`/`ScalarStore` carry a displacement field that +is left at 0 while `IntBinaryImmediate(W64, Add, 0)` and +`IntBinaryImmediate(W64, Add, 4)` compute the address into a register. +Folding the constant offset into the addressing mode removes those +instructions and the intermediate values that go with them. Address folding is a smaller adjacent win worth doing in the same pass over this code: `ScalarLoad`/`ScalarStore` carry a displacement field that @@ -118,10 +176,10 @@ instructions and the intermediate values that go with them. ## Acceptance Criteria -- [ ] Values defined by cheap, pure, operand-free instructions are - rematerialized instead of spilled. +- [ ] Cheap pure values marked for remat are rebuilt once per using block + instead of being reused across blocks and spilled. - [ ] Integer constants and `EnvironmentField` values go through one - mechanism rather than two. + target-independent mechanism rather than two. - [ ] The 256-distinct-address fixture stays at 16 B/op at 400 ops and beyond, with no cliff. - [ ] Constant offsets fold into load/store displacements. @@ -159,4 +217,17 @@ instructions and the intermediate values that go with them. `struct.new`, measured separately at 7.7x and 5.4x expansion, so remat should not be assumed to account for the full 26x reported there. +- 2026-08-07: Read Cranelift before designing this, which corrected the + starting assumption. This issue was first written claiming regalloc2 + rematerializes; it does not, and the concept does not appear in its + source at all. Cranelift does it in e-graph elaboration, with the policy + in `remat.isle` rules and the mechanism in `elaborate.rs`. The design + section above is written from that reading rather than from the earlier + guess. + + The useful discovery is how little wasmoon is missing. `class_to_value` + is already dominator-scoped with entries undone on exit, which is the + same structure as Cranelift's `ScopedHashMap`. The gap is the marked set + and the per-block rebuild, not the surrounding machinery. + ## Close Notes