How to Fix: Cranelift: missed DCE / constant folding on wasm compiling

7 min read

Cranelift Missed DCE and Constant Folding on Wasm: Why a Statically False Branch Survives and How to Fix It

A dead conditional branch that remains in generated code even though its condition is provably false is a classic optimizer pipeline bug. In this Cranelift WebAssembly case, the compiler computes enough information to know the branch cannot fire, but that knowledge does not get fully propagated into dead code elimination (DCE) at the right stage. The result is unnecessary control flow, noisier IR, and potentially worse machine code.

Reproducing the Problem

The reported issue is that Cranelift, while compiling Wasm, leaves behind a conditional branch whose predicate is effectively a compile-time constant. A reduced shape of the problem looks like this:

block0(v0: i32, v1: i32):
    v2 = iconst.i32 0
    v3 = icmp eq v2, v2   ; always true, or equivalent folded relation
    brif v2, block1, block2

Or, in a more realistic pattern, the condition may have started life as a comparison or masked expression and then later became constant only after one of the following:

  • constant folding
  • GVN-style simplification
  • instruction combining
  • Wasm-to-CLIF lowering introducing temporary values later simplified away

If the optimization pipeline does not revisit the branch after simplification, the branch can survive as a missed DCE opportunity.

For context and reproduction details, refer to the linked Godbolt example.

Understanding the Root Cause

This bug is fundamentally about optimization ordering. Cranelift often performs simplification in multiple passes, but a conditional branch can be created in one phase and become removable only after another phase rewrites its input.

The typical failure mode looks like this:

  1. A Wasm construct is lowered into Cranelift IR with explicit control flow.
  2. The branch condition is not initially a literal constant.
  3. A later pass proves the input expression is constant false, or rewrites it into a value equivalent to false.
  4. No subsequent pass canonicalizes brif false, then, else into an unconditional jump to the else block, or that canonicalization happens too late to trigger full CFG cleanup.
  5. Because the branch remains, the successor block structure also remains, preventing downstream dead block elimination and related simplifications.

In short, constant folding happened, but control-flow simplification did not fully follow through.

There are several technical reasons this can happen in compiler pipelines:

  • Pass separation: one pass folds values, another cleans CFG, but the second pass is not rerun.
  • Non-canonical branch representation: the branch condition may be folded into an equivalent false-producing expression without being normalized to an explicit zero/false constant in time.
  • Block parameter liveness: even if a branch is removable, extra care is needed if successor blocks carry arguments or have side constraints.
  • Conservative DCE: some DCE passes remove unused instructions but do not mutate terminators unless a dedicated control-flow simplifier handles them.

That is why this issue is better understood not as a single missing fold, but as a missing connection between value simplification and CFG simplification.

Step-by-Step Solution

The fix is to ensure that after branch conditions are simplified, Cranelift rewrites conditional terminators with constant predicates and then removes unreachable blocks. In practice, this usually means adding or strengthening a pass that performs:

  1. Detection of constant branch conditions
  2. Replacement of brif with jump
  3. Cleanup of now-unreachable blocks
  4. Optional rerunning of simple DCE or CFG simplification

1. Detect constant conditions on conditional branches

In a branch simplification or CFG cleanup pass, inspect the terminator and query whether the condition is a known constant.

match inst_data[term_inst] {
    InstructionData::Brif { cond, block_then, block_else, args_then, args_else } => {
        if let Some(c) = func.dfg.resolve_to_i32_constant(cond) {
            if c == 0 {
                // always false
            } else {
                // always true
            }
        }
    }
    _ => {}
}

The exact helper name depends on the Cranelift component you are modifying, but the important detail is to query the IR for a known integer constant, not merely a literal instruction shape.

2. Rewrite the conditional branch into an unconditional jump

Once the predicate is known, replace the terminator directly:

if c == 0 {
    pos.func.dfg.replace(term_inst).jump(block_else, args_else);
} else {
    pos.func.dfg.replace(term_inst).jump(block_then, args_then);
}
changed = true;

This is the critical step the bug is missing. If the branch remains as brif, later passes may still treat both successors as reachable.

3. Remove unreachable blocks

