Record the constant rematerialization gap as ISS-403 - #495
Merged
Conversation
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.
Milky2018
enabled auto-merge
August 7, 2026 06:38
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.
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.
GitHub #486 asks why 8,632 MilkIR instructions become 225,372 VCode instructions. Instrumenting that turned up a reproducible factor of two, independent of the branch-range problem ISS-402 fixed.
This PR records the finding. No code changes.
What was measured
Emitted code doubles once a function reuses integer constants. Marginal cost per wasm op, sweeping function size:
The cliff is not about size — it sits where the fixture starts reusing address constants. Making every address distinct removes it entirely:
Why
CSE merges the repeated
iconsts — 700 address uses collapse to 264 definitions after O2 — which replaces many short live ranges with one spanning the whole function. With 256 live at once against ~30 registers, the allocator gives each a stack slot and reloads it at every use. Decoding the output: 64.5% of the emitted bytes are spill/reload traffic across 244 slots.The module in #486 shows the same signature: 829 spills against 48,896 reloads.
The shape of the fix
Rematerialization — and the codebase already has the idea.
EnvironmentFieldvalues are rematerialized during AArch64 lowering, and GVN deliberately doesn't merge them; those two decisions are one coherent policy. Constants get the opposite treatment on both halves, which is the worst combination. ISS-403 argues for putting the property on VCode values so the allocator decides, with the existing lowering-level remat becoming its first client.Two disproved hypotheses, recorded so they aren't retried
Both looked right and both change the emitted code by exactly zero bytes (678,316 before and after, byte for byte, at 5,000 and 20,000 ops):
Stableglobals — looks like an inverted condition, and removing it does cut redundantglobal_valuein the IR (7 → 2). It changes no output because backend lowering already remats those values, which is what makes the skip deliberate rather than a bug.Both are real inefficiencies. Neither drives code size, and the actual cause turned out to be the reverse of the first: not too little CSE, but CSE without rematerialization.
Scope
Verified for integer constants in a synthetic fixture, where it accounts for a factor of 2. The #486 function also has
call_indirectandstruct.new, measured separately at 7.7× and 5.4×, so this should not be assumed to account for the full 26×.