How to Fix: Cranelift: `fcvt_to_sint_sat.{i8,i16}` wrong result on riscv64

7 min read

Cranelift on RISC-V64: Why fcvt_to_sint_sat.{i8,i16} Returns the Wrong Result and How to Fix It

This bug is a classic backend lowering failure: saturating float-to-signed-integer conversion for i8 and i16 on riscv64 produces incorrect values because the generated sequence does not preserve the exact saturation semantics required by Cranelift. The failure is especially confusing under fuzzing because tiny unrelated changes can alter register allocation or instruction selection, making the test pass or fail nondeterministically.

Understanding the Root Cause

The failing operation is fcvt_to_sint_sat, which means: convert a floating-point value to a signed integer, but if the input is NaN, too large, or too small, clamp the result to the destination type’s valid range instead of trapping or producing undefined behavior.

For i8 and i16, the required ranges are small:

  • i8: -128 to 127
  • i16: -32768 to 32767

On riscv64, conversions often happen through a wider integer register size such as i32 or i64. That is normal, but it becomes dangerous if the backend assumes that a plain floating-point conversion plus later narrowing is equivalent to a true saturating conversion for smaller signed types. It is not.

The usual root cause is one of these backend mistakes:

  1. The lowering converts to a wider integer first, but fails to clamp against the narrow destination bounds before narrowing.
  2. The implementation handles overflow based on the wider type range instead of the final i8/i16 range.
  3. The generated sequence mishandles NaN, which must saturate predictably instead of flowing through normal comparison logic.
  4. The backend uses a compare/branch or min/max pattern that is correct for i32/i64 but wrong once the result is truncated to a smaller signed type.

That explains why the issue appears only for fcvt_to_sint_sat.{i8,i16} and not necessarily for wider integer destinations. It also explains the fuzzing instability: unrelated instruction changes can affect temporary register use, instruction scheduling, or legalizer choices, exposing or hiding the bad lowering path.

In practical terms, the semantic contract should be:

if input is NaN: return 0 or backend-defined saturating result per IR semantics
if input < MIN_T: return MIN_T
if input > MAX_T: return MAX_T
otherwise: return trunc_toward_zero(input) as signed T

If the backend instead does something like “convert to i64, then narrow to i8/i16,” values outside the small signed range can wrap or sign-fold into the wrong result.

Step-by-Step Solution

The safest fix is to lower fcvt_to_sint_sat.i8 and fcvt_to_sint_sat.i16 through an explicitly clamped wider conversion sequence, then narrow only after the result is guaranteed to be in range.

1. Reproduce the Failure Reliably

Start by running the Cranelift test that exercises the bad legalization path on riscv64. If you have the original .clif reproducer from the issue, run it with interpretation and codegen verification enabled.

cargo test -p cranelift-codegen riscv64 -- --nocapture

If your workflow uses file-based tests:

cargo test -p cranelift-filetests -- --nocapture

Keep the reproducer as small as possible, but do not over-minimize if the bug is unstable. This class of issue can depend on backend lowering shape.

2. Inspect the RISC-V Lowering for Saturating Float-to-Int

Locate the backend code responsible for lowering fcvt_to_sint_sat on RISC-V. The exact file varies by Cranelift revision, but the logic is typically in the ISLE lowering rules or associated backend legalization code for riscv64.

Search for patterns involving:

fcvt_to_sint_sat
fcvt_to_sint
i8
i16
riscv64

You are looking for one of these suspicious implementations:

  • direct float-to-int conversion followed by ireduce
  • saturation against i32 or i64 bounds only
  • missing explicit handling for NaN

3. Implement Correct Clamp-Then-Convert Semantics

A robust lowering strategy is:

  1. Materialize destination-specific float bounds for i8 or i16.
  2. Compare the source float against those bounds.
  3. Handle NaN explicitly according to Cranelift IR semantics.
  4. Return min/max constants on out-of-range values.
  5. Only for in-range values, perform the normal float-to-int conversion to a wider integer type.
  6. Narrow after the value is proven safe.

Pseudocode:

fn lower_fcvt_to_sint_sat_i8(x: f32/f64) -> i8 {
    if is_nan(x) {
        return 0;
    }
    if x < -128.0 {
        return -128;
    }
    if x > 127.0 {
        return 127;
    }
    let tmp: i32 = fcvt_to_sint(x);
    return tmp as i8;
}

