How to Fix: Cranelift: RISC-V Wrong result for `select+icmp.i128`

6 min read

Cranelift on RISC-V can miscompile a seemingly simple pattern: a select driven by an icmp over i128 values. The result is a wrong runtime value on riscv64gc, even though interpretation succeeds. That points directly at a backend lowering bug rather than invalid IR.

Reproduce the Failure

This issue appears when Cranelift lowers a comparison on a 128-bit integer and then feeds that boolean into a select. On RISC-V, i128 is not a native scalar width, so the compiler must legalize the value into multiple machine-sized pieces. If that legalization or lowering sequence mishandles the condition bits, branchless select logic, or high/low half ordering, the generated code can return the wrong answer.

A typical reproducer follows the pattern from the issue:

test interpret
test run
target riscv64gc

function %a(i64, i64, i128, i128) -> i64, i8 {
block0(v0: i64, v1: i64, v2: i128, v3: i128):
    v4 = icmp eq v2, v3
    v5 = select v4, v0, v1
    v6 = iconst.i8 0
    return v5, v6
}

If test interpret passes but test run fails on riscv64gc, the bug is almost certainly in instruction selection, legalization, or backend-specific lowering.

Understanding the Root Cause

Why this happens comes down to the mismatch between Cranelift IR semantics and the target machine’s native capabilities.

On RISC-V 64-bit, there is no single native instruction for i128 compare or i128 select. Cranelift must lower:

  • the i128 compare into comparisons on the high 64 bits and low 64 bits,
  • the boolean result into a machine-level condition representation, and
  • the select into either conditional moves, bitwise masking, or explicit control flow.

The bug typically appears when one of these steps is inconsistent. Common failure modes include:

  • High/low limb ordering bugs, where the compiler compares the low half first or merges limbs incorrectly.
  • Condition materialization bugs, where a boolean produced from a legalized i128 icmp is not normalized to the expected 0/1 form before select lowering.
  • Select lowering bugs, where the backend assumes a condition format valid for native integer comparisons but invalid for expanded i128 compare sequences.
  • Signedness confusion, especially if equality and ordering predicates share lowering helpers and one path reuses logic incorrectly.

In short, the wrong result is not caused by the .clif test itself. It is caused by backend legalization for non-native 128-bit values on RISC-V.

Step-by-Step Solution

The safest fix is to make lowering explicit and correct for the pattern select(icmp.i128(…), a, b). The backend should first legalize the i128 compare into a canonical boolean, then lower select from that canonical form.

1. Add a focused regression test

Before changing codegen, lock in the failure with a target-specific test.

test interpret
test run
target riscv64gc

function %select_icmp_i128_eq(i64, i64, i128, i128) -> i64 {
block0(v0: i64, v1: i64, v2: i128, v3: i128):
    v4 = icmp eq v2, v3
    v5 = select v4, v0, v1
    return v5
}

Add variants for ne, signed predicates, and unsigned predicates if those are also lowered through the same path.

2. Inspect legalized IR or backend lowering

Use Cranelift debug output to inspect how i128 icmp becomes machine-level operations on RISC-V. You want to verify:

  • the compare is split into two 64-bit halves,
  • the final condition is a proper b1 or normalized integer boolean, and
  • the select consumes that boolean consistently.
cargo test -p cranelift-codegen riscv -- --nocapture

# or run filetests with verbose output
cargo test -p cranelift-filetests -- --nocapture

If your local workflow includes custom test runners, use the equivalent commands for dumping legalizations and selected instructions.

3. Fix compare legalization for i128

For equality, the canonical expansion is usually:

eq128(a_hi, a_lo, b_hi, b_lo):
    hi_eq = (a_hi == b_hi)
    lo_eq = (a_lo == b_lo)
    return hi_eq & lo_eq

For inequality:

ne128(a_hi, a_lo, b_hi, b_lo):
    hi_ne = (a_hi != b_hi)
    lo_ne = (a_lo != b_lo)
    return hi_ne | lo_ne

The important part is that the result must be converted into a backend-expected boolean form before it is used by select.

4. Normalize the condition before lowering select

If the RISC-V backend lowers select through integer masking or branches, ensure the condition is explicitly normalized.

cond = icmp.eq.i128 x, y
cond_i64 = bint.i64 cond       ; produce 0 or 1
result = select cond, lhs, rhs

If the bug exists because the backend uses a non-canonical truthy value, change lowering so select only sees 0 or 1, or bypass that ambiguity by lowering to explicit control flow:

if cond goto then_block else else_block
then_block:
    v = lhs
    goto merge
else_block:
    v = rhs
    goto merge
merge:
    return v

This is often the most robust fallback for non-native wide integer predicates.

5. Patch the RISC-V backend lowering path

The exact file depends on the current Cranelift tree layout, but the fix usually belongs in one of these areas:

  • legalization rules for non-native integer ops,
  • ISLE lowering for RISC-V select or compare patterns,
  • MachInst lowering where boolean values are materialized.

Pseudocode for the intended behavior:

match select(icmp_i128(cc, x, y), a, b) {
    let cond = lower_i128_icmp_to_b1(cc, x, y)
    return lower_select_from_b1(cond, a, b)
}

If a direct pattern is too fragile, split the lowering:

tmp = lower_i128_icmp_to_b1(cc, x, y)
res = lower_select(tmp, a, b)
return res

This avoids accidentally combining two partially correct lowerings into one wrong result.

6. Add target-specific regression coverage

Once the patch is in place, add filetests covering:

  • eq and ne over i128,
  • slt, sgt, ult, and ugt if supported by the same lowering path,
  • select returning different scalar types,
  • cases where only the high 64 bits differ,
  • cases where only the low 64 bits differ.
test run
target riscv64gc

function %high_half_diff(i64, i64, i128, i128) -> i64 {
block0(v0: i64, v1: i64, v2: i128, v3: i128):
    v4 = icmp eq v2, v3
    v5 = select v4, v0, v1
    return v5
}

7. Validate on both interpreter and native backend

A correct fix should make both modes agree:

  • interpret confirms IR semantics are right.
  • run confirms RISC-V code generation is now correct.

Also rerun nearby backend tests because boolean normalization changes can affect other select patterns.

Common Edge Cases

  • Only one 64-bit limb differs: backend logic that accidentally checks only the low half can pass many tests and still be wrong.
  • Signed vs unsigned compares: ordering predicates on i128 must compare the high half with the correct signedness before consulting the low half.
  • Select of non-i64 values: if the bug is in generic select lowering, the same issue may affect i32, pointers, or even vector-adjacent legalization paths.
  • Boolean representation mismatches: some lowering stages assume all-ones truth values, others assume 1. Mixing those conventions breaks branchless select logic.
  • Optimization interference: combine passes may fold compare-plus-select into a target-specific form that bypasses the fixed legalization path.

FAQ

Why does test interpret pass while test run fails?

Because the interpreter executes the Cranelift IR semantics directly, while test run exercises the RISC-V backend. If only native execution is wrong, the bug is in lowering or code generation.

Is this problem specific to icmp eq on i128?

Not necessarily. Equality is just the easiest pattern to expose. Any i128 comparison used by select, conditional branches, or boolean arithmetic may be affected if the same legalization path is reused.

What is the safest implementation strategy for a fix?

The safest route is to legalize i128 comparisons into explicit 64-bit limb operations, produce a canonical boolean, and then lower select from that canonical result. If needed, use explicit control flow rather than clever branchless lowering.

For ongoing tracking, link the regression test and patch discussion back to the relevant Wasmtime and Cranelift issue tracker entry so future backend refactors preserve the fix.

Leave a Reply

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