This is an automated email from the ASF dual-hosted git repository.

andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/main by this push:
     new 52140f3930 perf: optimize cast_decimal128_to_utf8 in 
datafusion-comet-spark-expr (#4924)
52140f3930 is described below

commit 52140f3930e7e11504d5df893a82c2adb08c406b
Author: Andy Grove <[email protected]>
AuthorDate: Wed Jul 22 15:01:16 2026 -0600

    perf: optimize cast_decimal128_to_utf8 in datafusion-comet-spark-expr 
(#4924)
    
    * perf: optimize cast_decimal128_to_utf8 in datafusion-comet-spark-expr
    
    * test: cover multi-group leading zeros and signed scientific coefficients
    
    Adds three assertions to test_decimal128_to_java_string: 10^19 + 5 to 
exercise the render_digits leading-zeros branch on a lower group; i128::MAX at 
scale 45 to hit the scientific-notation coefficient split on a two-group value; 
and its negation to exercise the sign push on the same shape.
---
 native/spark-expr/Cargo.toml                       |   4 +
 .../spark-expr/benches/cast_decimal_to_string.rs   |  83 ++++++++++++++
 native/spark-expr/src/conversion_funcs/numeric.rs  | 124 +++++++++++++++++----
 3 files changed, 187 insertions(+), 24 deletions(-)

diff --git a/native/spark-expr/Cargo.toml b/native/spark-expr/Cargo.toml
index 781c857cfe..5565933488 100644
--- a/native/spark-expr/Cargo.toml
+++ b/native/spark-expr/Cargo.toml
@@ -163,6 +163,10 @@ harness = false
 name = "to_json"
 harness = false
 
+[[bench]]
+name = "cast_decimal_to_string"
+harness = false
+
 [[bench]]
 name = "cast_float_to_decimal"
 harness = false
diff --git a/native/spark-expr/benches/cast_decimal_to_string.rs 
b/native/spark-expr/benches/cast_decimal_to_string.rs
new file mode 100644
index 0000000000..d0629300a9
--- /dev/null
+++ b/native/spark-expr/benches/cast_decimal_to_string.rs
@@ -0,0 +1,83 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use arrow::array::{Decimal128Array, RecordBatch};
+use arrow::datatypes::{DataType, Field, Schema};
+use criterion::{criterion_group, criterion_main, Criterion};
+use datafusion::physical_expr::{expressions::Column, PhysicalExpr};
+use datafusion_comet_spark_expr::{Cast, EvalMode, SparkCastOptions};
+use std::sync::Arc;
+
+const NUM_ROWS: usize = 4096;
+
+fn create_decimal_batch(precision: u8, scale: i8, unscaled: impl Fn(usize) -> 
i128) -> RecordBatch {
+    let values: Vec<Option<i128>> = (0..NUM_ROWS)
+        .map(|i| if i % 10 == 9 { None } else { Some(unscaled(i)) })
+        .collect();
+    let array = Decimal128Array::from(values)
+        .with_precision_and_scale(precision, scale)
+        .unwrap();
+    let schema = Arc::new(Schema::new(vec![Field::new(
+        "a",
+        DataType::Decimal128(precision, scale),
+        true,
+    )]));
+    RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap()
+}
+
+fn cast_to_utf8() -> Cast {
+    Cast::new(
+        Arc::new(Column::new("a", 0)),
+        DataType::Utf8,
+        SparkCastOptions::new_without_timezone(EvalMode::Legacy, false),
+        None,
+        None,
+    )
+}
+
+fn criterion_benchmark(c: &mut Criterion) {
+    // plain notation, fraction shorter than the integer part
+    let small_scale = create_decimal_batch(18, 2, |i| (i as i128 * 7919) - 
1_000_000);
+    // plain notation, leading zeroes in the fraction
+    let leading_zeroes = create_decimal_batch(18, 6, |i| (i as i128 % 97) + 1);
+    // wide coefficients spanning more than one 64-bit digit group
+    let wide = create_decimal_batch(38, 10, |i| {
+        i128::from(i as i64 + 1) * 1_234_567_890_123_456_789i128 * 
1_000_000_007i128
+    });
+    // scientific notation (adjusted exponent < -6)
+    let scientific = create_decimal_batch(38, 30, |i| (i as i128 % 13) + 1);
+
+    let cast = cast_to_utf8();
+
+    let mut group = c.benchmark_group("cast_decimal_to_string");
+    group.bench_function("decimal128_scale_2", |b| {
+        b.iter(|| cast.evaluate(&small_scale).unwrap());
+    });
+    group.bench_function("decimal128_leading_zeroes", |b| {
+        b.iter(|| cast.evaluate(&leading_zeroes).unwrap());
+    });
+    group.bench_function("decimal128_wide", |b| {
+        b.iter(|| cast.evaluate(&wide).unwrap());
+    });
+    group.bench_function("decimal128_scientific", |b| {
+        b.iter(|| cast.evaluate(&scientific).unwrap());
+    });
+    group.finish();
+}
+
+criterion_group!(benches, criterion_benchmark);
+criterion_main!(benches);
diff --git a/native/spark-expr/src/conversion_funcs/numeric.rs 
b/native/spark-expr/src/conversion_funcs/numeric.rs
index eff8ce6efa..b8458450dd 100644
--- a/native/spark-expr/src/conversion_funcs/numeric.rs
+++ b/native/spark-expr/src/conversion_funcs/numeric.rs
@@ -579,14 +579,15 @@ pub(crate) fn cast_decimal128_to_utf8(array: &ArrayRef, 
scale: i8) -> SparkResul
         .downcast_ref::<Decimal128Array>()
         .expect("Expected a Decimal128Array");
     let mut builder = StringBuilder::with_capacity(decimal_array.len(), 
decimal_array.len() * 16);
-    // Reuse a single String buffer across rows to avoid one allocation per 
value.
-    let mut buf = String::with_capacity(40);
+    // Reuse the output and digit buffers across rows to keep the loop 
allocation-free.
+    let mut buf = String::with_capacity(48);
+    let mut digits = [0u8; MAX_COEFF_DIGITS];
     for opt_val in decimal_array.iter() {
         match opt_val {
             None => builder.append_null(),
             Some(unscaled) => {
                 buf.clear();
-                decimal128_to_java_string(unscaled, scale, &mut buf);
+                decimal128_to_java_string(unscaled, scale, &mut digits, &mut 
buf);
                 builder.append_value(&buf);
             }
         }
@@ -594,42 +595,95 @@ pub(crate) fn cast_decimal128_to_utf8(array: &ArrayRef, 
scale: i8) -> SparkResul
     Ok(Arc::new(builder.finish()))
 }
 
+/// The largest power of ten that fits in a `u64`.
+const POW10_19: u128 = 10_000_000_000_000_000_000;
+
+/// Digits in the largest `u128`, which bounds the coefficient of any 
Decimal128.
+const MAX_COEFF_DIGITS: usize = 39;
+
+/// Renders the base-10 digits of `value` into `buf`, returning them without 
leading zeroes
+/// (except for `value == 0`, which renders as `"0"`).
+fn render_digits(mut value: u128, buf: &mut [u8; MAX_COEFF_DIGITS]) -> &str {
+    let mut pos = buf.len();
+
+    // Peel off 19 digits at a time so that all but the final group is 
produced with 64-bit
+    // arithmetic, which is much cheaper than repeated 128-bit division. Every 
group taken here
+    // has a non-zero quotient above it, so emitting its leading zeroes is 
correct.
+    while value >= POW10_19 {
+        let mut group = (value % POW10_19) as u64;
+        value /= POW10_19;
+        for _ in 0..19 {
+            pos -= 1;
+            buf[pos] = b'0' + (group % 10) as u8;
+            group /= 10;
+        }
+    }
+
+    let mut rest = value as u64;
+    loop {
+        pos -= 1;
+        buf[pos] = b'0' + (rest % 10) as u8;
+        rest /= 10;
+        if rest == 0 {
+            break;
+        }
+    }
+
+    // Only ASCII digits were written above.
+    std::str::from_utf8(&buf[pos..]).expect("ascii digits")
+}
+
 /// Formats a Decimal128 unscaled value into `out` matching Java's 
BigDecimal.toString():
 /// - Plain notation when scale >= 0 and adjusted_exponent >= -6
 /// - Scientific notation otherwise
 ///
 /// adjusted_exponent = -scale + (numDigits - 1)
-fn decimal128_to_java_string(unscaled: i128, scale: i8, out: &mut String) {
+///
+/// `digits` is scratch space for the coefficient; the caller reuses one 
buffer across rows.
+fn decimal128_to_java_string(
+    unscaled: i128,
+    scale: i8,
+    digits: &mut [u8; MAX_COEFF_DIGITS],
+    out: &mut String,
+) {
     use std::fmt::Write;
-    let negative = unscaled < 0;
-    let sign = if negative { "-" } else { "" };
-    let coeff = unscaled.unsigned_abs().to_string();
-    let num_digits = coeff.len() as i64;
-    let adj_exp = -(scale as i64) + (num_digits - 1);
+    let coeff = render_digits(unscaled.unsigned_abs(), digits);
+    let num_digits = coeff.len();
+    let adj_exp = -(scale as i64) + (num_digits as i64 - 1);
+
+    if unscaled < 0 {
+        out.push('-');
+    }
 
     if scale >= 0 && adj_exp >= -6 {
-        let scale_u = scale as usize;
-        let num_digits_u = num_digits as usize;
-        if scale_u == 0 {
-            write!(out, "{sign}{coeff}").unwrap();
-        } else if num_digits_u > scale_u {
-            let (int_part, frac_part) = coeff.split_at(num_digits_u - scale_u);
-            write!(out, "{sign}{int_part}.{frac_part}").unwrap();
+        let scale = scale as usize;
+        if scale == 0 {
+            out.push_str(coeff);
+        } else if num_digits > scale {
+            let (int_part, frac_part) = coeff.split_at(num_digits - scale);
+            out.push_str(int_part);
+            out.push('.');
+            out.push_str(frac_part);
         } else {
-            let leading = scale_u - num_digits_u;
-            write!(out, "{sign}0.{}{coeff}", "0".repeat(leading)).unwrap();
+            out.push_str("0.");
+            for _ in 0..scale - num_digits {
+                out.push('0');
+            }
+            out.push_str(coeff);
         }
     } else {
         if num_digits > 1 {
-            write!(out, "{sign}{}.{}", &coeff[..1], &coeff[1..]).unwrap();
+            out.push_str(&coeff[..1]);
+            out.push('.');
+            out.push_str(&coeff[1..]);
         } else {
-            write!(out, "{sign}{coeff}").unwrap();
+            out.push_str(coeff);
         }
+        out.push('E');
         if adj_exp > 0 {
-            write!(out, "E+{adj_exp}").unwrap();
-        } else {
-            write!(out, "E{adj_exp}").unwrap();
+            out.push('+');
         }
+        write!(out, "{adj_exp}").unwrap();
     }
 }
 
@@ -1492,7 +1546,7 @@ mod tests {
     fn test_decimal128_to_java_string() {
         fn fmt(unscaled: i128, scale: i8) -> String {
             let mut buf = String::new();
-            decimal128_to_java_string(unscaled, scale, &mut buf);
+            decimal128_to_java_string(unscaled, scale, &mut [0u8; 
MAX_COEFF_DIGITS], &mut buf);
             buf
         }
         // scale >= 0, adj_exp >= -6 → plain notation
@@ -1516,5 +1570,27 @@ mod tests {
         assert_eq!(fmt(1, -2), "1E+2");
         assert_eq!(fmt(123, -2), "1.23E+4");
         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");
+        // 10^19 + 5: the lower group is 0000000000000000005 and its leading 
zeros are load-bearing
+        // (the render_digits comment "emitting its leading zeroes is correct" 
applies here).
+        assert_eq!(fmt(10_000_000_000_000_000_005, 0), "10000000000000000005");
+        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"
+        );
+        // multi-group coefficient in scientific notation, both signs, so the 
sign push and
+        // the &coeff[..1] / &coeff[1..] split are covered on a value that 
spans two groups.
+        assert_eq!(
+            fmt(i128::MAX, 45),
+            "1.70141183460469231731687303715884105727E-7"
+        );
+        assert_eq!(
+            fmt(-i128::MAX, 45),
+            "-1.70141183460469231731687303715884105727E-7"
+        );
     }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to