Repository navigation
fix(arrow-cast): cast floats to decimal exactly once the scaled value reaches 2^53 - #12049
akupchinskiy wants to merge 2 commits into
Conversation
… reaches 2^53 Casting Float64/Float32 to a decimal computed round(10^scale * x) in f64. From 2^53 on an f64 no longer holds every integer, so the result was off: 295161.0 at scale 15 gave 295160999999999967232. There the shortest decimal representation of the input (the digits arrow displays it with, from ryu) is now scaled exactly; an integer below 2^53 is scaled without formatting. Below 2^53 results are unchanged. The array cast keeps the floating-point path in a first pass without calls in the per-value loop (precision bounds precomputed, i64 instead of f64 to i128 conversion) and redoes the cast with the exact path only when a value needs it, or on any failure for strict casts so that the error is the same. The existing float-to-decimal benchmarks get 39-92% faster; high-scale casts of non-integers, whose results were wrong, are about 12x slower. Adds benchmarks for Decimal128(38, 15) and (38, 20) casts of random and integer doubles.
…casts Rewrites the first pass of the float-to-decimal array cast as a separate function over plain slices, 64 values at a time, that writes the values and the `valid` and `exact` bitmaps as words. The kernels capture their invariants by value, convert with an unchecked i64 to native conversion (invalid slots are masked) and handle integers below 2^53 with a product of 2^53 or more in the first pass. The low-precision kernel (precision <= 15) has no branches and vectorizes; the second pass visits only the slots that need the exact path. Results are unchanged. Adds a test of the integer shortcut's range bound across decimal widths, precisions, scales and offsets against the scalar path.
mbutrovich
left a comment
There was a problem hiding this comment.
Thanks @akupchinskiy. Could we settle on #12048 which decimal a double should map to before going further on the code? I left the evidence in a comment there.
If AI tools helped write this PR or the issue, could you say which parts, as the AI Generated Submissions section of CONTRIBUTING.md asks?
| /// `input * 10^scale` rounded half away from zero, or `None` if `input` is not finite or the | ||
| /// result does not fit `D::Native`. | ||
| /// | ||
| /// The product is computed in floating point while it is below 2^53. Above that an `f64` no | ||
| /// longer holds every integer, and the floating-point product is off by up to half its ulp: | ||
| /// `295161.0` at scale 15 would give `295160999999999967232`. There the shortest decimal | ||
| /// representation of `input` (the digits arrow displays it with) is scaled exactly instead. | ||
| #[inline(always)] | ||
| fn float_to_decimal_native<D>(input: f64, scale: FloatScale) -> Option<D::Native> | ||
| where | ||
| D: DecimalType, | ||
| D::Native: DecimalCast, | ||
| { | ||
| let p = scale.mul * input; | ||
| // false for NaN and infinities | ||
| if p.abs() < 9_007_199_254_740_992.0 { | ||
| // |p.round()| <= 2^53: exact as i64, and cheaper than converting f64 to i128 | ||
| return D::Native::from_decimal(p.round() as i64); | ||
| } | ||
| shortest_decimal_scaled(input, scale.scale).and_then(D::Native::from_decimal) | ||
| } |
There was a problem hiding this comment.
Should the cast round the double's exact binary value at every magnitude, as I asked on #12048, instead of switching to the shortest representation at 2^53?
With the switch, the result of a cast depends on the target scale. On the head commit, 1.005 cast to Decimal128(38, 2) gives 1.00, while casting to Decimal128(38, 16) and then to Decimal128(38, 2) gives 1.01. On main both give 1.00.
The branch below 2^53 also isn't correctly rounded under either definition. On the head commit, float_to_decimal_opt::<Decimal128Type>(26.657349498176934, 38, 14) returns 2665734949817694, and both definitions give 2665734949817693. My comment on #12048 has the sample this came from.
Rounding the exact value needs no formatting. With x = m * 2^e and m < 2^53, the result is m * 10^scale * 2^e rounded half away from zero, which is one integer multiply and a shift. This sketch matches Rust's exact {:.N} formatting on 300,000 random doubles at scales 0 to 21 (except exact ties, where Rust rounds to even), and is exact for integers:
fn exact(x: f64, s: u32) -> Option<i128> {
if !x.is_finite() { return None; }
let bits = x.to_bits();
let biased = ((bits >> 52) & 0x7ff) as i32;
let frac = bits & ((1u64 << 52) - 1);
let (m, e) = if biased == 0 { (frac, -1074) } else { (frac | (1 << 52), biased - 1075) };
// m < 2^53 and 10^21 < 2^70, so the product fits
let p = m as i128 * 10i128.pow(s);
let mag = if e >= 0 {
if p != 0 && (p.leading_zeros() as i32) <= e { return None; }
p << e
} else {
let k = -e as u32;
if k > 124 { 0 } else { (p + (1i128 << (k - 1))) >> k }
};
Some(if bits >> 63 == 1 { -mag } else { mag })
}Wider scales would do the same in i256. It also handles Float32 input without a separate path, because widening to f64 is exact. With the shortest representation, 0.1f32 at Decimal128(38, 20) gives 0.10000000149011612000 on the head commit, which is not the digits arrow displays (0.1), so the doc comment's description doesn't hold for Float32.
| /// over plain slices, so that the loop holds its invariants and bit accumulators in registers; | ||
| /// kernels without branches vectorize. | ||
| #[inline(never)] | ||
| fn fast_pass<T, N, K>( |
There was a problem hiding this comment.
Could the two-pass array kernel (fast_pass, NativeFromI64, the valid and exact bitmaps) move to its own PR? It changes the common casts below 2^53 that the title doesn't cover, and it makes this fix hard to review on its own. A separate PR would let us review its speedups and its from_i64_wrapping and .expect("a failing row") paths separately, with the new benchmark cases in their own commit first so the benchmark bot can compare.
|
Thanks for the careful review, these are all fair points. Happy to split it up. I'll strip this PR down to just the fix: compute the exact value directly like On semantics, your The one thing that gives me pause is that today's behaviour below 2^53 is fairly common. DuckDB and On AI: yes. I found the bug myself while comparing engines on a benchmark. Claude Code helped write |
Which issue does this PR close?
295161.0at scale 15 becomes295160.999999999967232) #12048.Rationale for this change
Float-to-decimal casts computed
round(10^scale * x)inf64. From 2^53 on, the result iswrong:
295161.0at scale 15 becomes295160.999999999967232. At scale 15 and above thisaffects most values (see the issue).
What changes are included in this PR?
In
arrow-cast/src/cast/decimal.rs:Fix.
arithmetic. This is the representation arrow displays the float with, from
ryu(already adependency).
This applies to
float_to_decimal,float_to_decimal_optand the array cast. The doc comment offloat_to_decimaldescribes the switch.Array cast structure, which also makes the common casts faster (second commit).
two bitmaps,
validandexact(needs the exact path), as words. The kernels capture theirinvariants by value and convert with an unchecked
i64to native conversion; invalid slots aremasked by
valid. This replaces the__fixdfticall of thef64toi128conversion and theper-value precision check call.
precision <= 15, every valid value is below 10^15 < 2^53, so a product of 2^53 or more is aprecision overflow either way. The kernel is the rounded product plus a range check against
10^precision - 1, without branches, and vectorizes (on aarch64:fmul.2d,frinta.2d,fcvtas.2d, packed comparisons).with scale 0 to 22 gets the exact
i128product. Other values are markedexact.exactslots that are not null, throughset_indices(). In strictcasts, the first failing row reports the same error as the scalar function.
Benchmarks: three new cases in
arrow/benches/cast_kernels.rs, forDecimal128(38, 15)and(38, 20)casts of random doubles and of integer doubles.Are these changes tested?
Yes, in
arrow-cast/src/cast/decimal.rs:test_float_to_decimal_large_products: the cases from the issue. It also checks that valuesbelow 2^53 are unchanged (for example,
1.005at scale 2 is still100), plus tiny, huge andnon-finite inputs.
test_float_to_decimal_matches_reference: 20,000 random doubles (all four decimal widths,scales -40 to 79) and 20,000 doubles whose product lies near 2^53. Below 2^53 they are checked
against the old formula. From 2^53 on they are checked against an independent reference, which
takes the significant-digit count from
core'sDisplayand rounds the exact{:.1100}expansion half-to-even to it. One-off runs with 1M values each also passed.
test_cast_float_array_to_decimal_matches_scalar,test_low_precision_float_array_boundariesand
test_float_array_integer_shortcut_boundariescheck that the array cast matches the scalarfunctions:
f64::MAX);Benchmarks (
cast_kernels,float(32|64) (integers )?to decimal), Apple M2 Pro, median time perarray. The best of two interleaved runs of each version.
The only slower cases are high-scale casts of non-integers, where the previous results were
wrong (see the issue). There the cost is formatting the shortest digits with
ryu. AryuAPIthat returns the decimal digits and exponent without formatting would remove most of it.
Are there any significant user-facing changes?
Yes: casts whose scaled value is 2^53 or more now return the correct decimal. Results below 2^53
do not change. No public API changes.
AI disclosure
I found the bug while comparing query engines on a benchmark of mine. Claude Code helped write the
fix, the tests and this description, and Codex did review passes. I reviewed the code and checked
the results myself: tests, a comparison against an exact decimal reference on a few million
doubles, and benchmarks.