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

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-11226-d4edc6846e1b7176fe22ee5f309358f7330f4fcc
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git

commit dde917c40491f55462963900b6934d92d3dae42c
Author: Neil Conway <[email protected]>
AuthorDate: Sun Oct 4 09:05:13 2026 +0000

    perf: Optimize int -> decimal cast (#11226)
    
    # Which issue does this PR close?
    
    - N/A
    
    # Rationale for this change
    
    Casting integers to decimals currently uses `unary_opt` (in safe mode)
    or `try_unary` (otherwise). This is relatively slow, because neither
    kernel vectorizes well.
    
    In practice, `int` -> `decimal` casts are frequently infallible, either
    because the target type can accommodate any integer, or because the
    target type is chosen to be large enough to accommodate all inputs in
    the source data. We can exploit this by scanning the source data to
    check if all of the values will fit into the target type. If so, we can
    use `unary` to do the cast. Empirically, the cost of doing the scan is
    much smaller than the win from using the faster kernel (see benchmarks
    below).
    
    To make checking for out-of-range values efficient, we use a few
    techniques:
    
    1. Avoid scanning entirely if the target type is large enough to hold
    any value of the input type
    2. Check for out-of-range values with a branch-less scan, so it
    vectorizes well
    3. Use chunking and early-stopping: for many input distributions, if
    there is an out of range value it will likely appear in the first few
    chunks, so we don't need to scan the entire input anyway.
    
    Benchmarks: (512 rows, M4 Max)
    
      - cast int64 to decimal128(38, 10): 994.360 → 300.580 ns, −69.8%
    - cast int64 within 1e6 to decimal128(18, 2): 989.400 → 315.160 ns,
    −68.1%
    - cast int64 within 1e6 late overflow to decimal128(18, 2): 992.870 →
    1060.300 ns, +6.8%
      - cast int64 to decimal32(9, 0): 738.400 → 781.610 ns, +5.9%
      - cast int64 to decimal32(9, -1): 957.480 → 991.820 ns, +3.6%
    
    In the first case, no input scan is needed because the target type is
    large enough to hold any `int64`. In the second case, we need to scan
    the inputs to establish that no out-of-range value is found. The third
    benchmark is the worst-case, where an out-of-range value is found at the
    end of the input. The last two cases are existing benchmarks where an
    out-of-range value is found early in the scan.
    
    # What changes are included in this PR?
    
    * Add `integers_fit_digits`, which does a chunked, branch-free scan to
    check for out-of-range values
    * Use `unary` in `cast_integer_to_decimal`, when the cast is infallible
    * Add unit test
    * Extend benchmarks
    
    # Are these changes tested?
    
    Yes; existing tests pass and new test added.
    
    # Are there any user-facing changes?
    
    No.
    
    Co-authored-by: Jeffrey Vo <[email protected]>
---
 arrow-cast/src/cast/mod.rs    | 123 ++++++++++++++++++++++++++++++++++++++++--
 arrow/benches/cast_kernels.rs |  15 ++++++
 2 files changed, 133 insertions(+), 5 deletions(-)

diff --git a/arrow-cast/src/cast/mod.rs b/arrow-cast/src/cast/mod.rs
index 3d7fb88c79..f008bbcae4 100644
--- a/arrow-cast/src/cast/mod.rs
+++ b/arrow-cast/src/cast/mod.rs
@@ -362,6 +362,34 @@ where
     M::from_decimal(value.into())
 }
 
+/// Returns true if every value of `array` has at most `digits` decimal digits.
+///
+/// If the source type cannot hold that many digits, every value fits;
+/// otherwise the data is scanned, including null slots, since `unary`
+/// converts those as well.
+fn integers_fit_digits<T>(array: &PrimitiveArray<T>, digits: u32) -> bool
+where
+    T: ArrowPrimitiveType,
+    T::Native: ArrowNativeTypeOp,
+{
+    let Ok(bound) = T::Native::usize_as(10).pow_checked(digits) else {
+        // The type's maximum is below the bound, and its minimum, one larger
+        // in magnitude, is a power of two that no power of ten can equal.
+        return true;
+    };
+    let hi = bound.sub_wrapping(T::Native::ONE);
+    // Unsigned types have no negative values to bound.
+    let lo = T::Native::ZERO.sub_checked(hi).unwrap_or(T::Native::ZERO);
+
+    // The scan works in chunks, so an array that does not fit is rejected 
after
+    // its first failing chunk. Each chunk is checked without branching, so it
+    // vectorizes.
+    array
+        .values()
+        .chunks(64)
+        .all(|chunk| chunk.iter().fold(true, |ok, &v| ok & (v >= lo) & (v <= 
hi)))
+}
+
 fn cast_integer_to_decimal<
     T: ArrowPrimitiveType,
     D: DecimalType + ArrowPrimitiveType<Native = M>,
