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

GH-3530: Optimize DICTIONARY encoding/decoding data structures and use ByteBuffer#3566
iemejia wants to merge 1 commit into
apache:masterfrom
iemejia:parquet-perf-v2-par2-dictionary

Conversation

@iemejia

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

BenchmarkBaseline (M ops/s)Optimized (M ops/s)Speedup
encodeInt HIGH_CARD14.923.51.58x
encodeLong HIGH_CARD12.019.21.60x
encodeFloat HIGH_CARD14.421.91.52x
encodeDouble HIGH_CARD11.717.91.53x
encodeBinary LOW len=1075.6125.61.66x
encodeBinary LOW len=10013.2107.88.2x
encodeBinary LOW len=10001.5148.3~100x
encodeBinary HIGH len=106.413.22.1x
encodeFlba HIGH len=126.315.42.4x
encodeFlba HIGH len=166.114.62.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
iemejiaforce-pushed the parquet-perf-v2-par2-dictionary branch from a0d51b6 to 99962e1CompareJuly 14, 2026 18:39
@iemejia

Copy link
Copy Markdown
MemberAuthor

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

1 participant

@iemejia