Skip to content

[Epic] Faster division in integer and decimal kernels #11421

Open

Description

@mbutrovich

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), decimal rescaling, the rescaling of each i256 product in multiply_fixed_point, time unit casts, and Display for i256.

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 pays for a full division. For i128 the call to __divti3 or __udivti3 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. Downstream projects are already doing this on their own. @Dandandan added a hand-written StrengthReducedU64 (code) to DataFusion's hash repartitioning in apache/datafusion#21900, which made TPC-H SF10 queries up to 1.16x faster. Comet's WideDecimalBinaryExpr runs Knuth's Algorithm D twice per row to get a quotient and a remainder by the same power of ten (div_round_half_up). 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 follows from the prototype measurements in the tasks below.

Method

Granlund-Montgomery (1994) at every width, with a mask or shift for powers of two. The width of the high multiply changes, from 32x32 to 64 bits for 32-bit types, which vectorizes, up to 256x256 to 512 bits for i256. Within a width, the form depends on the divisor and the target.

Times are prototype measurements 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.

Image
Numbers behind the chart
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

Scope

This epic changes division by a scalar. Division by an array, where each row has its own divisor, keeps today's methods, because a precomputed divisor would be used only once.

Type By a scalar, today By a scalar, after this epic
i32, u32 Hardware divide Granlund-Montgomery with a 32-bit multiplier, which vectorizes (#11467)
i64, u64 Hardware divide Granlund-Montgomery with a 64-bit multiplier (#11467)
i128 __divti3 Granlund-Montgomery with a 128-bit multiplier, or DivisorI64 when the batch's values fit in i64 (#12232)
i256 i256::div_rem Granlund-Montgomery with a 256-bit multiplier, or DivisorI128 when the batch's values fit in i128 (#12232)

Where it plugs in

#11467 sets the API that the i128 and i256 types follow. Each divisor type is a #[non_exhaustive] enum whose variants are branch-free types, and the variant set differs by type. A kernel builds a divisor for each batch, matches on its variant once with a macro that lists the variants per type, and moves the variant into its closure.

The i256 variants are the exception: they branch on the shift, because a branch-free shift was slower on every runner (#12232). For a batch whose values fit in the next narrower type, a kernel builds that type's divisor instead. #12232 has no separate narrow variant.

What every kernel PR must do

  • Report before and after numbers from the benchmark bot, on x86_64 and arm64, and at x86-64-v3 for kernels Comet's x86 builds use.
  • Divide with unary for every divisor except -1, and test for -1 once outside the loop. A zero divisor and MIN / -1 are the only failures, and the divisor alone says whether either is possible. try_unary visits valid slots one at a time when there are nulls (measurements).
  • Keep today's behavior for nulls. [NULL, NULL] / 0, [] / 0 and [NULL, NULL] % 0 return Ok because try_unary skips null slots, and [1, NULL] / 0 returns DivideByZero. Build the divisor only once there's a valid value to divide.
  • Turn failures into nulls in the kernel when the caller asks for it (casts with safe: true), the way unary_opt maps None to null today.
  • For i128 and i256, check the batch's magnitude and divide batches that fit in the next narrower type with that type's divisor. On such values the wide divisor was as slow as or slower than / and % in perf(arrow-buffer): add precomputed divisors for i128 and i256 #12232, down to 0.55x.
  • Include benchmark cases with more than half of the values null and choose the unary versus try_unary threshold from them. unary divides every slot, so its cost doesn't fall as nulls increase, and in perf(arrow-arith): divide integers by a scalar with a precomputed divisor #12076 it was 0.27x to 0.72x of the existing kernel with nine tenths null on x86.

Tasks

Steps that don't need a precomputed divisor, or that remove costs that would hide the divisor's gains in the kernel benchmarks:

Steps that use the divisor:

Optional follow-ups:

Follow-ups in other repos

Describe alternatives you've considered

Lemire, Kaser and Kurz 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. #11467 does use the paper's observation in section 3.1 that the remainder doesn't depend on the sign of the divisor.

Depending on strength_reduce would avoid carrying the code. It supports unsigned types only and has no 256-bit type. In the arrow-cast measurements in #12169 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.

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

The prototype is on a fork (diff), and its README explains how to reproduce each table. divproto/ is a standalone crate with the divisors, their tests and the microbenchmarks. Prototype numbers are from an Apple M5 Max (aarch64), release builds and criterion, against main at 6b34163. Each task's PR reports its own numbers from the benchmark bot.

Comet's pmod on 8192 hash values, in us per batch. 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.

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

Activity

  1. mbutrovich commented on Oct 7, 2026

    @mbutrovich
    ContributorAuthor

    take

  2. changed the title [-][Epic] Divide by loop-invariant divisors with a precomputed reciprocal[/-] [+][Epic] Faster division in integer and decimal kernels[/+] on Oct 12, 2026
  3. coderfender commented on Nov 10, 2026

    @coderfender

    This is awesome ! Would love to pick up work here. Thank you @mbutrovich

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions