andygrove commented on code in PR #5193:
URL: https://github.com/apache/datafusion-comet/pull/5193#discussion_r3695850331


##########
native/spark-expr/src/math_funcs/internal/make_decimal.rs:
##########
@@ -45,14 +47,41 @@ pub fn spark_make_decimal(
         ColumnarValue::Array(a) => match a.data_type() {
             DataType::Int64 => {
                 let arr = a.as_primitive::<Int64Type>();
-                let mut result = Decimal128Builder::new();
-                for v in arr.into_iter() {
-                    result.append_option(long_to_decimal(v, precision, scale, 
fail_on_error)?)
-                }
                 let result_type = DataType::Decimal128(precision, scale);
+                // The Int64 is already the unscaled Decimal128 value, so we 
only reinterpret
+                // the bits (an Arrow Int64->Decimal cast would rescale the 
value). Both arity
+                // helpers reuse the input null buffer and only invoke the 
closure on valid rows.
+                let result: Decimal128Array = if fail_on_error {

Review Comment:
   A question on the helper choice here. Arrow's own docs on `try_unary` note 
it "is often significantly slower than `unary`" because LLVM cannot vectorize 
fallible closures, and `unary_opt` has the same per-row shape. Meanwhile 
`checkoverflow.rs`, in this same directory and solving the same overflow 
problem, already uses a different idiom: widen unconditionally, then a 
short-circuiting `is_valid_decimal_precision` scan, paying for null-masking or 
error construction only when an overflow actually exists.
   
   I benchmarked the three shapes on 8192-row Int64 batches at `Decimal128(18, 
2)`, the widest precision `DecimalAggregates` produces for `MakeDecimal` (times 
are non-ANSI / ANSI):
   
   | shape | no nulls | sparse nulls | dense nulls |
   |---|---|---|---|
   | builder loop (today) | 19.0 / 19.1 µs | 22.7 / 22.7 µs | 19.1 / 19.2 µs |
   | this PR | 14.3 / 14.3 µs | 15.8 / 14.9 µs | 10.7 / 9.8 µs |
   | `unary` + scan | 5.58 / 8.50 µs | 5.44 / 7.92 µs | 5.48 / 9.04 µs |
   
   Your version is a genuine 1.3x to 1.9x win over what is there now. The 
`checkoverflow.rs` idiom looks like another 1.7x to 2.6x on top of that. 
`MakeDecimal` sits on the decimal sum and avg path, which is why its sibling 
got optimized in the first place, so it is worth getting right here.
   
   Would you be up for trying that shape and seeing whether you reproduce the 
gain? The scan still finds the first offending value, so your new ordering test 
should keep passing. If you would rather stay with `unary_opt`/`try_unary`, 
could you note why in the PR description so the choice is on the record?
   
   Either way, would you mind adding a `make_decimal` bench? 
`benches/unscaled_value.rs` and `benches/check_overflow.rs` cover the 
neighbouring expressions and would be easy to mirror. That would make the 
numbers checkable rather than something a reviewer has to measure by hand.



##########
native/spark-expr/src/math_funcs/internal/make_decimal.rs:
##########
@@ -45,14 +47,41 @@ pub fn spark_make_decimal(
         ColumnarValue::Array(a) => match a.data_type() {
             DataType::Int64 => {
                 let arr = a.as_primitive::<Int64Type>();
-                let mut result = Decimal128Builder::new();
-                for v in arr.into_iter() {
-                    result.append_option(long_to_decimal(v, precision, scale, 
fail_on_error)?)
-                }
                 let result_type = DataType::Decimal128(precision, scale);
+                // The Int64 is already the unscaled Decimal128 value, so we 
only reinterpret
+                // the bits (an Arrow Int64->Decimal cast would rescale the 
value). Both arity
+                // helpers reuse the input null buffer and only invoke the 
closure on valid rows.
+                let result: Decimal128Array = if fail_on_error {
+                    // ANSI mode: overflow is a hard error. `try_unary` 
surfaces the closure's
+                    // ArrowError; unwrap the ExternalError back to 
DataFusionError::External so
+                    // the ANSI error variant (not a generic ArrowError) is 
preserved.
+                    try_unary::<Int64Type, _, Decimal128Type>(arr, |v| {
+                        let v = v as i128;
+                        validate_decimal_precision(v, precision, scale)
+                            .map(|()| v)
+                            .map_err(|_| {
+                                
ArrowError::ExternalError(Box::new(decimal_overflow_error(
+                                    v, precision, scale,
+                                )))
+                            })
+                    })
+                    .map_err(|e| match e {
+                        ArrowError::ExternalError(inner) => 
DataFusionError::External(inner),
+                        other => DataFusionError::from(other),
+                    })?
+                } else {
+                    // Non-ANSI: overflow becomes null. `unary_opt` applies 
the closure only to
+                    // valid rows and marks a row null wherever the closure 
returns None.
+                    arr.unary_opt::<_, Decimal128Type>(|v| {

Review Comment:
   `long_to_decimal` is still here for the scalar path, and the array path now 
inlines the same validate-and-convert rule twice more. That is three copies 
with nothing keeping them in sync. Could one helper cover all three? Something 
like:
   
   ```rust
   #[inline]
   fn to_unscaled(v: i64, precision: u8, scale: i8) -> Option<i128> {
       let v = v as i128;
       validate_decimal_precision(v, precision, scale).ok().map(|()| v)
   }
   ```
   
   Then the non-ANSI array path becomes `arr.unary_opt::<_, Decimal128Type>(|v| 
to_unscaled(v, precision, scale))`, the ANSI path becomes 
`to_unscaled(...).ok_or_else(|| overflow_err(v, precision, scale))`, and 
`long_to_decimal` builds on the same two pieces.



##########
native/core/src/execution/columnar_to_row.rs:
##########
@@ -1055,14 +1056,10 @@ impl ColumnarToRowContext {
                 // Parquet stores small-precision decimals as Int32 for 
efficiency, and the
                 // reader may surface them as the physical Int32 type. The 
value is already
                 // scaled (e.g., -1 means -0.01 for scale 2). Reinterpret (not 
cast) to
-                // Decimal128 preserving the value.
-                let int_array = 
array.as_any().downcast_ref::<Int32Array>().ok_or_else(|| {
-                    CometError::Internal("Failed to downcast to 
Int32Array".to_string())
-                })?;
-                let decimal_array: Decimal128Array = int_array
-                    .iter()
-                    .map(|v| v.map(|x| x as i128))
-                    .collect::<Decimal128Array>()
+                // Decimal128 preserving the value. `arity::unary` widens the 
value and reuses
+                // the input null buffer zero-copy (an Arrow cast would 
rescale the value).
+                let int_array = array.as_primitive::<Int32Type>();
+                let decimal_array = unary::<_, _, Decimal128Type>(int_array, 
|x| x as i128)

Review Comment:
   This reads well, and the note about an Arrow cast rescaling the value is 
worth having in the code. I confirmed that dropping the `downcast_ref` + 
`ok_or_else` is safe, since the match is on `array.data_type()`, so nothing new 
can panic here.
   
   The substance of the change is null-buffer handling, and 
`test_convert_int32_to_decimal128` and `test_convert_int64_to_decimal128` both 
use all-valid arrays, so that is the one thing the tests do not exercise. Could 
you add a null to those arrays? A sliced input would be good too, following 
`test_map_data_conversion_sliced_maparray` further down the file. I checked 
both cases by hand and they behave correctly, so this is just about locking the 
behaviour in.



##########
native/spark-expr/src/math_funcs/internal/make_decimal.rs:
##########
@@ -45,14 +47,41 @@ pub fn spark_make_decimal(
         ColumnarValue::Array(a) => match a.data_type() {
             DataType::Int64 => {
                 let arr = a.as_primitive::<Int64Type>();
-                let mut result = Decimal128Builder::new();
-                for v in arr.into_iter() {
-                    result.append_option(long_to_decimal(v, precision, scale, 
fail_on_error)?)
-                }
                 let result_type = DataType::Decimal128(precision, scale);
+                // The Int64 is already the unscaled Decimal128 value, so we 
only reinterpret
+                // the bits (an Arrow Int64->Decimal cast would rescale the 
value). Both arity
+                // helpers reuse the input null buffer and only invoke the 
closure on valid rows.
+                let result: Decimal128Array = if fail_on_error {
+                    // ANSI mode: overflow is a hard error. `try_unary` 
surfaces the closure's
+                    // ArrowError; unwrap the ExternalError back to 
DataFusionError::External so
+                    // the ANSI error variant (not a generic ArrowError) is 
preserved.
+                    try_unary::<Int64Type, _, Decimal128Type>(arr, |v| {

Review Comment:
   `PrimitiveArray::try_unary` is generic over the error type. It is only the 
free function `arity::try_unary` that pins it to `ArrowError`. If you call the 
method form, as you already do for `unary_opt` just below, the closure can 
return `DataFusionError` directly:
   
   ```rust
   arr.try_unary::<_, Decimal128Type, DataFusionError>(|v| {
       let v = v as i128;
       validate_decimal_precision(v, precision, scale)
           .map(|()| v)
           .map_err(|_| {
               DataFusionError::External(Box::new(decimal_overflow_error(v, 
precision, scale)))
           })
   })?
   ```
   
   I compiled and ran this and it produces the identical error, same variant 
and same `SparkError` payload. It drops the `arrow::error::ArrowError` import, 
the `map_err` match, and the `other =>` arm that cannot currently be reached. 
It also removes the chance that a later edit loses the unwrap and turns the 
ANSI failure into a generic `ArrowError`, which would break the 
`NUMERIC_VALUE_OUT_OF_RANGE` mapping. This may become moot if you take the scan 
approach in my other comment.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to