fn lower_fcvt_to_sint_sat_i16(x: f32/f64) -> i16 {
    if is_nan(x) {
        return 0;
    }
    if x < -32768.0 {
        return -32768;
    }
    if x > 32767.0 {
        return 32767;
    }
    let tmp: i32 = fcvt_to_sint(x);
    return tmp as i16;
}

If your backend already uses a branchless select-based lowering, keep that style, but make sure the comparisons are against the final destination range, not the wider temporary type.

4. Avoid a Common Off-by-One Error

For signed saturation, the upper bound for i8 is 127, not 128. Likewise, for i16, the upper bound is 32767, not 32768. This matters because float comparisons near the edge can otherwise let one invalid value through before narrowing corrupts it.

// Correct signed ranges
i8  => [-128, 127]
i16 => [-32768, 32767]

5. Add Regression Tests in Cranelift Filetest Form

Add filetests that pin the exact semantics for NaN, negative overflow, positive overflow, and values near truncation boundaries.

test interpret
test run
target riscv64

function %sat_i8(f64) -> i8 {
block0(v0: f64):
    v1 = fcvt_to_sint_sat.i8 v0
    return v1
}

; Suggested cases to validate:
; NaN        -> 0
; -1000.0    -> -128
; -128.9     -> -128
; -128.0     -> -128
; -127.9     -> -127
; 126.9      -> 126
; 127.0      -> 127
; 127.9      -> 127
; 1000.0     -> 127

Repeat for i16:

function %sat_i16(f64) -> i16 {
block0(v0: f64):
    v1 = fcvt_to_sint_sat.i16 v0
    return v1
}

6. Validate on Real RISC-V64 Codegen

Do not stop at the interpreter. This bug is in backend lowering, so verify emitted code paths using the RISC-V target.

cargo test -p cranelift-codegen test_riscv64 -- --nocapture

If available in your environment, also inspect generated machine code or VCode output to ensure the backend no longer performs an unchecked wide conversion followed by narrowing.

7. Prefer a Shared Helper for Small Signed Saturating Conversions

If both i8 and i16 follow the same pattern, factor the logic into a reusable lowering helper. That reduces the chance of one type being fixed while the other remains subtly wrong.

lower_fcvt_to_sint_sat_smallint(src, min_val, max_val, dst_ty)

This is especially useful in Cranelift backends where legalization rules are type-driven and repeated across multiple integer widths.

Common Edge Cases

  • NaN handling: If NaN is not checked first, ordered comparisons may all return false and the conversion may fall through to an invalid path.
  • Negative zero: -0.0 should convert to 0, not trigger a sign-related special case.
  • Fractional truncation: Saturating conversion still uses normal float-to-int truncation toward zero for in-range inputs such as 126.9 to 126 and -127.9 to -127.
  • Boundary constants represented as floats: Ensure the exact comparison constants are appropriate for the source float width, especially when using f32 vs f64.
  • Wrong narrowing order: Converting to i64 and then reducing to i8/i16 before saturation will produce wrapped values.
  • Backend-specific instruction semantics: Some target instructions set flags or return architecture-defined overflow results that are not equivalent to Cranelift’s saturating IR semantics.

FAQ

Why does this bug only show up for i8 and i16?

Because those types are commonly lowered through wider integer registers on riscv64. If saturation is performed using the wider type’s limits instead of the final destination limits, the later narrowing step can corrupt the result.

Why does changing an unrelated instruction make the test pass?

This usually indicates a backend lowering or register-allocation sensitivity. Small code shape changes can alter temporary values, selected instructions, or legalization paths, masking the faulty sequence without actually fixing the underlying semantic bug.

Should this be fixed in the interpreter or the RISC-V backend?

If the interpreter matches the IR semantics and only riscv64 code generation is wrong, the fix belongs in the RISC-V backend lowering/legalization. The regression tests should cover both interpretation and target-specific codegen behavior.

The key takeaway is simple: saturating float-to-small-signed-int conversion must saturate against the final destination type before narrowing. Once the RISC-V lowering enforces that rule for fcvt_to_sint_sat.i8 and fcvt_to_sint_sat.i16, the wrong-result fuzz case disappears and the behavior becomes stable across code shape changes.

Leave a Reply

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