bhaskargurram-ai opened a new pull request, #11332:
URL: https://github.com/apache/arrow-rs/pull/11332

   # Which issue does this PR close?
   
   - Closes #11308.
   
   # Rationale for this change
   
   Casts and parsing into decimal types always round half away from zero when 
they drop digits. Configurable rounding was a stretch goal of #5068. #10850 
consolidated string-to-decimal parsing into one code path and settled the 
default rounding, and #11308 tracks what was left: letting callers pick a 
different rounding mode, such as half to even or truncation toward zero, while 
keeping the current default.
   
   The main concern raised in #5068 was not to regress decimal parsing 
performance. So the default mode is designed to run exactly the code it runs 
today, with no per-value check of the mode (see the performance notes below).
   
   # What changes are included in this PR?
   
   - A new `DecimalRoundingMode` enum in `arrow-cast` (re-exported as 
`arrow_cast::DecimalRoundingMode` and `arrow::compute::DecimalRoundingMode`). 
It has three variants: `HalfAwayFromZero` (the default, same as today), 
`HalfToEven` and `TowardZero`. The enum is `#[non_exhaustive]` so that other 
modes (floor, ceiling, ...) can be added later without a breaking change.
   - A new `CastOptions::decimal_rounding` field. These cast paths use it:
     - **Utf8 / LargeUtf8 / Utf8View -> Decimal32/64/128/256.** The 
consolidated parser from #10850 used to look only at the first discarded digit. 
For `HalfToEven` it now also records whether any later discarded digit is 
non-zero, so it can tell an exact tie (`2.5`) from a value just above one 
(`2.50001`). Ties then go to the even last kept digit. The mode is a 
compile-time parameter of the parser (a private `DecimalRounding` trait), and 
the cast picks the right copy once per array. This keeps the per-value cost of 
the default mode the same as before. It also works with exponents and negative 
scales.
     - **Float16/32/64 -> decimal.** The rounding function (`f64::round`, 
`f64::round_ties_even` or `f64::trunc`) is chosen once per array and inlined 
into the loop. As before, it is applied to `value * 10^scale` computed in 
`f64`, so the result follows the binary value. For example, `1.005_f64` becomes 
`1.00` at scale 2 in every mode. This is noted in the field docs.
     - **Decimal -> decimal with a smaller scale** (same or different decimal 
width). The downscaler rounds a tie to the even quotient for `HalfToEven` and 
drops the remainder for `TowardZero`. The rounding of the quotient goes through 
the same `DecimalRounding` trait and is chosen once per array, so the default 
per-value code is the same as before.
   - A new `arrow_cast::parse::parse_decimal_with_rounding` for standalone 
parsing. `parse_decimal` keeps its signature and behaviour.
   - In every mode, precision is checked after rounding. For example, `99.995` 
fits `Decimal(4, 2)` only with `TowardZero`.
   
   Out of scope here, possible follow-ups:
   - CSV and JSON reader options. Both readers call `parse_decimal` today. With 
`parse_decimal_with_rounding` in place, they would only need a `ReaderBuilder` 
option. I left that out to keep this PR to one crate and to settle the API 
first.
   - `rescale_decimal` (the public single-value helper) and the Variant 
conversions still use the default mode.
   - Integer -> decimal with a negative scale truncates today (e.g. `Int32 150` 
to `Decimal(5, -2)` is `1`), unlike the other paths, which round half away from 
zero. I did not change that default here. It could take the mode too if you 
want the paths to be consistent.
   
   # Are these changes tested?
   
   Yes.
   - `parse.rs`: `test_parse_decimal_with_rounding` covers all three modes for 
Decimal128 and Decimal256. Cases include positive and negative ties, a non-zero 
digit after the 5, long inputs that span several accumulator chunks, exponents, 
negative scales, and inputs with no discarded digits. It also checks that the 
default matches `parse_decimal`. 
`test_parse_decimal_with_rounding_checks_precision_after_rounding` covers 
precision and native-type overflow.
   - `cast/mod.rs`: `test_cast_string_to_decimal_rounding` (Utf8, LargeUtf8 and 
Utf8View into all four decimal types, safe and unsafe), 
`test_cast_float_to_decimal_rounding` (Float16/32/64), 
`test_cast_decimal_to_decimal_rounding` (Decimal128/256 into all four decimal 
types, on both the infallible and the precision-checked path) and 
`test_cast_to_decimal_rounding_checks_precision_after_rounding`.
   - The new tests fail if the mode is ignored (always using the default), or 
if `HalfToEven` rounds ties up.
   - `cargo test -p arrow-cast` passes (406 unit, 4 integration and 13 doc 
tests). `cargo clippy -p arrow-cast --all-targets --all-features -- -D 
warnings`, `cargo clippy -p parquet-variant-compute --tests -- -D warnings` and 
`cargo fmt --all -- --check` are clean.
   
   Performance: in the default mode, every path runs the same per-value logic 