@@ -384,6 +412,23 @@ where
         ))
     };
 
+    // An invalid precision or scale would let the bound below admit values the
+    // decimal native type cannot hold, so reject it before choosing a kernel.
+    validate_decimal_precision_and_scale::<D>(precision, scale)?;
+
+    // Scan for values that don't fit in the target type before choosing a
+    // kernel: in the common case that all values fit, the cast is infallible
+    // and we can use the `unary` kernel, which is very fast. Empirically, the
+    // cost of doing the out-of-range scan is small relative to the win from
+    // using a faster kernel.
+    //
+    // A value fits when it has at most `precision - scale` decimal digits: a
+    // nonnegative scale appends that many trailing zeros, and a negative scale
+    // removes digits first. Validation guarantees the difference is not
+    // negative.
+    let digits = (precision as i32 - scale as i32) as u32;
+    let all_fit = integers_fit_digits(array, digits);
+
     let array = if scale < 0 {
         // Compute the scale factor once in the source type. Scaling before the
         // checked conversion permits values that only fit the decimal native
@@ -393,6 +438,10 @@ where
             .ok();
 
         match (scale_factor, cast_options.safe) {
+            (Some(scale_factor), _) if all_fit => array.unary::<_, D>(|v| {
+                integer_to_decimal_native::<_, M>(v.div_wrapping(scale_factor))
+                    .expect("value fits the decimal")
+            }),
             (Some(scale_factor), true) => array.unary_opt::<_, D>(|v| {
                 let v = v
                     .div_checked(scale_factor)
@@ -424,18 +473,25 @@ where
             ))
         })?;
 
-        match cast_options.safe {
-            true => array.unary_opt::<_, D>(|v| {
+        if all_fit {
+            array.unary::<_, D>(|v| {
+                integer_to_decimal_native::<_, M>(v)
+                    .expect("value fits the decimal")
+                    .mul_wrapping(scale_factor)
+            })
+        } else if cast_options.safe {
+            array.unary_opt::<_, D>(|v| {
                 let v = integer_to_decimal_native::<_, M>(v)
                     .and_then(|v| v.mul_checked(scale_factor).ok())?;
                 (D::is_valid_decimal_precision(v, precision)).then_some(v)
-            }),
-            false => array.try_unary::<_, D, _>(|v| {
+            })
+        } else {
+            array.try_unary::<_, D, _>(|v| {
                 let v = integer_to_decimal_native::<_, M>(v)
                     .ok_or_else(|| overflow(v))
                     .and_then(|v| v.mul_checked(scale_factor))?;
                 D::validate_decimal_precision(v, precision, scale).map(|()| v)
-            })?,
+            })?
         }
     };
 
@@ -11048,6 +11104,63 @@ mod tests {
         );
     }
 
