Skip to content

Record the constant rematerialization gap as ISS-403 - #495

Merged
Milky2018 merged 2 commits into
mainfrom
milky/iss-403-constant-remat
Aug 7, 2026
Merged

Record the constant rematerialization gap as ISS-403#495
Milky2018 merged 2 commits into
mainfrom
milky/iss-403-constant-remat

Conversation

@Milky2018

Copy link
Copy Markdown
Owner

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:

ops marginal B/op
25–250 16.0
300 32.0
20,000 33.9

The cliff is not about size — it sits where the fixture starts reusing address constants. Making every address distinct removes it entirely:

ops B/op
400 (256 distinct) 32.0
400 (all distinct) 16.1
800 (all distinct) 16.0

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. EnvironmentField values 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):

  1. GVN skips Stable globals — looks like an inverted condition, and removing it does cut redundant global_value in 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.
  2. The fixed 300-instruction GVN budget starves large functions — raising it to full function size also changes nothing.

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_indirect and struct.new, measured separately at 7.7× and 5.4×, so this should not be assumed to account for the full 26×.

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
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.
@Milky2018
Milky2018 merged commit 6068592 into main Aug 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant