Skip to content

Use JDK intrinsics for Binary equality/comparison/hashing - #3782

Open
abstractdog wants to merge 1 commit into
apache:masterfrom
abstractdog:binary-intrinsics
Open

Use JDK intrinsics for Binary equality/comparison/hashing#3782
abstractdog wants to merge 1 commit into
apache:masterfrom
abstractdog:binary-intrinsics

Conversation

@abstractdog

@abstractdog abstractdog commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

Binary#equals, Binary#lexicographicCompare, and Binary#hashCode sit on hot paths for statistics min/max maintenance, dictionary hash-map probing, predicate evaluation, and bloom-filter build — so any per-byte overhead is amortized across every reader and writer. The current implementations use hand-rolled scalar byte loops; the JDK 9+ range overloads of Arrays.equals / Arrays.compareUnsigned / Arrays.hashCode / ByteBuffer.mismatch route through ArraysSupport.vectorizedMismatch, an @IntrinsicCandidate helper HotSpot substitutes with a SIMD byte-scan (SSE / AVX2 / NEON).

What changes are included in this PR?

  • Replace the scalar loops in Binary.java for equals, lexicographicCompare, and hashCode with the intrinsic-backed Arrays.* / ByteBuffer.mismatch APIs across all four Binary shape pairs (byte[]/byte[], byte[]/ByteBuffer, ByteBuffer/ByteBuffer).
  • Add a JMH suite parquet-benchmarks/BinaryComparisonBenchmark covering equals / compareTo / hashCode at length regimes 8, 64, and 512, both worst-case (full match) and average-case (mid-length mismatch).

Follows the same technique already accepted for DeltaByteArrayWriter in #3465. Complementary to the Binary#hashCode caching in #3566 — caching reduces call frequency, intrinsics speed up the calls that remain plus equals / compareTo which caching does not address.

Are these changes tested?

Yes. Covered by the existing Binary unit tests (equality, comparison, hashing across all shape pairs) plus the new JMH suite. compareTo semantics are preserved — Arrays.compareUnsigned matches the contract of the previous loop (unsigned byte order, shorter-runs-first on prefix match).

Are there any user-facing changes?

No API changes. Behavior is identical; only the implementation is faster.

Benchmark — JDK 17, 1 fork, 3×1s warmup, 5×1s measurement, throughput (ops/s):

bench len before after speedup
equalsMatch_bytesBytes 512 9.1 M 39.1 M 4.30×
equalsMatch_bytesBytes 64 41.4 M 160.9 M 3.89×
equalsMismatch_bytesBytes 512 14.6 M 55.5 M 3.79×
compareTo_bytesBytes 512 15.1 M 58.8 M 3.91×
compareTo_bytesBytes 64 59.0 M 199.4 M 3.38×
equalsMismatch_bytesBuf 64 63.8 M 190.2 M 2.98×

Wins scale with length — at len=512 the SIMD lane count amortizes fully (≈4×); at len=64 it's ≈3–4×; len=8 sees only 1.1–1.7× because there's barely enough work for one SIMD lane. hashCode is unchanged in JDK 17 numbers — the 31*h+b polynomial has a serial cross-iteration dependency; HotSpot only gained a lane-split intrinsic (ArraysSupport.vectorizedHashCode) in JDK 21, so the new Arrays.hashCode call is equivalent on 17 and picks up the vectorization automatically on 21+.

For further reference on intrinsics, see:

https://github.com/openjdk/jdk17u/blob/1ed3717f5a226981a1ebeff70dcafadc74569190/src/java.base/share/classes/java/util/Arrays.java#L2582

https://github.com/openjdk/jdk17u/blob/1ed3717f5a226981a1ebeff70dcafadc74569190/src/hotspot/share/opto/library_call.cpp#L5393

Replace the hand-rolled scalar byte loops in Binary#equals,
Binary#lexicographicCompare, and Binary#hashCode with the JDK 9+
Arrays.equals(byte[],int,int,byte[],int,int),
Arrays.compareUnsigned(byte[],int,int,byte[],int,int), Arrays.hashCode,
and ByteBuffer.mismatch APIs. These range overloads route through
ArraysSupport.vectorizedMismatch, an @IntrinsicCandidate helper HotSpot
substitutes with a SIMD byte-scan (SSE / AVX2 / NEON on modern hardware).

Measured on this project's BinaryComparisonBenchmark (JDK 17, 1 fork,
3x1s warmup, 5x1s measurement, throughput):

  equalsMatch_bytesBytes    len=512   9.1M ->  39.1M ops/s   (4.30x)
  equalsMatch_bytesBytes    len= 64  41.4M -> 160.9M ops/s   (3.89x)
  equalsMismatch_bytesBytes len=512  14.6M ->  55.5M ops/s   (3.79x)
  compareTo_bytesBytes      len=512  15.1M ->  58.8M ops/s   (3.91x)
  compareTo_bytesBytes      len= 64  59.0M -> 199.4M ops/s   (3.38x)
  equalsMismatch_bytesBuf   len= 64  63.8M -> 190.2M ops/s   (2.98x)

Short values (len=8) see only 1.1-1.7x because there's barely enough
work for one SIMD lane.

hashCode is separate. The 31*h+b polynomial has a serial dependency
across iterations, so SIMD needs a lane-split algebraic trick that
HotSpot only gained in JDK 21 (via ArraysSupport.vectorizedHashCode,
which is @IntrinsicCandidate). On JDK 17 -- this project's target --
Arrays.hashCode is a plain scalar loop and shows no measured speedup
here. The call is kept for forward compatibility: JDK 21+ runtimes pick
up the vectorized intrinsic silently; a bespoke loop would stay stuck
at scalar.

These primitives sit on Binary equality, comparison and hash-map
probing, which is the hot code path for statistics min/max maintenance,
dictionary probing, predicate evaluation, and bloom-filter build --
i.e. broadly amortized across reader and writer.

Semantics are preserved bit-for-bit:
- hashCode(byte[]) uses the same 31*h + b polynomial (Arrays.hashCode
  on full arrays; the polynomial expanded for slices).
- equals is bytewise identity.
- lexicographicCompare is unsigned bytewise with shorter-first tie-break
  on prefix match, matching Arrays.compareUnsigned's contract.

The change is JIT-friendly across the four Binary/Binary shape pairs
(byte[]/byte[], byte[]/ByteBuffer, ByteBuffer/ByteBuffer) and for
heap-backed ByteBuffers unwraps to the intrinsic byte[] path via
buffer.array().

Follows the same technique already accepted for DeltaByteArrayWriter in
PR apache#3465. Complementary to the Binary hashCode
caching in the open dictionary-optimization PR
apache#3566: caching reduces call frequency; intrinsics
speed up the calls that remain plus equals/compareTo which caching does
not address.

Includes a JMH micro-benchmark
(parquet-benchmarks/BinaryComparisonBenchmark) covering equals /
compareTo / hashCode across length regimes 8, 64 and 512 for the three
Binary shape combinations, with both worst-case (full match) and
average-case (mid-length mismatch) inputs.
@Fokko

Fokko commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Thanks for raising this PR @abstractdog, very interesting. Any way to verify using tests that we don't change hashes?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants