How to Fix: Cranelift: RISC-V wrong result for `fcvt_to_sint_sat.{i8,i16}`
Cranelift RISC-V wrong result for fcvt_to_sint_sat.{i8,i16}: root cause and fix
This bug comes from a subtle but critical mismatch between saturating float-to-int semantics in Cranelift IR and the way the RISC-V backend materialized the conversion for narrow signed integer types. On riscv64gc, fcvt_to_sint_sat.i8 and fcvt_to_sint_sat.i16 could produce the wrong value for edge-case floating-point inputs because the lowering path converted through a wider integer representation without applying the correct final saturation behavior for the destination width.
Reproducing the failure
The issue is visible with a small .clif test targeting RISC-V:
test interpret
test run
target riscv64gc
function %a(f64) -> i8 {
block0(v0: f64):
v1 = fcvt_to_sint_sat.i8 v0
return v1
}
The key detail is that interpret uses Cranelift’s reference semantics, while run executes the backend-generated machine code. If those disagree, the problem is almost always in instruction selection, legalization, or a backend-specific lowering sequence.
For this issue, the interpreter returns the correct saturated signed 8-bit or 16-bit value, but the generated RISC-V code can return an incorrect result for inputs near or outside the destination range.
Understanding the Root Cause
Saturating conversion means the result must be clamped to the destination type’s range instead of overflowing or invoking target-specific undefined behavior. For signed integer destinations, that means:
i8range:-128to127i16range:-32768to32767
On RISC-V, there is no single instruction that directly implements Cranelift’s exact fcvt_to_sint_sat.i8 or fcvt_to_sint_sat.i16 semantics. Backend lowering therefore has to synthesize the operation from multiple steps.
The bug appears when the backend performs the conversion in a wider integer type such as i32 or i64, but then relies on a plain narrowing or sign-extension path afterward. That is not enough. A normal float-to-int conversion to a wider type answers the question “what is the integer value in this wider domain?” Cranelift’s saturating narrow conversion asks a different question: “what is the integer value after clamping specifically to the target narrow signed range?”
Those are only equivalent if the implementation explicitly clamps against the destination type’s min and max before or during narrowing.
Typical failure modes include:
- Converting
f64toi64, then truncating toi8ori16, which can wrap or keep the wrong low bits. - Using a hardware conversion that handles out-of-range inputs differently from Cranelift’s saturating semantics.
- Correctly handling NaN and large magnitudes for wide integer conversion, but forgetting that the final narrow type still needs its own clamp stage.
In short, the root cause is a backend lowering sequence that treated narrow signed saturation as if it were just a wide signed conversion followed by narrowing. That loses the semantics of fcvt_to_sint_sat.i8 and fcvt_to_sint_sat.i16.
Step-by-Step Solution
The reliable fix is to lower the operation in a way that preserves the destination width’s signed saturation rules explicitly.
1. Identify the lowering path for RISC-V
Locate the backend code responsible for legalizing or lowering fcvt_to_sint_sat on RISC-V. Depending on your Cranelift revision, this will usually be in the RISC-V ISLE lowering rules or surrounding legalization code.
You are looking for logic that handles:
fcvt_to_sint_sat.i8fcvt_to_sint_sat.i16- possibly a generic
fcvt_to_sint_satpath reused for all widths
2. Do not implement narrow saturation as “wide convert, then narrow”
If your current lowering looks conceptually like this, it is the source of the bug:
; incorrect idea
v_wide = fcvt_to_sint_sat.i64 x
v_narrow = ireduce.i8 v_wide
or:
; also incorrect for saturating narrow semantics
v_wide = raw_fp_to_int_conversion x
v_narrow = narrow_or_sign_extend v_wide
This can return incorrect values for inputs outside the narrow signed range.
3. Clamp against the destination type range first
The correct lowering should compare the input floating-point value against the destination type’s signed bounds represented as floating-point constants, then select the clamped result when necessary.
Conceptually, for i8:
; conceptual lowering for fcvt_to_sint_sat.i8 from f64
if isnan(x):
return 0
if x <= -128.0:
return -128
if x >= 127.0:
return 127
return trunc_toward_zero_to_i8(x)
And similarly for i16:
; conceptual lowering for fcvt_to_sint_sat.i16 from f64
if isnan(x):
return 0
if x <= -32768.0:
return -32768
if x >= 32767.0:
return 32767
return trunc_toward_zero_to_i16(x)
If the backend cannot directly produce i8 or i16 from the hardware conversion, it is still safe to convert to a wider integer after the float-domain clamps have already guaranteed the value is within range:
; safe pattern
if isnan(x):
return 0
if x <= MIN_AS_FLOAT:
return MIN_INT
if x >= MAX_AS_FLOAT:
return MAX_INT
tmp = fp_to_sint_i32_or_i64(x)
result = narrow_to_i8_or_i16(tmp)
The difference is crucial: narrowing is only safe after bounds enforcement.
4. Encode the logic in the RISC-V lowering rules
In backend terms, the fix typically involves:
- Emitting floating-point comparisons against exact boundary constants.
- Handling NaN explicitly, since saturating conversions usually define NaN to become zero.
- Selecting
MINorMAXconstants for out-of-range values. - Only using a hardware
fcvtinstruction in the in-range path.
A pseudo-implementation might look like this:
function lower_fcvt_to_sint_sat_narrow(x, dst_ty):
min_int = signed_min(dst_ty)
max_int = signed_max(dst_ty)
min_fp = fp_constant_for(min_int)
max_fp = fp_constant_for(max_int)
if fp_is_nan(x):
return iconst(dst_ty, 0)
if fp_le(x, min_fp):
return iconst(dst_ty, min_int)
if fp_ge(x, max_fp):
return iconst(dst_ty, max_int)
wide = fp_to_sint(x, i32_or_i64)
return narrow_to_dst_ty(wide)
When implementing this in Cranelift’s backend, prefer existing helper patterns for:
- floating-point ordered/unordered comparisons
- immediate constants
- integer narrowing
- reusing legal lowering code shared by similar conversion ops
5. Add regression tests
This issue should always be covered by backend tests that compare interpret and run. Add focused test cases for both i8 and i16 and include boundary-adjacent inputs.
test interpret
test run
target riscv64gc
function %sat_i8_lo(f64) -> i8 {
block0(v0: f64):
v1 = fcvt_to_sint_sat.i8 v0
return v1
}
; try values below, at, and above the bounds
; run: %sat_i8_lo(-129.0) == -128
; run: %sat_i8_lo(-128.0) == -128
; run: %sat_i8_lo(-127.9) == -127
; run: %sat_i8_lo(126.9) == 126
; run: %sat_i8_lo(127.0) == 127
; run: %sat_i8_lo(128.0) == 127
function %sat_i16_lo(f64) -> i16 {
block0(v0: f64):
v1 = fcvt_to_sint_sat.i16 v0
return v1
}
; run: %sat_i16_lo(-32769.0) == -32768
; run: %sat_i16_lo(-32768.0) == -32768
; run: %sat_i16_lo(32767.0) == 32767
; run: %sat_i16_lo(32768.0) == 32767
Also add NaN and infinity coverage:
; run: %sat_i8_lo(+NaN) == 0
; run: %sat_i8_lo(+Inf) == 127
; run: %sat_i8_lo(-Inf) == -128
6. Verify with the interpreter and native execution
After patching the lowering logic, run the Cranelift test suite for the RISC-V backend and make sure both the semantic interpreter and generated machine code agree across all boundary cases.
This matters because backend-only conversion bugs often hide until you compare a reference execution path against emitted code on the target ISA.
Common Edge Cases
Even after fixing the obvious failure, there are several edge cases worth testing.
NaN inputs
NaN often takes a separate lowering path because ordinary comparisons can be unordered. If the implementation forgets explicit NaN handling, the select chain can fall through to a hardware conversion instruction and return a target-specific value instead of the expected saturating result.
Positive and negative infinity
+Inf should clamp to the destination’s signed maximum, and -Inf should clamp to the signed minimum. If infinity is not caught by the float-range checks, the hardware conversion may set flags or produce a value that does not match Cranelift IR semantics.
Boundary equality
Be careful with < vs <= and > vs >=. For saturating conversions, exact boundary values such as -128.0 and 127.0 for i8 must return those exact integers.
Fractional values near the bounds
Values like 127.9 for i8 are especially important. Depending on the exact semantics, in-range conversion should truncate toward zero, while out-of-range values should saturate. If the compare threshold is wrong, these values can be off by one.
Signed minimum asymmetry
Signed integer ranges are asymmetric: for i8, the minimum is -128 while the maximum is 127. The lowering must use the correct constants on each side; reusing one absolute bound is incorrect.
Cross-type reuse bugs
If the RISC-V backend shares one helper for i8, i16, i32, and i64, a fix for narrow types must not accidentally change correct behavior for wider conversions. Keep tests for all signed widths if the implementation is generic.
How to validate the fix
A solid validation strategy includes all of the following:
- Targeted .clif regression tests for
i8andi16. - Boundary-value coverage around min and max signed limits.
- Special floating-point inputs such as NaN and infinities.
- Cross-checking interpret vs run to ensure backend semantics match the IR.
- Running the full RISC-V backend suite to make sure no shared lowering path regressed.
If you have access to backend disassembly, inspect the generated code as well. The corrected sequence should visibly include compare-and-select logic or an equivalent clamping structure before the final narrow result is produced.
FAQ
Why does this bug affect i8 and i16 more obviously than wider integer types?
Because narrow types are often implemented by converting to a wider register-sized integer first and then reducing the width. That shortcut is unsafe for saturating conversions unless the backend clamps to the narrow type’s exact range before narrowing.
Why isn’t a normal RISC-V fcvt instruction enough?
RISC-V fcvt instructions do not directly encode Cranelift’s full saturating narrow signed conversion semantics, especially for NaN, infinity, and out-of-range values targeting i8 or i16. Backend lowering must synthesize the missing behavior explicitly.
What is the safest long-term fix pattern for similar backend bugs?
Use a generic lowering helper that performs: NaN handling, float-domain range checks, constant clamp selection, and only then an in-range hardware conversion. That pattern is portable across ISAs and avoids relying on target-specific overflow behavior.
For Cranelift maintainers, the lesson is simple: whenever an IR operation includes saturation and a narrow destination type, treat that width as part of the semantic contract during lowering. If the backend postpones width-sensitive behavior until after a wider conversion, it risks exactly the kind of wrong-code bug seen here on RISC-V.