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 4cfd0739c3 perf: vectorize integer-to-decimal cast (#4939)
4cfd0739c3 is described below

commit 4cfd0739c353df378d2d0a1d4fcb4d6f5dd987e7
Author: Andy Grove <[email protected]>
AuthorDate: Wed Aug 5 06:44:11 2026 -0600

    perf: vectorize integer-to-decimal cast (#4939)
    
    * perf: vectorize integer-to-decimal cast
    
    cast_int_to_decimal128_internal used a per-element Decimal128Builder loop 
with a
    null check and branch per row. Replace it with a single vectorized unary_opt
    pass: a value that overflows the multiply or does not fit the output 
precision
    maps to null, and the input null buffer is carried over.
    
    ANSI mode must raise on out-of-range values rather than nulling them. 
unary_opt
    only nulls non-null inputs that overflow, so a null count beyond the input's
    signals an overflow; that O(1) check gates a rare element-wise rescan that
    reports the first offending value with Spark's exact error.
    
    This is a single pass for every eval mode and shape, so all shapes are 
faster
    (28-50%) with no regression, including the overflow case. Add unit tests 
for the
    legacy null-on-overflow, ANSI no-overflow, and ANSI overflow-error paths, 
plus a
    criterion benchmark.
    
    Part of #4936.
    
    * docs: record integer-to-decimal cast performance audit entry
    
    * refactor: share fit-at-precision predicate; test EvalMode::Try; fix 
outdated comment
    
    Extracts the 'multiply-then-fits-target-precision' predicate into a single 
'fits' closure so the vectorized pass and the ANSI rescan cannot drift out of 
sync. Adds test_cast_int_to_decimal128_overflow_try_nulls asserting 
EvalMode::Try shares the null-on-overflow branch. Replaces the outdated 
'sentinel + masking path' comment on the Legacy test with an accurate 
description of the null-on-overflow path.
    
    * test: pin ANSI overflow error variant and first-offending-value scan order
    
    test_cast_int_to_decimal128_overflow_ansi_errors asserted only is_err(), so 
a
    refactor that scanned in reverse or returned a different variant would still
    pass. Match on SparkError::NumericValueOutOfRange with its precision, scale 
and
    value, and add a second overflowing value after the first so the assertion 
also
    pins the documented 'reports the first offending value' behavior.
---
 .../expression-audits/conversion_funcs.md          |   1 +
 native/spark-expr/Cargo.toml                       |   3 +
 native/spark-expr/benches/cast_int_to_decimal.rs   |  98 +++++++++++++
 native/spark-expr/src/conversion_funcs/numeric.rs  | 159 ++++++++++++++++-----
 4 files changed, 222 insertions(+), 39 deletions(-)

diff --git 
a/docs/source/contributor-guide/expression-audits/conversion_funcs.md 
b/docs/source/contributor-guide/expression-audits/conversion_funcs.md
index 64e01822dc..ea9a9bf737 100644
--- a/docs/source/contributor-guide/expression-audits/conversion_funcs.md
+++ b/docs/source/contributor-guide/expression-audits/conversion_funcs.md
@@ -37,5 +37,6 @@
 - Spark registers the type-name conversion functions (`bigint`, `binary`, 
`boolean`, `date`, `decimal`, `double`, `float`, `int`, `smallint`, `string`, 
`timestamp`, `tinyint`) as cast aliases. Each lowers to the same `Cast` node, 
so Comet handles it via the `cast` implementation with the same compatibility 
profile.
 - Performance (tuned 2026-07-14, PR 
[#4920](https://github.com/apache/datafusion-comet/pull/4920)): narrowing 
integer casts (`spark_cast_int_to_int`) map the values buffer in a single pass 
with Arrow `unary`/`try_unary` and carry the null buffer over untouched, 
replacing an element-by-element `Option`/`Result` iterator-collect. Up to 100x 
faster on narrowing casts. Benchmark: `benches/cast_numeric.rs`.
 - Performance (tuned 2026-07-15, PR 
[#4940](https://github.com/apache/datafusion-comet/pull/4940)): 
float/double-to-decimal casts (`cast_floating_point_to_decimal128`) now convert 
in a single vectorized `unary_opt` pass that maps out-of-range values (NaN, 
infinity, precision overflow) to null, replacing the per-element 
`Decimal128Builder` loop. ANSI raises via an O(1) null-count check plus a rare 
element-wise rescan. 15-36% faster with no regression on any shape. Benchmark: 
`benches/cast [...]
+- Performance (tuned 2026-07-15, PR 
[#4939](https://github.com/apache/datafusion-comet/pull/4939)): 
integer-to-decimal casts (`cast_int_to_decimal128_internal`) now convert in a 
single vectorized `unary_opt` pass that maps overflowing values to null, 
replacing the per-element `Decimal128Builder` loop. ANSI raises via an O(1) 
null-count check plus a rare element-wise rescan. 28-50% faster with no 
regression on any shape. Benchmark: `benches/cast_int_to_decimal.rs`.
 
 [Spark Expression Support]: ../../user-guide/latest/expressions.md
diff --git a/native/spark-expr/Cargo.toml b/native/spark-expr/Cargo.toml
index b85153a59d..cd7cd89849 100644
--- a/native/spark-expr/Cargo.toml
+++ b/native/spark-expr/Cargo.toml
@@ -215,3 +215,6 @@ harness = false
 name = "unscaled_value"
 harness = false
 
+[[bench]]
+name = "cast_int_to_decimal"
+harness = false
\ No newline at end of file
diff --git a/native/spark-expr/benches/cast_int_to_decimal.rs 
b/native/spark-expr/benches/cast_int_to_decimal.rs
new file mode 100644
index 0000000000..8949c5c327
--- /dev/null
+++ b/native/spark-expr/benches/cast_int_to_decimal.rs
@@ -0,0 +1,98 @@
+// 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::{Int32Array, Int64Array, 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::hint::black_box;
+use std::sync::Arc;
+
+fn i32_batch(size: usize, null_every: usize) -> RecordBatch {
+    let a: Int32Array = (0..size)
+        .map(|i| {
+            if null_every != 0 && i % null_every == 0 {
+                None
+            } else {
+                Some((i as i32) % 100_000)
+            }
+        })
+        .collect();
+    let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, 
true)]));
+    RecordBatch::try_new(schema, vec![Arc::new(a)]).unwrap()
+}
+
+fn i64_batch(size: usize, big: bool) -> RecordBatch {
+    let a: Int64Array = (0..size)
+        .map(|i| {
+            if big {
+                // Large enough that value * 10^4 overflows Decimal128(15, 4).
+                Some(1_000_000_000_000_000_i64 + i as i64)
+            } else {
+                Some((i as i64) % 100_000)
+            }
+        })
+        .collect();
+    let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int64, 
true)]));
+    RecordBatch::try_new(schema, vec![Arc::new(a)]).unwrap()
+}
+
+fn cast(col: &str, to: DataType, mode: EvalMode) -> Cast {
+    Cast::new(
+        Arc::new(Column::new(col, 0)),
+        to,
+        SparkCastOptions::new_without_timezone(mode, false),
+        None,
+        None,
+    )
+}
+
+fn criterion_benchmark(c: &mut Criterion) {
+    let size = 8192;
+    let dec_15_4 = DataType::Decimal128(15, 4);
+    let dec_38_4 = DataType::Decimal128(38, 4);
+
+    let i32_no_nulls = i32_batch(size, 0);
+    let i32_nulls = i32_batch(size, 10);
+    let i64_small = i64_batch(size, false);
+    let i64_big = i64_batch(size, true);
+
+    let c_i32 = cast("a", dec_15_4.clone(), EvalMode::Legacy);
+    let c_i32_ansi = cast("a", dec_15_4.clone(), EvalMode::Ansi);
+    let c_i64 = cast("a", dec_38_4, EvalMode::Legacy);
+    let c_i64_overflow = cast("a", dec_15_4, EvalMode::Legacy);
+
+    c.bench_function("cast_int_to_decimal: i32 -> dec(15,4)", |b| {
+        b.iter(|| black_box(c_i32.evaluate(black_box(&i32_no_nulls)).unwrap()))
+    });
+    c.bench_function("cast_int_to_decimal: i32 -> dec(15,4), nulls", |b| {
+        b.iter(|| black_box(c_i32.evaluate(black_box(&i32_nulls)).unwrap()))
+    });
+    c.bench_function("cast_int_to_decimal: i64 -> dec(38,4)", |b| {
+        b.iter(|| black_box(c_i64.evaluate(black_box(&i64_small)).unwrap()))
+    });
+    c.bench_function("cast_int_to_decimal: i32 -> dec(15,4) ansi", |b| {
+        b.iter(|| 
black_box(c_i32_ansi.evaluate(black_box(&i32_no_nulls)).unwrap()))
+    });
+    c.bench_function("cast_int_to_decimal: i64 -> dec(15,4) overflow", |b| {
+        b.iter(|| 
black_box(c_i64_overflow.evaluate(black_box(&i64_big)).unwrap()))
+    });
+}
+
+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 90fc5ffc5b..029a38e3e7 100644
--- a/native/spark-expr/src/conversion_funcs/numeric.rs
+++ b/native/spark-expr/src/conversion_funcs/numeric.rs
@@ -20,9 +20,9 @@ use crate::conversion_funcs::utils::cast_overflow;
 use crate::conversion_funcs::utils::MICROS_PER_SECOND;
 use crate::{EvalMode, SparkError, SparkResult};
 use arrow::array::{
-    Array, ArrayRef, AsArray, BooleanBuilder, Decimal128Array, 
Decimal128Builder, Float32Array,
-    Float64Array, GenericStringBuilder, Int16Array, Int32Array, Int64Array, 
Int8Array,
-    OffsetSizeTrait, PrimitiveArray, StringBuilder, 
TimestampMicrosecondBuilder,
+    Array, ArrayRef, AsArray, BooleanBuilder, Decimal128Array, Float32Array, 
Float64Array,
+    GenericStringBuilder, Int16Array, Int32Array, Int64Array, Int8Array, 
OffsetSizeTrait,
+    PrimitiveArray, StringBuilder, TimestampMicrosecondBuilder,
 };
 use arrow::datatypes::{
     i256, is_validate_decimal_precision, ArrowPrimitiveType, DataType, 
Decimal128Type, Float32Type,
@@ -734,48 +734,40 @@ where
     T: ArrowPrimitiveType,
     T::Native: Into<i128>,
 {
-    let mut builder = Decimal128Builder::with_capacity(array.len());
     let multiplier = 10_i128.pow(scale as u32);
 
-    for i in 0..array.len() {
-        if array.is_null(i) {
-            builder.append_null();
-        } else {
-            let v = array.value(i).into();
-            let scaled = v.checked_mul(multiplier);
-            match scaled {
-                Some(scaled) => {
-                    if !is_validate_decimal_precision(scaled, precision) {
-                        match eval_mode {
-                            EvalMode::Ansi => {
-                                return Err(SparkError::NumericValueOutOfRange {
-                                    value: v.to_string(),
-                                    precision,
-                                    scale,
-                                });
-                            }
-                            EvalMode::Try | EvalMode::Legacy => 
builder.append_null(),
-                        }
-                    } else {
-                        builder.append_value(scaled);
-                    }
+    // Single spelling of the "does this value fit at the target precision 
after scaling" check,
+    // shared between the vectorized pass and the ANSI rescan below.
+    let fits = |v: i128| -> Option<i128> {
+        v.checked_mul(multiplier)
+            .filter(|scaled| is_validate_decimal_precision(*scaled, precision))
+    };
+
+    // Single vectorized pass: a value that overflows the multiply or does not 
fit the output
+    // precision maps to null. `unary_opt` only applies the closure to 
non-null slots and carries
+    // the input null buffer over, replacing the per-element builder loop 
without a second pass.
+    let result: Decimal128Array = array.unary_opt::<_, Decimal128Type>(|v| 
fits(v.into()));
+
+    // ANSI must raise on out-of-range values instead of nulling them. 
`unary_opt` only nulls
+    // non-null inputs that overflow, so a null count beyond the input's 
signals an overflow to
+    // report. This check is O(1); the element-wise rescan runs only on the 
rare error path and
+    // reports the first offending value with Spark's exact error.
+    if eval_mode == EvalMode::Ansi && result.null_count() > array.null_count() 
{
+        for i in 0..array.len() {
+            if !array.is_null(i) {
+                let v: i128 = array.value(i).into();
+                if fits(v).is_none() {
+                    return Err(SparkError::NumericValueOutOfRange {
+                        value: v.to_string(),
+                        precision,
+                        scale,
+                    });
                 }
-                _ => match eval_mode {
-                    EvalMode::Ansi => {
-                        return Err(SparkError::NumericValueOutOfRange {
-                            value: v.to_string(),
-                            precision,
-                            scale,
-                        })
-                    }
-                    EvalMode::Legacy | EvalMode::Try => builder.append_null(),
-                },
             }
         }
     }
-    Ok(Arc::new(
-        builder.with_precision_and_scale(precision, scale)?.finish(),
-    ))
+
+    Ok(Arc::new(result.with_precision_and_scale(precision, scale)?))
 }
 
 pub(crate) fn cast_int_to_decimal128(
@@ -1322,6 +1314,95 @@ mod tests {
         assert_eq!(decimal_array.value(1), -10000); // -100 * 10^2
         assert!(decimal_array.is_null(2));
     }
+
+    #[test]
+    fn test_cast_int_to_decimal128_overflow_legacy_nulls() {
+        // 1000 * 10^2 = 100000 does not fit precision 3 -> null (legacy). 
Valid values and the
+        // input null are preserved, exercising the vectorized 
null-on-overflow path.
+        let array: ArrayRef = Arc::new(Int32Array::from(vec![Some(9), 
Some(1000), None, Some(-9)]));
+        let result = cast_int_to_decimal128(
+            &array,
+            EvalMode::Legacy,
+            &DataType::Int32,
+            &DataType::Decimal128(3, 2),
+            3,
+            2,
+        )
+        .unwrap();
+        let d = result.as_primitive::<Decimal128Type>();
+        assert_eq!(d.value(0), 900); // 9.00
+        assert!(d.is_null(1)); // overflow -> null
+        assert!(d.is_null(2)); // input null preserved
+        assert_eq!(d.value(3), -900);
+        assert_eq!(d.data_type(), &DataType::Decimal128(3, 2));
+    }
+
+    #[test]
+    fn test_cast_int_to_decimal128_overflow_try_nulls() {
+        // Try shares the Legacy null-on-overflow branch but is a distinct 
enum arm; assert it
+        // explicitly so a future refactor cannot regress it silently.
+        let array: ArrayRef = Arc::new(Int32Array::from(vec![Some(9), 
Some(1000), None, Some(-9)]));
+        let result = cast_int_to_decimal128(
+            &array,
+            EvalMode::Try,
+            &DataType::Int32,
+            &DataType::Decimal128(3, 2),
+            3,
+            2,
+        )
+        .unwrap();
+        let d = result.as_primitive::<Decimal128Type>();
+        assert_eq!(d.value(0), 900);
+        assert!(d.is_null(1));
+        assert!(d.is_null(2));
+        assert_eq!(d.value(3), -900);
+    }
+
+    #[test]
+    fn test_cast_int_to_decimal128_no_overflow_ansi() {
+        let array: ArrayRef = Arc::new(Int32Array::from(vec![Some(9), None, 
Some(-9)]));
+        let result = cast_int_to_decimal128(
+            &array,
+            EvalMode::Ansi,
+            &DataType::Int32,
+            &DataType::Decimal128(3, 2),
+            3,
+            2,
+        )
+        .unwrap();
+        let d = result.as_primitive::<Decimal128Type>();
+        assert_eq!(d.value(0), 900);
+        assert!(d.is_null(1));
+        assert_eq!(d.value(2), -900);
+    }
+
+    #[test]
+    fn test_cast_int_to_decimal128_overflow_ansi_errors() {
+        // Two overflowing values: the rescan is documented to report the 
first one, so asserting
+        // on 1000 rather than 2000 pins the scan order as well as the error 
variant and payload.
+        let array: ArrayRef = Arc::new(Int32Array::from(vec![Some(9), 
Some(1000), Some(2000)]));
+        let result = cast_int_to_decimal128(
+            &array,
+            EvalMode::Ansi,
+            &DataType::Int32,
+            &DataType::Decimal128(3, 2),
+            3,
+            2,
+        );
+        let err = result.unwrap_err();
+        assert!(
+            matches!(
+                err,
+                SparkError::NumericValueOutOfRange {
+                    ref value,
+                    precision: 3,
+                    scale: 2
+                } if value == "1000"
+            ),
+            "unexpected error: {err:?}"
+        );
+    }
+
     #[test]
     fn test_cast_int_to_timestamp() {
         let timezones: [Option<Arc<str>>; 6] = [


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

Reply via email to