After rewriting terminators, run a reachability walk from the entry block and remove any blocks no longer reachable.

let reachable = compute_reachable_blocks(func, entry_block);
for block in func.layout.blocks() {
    if !reachable.contains(block) {
        remove_block_and_insts(func, block);
        changed = true;
    }
}

This enables full cleanup of the dead side of the original conditional.

4. Rerun lightweight DCE or simplification

Eliminating blocks can expose more unused instructions or trivial jumps. A final cleanup pass is often enough:

do {
    changed = false;
    changed |= simplify_constant_branches(func);
    changed |= eliminate_unreachable_blocks(func);
    changed |= remove_unused_insts(func);
} while changed;

This fixed-point approach is common in compilers because one optimization frequently unlocks another.

5. Add a regression test

The safest long-term fix is a targeted regression test in the Cranelift test suite. The test should assert that a branch with a statically false condition is gone after the optimization pipeline.

function %test() -> i32 {
block0:
    v0 = iconst.i32 0
    brif v0, block1, block2
block1:
    return v0
block2:
    v1 = iconst.i32 7
    return v1
}

Expected optimized form:

function %test() -> i32 {
block0:
    jump block2
block2:
    v1 = iconst.i32 7
    return v1
}

If your test framework checks textual CLIF after legalization or after a specific optimization stage, place the assertion at the point where the fold is supposed to have taken effect.

6. Validate on Wasm-specific lowering paths

Because the issue appears during wasm compiling, verify not only generic CLIF optimizations but also Wasm lowering patterns that generate branchable expressions from:

  • i32.eqz
  • select-like lowered control flow
  • sign-extended integer comparisons
  • trap or bounds-check related predicates

That ensures the fix covers the real frontend path, not just synthetic IR.

Common Edge Cases

Even after implementing the branch rewrite, several tricky cases can still cause incomplete cleanup.

1. Branch arguments differ between successors

If the original brif passes different block parameters to the true and false targets, your replacement must preserve the correct argument list for the chosen edge. Accidentally reusing the wrong arguments can silently corrupt SSA form.

2. Side-effecting instructions in the condition chain

Most arithmetic and compare instructions are pure, but if the condition depends on a value produced near side effects, ensure the simplification removes only the branch, not instructions that must remain for ordering or trapping semantics.

3. Trap-producing or poison-sensitive operations

If a condition originates from an operation with special semantics, folding must respect them. For example, simplification is only valid if the expression is truly compile-time constant without skipping a required trap or observable behavior.

4. Critical edge and CFG bookkeeping

After replacing a conditional with a jump, predecessor lists, branch metadata, and block parameters must stay consistent. Missing CFG updates can lead to verifier failures later in the pipeline.

5. Late-phase reintroduction of redundant control flow

Sometimes legalization or architecture-specific lowering can reintroduce branch structure. If that happens, you may need a final CFG cleanup pass later than the current one.

6. Wasm-specific boolean conventions

Wasm booleans are represented as integer values, typically 0 or non-zero. Your fold logic should treat any known non-zero constant as true, not just literal 1.

FAQ

Why doesn't constant folding automatically remove the branch?

Constant folding usually simplifies values, not control flow. If the compiler folds the condition expression to a constant but no subsequent pass rewrites the terminator, the branch remains in the IR.

Is this a DCE bug or a branch simplification bug?

It is usually both in combination, but the more precise diagnosis is a control-flow simplification gap. DCE often cannot remove the dead path until the conditional branch is first turned into an unconditional jump.

What is the best place in Cranelift to fix this?

The best place is the pass responsible for CFG simplification or branch canonicalization after value simplification has already occurred. If no such pass exists at the right point in the Wasm pipeline, add one there rather than relying on ad hoc folding in lowering code.

Final Takeaway

The issue exists because Cranelift proves a branch condition is constant but does not fully exploit that fact to simplify the terminator and prune dead CFG. The robust fix is straightforward: detect constant predicates on brif, rewrite them into jump, remove unreachable blocks, and lock the behavior in with a regression test. Once that pass ordering is corrected, the dead branch disappears exactly as expected.

Leave a Reply

Your email address will not be published. Required fields are marked *