Skip to content

perf: optimize cast_decimal128_to_utf8 in datafusion-comet-spark-expr#4924

Open
andygrove wants to merge 1 commit into
apache:mainfrom
andygrove:auto-opt/cast_decimal128_to_utf8-datafusion-comet-20260714-114428
Open

perf: optimize cast_decimal128_to_utf8 in datafusion-comet-spark-expr#4924
andygrove wants to merge 1 commit into
apache:mainfrom
andygrove:auto-opt/cast_decimal128_to_utf8-datafusion-comet-20260714-114428

Conversation

@andygrove

@andygrove andygrove commented Jul 14, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

N/A

Rationale for this change

Optimize existing expression.

What changes are included in this PR?

Replaced the per-row u128 to_string heap allocation, format! machinery, and "0".repeat in the LEGACY Decimal128-to-string cast with an allocation-free stack digit buffer that renders the coefficient in 19-digit 64-bit chunks and appends slices directly to the reused output buffer.

How are these changes tested?

Existing tests + expanded unit tests.

Benchmark (criterion):

  • decimal128_leading_zeroes: 73.764% faster (base 197033ns -> cand 51693ns)
  • decimal128_wide: 20.876% faster (base 198585ns -> cand 157128ns)
  • decimal128_scientific: 49.361% faster (base 172590ns -> cand 87397ns)
  • decimal128_scale_2: 52.624% faster (base 155170ns -> cand 73513ns)

Full criterion output:

cast_decimal_to_string/decimal128_scale_2
                        time:   [73.783 µs 73.913 µs 74.059 µs]
                        change: [−52.799% −52.624% −52.452%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 1 outliers among 100 measurements (1.00%)
  1 (1.00%) high mild
cast_decimal_to_string/decimal128_leading_zeroes
                        time:   [51.381 µs 51.495 µs 51.615 µs]
                        change: [−73.817% −73.764% −73.700%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 22 outliers among 100 measurements (22.00%)
  16 (16.00%) low severe
  1 (1.00%) low mild
  3 (3.00%) high mild
  2 (2.00%) high severe
cast_decimal_to_string/decimal128_wide
                        time:   [157.06 µs 157.33 µs 157.66 µs]
                        change: [−20.987% −20.876% −20.752%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 4 outliers among 100 measurements (4.00%)
  1 (1.00%) high mild
  3 (3.00%) high severe
cast_decimal_to_string/decimal128_scientific
                        time:   [87.563 µs 87.699 µs 87.832 µs]
                        change: [−49.456% −49.361% −49.272%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 2 outliers among 100 measurements (2.00%)
  1 (1.00%) low severe
  1 (1.00%) high mild

@andygrove andygrove marked this pull request as ready for review July 14, 2026 18:25

@mbutrovich mbutrovich 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.

First pass, thanks @andygrove!

Comment on lines +1499 to +1505
// values needing more than one 64-bit digit group
assert_eq!(fmt(10_000_000_000_000_000_000, 0), "10000000000000000000");
assert_eq!(fmt(i128::MAX, 0), i128::MAX.to_string());
assert_eq!(fmt(i128::MIN, 0), i128::MIN.to_string());
assert_eq!(
fmt(99_999_999_999_999_999_999_999_999_999_999_999_999, 10),
"9999999999999999999999999999.9999999999"

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.

native/spark-expr/src/conversion_funcs/numeric.rs:1499-1506 (new tests) covers i128::MAX, i128::MIN, and the max-precision plain-notation coefficient, but every multi-group case is either non-negative or in plain notation. The sign push and the coefficient-splitting for scientific notation (&coeff[..1] / &coeff[1..]) are the parts most likely to regress if someone later refactors render_digits, and neither is exercised on a coefficient that spans two 64-bit groups.

Suggested change: add cases such as

// multi-group coefficient in scientific notation, both signs
assert_eq!(fmt(i128::MAX, 45), "1.70141183460469231731687303715884105727E-7");
assert_eq!(fmt(-i128::MAX, 45), "-1.70141183460469231731687303715884105727E-7");

(compute the expected strings from BigDecimal(unscaled, scale).toString() or the pre-PR implementation so they are anchored to Spark, not to the new code).

assert_eq!(fmt(-123, -2), "-1.23E+4");

// values needing more than one 64-bit digit group
assert_eq!(fmt(10_000_000_000_000_000_000, 0), "10000000000000000000");

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.

native/spark-expr/src/conversion_funcs/numeric.rs:1500 tests 10_000_000_000_000_000_000 (scale 0), which is the smallest two-group value, but no test covers a value like 10^19 + 5 where the lower group is 0000000000000000005 and the emitted leading zeros of that group are load-bearing. This is exactly the case the code comment at render_digits calls out ("emitting its leading zeroes is correct"), so it deserves a guard test.

Suggested change:

assert_eq!(fmt(10_000_000_000_000_000_005, 0), "10000000000000000005");

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants