Repository navigation
GH-46557: [C++][R] Check overflow in float to int casts that allow truncation - #52469
advitrocks9 wants to merge 6 commits into
Conversation
|
|
|
|
|
|
| } else if (!options.allow_int_overflow) { | ||
| RETURN_NOT_OK(CheckFloatToInt<WasOutOfRange>(batch[0], *out)); |
| return Status::Invalid("Float value ", val, " out of range converting to ", | ||
| *output.type); |
|
Thanks for picking up this PR @advitrocks9! I've triggered a Copilot review and asked one of our C++ maintainers to take a look at this. |
|
Fixed copilot's concerns |
dmitry-chirkov-dremio
left a comment
There was a problem hiding this comment.
The WasOutOfRange/Checker approach looks useful. Could we also use the range check before conversion on the checked float-to-integer paths, including when allow_float_truncate is false?
The current order still performs an undefined conversion before reporting the error. Adding ARROW_DISABLE_UBSAN to the half-float path suppresses that check rather than avoiding the invalid conversion. Please also cover null slots containing out-of-range values.
There may be an opportunity to share the validation loop with CheckIntegersInRange, but I would leave that cleanup and the similar Gandiva casts to separate work.
|
Thanks! I've committed the fixes. For allow_float_truncate=false I kept the round trip, since an input-only check was 1.6-2x slower. I also kept the half float exemption for null slots and unchecked casts, same as float and double. |
|
|
Against what baseline? "The before" state or the 4th commit? |
| for (bool allow_float_truncate : {false, true}) { | ||
| auto opts = CastOptions::Safe(int8()); | ||
| opts.allow_float_truncate = allow_float_truncate; | ||
| for (auto json : {"[128.0]", "[-129.0]", "[NaN]", "[Inf]", "[-Inf]"}) { |
There was a problem hiding this comment.
Can we also test 127.5 and -128.5 when truncation is disabled?
| CheckCastFails(ArrayFromJSON(from, "[256.0]"), opts); | ||
| CheckCastFails(ArrayFromJSON(from, "[-1.0]"), opts); |
| auto opts = CastOptions::Safe(int64()); | ||
| opts.allow_float_truncate = true; | ||
| CheckCastFails(ArrayFromJSON(from, "[9223372036854775808.0]"), opts); | ||
| CheckCast(ArrayFromJSON(from, "[-9223372036854775808.0]"), |
There was a problem hiding this comment.
What about 9223372036854775807, 9223372036854775807.5, -9223372036854775808.5?
| EXPECT_RAISES_WITH_MESSAGE_THAT(Invalid, ::testing::HasSubstr("Float value 1.5"), | ||
| Cast(ArrayFromJSON(float16(), "[1.5]"), opts)); | ||
| opts.allow_float_truncate = true; | ||
| EXPECT_RAISES_WITH_MESSAGE_THAT(Invalid, ::testing::HasSubstr("Float value inf out"), |
| template <typename InType, typename OutType, typename InT = typename InType::c_type, | ||
| typename OutT = typename OutType::c_type> | ||
| struct WasOutOfRange { | ||
| static constexpr auto kMin = static_cast<InT>(std::numeric_limits<OutT>::min()); |
There was a problem hiding this comment.
These seems to assume that OutT is integral, can we add a static_assert about that?
| if constexpr (std::is_same_v<Checker<InType, OutType>, | ||
| WasOutOfRange<InType, OutType>>) { |
There was a problem hiding this comment.
Can you please add a relevant method on the various checkers? It doesn't make sense to put type specialization here.
| auto out_at = [&](int64_t i) { | ||
| if constexpr (std::is_same_v<Checker<InType, OutType>, | ||
| WasOutOfRange<InType, OutType>>) { | ||
| return OutT{}; | ||
| } else { | ||
| return out_data[i]; | ||
| } | ||
| }; |
| Status CastFloatingToInteger(KernelContext* ctx, const ExecSpan& batch, ExecResult* out) { | ||
| const auto& options = checked_cast<const CastState*>(ctx->state())->options; | ||
| if (options.allow_float_truncate && !options.allow_int_overflow) { | ||
| RETURN_NOT_OK(CheckFloatToInt<WasOutOfRange>(batch[0], *out)); |
There was a problem hiding this comment.
Why check before while allow_float_truncate checks after the conversion?
|
I've dismissed myself as a review so I don't get notifications on this, but once we're happy with the C++, mind either pinging me to take a look at the R side of things or splitting it out into a follow-up @advitrocks9? |
|
|
|
@pitrou Thanks! I've made the changes. I'd moved the range check first so out of range values wouldn't be converted, but the cast still converts them in null slots and when truncation isn't allowed. So I moved it back after the conversion and removed I added the cases you listed. 9223372036854775807 and 9223372036854775807.5 round to 2^63 as float or double, so they fail, and -9223372036854775808.5 rounds to -2^63 and casts to INT64_MIN. @dmitry-chirkov-dremio That means I've undone the reordering you asked for. I measured the 1.6-2x against main's round trip, in a standalone loop rather than the kernel. I've also fixed the description. |


Rationale for this change
With
allow_float_truncate=trueandallow_int_overflow=false, a float to int cast never checks the range, so out of range values silently come back as garbage. Casting1e308,3e9orNaNfrom float64 to int32 returns-2147483648.What changes are included in this PR?
Adds a
WasOutOfRangecheck for that case. Also exempts the half float to int cast fromfloat-cast-overflow, like float and double, and prints half float values in cast errors instead of their raw bits. R's%/%passesallow_int_overflow = TRUE, since it casts rows it then masks.Are these changes tested?
Cast.FloatingToIntOverflowtests casts from float16, float32 and float64 to int8 and uint8 with and without truncation, an out of range value in a null slot, the int64 and uint64 limits, and the half float error messages. It fails without the fix.Are there any user-facing changes?
These casts raise on out of range, NaN and infinite values, including R's
as.integer().This PR contains a "Critical Fix". These casts silently returned wrong values.
allow_int_overflowoption ignored whenallow_float_truncateset to True #46557