+    /// Casts that cannot overflow, whether the source type or the data 
decides,
+    /// must match the checked conversion at the extremes, and a value beyond 
the
+    /// bound must still become null or an error.
+    #[test]
+    fn test_cast_integer_to_decimal_bounds() {
+        // Every i32 has at most ten digits, so the type decides.
+        let array = Int32Array::from(vec![Some(i32::MIN), None, Some(-1), 
Some(i32::MAX)]);
+        generate_cast_test_case!(
+            &array,
+            Decimal128Array,
+            &DataType::Decimal128(12, 2),
+            vec![
+                Some(i32::MIN as i128 * 100),
+                None,
+                Some(-100),
+                Some(i32::MAX as i128 * 100)
+            ]
+        );
+        // An i64 can exceed sixteen digits, so the data decides.
+        let bound = 9_999_999_999_999_999_i64;
+        let array = Int64Array::from(vec![Some(-bound), None, Some(bound)]);
+        generate_cast_test_case!(
+            &array,
+            Decimal128Array,
+            &DataType::Decimal128(18, 2),
+            vec![Some(-bound as i128 * 100), None, Some(bound as i128 * 100)]
+        );
+        // A negative scale divides first, truncating toward zero.
+        let array = Int32Array::from(vec![Some(-1_234_567), Some(9_999_999)]);
+        generate_cast_test_case!(
+            &array,
+            Decimal32Array,
+            &DataType::Decimal32(5, -2),
+            vec![Some(-12_345), Some(99_999)]
+        );
+        // One value beyond the bound sends the whole array down the checked 
path.
+        let array = Int64Array::from(vec![Some(bound + 1), Some(1)]);
+        let result = cast(&array, &DataType::Decimal128(18, 2)).unwrap();
+        let result = result.as_primitive::<Decimal128Type>();
+        assert!(result.is_null(0));
+        assert_eq!(result.value(1), 100);
+        let options = CastOptions {
+            safe: false,
+            ..Default::default()
+        };
+        assert!(cast_with_options(&array, &DataType::Decimal128(18, 2), 
&options).is_err());
+        // A precision beyond the target type is an error even when the values 
fit it.
+        let array = Int64Array::from(vec![5_000_000_000]);
+        for safe in [true, false] {
+            let options = CastOptions {
+                safe,
+                ..Default::default()
+            };
+            assert!(cast_with_options(&array, &DataType::Decimal32(10, 0), 
&options).is_err());
+        }
+    }
+
     #[test]
     fn test_cast_numeric_to_decimal128_overflow() {
         let array = Int64Array::from(vec![i64::MAX]);
diff --git a/arrow/benches/cast_kernels.rs b/arrow/benches/cast_kernels.rs
index de8ebb4a91..811a46cd58 100644
--- a/arrow/benches/cast_kernels.rs
+++ b/arrow/benches/cast_kernels.rs
@@ -263,6 +263,11 @@ fn add_benchmark(c: &mut Criterion) {
     let bool_array: ArrayRef = Arc::new(create_boolean_array(8192, 0.1, 0.5));
     let bool_array_no_nulls: ArrayRef = Arc::new(create_boolean_array(8192, 
0.0, 0.5));
     let i64_array = build_array::<Int64Type>(512);
+    let i64_within_1e6 = create_primitive_array_range::<Int64Type>(512, 0.1, 
-1_000_000..1_000_000);
+    let mut values: Vec<Option<i64>> = i64_within_1e6.iter().collect();
+    *values.last_mut().unwrap() = Some(10_000_000_000_000_000);
+    let i64_late_overflow = Arc::new(Int64Array::from(values)) as ArrayRef;
+    let i64_within_1e6 = Arc::new(i64_within_1e6) as ArrayRef;
     let f32_array = build_array::<Float32Type>(512);
     let f32_utf8_array = cast(&build_array::<Float32Type>(512), 
&DataType::Utf8).unwrap();
     let i32_utf8_array = cast(&build_array::<Int32Type>(512), 
&DataType::Utf8).unwrap();
@@ -358,6 +363,16 @@ fn add_benchmark(c: &mut Criterion) {
     c.bench_function("cast int64 to decimal32(9, -1) 512", |b| {
         b.iter(|| cast_array(&i64_array, DataType::Decimal32(9, -1)))
     });
+    c.bench_function("cast int64 to decimal128(38, 10) 512", |b| {
+        b.iter(|| cast_array(&i64_array, DataType::Decimal128(38, 10)))
+    });
+    c.bench_function("cast int64 within 1e6 to decimal128(18, 2) 512", |b| {
+        b.iter(|| cast_array(&i64_within_1e6, DataType::Decimal128(18, 2)))
+    });
+    c.bench_function(
+        "cast int64 within 1e6 late overflow to decimal128(18, 2) 512",
+        |b| b.iter(|| cast_array(&i64_late_overflow, DataType::Decimal128(18, 
2))),
+    );
     c.bench_function("cast date64 to date32 512", |b| {
         b.iter(|| cast_array(&date64_array, DataType::Date32))
     });

Reply via email to