mbutrovich opened a new issue, #11421:
URL: https://github.com/apache/arrow-rs/issues/11421

   ### Is your feature request related to a problem or challenge?
   
   Many kernels divide every value in an array by the same divisor. The divisor 
is known before the loop starts, but not at compile time. In arrow-rs this 
includes integer `/` and `%` by a scalar 
([`integer_op`](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow-arith/src/numeric.rs#L344-L371)),
 decimal rescaling in 
[`make_downscaler`](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow-cast/src/cast/decimal.rs#L235-L246)
 and 
[`cast_decimal_to_integer`](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow-cast/src/cast/decimal.rs#L786-L801),
 time unit casts 
([1](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow-cast/src/cast/mod.rs#L2218),
 
[2](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow-cast/src/cast/mod.rs#L2423),
 
[3](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arr
 ow-cast/src/cast/mod.rs#L2648)), and [`Display for 
i256`](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow-buffer/src/bigint/mod.rs#L83-L84),
 which divides by 10^38 twice per value.
   
   LLVM turns division by a compile-time constant into a multiply and shifts, 
but it can't do that when the divisor is only known at run time. So each row 
costs a hardware divide for 64-bit and smaller types, a call to `__divti3` or 
`__udivti3` for `i128`, and a run of Knuth's Algorithm D 
([`div_rem_knuth`](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow-buffer/src/bigint/div.rs#L83))
 for `i256`. For `i128` the call stays even with a literal divisor such as 
10^18 (rustc 1.98.1, aarch64).
   
   The usual fix is to compute a reciprocal of the divisor once and turn each 
division into a high multiply, a shift, and a small correction. This issue uses 
the method of [Granlund and Montgomery 
(1994)](https://gmplib.org/~tege/divcnst-pldi94.pdf), which needs an N-bit 
multiplier for N-bit dividends.
   
   Downstream projects are already doing this on their own. @Dandandan added a 
hand-written `StrengthReducedU64` 
([code](https://github.com/apache/datafusion/blob/db83fccb9ac10f843be04daa5826501d2338caf4/datafusion/physical-plan/src/repartition/mod.rs#L941-L1002))
 to DataFusion's hash repartitioning in apache/datafusion#21900, which made 
TPC-H SF10 queries up to 1.16x faster. Comet's `WideDecimalBinaryExpr` rescales 
every `i256` product with 
[`div_round_half_up`](https://github.com/apache/datafusion-comet/blob/8d0bb01a5a99924024e07c1722e6eddd9260e167/native/spark-expr/src/math_funcs/wide_decimal_binary_expr.rs#L123-L146),
 which computes `value / divisor` and `value % divisor` by the same power of 
ten. That runs Algorithm D twice per row. With these primitives in arrow-rs, 
arrow's own kernels could use them, and DataFusion and Comet would share one 
tested implementation.
   
   ### Describe the solution you'd like
   
   Precomputed divisors in `arrow-buffer`, next to `i256` and its division 
code, that arrow's kernels and downstream projects can share. The design below 
follows from the prototype measurements under "Additional context". Two 
questions for maintainers are at the end.
   
   #### Proposed method: Granlund-Montgomery at every width
   
   The proposal is Granlund-Montgomery at every width, with a mask or shift for 
powers of two. That's one algorithm, one correctness argument and one set of 
tests, and only the width of the high multiply changes: 32x32 to 64 bits for 
32-bit types, which vectorizes, up to 256x256 to 512 bits for `i256`.
   
   Times are in us per 8192-value batch. "Today" is arrow-arith's `rem` or 
`div` kernel for the integer rows, the current arrow-cast code for the 
`Decimal128` rows, and Comet's current code for the `i256` rows. The detailed 
tables are under "Additional context".
   
   <img width="1600" height="1012" alt="Image" 
src="https://github.com/user-attachments/assets/541e3306-3af9-430d-b8e2-cdacfd7435ad";
 />
   
   <details><summary>Numbers behind the chart</summary>
   
   | Case | Today | Granlund-Montgomery | Speedup | Variant |
   |---|---|---|---|---|
   | `UInt32Array % 13` | 3.87 | 0.97 | 4.0x | 32-bit, vectorized |
   | `Int32Array / 13` | 3.99 | 1.89 | 2.1x | 32-bit, vectorized |
   | `UInt64Array % 13` | 4.72 | 2.95 | 1.6x | 64-bit |
   | `UInt64Array % 16` | 4.77 | 0.62 | 7.7x | Mask |
   | `Decimal128` downscale, 18-digit values | 21.3 | 11.5 | 1.9x | 64-bit, 
chosen per batch |
   | `Decimal128` downscale by 10^8, 38-digit values | 32.4 | 24.7 | 1.3x | 
128-bit |
   | `Decimal128` downscale by 10^18, 38-digit values | 73.7 | 24.7 | 3.0x | 
128-bit |
   | Comet `i256` multiply, products fit in `i128` | 266.5 | 63.4 | 4.2x | 
128-bit, chosen per batch |
   | Comet `i256` multiply, 48-digit products | 398.1 | 147.1 | 2.7x | 256-bit |
   
   </details>
   
   The 32-bit Granlund-Montgomery loops compile to NEON vector code, because 
their 32x32 to 64-bit multiply has vector forms. The 64-bit and wider methods 
need a 64x64 to 128-bit high multiply, which has no vector form on NEON, so 
those loops stay scalar.
   
   #### Choosing the variant per batch
   
   For `i128` and `i256`, the kernel would pick the narrowest 
Granlund-Montgomery variant that fits each batch, from a scan of the values' 
magnitude. One variant per type isn't enough: the 128-bit variant alone made 
18-digit `Decimal128` casts 16% slower than today (24.8 vs 21.3 us), while the 
64-bit variant made them 1.9x faster. In Comet, the 128-bit variant made 
products that fit in `i128` 4.2x faster than today. The scan cost 4% to 8% when 
it found wide values. The choice can't come from the declared precision, 
because arrow doesn't check stored values against it 
([`with_precision_and_scale`](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow-array/src/array/primitive_array.rs#L1709-L1715)).
   
   #### Rounding and failures
   
   Each divisor type would offer `new`, which returns `None` for a zero 
divisor, `div_rem`, which returns the same results as `wrapping_div` and 
`wrapping_rem`, and `checked_div_rem`, which returns `None` only for `MIN / -1`.
   
   Callers round the quotient in different ways, but each mode is a sign and 
remainder correction applied to the truncated `(q, r)`. These could be generic 
helpers on top of `div_rem` instead of separate division routines:
   
   | Rounding | Correction to `(q, r)` | Callers today |
   |---|---|---|
   | Toward zero | None | arrow `div` and `rem`, Comet [decimal to integer 
casts](https://github.com/apache/datafusion-comet/blob/8d0bb01a5a99924024e07c1722e6eddd9260e167/native/spark-expr/src/conversion_funcs/numeric.rs#L548)
 |
   | Floor (`div_floor`) | Subtract 1 from `q` when `r != 0` and the signs of 
`n` and `d` differ | Comet 
[`floor`](https://github.com/apache/datafusion-comet/blob/8d0bb01a5a99924024e07c1722e6eddd9260e167/native/spark-expr/src/math_funcs/floor.rs#L84-L86)
 |
   | Ceiling (`div_ceil`) | Add 1 to `q` when `r != 0` and the signs match | 
Comet 
[`ceil`](https://github.com/apache/datafusion-comet/blob/8d0bb01a5a99924024e07c1722e6eddd9260e167/native/spark-expr/src/math_funcs/ceil.rs#L84-L86)
 |
   | Half away from zero (`HALF_UP` in Java's `RoundingMode`) | Move `q` one 
step away from zero when `\|r\| >= \|d\| - \|r\|` | arrow-cast 
[`make_downscaler`](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow-cast/src/cast/decimal.rs#L235-L246),
 Comet `div_round_half_up` and 
[`round`](https://github.com/apache/datafusion-comet/blob/8d0bb01a5a99924024e07c1722e6eddd9260e167/native/spark-expr/src/math_funcs/round.rs#L218-L231),
 DataFusion 
[`round`](https://github.com/apache/datafusion/blob/db83fccb9ac10f843be04daa5826501d2338caf4/datafusion/functions/src/math/round.rs#L671-L680)
 |
   | Euclidean remainder (`rem_euclid`) | Add `\|d\|` to `r` when `r < 0` | 
Comet shuffle 
[`pmod`](https://github.com/apache/datafusion-comet/blob/8d0bb01a5a99924024e07c1722e6eddd9260e167/native/shuffle/src/comet_partitioning.rs#L123-L129)
 |
   
   The half-up test is written as `|r| >= |d| - |r|` because `2 * |r|` can 
overflow when `|d|` is close to the type's maximum.
   
   Division can fail only on a zero divisor or `MIN / -1`, and the divisor 
alone says whether either is possible, so `checked_div_rem` only needs a 
per-row compare when the divisor is -1. Turning failures into nulls (Spark's 
legacy mode, casts with `safe: true`) stays in the kernel, the way `unary_opt` 
maps `None` to null today.
   
   Today `[NULL, NULL] / 0`, `[] / 0` and `[NULL, NULL] % 0` return `Ok`, 
because 
[`try_unary`](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow-array/src/array/primitive_array.rs#L1018)
 skips null slots. `[1, NULL] / 0` returns `DivideByZero`. Kernels should build 
the divisor only once there's a valid value to divide, so they keep that 
behavior.
   
   #### Where it plugs in
   
   A sealed trait in `arrow-buffer`, next to 
[`ArrowNativeType`](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow-buffer/src/native.rs#L53)
 and `i256`, would give each integer type and `i256` its divisor, and integer 
and decimal kernels would take it as a bound. An associated type on 
[`ArrowNativeTypeOp`](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow-array/src/arithmetic.rs#L41)
 would also be non-breaking, because `ArrowNativeType` is sealed, but 
`ArrowNativeTypeOp` covers floats too, which would need a divisor that does 
nothing. The kernel builds a divisor for each batch and matches on its variant 
once, outside the loop.
   
   #### Tasks
   
   Steps that can start now:
   
   - [ ] Make 
[`i256::div_rem`](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow-buffer/src/bigint/mod.rs#L488)
 public. It is private today, so callers that need both results run Algorithm D 
twice.
   - [ ] Add the benchmarks the kernel PRs need: integer division by a scalar 
([`arithmetic_kernels`](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow/benches/arithmetic_kernels.rs#L29-L71)
 covers only `Float32`), 18-digit and 38-digit decimal downscale (the [existing 
case](https://github.com/apache/arrow-rs/blob/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120/arrow/benches/cast_kernels.rs#L453)
 uses 10-digit values), and `i256` rescaling.
   - [ ] Measure the prototype on x86-64, where the cost of the hardware 64-bit 
divide differs across generations. Find out why the fallible decimal cast gains 
less with Granlund-Montgomery (1.16x) than with `strength_reduce` (1.23x) when 
the infallible casts show the opposite.
   
   Steps after the API is agreed:
   
   - [ ] Add the divisor types for 32-bit and 64-bit integers, with tests 
against `wrapping_div` and `wrapping_rem` that cover `MIN`, `MAX`, `-1`, powers 
of two and random inputs.
   - [ ] Add the divisor types for `i128`, `u128` and `i256`, with the same 
tests plus `10^k - 1`, `10^k`, `10^k + 1` and values on each side of 2^64.
   - [ ] Add the rounding helpers, tested against `num::integer::div_floor`, 
`num::integer::div_ceil`, `rem_euclid`, and a half-up reference built on `/` 
and `%`.
   - [ ] Add the sealed trait that gives each integer type and `i256` its 
divisor.
   - [ ] Use it in each kernel, one PR per kernel with before and after 
numbers, in this order: integer `/` and `%` by a scalar, decimal downscale, 
decimal `/` and `%` by a scalar, decimal to integer, time unit casts, and 
`Display for i256`.
   
   #### Follow-ups in other repos
   
   - DataFusion could replace `StrengthReducedU64` once it uses an arrow 
release that has the `u64` divisor. A power-of-two case in the divisor type 
covers what 
[`partition_indices`](https://github.com/apache/datafusion/blob/db83fccb9ac10f843be04daa5826501d2338caf4/datafusion/physical-plan/src/repartition/mod.rs#L961-L980)
 does today. The swap needs a repartition benchmark to show there's no 
regression.
   - Comet could use the `i256` divisor in `WideDecimalBinaryExpr`, with the 
`i128` variant chosen per batch, and the `i32` divisor in 
[`pmod`](https://github.com/apache/datafusion-comet/blob/8d0bb01a5a99924024e07c1722e6eddd9260e167/native/shuffle/src/comet_partitioning.rs#L123-L129).
 With a vectorized 32-bit Granlund-Montgomery divisor, `pmod` was 4.0x faster 
than today. Comet depends on arrow 59.2.0 and DataFusion 55.1.0, so it would 
adopt these later than DataFusion.
   - Two Comet changes need no arrow-rs release. Computing the remainder as 
`value - quotient * divisor` instead of a second `%` made its multiply kernel 
1.1x to 1.5x faster. In `pmod`, `|r| < n`, so `r + n` is already in range and 
the second `%` can go, which made it 1.3x faster.
   
   ### Describe alternatives you've considered
   
   [Lemire, Kaser and Kurz (2019)](https://arxiv.org/abs/1902.01961) compute 
the remainder from a 2N-bit reciprocal, as DataFusion's `StrengthReducedU64` 
does. In the prototype it beat Granlund-Montgomery only on the 64-bit path of 
the `Decimal128` rounding (9.7 vs 13.5 us per batch, where Granlund-Montgomery 
is still 1.6x to 1.8x faster than today), tied on `UInt64Array % 13`, and lost 
at 32 bits, where Granlund-Montgomery vectorizes. That one gain isn't worth a 
second algorithm's code and tests.
   
   Depending on 
[`strength_reduce`](https://github.com/ejmahler/strength_reduce) would avoid 
carrying the code. It supports unsigned types only and has no 256-bit type. In 
the arrow-cast measurements below it was slower than the fastest hand-written 
variant in every case except the fallible cast. Its last release (0.2.4) was in 
November 2022.
   
   Precomputed tables of the reciprocals of 10^k would remove construction cost 
for decimal rescaling. In the prototype, building a divisor costs 1.4 to 2.5 ns 
for `u64`, 22 ns for `i128` and 451 ns for `i256`. The `i256` constructor 
divides one bit at a time and could use Algorithm D instead. That's at most 
0.45 us per 8192-value batch. For scalars and very small batches, plain 
division is cheaper, so kernels would need a row-count threshold or the tables.
   
   This epic is about how a division is computed, not about decimal result 
types. Precision-loss decimal arithmetic stays out of scope, as @tustvold 
decided in #4664.
   
   ### Additional context
   
   These numbers are from aarch64 only: an Apple M5 Max, release builds and 
criterion, in us per 8192-value batch, with the fastest method in each table 
row in bold. The `divproto` and arrow-cast benchmarks used rustc 1.99.0, and 
the Comet benchmark used 1.98.1. The arrow-cast and Comet numbers are medians 
of three runs.
   
   Integer remainder and division by a scalar, from the `batches` group of the 
`divproto` benchmark. Today is arrow-arith's `rem` or `div` kernel with a 
scalar divisor, and each method runs through `PrimitiveArray::unary`. A divisor 
of 200 gave results within 5% of 13.
   
   <img width="1600" height="996" alt="Image" 
src="https://github.com/user-attachments/assets/0677a038-1814-4aa8-ad32-2de80d45718a";
 />
   
   <details><summary>Numbers behind the chart, in us per batch</summary>
   
   | Operation | Today | Granlund-Montgomery | Mask |
   |---|---|---|---|
   | `UInt32Array % 13` | 3.87 | **0.97 (4.0x)** | not applicable |
   | `UInt32Array % 16` | 3.85 | 0.98 (3.9x) | **0.28 (13.7x)** |
   | `Int32Array / 13` | 3.99 | **1.89 (2.1x)** | not applicable |
   | `UInt64Array % 13` | 4.72 | **2.95 (1.6x)** | not applicable |
   | `UInt64Array % 16` | 4.77 | 2.97 (1.6x) | **0.62 (7.7x)** |
   
   </details>
   
   Comet's `pmod` on 8192 hash values, from the same benchmark. Today is 
Comet's code, which divides twice for negative hashes. "One division" replaces 
the second `%` with `r + n`. The biased method adds 2^31 to the hash so that 
one unsigned remainder gives the result.
   
   <img width="1600" height="708" alt="Image" 
src="https://github.com/user-attachments/assets/bb76edf8-9f85-4513-989d-633271e37299";
 />
   
   <details><summary>Numbers behind the chart, in us per batch</summary>
   
   | Partitions | Today | One division | Biased Granlund-Montgomery | Mask |
   |---|---|---|---|---|
   | 13 | 5.84 | 4.59 (1.3x) | **1.45 (4.0x)** | not applicable |
   | 16 | 5.98 | 4.52 (1.3x) | 1.45 (4.1x) | **0.28 (21.4x)** |
   
   </details>
   
   The arrow-cast `decimal_downscale` benchmark, which calls `cast` on an 
8192-value array, with each method in `make_downscaler`. Today is main.
   
   <img width="1600" height="1236" alt="Image" 
src="https://github.com/user-attachments/assets/dddacee5-1d11-470d-83f7-d3678fe2d98b";
 />
   
   <details><summary>Numbers behind the chart, in us per batch</summary>
   
   | Cast | Today | `strength_reduce` | Granlund-Montgomery | 
Granlund-Montgomery with the per-batch 64-bit path |
   |---|---|---|---|---|
   | `(18, 6)` to `(18, 2)`, 18-digit values | 21.3 | 19.5 (1.1x) | 24.8 (0.9x) 
| **11.5 (1.9x)** |
   | `(38, 10)` to `(38, 2)`, 15-digit values | 21.4 | 19.5 (1.1x) | 24.7 
(0.9x) | **11.4 (1.9x)** |
   | `(38, 10)` to `(38, 2)`, 38-digit values | 32.4 | 28.8 (1.1x) | **24.7 
(1.3x)** | 26.8 (1.2x) |
   | `(38, 20)` to `(38, 2)`, 38-digit values | 73.7 | 28.7 (2.6x) | **24.7 
(3.0x)** | 26.8 (2.8x) |
   | `(38, 10)` to `(30, 2)`, 30-digit values, fallible | 42.4 | **34.4 
(1.2x)** | 36.4 (1.2x) | 38.2 (1.1x) |
   
   </details>
   
   The per-batch 64-bit path in this chart used the hardware divide. A separate 
run with Granlund-Montgomery's 64-bit variant there measured within 2% of it 
(11.2 vs 11.4 us for 18-digit values), and the prototype now uses that variant.
   
   Comet's `wide_decimal` benchmark, which evaluates the 
`WideDecimalBinaryExpr` multiply on an 8192-row batch, with each method in the 
kernel. Today is `div_round_half_up`, which runs Algorithm D twice per row.
   
   <img width="1600" height="884" alt="Image" 
src="https://github.com/user-attachments/assets/3e26139c-8ea7-4b78-9c99-08dcf5315c15";
 />
   
   <details><summary>Numbers behind the chart, in us per batch</summary>
   
   | Case | Today | One Algorithm D call, remainder by multiply | 
Granlund-Montgomery, 256-bit | Granlund-Montgomery, 128-bit when products fit |
   |---|---|---|---|---|
   | The existing 
[`multiply`](https://github.com/apache/datafusion-comet/blob/8d0bb01a5a99924024e07c1722e6eddd9260e167/native/spark-expr/benches/wide_decimal.rs#L156)
 case, 30-digit products | 266.5 | 233.8 (1.1x) | 148.4 (1.8x) | **63.4 
(4.2x)** |
   | 48-digit products, 34-digit quotients | 398.1 | 301.9 (1.3x) | **147.1 
(2.7x)** | 153.2 (2.6x) |
   | 42-digit products, 8-digit quotients | 351.5 | 234.5 (1.5x) | **137.1 
(2.6x)** | 143.6 (2.4x) |
   
   </details>
   
   The prototype is on a fork 
([diff](https://github.com/apache/arrow-rs/compare/6b34163d51aa59f7cac2edcb74e8bd1ddc6b9120...mbutrovich:arrow-rs:ee0358cd48c9bdeb7ca5a4c52d479f949c17f736)),
 and its 
[README](https://github.com/mbutrovich/arrow-rs/blob/ee0358cd48c9bdeb7ca5a4c52d479f949c17f736/divproto/README.md)
 explains how to reproduce each table. 
[`divproto/`](https://github.com/mbutrovich/arrow-rs/tree/ee0358cd48c9bdeb7ca5a4c52d479f949c17f736/divproto)
 is a standalone crate with the divisors, their tests and the microbenchmarks. 
The arrow-cast change in the same diff is the last column of the downscale 
table, and `divproto/variants/` has patches for the other columns and for the 
Comet kernel.
   
   Questions for maintainers:
   
   - Does a public `arrow_buffer::divisor` module, with a `Divisor<T>` type and 
the sealed trait, work as the API, or would you prefer a different shape or 
name?
   - Can the arrow-rs benchmark runners, or anyone with x86-64 hardware, run 
`divproto` and the arrow-cast benchmark? All the numbers here are from aarch64, 
and the 64-bit results depend on the hardware divide.
   


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