Skip to content

fix(arrow-cast): round decimal to float conversions once - #10509

Draft
davidlghellin wants to merge 5 commits into
apache:mainfrom
davidlghellin:fix-decimal-to-float-double-rounding
Draft

fix(arrow-cast): round decimal to float conversions once#10509
davidlghellin wants to merge 5 commits into
apache:mainfrom
davidlghellin:fix-decimal-to-float-double-rounding

Conversation

@davidlghellin

@davidlghellin davidlghellin commented Aug 1, 2026

Copy link
Copy Markdown

Which issue does this PR close?

Rationale for this change

cast(decimal, Float64) and cast(decimal, Float32) could return a float that is not the nearest
one to the decimal's exact value — see the issue for the full analysis and the failing values.

The short version is two independent double roundings. single_decimal_to_float_lossy computed
f(x) / 10_f64.powi(scale), where f(x) rounds the unscaled integer once it exceeds 2^53 and
10_f64.powi(scale) is itself 1 ULP off for some scales a DECIMAL(38,s) can declare; dividing two
wrongly rounded operands does not give the correctly rounded quotient. Separately, the Float32
target computed the f64 result and narrowed it with as f32, rounding a second time — a decimal
just above an f32 midpoint can collapse onto that midpoint in f64, and round-half-even then sends
it the wrong way, so fixing the f64 path alone leaves those cases unchanged.

The expected result is the float nearest the decimal's exact value: what parsing the decimal's own
text gives, and what Java's BigDecimal.doubleValue() / floatValue() give.

What changes are included in this PR?

  • single_decimal_to_float_lossy keeps the existing arithmetic as a fast path, but only where both
    operands are exact: -22 <= scale <= 22 and |unscaled| < 2^53. A negative scale multiplies by
    10^-scale rather than dividing by 10^scale, since only the former is an exact power of ten.
    Everything outside that range goes through a correctly rounded decimal-string parse.
  • New single_decimal_to_f32_lossy, used by the Float32 target, narrowing in one step. Same shape,
    smaller exact range: 10^k is exact in an f32 only up to k = 10 (5^10 is the largest power
    of five that fits the 24-bit significand), and the integer bound is 2^24.
  • Float16 is unchanged and now consumes the corrected f64. It is still not correctly rounded —
    half::f16::from_str is f32::from_str(..).map(f16::from_f32), so there is no correctly rounded
    decimal → f16 conversion to compare against. Noted as a remaining limitation.

Are these changes tested?

Yes, six tests in arrow-cast/src/cast/mod.rs:

  • Float64 and Float32 against expected constants, each including control cases that already
    passed before this change.
  • Decimal256, where the i256 -> f64 step is least likely to leave the value intact.
  • A test that every value lands on the same float as parsing its own text — that is the definition of
    correctly rounded, and it needs no expected constants. It sweeps both sides of each fast-path
    boundary: the 2^53 / 2^24 significand limits and the scale ±22 / ±10 limits on exact powers of ten.
  • A test for values carrying more digits than their declared precision, which must not be truncated.
  • A test for the narrow-precision path, which every other test here misses by declaring precision 38
    or 76.

Also checked out of tree against randomized sweeps of (unscaled, scale) pairs, comparing every
result to an independently built decimal string, for Decimal128 to both f32 and f64: 0
mismatches, where the same sweep failed on a large share of the wide cases before the change.

Are there any user-facing changes?

Yes. Casts from decimal to Float32/Float64 can now return a different — correctly rounded — value
for wide decimals, and single_decimal_to_f32_lossy is new public API.

AI disclosure

This change was developed with AI assistance (Claude).

the nearest one to the decimal's exact value, because both paths rounded
twice.

For Float64, `unscaled / 10f64.powi(scale)` rounds the unscaled integer
when it exceeds 2^53 and uses a `powi` result that is 1 ULP off for some
scales. Keep that arithmetic as a fast path only where both operands are
exact (-22 <= scale <= 22 and |unscaled| < 2^53), multiplying by 10^-scale
when the scale is negative since only that is an exact power of ten, and
otherwise round once via a correctly rounded decimal-string parse.

For Float32, computing in f64 and narrowing with `as f32` rounds twice
again, independently of the above: a decimal just above an f32 midpoint
can collapse onto that midpoint in f64, and round-half-even then sends it
the wrong way. Add `single_decimal_to_f32_lossy`, which narrows in one
step; its exact range is smaller, since 10^k is exact in an f32 only up
to k = 10.

Float16 is unchanged and now consumes the corrected f64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added arrow Changes to the arrow crate arrow-cast labels Aug 1, 2026
@Jefffrey

Jefffrey commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

run benchmark cast_kernels
env:
BENCH_FILTER: decimal.*to.*float

@adriangbot

This comment was marked as duplicate.

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

Comparing fix-decimal-to-float-double-rounding (270bd2a) to fd8ead5 (merge-base) diff

Run configuration
run benchmark cast_kernels
env:
  BENCH_FILTER: "decimal.*to.*float"
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