as on `main`. The mode is chosen once per array (string parsing, float and 
decimal downscaling), and only `HalfToEven` tracks the extra "any later 
non-zero digit" flag while parsing. In an earlier revision the downscaler 
matched on the mode for every value, and the benchmark below showed that as +7% 
on `decimal128 to decimal128 512 with lower scale (infallible)`. With the mode 
chosen once per array, that case measures -1.6%.
   
   I measured on a shared, noisy 4-core VM, so please treat the numbers as 
indicative. I built the `main` and branch benchmark binaries, ran them 
alternately 3 times, and took the best time of each. These are the decimal 
cases of `cargo bench -p arrow --features test_utils --bench cast_kernels -- 
decimal` (times in µs):
   
   | benchmark | main | this PR | change |
   |---|---|---|---|
   | cast int64 to decimal32(9, 0) 512 | 1.54 | 1.44 | -6.4% |
   | cast int64 to decimal32(9, -1) 512 | 2.92 | 2.89 | -0.9% |
   | cast decimal128 to decimal128 512 | 8.25 | 7.72 | -6.5% |
   | cast decimal128 to decimal128 512 lower precision | 52.88 | 55.98 | +5.9% |
   | cast decimal128 to decimal256 512 | 32.29 | 31.18 | -3.4% |
   | cast decimal256 to decimal128 512 | 477.72 | 524.93 | +9.9% |
   | cast decimal256 to decimal256 512 | 148.25 | 163.77 | +10.5% |
   | cast decimal128 to decimal128 512 with same scale | 0.08 | 0.08 | -3.0% |
   | cast decimal128 to decimal128 512 with lower scale (infallible) | 67.40 | 
66.33 | -1.6% |
   | cast decimal256 to decimal256 512 with same scale | 0.08 | 0.08 | -1.7% |
   | cast string to decimal128(38, 3) | 146.61 | 140.83 | -3.9% |
   | cast string to decimal256(76, 3) | 187.97 | 207.84 | +10.6% |
   | cast float64 to decimal128(32, 3) | 73.69 | 78.82 | +7.0% |
   | cast invalid float64 to to decimal128(32, 3) | 36.09 | 37.44 | +3.7% |
   | cast decimal128 to float64 | 39.50 | 40.64 | +2.9% |
   | cast decimal128 to int8 | 91.53 | 95.02 | +3.8% |
   | cast decimal128 to int64 | 75.58 | 77.48 | +2.5% |
   | cast decimal256 to float64 | 172.09 | 161.79 | -6.0% |
   | cast decimal256 to int64 | 221.60 | 233.71 | +5.5% |
   
   The geometric mean is +1.4%. Paths this PR does not touch (decimal to 
int/float, int to decimal, same scale) moved between -6% and +6%, which is 
roughly the noise on this machine. Three cases were about +7% to +10% in both 
of my runs: `string to decimal256`, `float64 to decimal128` and the Decimal256 
downscales. Their per-value logic in the default mode is unchanged, so I 
suspect code layout or inlining differences, but I could not confirm that here.
   
   `cargo bench -p arrow-cast --bench parse_decimal`, run the same way, had a 
geometric mean of -0.2% over its 38 cases. Individual cases ranged from -21% 
(`string decimal32 9 digits`) to +22% (`string decimal128 exponent`). I 
compared the disassembly of `parse_decimal::<Decimal128Type>` in the two 
benchmark binaries: the instruction mix is nearly identical, with about 25 more 
instructions out of roughly 700, mostly register moves.
   
   Given the concern in #5068, it would help if a maintainer could run the 
benchmark bot on `parse_decimal` and `cast_kernels`. An earlier revision passed 
the mode as a runtime argument instead of a type parameter. I am happy to go 
back to that if you prefer the simpler code.
   
   # Are there any user-facing changes?
   
   Yes:
   - New public API: `DecimalRoundingMode`, `CastOptions::decimal_rounding` and 
`parse_decimal_with_rounding`.
   - **Breaking change**: `CastOptions` gets a new public field, so code that 
builds it with a struct literal listing every field must add 
`..Default::default()` or the new field. A `const` `CastOptions` needs 
`decimal_rounding: DecimalRoundingMode::HalfAwayFromZero` because 
`Default::default()` is not const. This PR updates the struct literals in this 
repository that way (most of the diff in `cast/mod.rs` tests and two in 
`parquet-variant-compute`).
   
   Note: #11302 (open) also changes `cast_floating_point_to_decimal`. Whichever 
PR merges second will need a small rebase. The float change here is only the 
choice of rounding function.
   
   


-- 
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]

Reply via email to