Skip to content

perf: optimize parse_string_to_decimal (30-40% faster)#4916

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

perf: optimize parse_string_to_decimal (30-40% faster)#4916
andygrove wants to merge 1 commit into
apache:mainfrom
andygrove:auto-opt/parse_string_to_decimal-datafusion-comet-20260714-040039

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 six redundant scans plus two str::parse calls with a single validating byte pass and direct i128 digit accumulation, gated the seven inf/nan string comparisons behind a first-byte check, and swapped 10_i128.pow for a const power-of-ten table.

How are these changes tested?

Existing tests.

Benchmark (criterion):

  • decimal_38_10: 41.516% faster (base 265533ns -> cand 155293ns)
  • decimal_18_2: 36.846% faster (base 302661ns -> cand 191142ns)
  • decimal_38_10: 40.979% faster (base 266661ns -> cand 157386ns)
  • decimal_18_2: 36.564% faster (base 305466ns -> cand 193776ns)
  • decimal_38_10: 40.66% faster (base 264940ns -> cand 157214ns)
  • decimal_18_2: 36.607% faster (base 303296ns -> cand 192269ns)

Full criterion output:

cast_string_to_decimal/legacy/decimal_38_10
                        time:   [156.99 µs 157.35 µs 157.96 µs]
                        change: [−41.202% −40.979% −40.792%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 10 outliers among 100 measurements (10.00%)
  2 (2.00%) low severe
  1 (1.00%) low mild
  1 (1.00%) high mild
  6 (6.00%) high severe
cast_string_to_decimal/legacy/decimal_18_2
                        time:   [193.54 µs 193.64 µs 193.77 µs]
                        change: [−36.658% −36.564% −36.469%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 10 outliers among 100 measurements (10.00%)
  3 (3.00%) low severe
  1 (1.00%) low mild
  2 (2.00%) high mild
  4 (4.00%) high severe

cast_string_to_decimal/ansi/decimal_38_10
                        time:   [155.19 µs 155.26 µs 155.35 µs]
                        change: [−41.605% −41.516% −41.425%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 14 outliers among 100 measurements (14.00%)
  4 (4.00%) low severe
  3 (3.00%) low mild
  5 (5.00%) high mild
  2 (2.00%) high severe
cast_string_to_decimal/ansi/decimal_18_2
                        time:   [191.08 µs 191.15 µs 191.25 µs]
                        change: [−36.908% −36.846% −36.785%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 8 outliers among 100 measurements (8.00%)
  1 (1.00%) low mild
  3 (3.00%) high mild
  4 (4.00%) high severe

cast_string_to_decimal/try/decimal_38_10
                        time:   [156.94 µs 157.14 µs 157.42 µs]
                        change: [−40.772% −40.660% −40.556%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 14 outliers among 100 measurements (14.00%)
  6 (6.00%) low mild
  3 (3.00%) high mild
  5 (5.00%) high severe
cast_string_to_decimal/try/decimal_18_2
                        time:   [192.19 µs 192.30 µs 192.46 µs]
                        change: [−36.697% −36.607% −36.524%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 3 outliers among 100 measurements (3.00%)
  1 (1.00%) high mild
  2 (2.00%) high severe

@andygrove andygrove changed the title perf: optimize parse_string_to_decimal in datafusion-comet-spark-expr perf: optimize parse_string_to_decimal (30-40% faster) Jul 14, 2026
@andygrove andygrove marked this pull request as ready for review July 14, 2026 14:47

@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!

(EvalMode::Ansi, "ansi"),
(EvalMode::Try, "try"),
] {
let mut group = c.benchmark_group(format!("cast_string_to_decimal/{mode_name}"));

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/benches/cast_string_to_decimal.rs:72 creates group cast_string_to_decimal/{mode_name} with a decimal_38_10 bench function. The existing native/spark-expr/benches/cast_from_string.rs:124-131 already creates the identical group cast_string_to_decimal/{mode_name} with an identical decimal_38_10 function, over the same three eval modes and the same string shapes (plain, fixed-point, negative, scientific). Criterion keys saved results by group/function, so the two benches write to and overwrite the same target/criterion/cast_string_to_decimal/legacy/decimal_38_10 directory. Whichever bench runs last clobbers the other's baseline, which makes the reported before/after comparison unreliable.

The only thing the new file adds over the existing one is the decimal_18_2 variant and a seeded StdRng (the existing bench uses unseeded rand::random, so its numbers are not reproducible run to run).

Suggested change: do not add a second benchmark file. Fold the improvements into the existing create_decimal_cast_string_batch path in cast_from_string.rs: switch it to a seeded StdRng and add the decimal_18_2 data type to the existing loop at cast_from_string.rs:116-133. Delete benches/cast_string_to_decimal.rs and the corresponding [[bench]] entry in Cargo.toml. If you keep a separate file, rename its group to something distinct like parse_string_to_decimal/... so it does not overwrite the existing baseline.

/// The first 38 digits always fit (`i128::MAX` is ~1.7e38), so only the digits past
/// them need the per-digit overflow checks.
#[inline]
fn digits_to_i128(digits: &[u8]) -> Option<i128> {

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.

digits_to_i128 at native/spark-expr/src/conversion_funcs/string.rs (new helper) splits the digit slice at 38 and skips overflow checks on the first 38 digits, relying on the fact that 38 nines (about 9.99e37) fit under i128::MAX (about 1.7e38). The reasoning is correct, and leading zeros are handled correctly because zeros in the head accumulate to 0 and the tail uses checked_mul/checked_add. But this boundary is exactly where a future edit could regress, and the current Scala fuzz tests (CometCastSuite.scala:958-961) cap the generated digit count at 38, so they never exercise a 39-plus-digit integral part.

Suggested change: add a Rust unit test in the #[cfg(test)] block of string.rs asserting parse results for a 38-digit value (parses), a 39-digit value (overflows to the invalid_decimal_cast error / NULL in non-ANSI), and a 40-digit value with leading zeros that reduce to a small value (parses). This locks the boundary that the optimization now depends on.


/// `10^exp`, using the precomputed table for the range that fits in an `i128`.
#[inline]
fn pow10_i128(exp: u32) -> i128 {

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.

pow10_i128 falls back to 10_i128.pow(exp) on a table miss (exp >= 39). Two of the three call sites are guarded by scale_adjustment > 38 / abs_scale_adjustment > 38 checks, so they never miss. The checked_mul(pow10_i128(fractional_scale as u32)) combine step at the end of parse_decimal_str, however, is not bounded: fractional_scale is fractional_part.len(), so an input like "0." + "0"*40 calls pow10_i128(40), misses the table, and evaluates 10_i128.pow(40), which panics in debug and wraps in release. This is identical to the old code (10_i128.pow(fractional_scale as u32)), so the PR introduces no regression, but the new named helper is the natural place to make the fallback total.

Suggested change: make pow10_i128 return Option<i128> (or add a checked_pow10 variant) and thread it through the combine step so an over-long fraction produces the invalid_decimal_cast error path instead of a panic. If you would rather keep this PR scoped to performance, leave a one-line comment on pow10_i128 noting that callers must keep exp <= 38 and that the 10_i128.pow fallback is unreachable in current callers, so it is not silently masking a bug.

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