Skip to content

GH-3530: Optimize DICTIONARY encoding/decoding data structures and use ByteBuffer - #3566

Open
iemejia wants to merge 1 commit into
apache:masterfrom
iemejia:parquet-perf-v2-par2-dictionary
Open

iemejia wants to merge 1 commit into
apache:masterfrom
iemejia:parquet-perf-v2-par2-dictionary

Conversation

@iemejia

@iemejia iemejia commented May 17, 2026

Copy link
Copy Markdown
Member

Part of #3530 — Apache Parquet Java Performance Improvements

Summary

Optimize dictionary encoding and decoding data structures.

Encoding:

  • Replace LinkedOpenHashMap with OpenHashMap + ArrayList for all DictionaryValuesWriter subclasses, eliminating insertion-order linked-list overhead and enabling O(1) indexed access for dictionary page serialization and fallback.
  • Make IntList.size() O(1) by tracking totalSize incrementally instead of summing across slab arrays.

Decoding:

  • Convert PlainValuesDictionary numeric constructors (INT32, INT64, FLOAT, DOUBLE) from InputStream-based per-byte reads to direct ByteBuffer.getInt/getLong/getFloat/getDouble.

Binary hashCode caching:

  • Cache hashCode() for Binary instances not backed by reusable byte arrays, avoiding redundant recomputation during dictionary hash-map probes.

JMH benchmarks: DictionaryEncodingBenchmark, DictionaryDecodingBenchmark with TestDataFactory and BenchmarkEncodingUtils.

Benchmark results

Environment: JDK 25.0.3 (Temurin), OpenJDK 64-Bit Server VM, JMH 1.37, Linux x86_64.

Encoding (100K values/iteration, 2 averaged runs):

Benchmark Baseline (M ops/s) Optimized (M ops/s) Speedup
encodeInt HIGH_CARD 14.9 23.5 1.58x
encodeLong HIGH_CARD 12.0 19.2 1.60x
encodeFloat HIGH_CARD 14.4 21.9 1.52x
encodeDouble HIGH_CARD 11.7 17.9 1.53x
encodeBinary LOW len=10 75.6 125.6 1.66x
encodeBinary LOW len=100 13.2 107.8 8.2x
encodeBinary LOW len=1000 1.5 148.3 ~100x
encodeBinary HIGH len=10 6.4 13.2 2.1x
encodeFlba HIGH len=12 6.3 15.4 2.4x
encodeFlba HIGH len=16 6.1 14.6 2.4x
Numeric LOW_CARD (all types) ~120 ~120 ~1.0x

The extreme Binary LOW_CARD speedup (up to ~100x for len=1000) is due to eliminating LinkedOpenHashMap per-entry linked-list overhead, autoboxing, and Binary.hashCode() recomputation. With only ~100 distinct values in the hash map, the old code spent most time on hashCode() over the full key bytes at every probe.

Decoding: ~1.0x across all types (the ByteBuffer constructor optimization is once per row group; per-value decode is an array index lookup and was not changed).

…and use ByteBuffer

Encoding improvements:
- Replace LinkedOpenHashMap with OpenHashMap + ArrayList for all
  DictionaryValuesWriter subclasses, eliminating insertion-order
  overhead and enabling O(1) indexed access for dictionary page
  serialization and fallback
- Make IntList.size() O(1) by tracking totalSize incrementally
  instead of summing across slab arrays

Decoding improvements:
- Convert PlainValuesDictionary numeric constructors (INT32, INT64,
  FLOAT, DOUBLE) from InputStream-based per-byte reads to direct
  ByteBuffer.getInt/getLong/getFloat/getDouble (JVM intrinsics)

Binary hashCode caching:
- Cache hashCode() for Binary instances that are not backed by reusable
  byte arrays, avoiding redundant recomputation during dictionary
  lookups (hash map probes)

JMH benchmarks:
- DictionaryEncodingBenchmark: scalar encoding for INT32, INT64, FLOAT,
  DOUBLE, BINARY, and FIXED_LEN_BYTE_ARRAY with LOW/HIGH cardinality
  and variable-length string/FLBA dimensions
- DictionaryDecodingBenchmark: scalar decoding for all types with
  matching parameterization
- TestDataFactory: shared data generation utility for reproducible
  benchmark inputs
- BenchmarkEncodingUtils: helper to drain DictionaryValuesWriter into
  encoded dictionary page + data bytes for decoder setup
@iemejia
iemejia force-pushed the parquet-perf-v2-par2-dictionary branch from a0d51b6 to 99962e1 Compare July 14, 2026 18:39
@iemejia

iemejia commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

This one is probably the second one with the most impact of the PRs, in case you have some cycles or know of someone who can take a look at this @Fokko . Notice that the core changes are small, the extras are the benchmark and the tests, but the core PR is smaller than it seems.

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

1 participant