Repository navigation
perf: unroll PrimitiveArray hashing for the no-null path - #26191
Conversation
Splits the single scalar loop in `hash_array_primitive`'s no-null branch
into two const-generic-free helpers that batch independent hashes so the
pipeline isn't serialized on store-to-load deps:
- `hash_prim_fresh_dense`: overwrite mode, 16 rows per iteration.
- `hash_prim_rehash_dense`: fold-with-prev mode, 8 rows per iteration
(narrower because each lane holds both `prev` and `value` live).
Both use `get_unchecked` / `get_unchecked_mut` with proven invariants
(hashes.len() == values.len()) and a scalar tail for the remainder.
Benchmarks (int64, 8192 rows, no nulls) on Apple M4 Max:
single, no nulls : neutral to small regression (<+7%)
multiple, no nulls : ~-14%
The nullable path is unchanged -- see PR apache#26143 for the sibling change.
|
run benchmark with_hashes |
5027475 to
4d6ff20
Compare
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing rich-T-kid/primitive-hash-no-null-unroll (4d6ff20) to 1c49b7f (merge-base) diff Run configurationrun benchmark with_hashesResults will be posted here when complete File an issue against this benchmark runner |
|
run benchmark with_hashes |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #26191 +/- ##
==========================================
+ Coverage 82.78% 82.80% +0.02%
==========================================
Files 1148 1149 +1
Lines 451049 452284 +1235
Branches 451049 452284 +1235
==========================================
+ Hits 373381 374533 +1152
- Misses 54928 54953 +25
- Partials 22740 22798 +58 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
🤖 Benchmark completed (GKE) | trigger Instance: Comparing rich-T-kid/primitive-hash-no-null-unroll (4d6ff20) to 1c49b7f (merge-base) diff Run configurationrun benchmark with_hashesCPU Details (lscpu)Details
Resource Usagewith_hashes — base (merge-base)
with_hashes — branch
File an issue against this benchmark runner |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing rich-T-kid/primitive-hash-no-null-unroll (4d6ff20) to 1c49b7f (merge-base) diff Run configurationrun benchmark with_hashesResults will be posted here when complete File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: Comparing rich-T-kid/primitive-hash-no-null-unroll (4d6ff20) to 1c49b7f (merge-base) diff Run configurationrun benchmark with_hashesCPU Details (lscpu)Details
Resource Usagewith_hashes — base (merge-base)
with_hashes — branch
File an issue against this benchmark runner |
|
run benchmark with_hashes |
1 similar comment
|
run benchmark with_hashes |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing rich-T-kid/primitive-hash-no-null-unroll (da7240f) to 1c49b7f (merge-base) diff Run configurationrun benchmark with_hashes
env:
BENCH_FILTER: "int64"Results will be posted here when complete File an issue against this benchmark runner |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing rich-T-kid/primitive-hash-no-null-unroll (da7240f) to 1c49b7f (merge-base) diff Run configurationrun benchmark with_hashes
env:
BENCH_FILTER: "int64"Results will be posted here when complete File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: Comparing rich-T-kid/primitive-hash-no-null-unroll (da7240f) to 1c49b7f (merge-base) diff Run configurationrun benchmark with_hashes
env:
BENCH_FILTER: "int64"CPU Details (lscpu)Details
Resource Usagewith_hashes — base (merge-base)
with_hashes — branch
File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: Comparing rich-T-kid/primitive-hash-no-null-unroll (da7240f) to 1c49b7f (merge-base) diff Run configurationrun benchmark with_hashes
env:
BENCH_FILTER: "int64"CPU Details (lscpu)Details
Resource Usagewith_hashes — base (merge-base)
with_hashes — branch
File an issue against this benchmark runner |
|
@jayzhan211 could u take a look |
jayzhan211
left a comment
There was a problem hiding this comment.
Thanks @Rich-T-kid , overall LGTM
|
run benchmark with_hashes |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing rich-T-kid/primitive-hash-no-null-unroll (6c77ea2) to 1c49b7f (merge-base) diff Run configurationrun benchmark with_hashesResults will be posted here when complete File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: Comparing rich-T-kid/primitive-hash-no-null-unroll (6c77ea2) to 1c49b7f (merge-base) diff Run configurationrun benchmark with_hashesCPU Details (lscpu)Details
Resource Usagewith_hashes — base (merge-base)
with_hashes — branch
File an issue against this benchmark runner |
|
thanks @jayzhan211 I addressed your feedback 🚀 |
|
I've been looking at architecture-specific opportunities in the codebase, and came across this recent PR. I just wanted to add my analysis, even though this already merged.
@Rich-T-kid I read the generated code on both sides after this merged, and I have a different hypothesis for where the gain comes from. As I read the aarch64 code from before this PR, the old loops weren't serialized. The fresh loop never loaded from What I think changed is how LLVM vectorized the rehash loop. ldr q1, [x12], #16 ; 2 values
ldr q2, [x11] ; 2 previous hashes
eor3.16b v1, v1, v2, v0 ; value ^ prev ^ constant
mov.d x14, v1[1] ; lane 1 to GPR
fmov x15, d1 ; lane 0 to GPR
mul x16, x14, x10
umulh x14, x14, x10
mul x17, x15, x10
umulh x15, x15, x10
eor x15, x15, x17
fmov d1, x15 ; lane 0 back to NEON
eor x14, x14, x16
fmov d2, x14 ; lane 1 back to NEON
mov.d v1[1], v2[0]
str q1, [x11], #16The The bot numbers fit that. The The listing doesn't explain the fresh path to me. It went from 4x to 16x unrolled, which cuts the instructions per row by about 5% (5.5 to 5.25) and keeps the same two multiplies per row. On x86_64 at the default target CPU, LLVM vectorizes both old loops two rows at a time with Should we change the comment on |
Which issue does this PR close?
Rationale for this change
The no-null branch of
hash_array_primitiveis a tightzipoverhashes_bufferandarray.values(). ForPrimitiveArray<Int64>and friendsthe whole hash is a handful of cycles, so the serialization imposed by one
scalar iteration per row shows up as a measurable per-batch cost.
What changes are included in this PR?
Replace the two scalar loops with two small helpers that batch independent
hashes before storing:
hash_prim_fresh_dense: single-column case (rehash == false), 16 rowsper iteration. Each lane only reads
values[row], so lane count can gowide before register pressure matters.
hash_prim_rehash_dense: multi-column case (rehash == true), 8 rowsper iteration. Each lane holds both
prev_hashandvaluelive;widening past 8 spilled registers in the multi-column case.
Both use
std::array::from_fn::<LANES>to gather + hash into a temporaryarray, then store the whole batch, keeps the independent hash chains in
flight so the pipeline isn't serialized on store-to-load deps. A scalar
tail handles rows beyond the last
LANES-aligned boundary.What is the testing strategy for this PR?
Existing
hash_utils::testsAre there any user-facing changes?
no