How to Fix: Cranelift: Wrong result for `fcvt_to_sint_sat.i16` on riscv64

5 min read

Cranelift riscv64 bug: fixing wrong results for fcvt_to_sint_sat.i16

When a floating-point to signed-integer saturating conversion returns the wrong i16 value on riscv64, the problem is usually not in the test itself but in how the backend lowers the IR operation into target-specific instructions. This issue appears in Cranelift when fcvt_to_sint_sat.i16 does not preserve the expected saturating semantics for out-of-range, NaN, or boundary float inputs.

Reproducing the issue

The minimal .clif test case is straightforward:

test interpret
test run
target riscv64

function %a(f32) -> i16 system_v {
block0(v0: f32):
    v1 = fcvt_to_sint_sat.i16 v0
    return v1
}

This function should convert an f32 into a signed 16-bit integer using saturating semantics:

  • If the value is within [-32768, 32767], convert normally.
  • If the value is too large, clamp to 32767.
  • If the value is too small, clamp to -32768.
  • If the value is NaN, return the saturating-defined fallback, which in Cranelift lowering is typically handled explicitly rather than relying on target default behavior.

The bug shows up because the generated riscv64 sequence can behave differently from Cranelift IR semantics, especially around exceptional floating-point values and narrow integer widths.

Understanding the Root Cause

The core issue is a mismatch between Cranelift IR semantics and native RISC-V floating-point conversion instructions. The IR operation fcvt_to_sint_sat.i16 is a saturating conversion, but a raw hardware conversion typically does not directly implement all of the following in one step:

  • precise saturation to the target signed range,
  • correct handling of NaN,
  • correct behavior for positive and negative overflow,
  • proper narrowing to 16-bit signed after intermediate wider conversion.

On riscv64, backend lowering often performs conversions through wider integer types such as i32 or i64. If the lowering path assumes that a hardware fcvt instruction already matches saturating semantics, the final result can be wrong for edge values. This is especially dangerous when:

  • the backend converts f32 -> i32/i64 first,
  • then truncates or narrows to i16,
  • without explicit pre-clamping against -32768.0 and 32767.0.

For example, a large positive float may convert to a large intermediate integer or trigger target-defined exceptional behavior, and then narrowing to i16 can wrap or produce a non-saturated result. Likewise, NaN may not map to the expected result unless the lowering explicitly checks for unordered comparisons.

In short, the bug happens because target instruction behavior is not identical to Cranelift’s saturating IR contract, and the i16 case needs explicit guards during lowering.

Step-by-Step Solution

The reliable fix is to lower fcvt_to_sint_sat.i16 into an explicit clamp-and-convert sequence instead of depending on a single target conversion instruction.

1. Define the signed i16 bounds in floating-point form

You must compare the input against exact f32 bounds before conversion:

MIN_I16_F32 = -32768.0
MAX_I16_F32 = 32767.0

2. Handle NaN explicitly

A saturating conversion should not rely on unspecified or target-specific NaN conversion behavior. Insert a check before normal conversion logic.

if is_nan(x) {
    return 0; // or the Cranelift-defined saturating result for NaN in this lowering path
}

3. Clamp before converting

Perform ordered comparisons and clamp to the legal i16 range:

if x <= -32768.0 {
    return -32768;
}
if x >= 32767.0 {
    return 32767;
}

4. Convert only after the value is known to be in range

Once clamped, a normal signed conversion can safely occur through a wider type if needed:

tmp = fcvt_to_sint_i32(x)
result = ireduce.i16 tmp
return result

5. Update backend lowering logic

In the riscv64 backend, replace any lowering path that effectively does this:

tmp = raw_target_fcvt(x)
result = narrow_to_i16(tmp)

with logic equivalent to this:

if unordered(x, x) {
    return 0
}
if x <= -32768.0 {
    return -32768
}
if x >= 32767.0 {
    return 32767
}
tmp = fcvt_to_sint_i32(x)
return ireduce.i16 tmp

6. Add regression tests

Expand the original .clif test with boundary and exceptional values. Good regression coverage includes:

; Values to validate
; NaN
; 32767.0
; 32768.0
; -32768.0
; -32769.0
; 0.0
; 1.0
; -1.0
; 123.75
; 65535.0

If your test harness supports run directives, validate exact outputs for each case to ensure the backend matches the interpreter.

7. Verify interpreter vs backend consistency

This issue is often exposed because test interpret and test run disagree. After patching the lowering, confirm both paths return identical results:

cargo test -p cranelift-codegen riscv64
cargo test -p cranelift-filetests

If available in your local setup, also run the relevant Cranelift filetest subset for conversion operations.

Common Edge Cases

  • NaN inputs: Must be handled explicitly. A backend that assumes hardware conversion semantics may produce inconsistent results.
  • Positive overflow: Values above 32767.0 must saturate to 32767, not wrap after narrowing.
  • Negative overflow: Values below -32768.0 must saturate to -32768.
  • Fractional values: Ensure conversion rounds according to Cranelift’s expected truncation behavior after range checking.
  • Boundary comparisons: Be careful with 32767.0 and -32768.0 exactly; off-by-one comparison mistakes are common.
  • Intermediate width bugs: Converting to i64 and then reducing to i16 is only safe after explicit clamping.
  • Other narrow integer types: If i16 is affected, review lowering for i8 and unsigned saturating conversions too.

FAQ

Why does this bug appear on riscv64 but not necessarily on other architectures?

Different backends implement saturating float-to-int conversion differently. Some architectures have instruction patterns or lowering sequences that accidentally match Cranelift semantics more closely, while riscv64 may require more explicit clamping and NaN handling.

Can I fix this by only changing the final narrowing to i16?

No. The wrong result usually happens earlier, during the float-to-integer conversion step or from missing saturation checks. Narrowing alone cannot restore correct semantics if overflow or NaN was already mishandled.

Should I audit only fcvt_to_sint_sat.i16?

No. If this lowering path is wrong for i16, similar logic may be incorrect for fcvt_to_sint_sat.i8, fcvt_to_uint_sat variants, or any path that converts through a wider intermediate without explicit range checks.

The practical takeaway is simple: never map Cranelift saturating conversions directly to a raw target conversion unless the target instruction exactly matches the IR semantics. For riscv64 and fcvt_to_sint_sat.i16, the robust fix is explicit NaN handling, explicit range clamping, and only then a safe integer conversion.

Leave a Reply

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