group                           fix-decimal-to-float-double-rounding    main
-----                           ------------------------------------    ----
"cast decimal128 to float64"    1.64     44.5±0.12µs        ? ?/sec     1.00     27.1±0.02µs        ? ?/sec
"cast decimal256 to float64"    1.22     83.7±0.20µs        ? ?/sec     1.00     68.6±0.09µs        ? ?/sec

Resource Usage

base (merge-base)

Metric Value
Wall time 20.0s
Peak memory 13.5 MiB
Avg memory 9.6 MiB
CPU user 17.9s
CPU sys 0.0s
Peak spill 0 B

branch

Metric Value
Wall time 25.0s
Peak memory 8.1 MiB
Avg memory 6.8 MiB
CPU user 21.0s
CPU sys 0.0s
Peak spill 0 B

File an issue against this benchmark runner

@davidlghellin

Copy link
Copy Markdown
Author

run benchmark cast_kernels env: BENCH_FILTER: decimal.*to.*float

Thanks for running it! The slowdown is real and I can reproduce it locally. Looking into it now,
will push a fix before this is ready for review.

@davidlghellin
davidlghellin marked this pull request as draft August 2, 2026 09:14
@davidlghellin

Copy link
Copy Markdown
Author

Pushed the fix for the regression. The guard around the new string path was landing in the hot loop; it now decides once per array instead of once per value.
Locally decimal128 to float64 is back to no detected change and decimal256 to float64 is ~20% faster than main.

@davidlghellin

Copy link
Copy Markdown
Author

run benchmark cast_kernels
env:
BENCH_FILTER: decimal.*to.*float

@davidlghellin
davidlghellin marked this pull request as ready for review August 2, 2026 10:06
@davidlghellin

Copy link
Copy Markdown
Author

@Jefffrey I pushed the fix for the regression — could you run the benchmark again?
It seems I can't trigger it myself.

@Jefffrey

Jefffrey commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

run benchmark cast_kernels
env:
BENCH_FILTER: decimal.*to.*float

@adriangbot

This comment was marked as duplicate.

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

Comparing fix-decimal-to-float-double-rounding (271f5fb) to ed92960 (merge-base) diff

Run configuration
run benchmark cast_kernels
env:
  BENCH_FILTER: "decimal.*to.*float"
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

group                           fix-decimal-to-float-double-rounding    main
-----                           ------------------------------------    ----
"cast decimal128 to float64"    1.00     27.1±0.03µs        ? ?/sec     1.00     27.2±0.02µs        ? ?/sec
"cast decimal256 to float64"    1.00     61.1±0.14µs        ? ?/sec     1.12     68.6±0.09µs        ? ?/sec

Resource Usage

base (merge-base)

Metric Value
Wall time 20.0s
Peak memory 13.5 MiB
Avg memory 9.6 MiB
CPU user 17.8s
CPU sys 0.0s
Peak spill 0 B

branch

Metric Value
Wall time 20.0s
Peak memory 9.9 MiB
Avg memory 6.9 MiB
CPU user 17.0s
CPU sys 0.0s
Peak spill 0 B

File an issue against this benchmark runner

@Jefffrey Jefffrey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the comments explaining the logic are nice, but i feel this PR is quite overly verbose both in terms of comments and code organization that it makes it hard to follow

Comment thread arrow-cast/src/cast/mod.rs Outdated
// has to go through the string path.
if !(-22..=22).contains(&scale) {
return cast_decimal_to_float::<D, Float64Type, _>(array, |x| {
single_decimal_to_float_lossy::<D, F>(as_float, x, scale)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i feel like we can simplify/inline a lot of this code, as there is a lot of indirection going on here

for example im trying to read this but im getting a flow like:

  1. from cast_from_decimal(), call cast_decimal_array_to_f64()
  2. if we pass this check, we call cast_decimal_to_float() which calls single_decimal_to_float_lossy() for each element
  3. but single_decimal_to_float_lossy() also checks the scale again and may call decimal_to_f64_rounded_once()

we can cut out cast_decimal_to_float() here since if we look at it, its a very simple function that can be inlined. and perhaps find some way not to check the scale again?

/// [`decimal_to_f64_rounded_once`].
#[cold]
#[inline(never)]
fn decimal_to_f32_rounded_once<D: DecimalType>(x: D::Native, scale: i32, unscaled: f64) -> f32 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems duckdb also did a fix for this recently: duckdb/duckdb#22874

notably they dont parse through a string, but seemingly manually parse the digits mathematically to avoid heap allocation

Comment thread arrow-cast/src/cast/mod.rs Outdated
/// round-half-even then sends it the wrong way. So this narrows directly rather
/// than reusing the `f64` conversion.
#[inline(always)]
pub fn single_decimal_to_f32_lossy<D, F>(f: &F, x: D::Native, scale: i32) -> f32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub fn single_decimal_to_f32_lossy<D, F>(f: &F, x: D::Native, scale: i32) -> f32
fn single_decimal_to_f32_lossy<D, F>(f: &F, x: D::Native, scale: i32) -> f32

this doesn't need to be public

@davidlghellin
davidlghellin marked this pull request as draft August 3, 2026 12:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arrow Changes to the arrow crate arrow-cast

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Casting decimal to Float64 / Float32 is not correctly rounded

3 participants