Skip to content

GH-3464: Improve DeltaByteArrayWriter.writeBytes to avoid unnecessary allocation and scalar prefix comparison - #3465

Merged
Fokko merged 3 commits into
apache:masterfrom
arouel:dba-write-bytes
May 6, 2026
Merged

GH-3464: Improve DeltaByteArrayWriter.writeBytes to avoid unnecessary allocation and scalar prefix comparison#3465
Fokko merged 3 commits into
apache:masterfrom
arouel:dba-write-bytes

Conversation

@arouel

@arouelarouel commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

DeltaByteArrayWriter.writeBytes() is on the hot path for DELTA_BYTE_ARRAY encoding and had avoidable overhead:

  1. Per-value allocation from getBytes().
    getBytes() always creates a new array. For prefix comparison this copy is unnecessary.

  2. Scalar prefix scan.
    The byte-by-byte loop is replaced with Arrays.mismatch(...), which maps to optimized JVM intrinsics.

In profiling (custom JFR benchmark on a large merge workload), this method was a top allocation hotspot before the change.

What changes are included in this PR?

In DeltaByteArrayWriter.writeBytes():

  • v.getBytes() -> v.getBytesUnsafe() for read-only prefix comparison.
  • Manual prefix loop -> Arrays.mismatch(previous, 0, length, vb, 0, length).
  • previous = vb -> previous = v.isBackingBytesReused() ? v.getBytes() : vb
    (defensive copy only when the backing bytes may be reused by the caller).

This preserves semantics while removing unnecessary allocations in the common case.

Benchmark signal (custom, directional)

On a custom JFR-profiled merge workload (180M rows, 4 binary columns), this change significantly reduced allocations and lowered CPU attributed to this path.
(Results are workload/JDK dependent and provided as directional evidence.)

Are these changes tested?

Yes.

  • Existing coverage: TestDeltaByteArray round-trip tests (including skip/skipN/reset paths).
  • Added regression test: testReusedBackingArrayRegression in TestDeltaByteArray to verify correctness when the same mutable backing array is reused across writes.

Are there any user-facing changes?

No API or format changes. This is a transparent performance optimization; encoded data remains compatible/interchangeable.

Closes#3464

@arouelarouel changed the title GH-3464 Improve DeltaByteArrayWriter.writeBytes to avoid unnecessary allocation and scalar prefix comparisonGH-3464: Improve DeltaByteArrayWriter.writeBytes to avoid unnecessary allocation and scalar prefix comparisonApr 12, 2026

@FokkoFokko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a minor optimization, but apart from that, this looks great to me 👍

@arouel

Copy link
Copy Markdown
ContributorAuthor

@Fokko thank you for the feedback. I applied your suggestions.

@Fokko

Fokko commented May 6, 2026

Copy link
Copy Markdown
Contributor

Thanks for the follow up @arouel Let's get this in, this looks great! 🙌 Thanks for introducing Arrays.mismatch here!

@Fokko
Fokko merged commit 2346fdb into apache:masterMay 6, 2026
5 checks passed
abstractdog added a commit to abstractdog/parquet-java that referenced this pull request Sep 7, 2026
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.
abstractdog added a commit to abstractdog/parquet-java that referenced this pull request Sep 7, 2026
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.
Sign up for freeto 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.

Improve DeltaByteArrayWriter.writeBytes to avoid unnecessary allocation and scalar prefix comparison

2 participants

@arouel@Fokko