Optimize consuming MAP key access - #19168
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #19168 +/- ##
============================================
- Coverage 66.63% 57.31% -9.32%
+ Complexity 1423 7 -1416
============================================
Files 3443 2649 -794
Lines 218663 159046 -59617
Branches 34801 26134 -8667
============================================
- Hits 145705 91157 -54548
+ Misses 61230 60078 -1152
+ Partials 11728 7811 -3917
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR optimizes MAP-key lookups in Pinot’s forward index path by enabling selective value extraction (scan serialized MAP entries and deserialize only the matching value) instead of deserializing the entire MAP for every key access.
Changes:
- Added
ForwardIndexReader#getMapValue(...)as a default SPI API, and switchedMapKeyIndexReaderto use it (with a full-map fallback via the default implementation). - Implemented
MapUtils.deserializeMapValue(...)to scan length-prefixed MAP frames and deserialize only the selected value, including support for directByteBufferinputs (e.g., off-heap views). - Introduced a read-only zero-copy
ByteBufferview inMutableOffHeapByteArrayStore, added focused unit tests, and added a JMH benchmark to compare approaches.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pinot-spi/src/test/java/org/apache/pinot/spi/utils/MapUtilsTest.java | Adds unit coverage for selective MAP-value extraction (colliding keys, non-ASCII keys, byte order). |
| pinot-spi/src/main/java/org/apache/pinot/spi/utils/MapUtils.java | Adds selective MAP-value deserialization from a length-prefixed MAP frame, including direct-buffer support. |
| pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/ForwardIndexReader.java | Adds default getMapValue(...) API to allow optimized implementations while preserving fallback behavior. |
| pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/map/MapKeyIndexReaderTest.java | Verifies MapKeyIndexReader works both with selective and fallback implementations. |
| pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/mutable/VarByteSVMutableForwardIndexTest.java | Adds coverage for the mutable forward index’s selective getMapValue(...) path. |
| pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/map/MapKeyIndexReader.java | Switches extraction to ForwardIndexReader#getMapValue(...) to enable selective reads. |
| pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/forward/VarByteSVMutableForwardIndex.java | Overrides getMapValue(...) to use selective ByteBuffer-based extraction. |
| pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/writer/impl/MutableOffHeapByteArrayStore.java | Adds a read-only, zero-copy ByteBuffer accessor for stored values. |
| pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkMapKeyAccess.java | Adds JMH benchmark comparing full-map deserialize vs selective key lookup. |
| int valueLength = byteBuffer.getInt(); | ||
| if (!matches) { | ||
| skip(byteBuffer, valueLength); | ||
| continue; | ||
| } | ||
| // Keys within a frame are unique - the write path iterates a Map - so the first match is the only match and | ||
| // the remaining entries never need to be scanned. | ||
| byte[] valueBytes = new byte[valueLength]; | ||
| byteBuffer.get(valueBytes); |
| assertReaderBehavior(new FullMapOnlyReader()); | ||
| } | ||
|
|
||
| private static void assertReaderBehavior(ForwardIndexReader reader) { |
| @SuppressWarnings("rawtypes") | ||
| private abstract static class BaseReader implements ForwardIndexReader { |
| /// Deserializes only the value for the requested key from a length-prefixed MAP frame. | ||
| /// Non-matching keys and values are skipped without allocating byte arrays or invoking Jackson. | ||
| /// | ||
| /// @param bytes Serialized MAP frame | ||
| /// @param key Key whose value should be deserialized | ||
| /// @return Deserialized value, or `null` if the key is missing, has a null value, or cannot be deserialized | ||
| @Nullable | ||
| public static Object deserializeMapValue(byte[] bytes, String key) { |
cf37985 to
808a5f5
Compare
deserializeMapValue walked every key one relative get at a time - even after a mismatch was already certain - purely to advance the position, and kept scanning the frame after the match was found. Compare through absolute gets so a length mismatch or a differing byte skips the rest of the key outright, and return on the first match. Keys within a frame are unique because the write path iterates a Map, so the first match is the only match. Bounds-check the key length up front so the absolute gets are provably in range and a truncated frame still surfaces as BufferUnderflowException. Isolated JMH, flat string values, fixed-length dotted keys, JDK 25: entries key full map before after 4 first 0.556 0.166 0.114 us/op 16 first 2.163 0.360 0.112 us/op 64 first 8.886 1.074 0.118 us/op 64 last 8.763 1.245 0.593 us/op First-key lookup no longer scales with map size. Allocation is unchanged at 856 B/op versus 62792 B/op for the full-map path. Also cover MapKeyIndexReader, which had no test despite being the caller that changed, over both a reader that overrides getMapValue and one that inherits the default, plus non-ASCII keys and a little-endian buffer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
808a5f5 to
aa56bbb
Compare
Description
Consuming MAP key lookups currently materialize and deserialize the full MAP value before extracting one requested key. This change adds a selective forward-index read path that scans the serialized MAP entries and deserializes only the matching value.
Changes
ForwardIndexReader#getMapValueAPI while preserving the existing full-MAP fallback.MapKeyIndexReaderand add focused unit coverage.BenchmarkMapKeyAccessfor comparing full-map and selective lookup costs.Performance
Isolated JMH run for a 64-entry MAP, looking up the last key, on JDK 25 (1 fork, 2 warmup iterations, 3 measurement iterations):
The benchmark is an isolated forward-index lookup measurement; it does not represent an end-to-end broker/server query latency result.
Validation
./mvnw -pl pinot-spi,pinot-segment-local -am -Dtest=MapUtilsTest,VarByteSVMutableForwardIndexTest -Dsurefire.failIfNoSpecifiedTests=false test./mvnw -pl pinot-perf -am -DskipTests package./mvnw spotless:apply -pl pinot-spi,pinot-segment-spi,pinot-segment-local,pinot-perf./mvnw license:format license:check -pl pinot-spi,pinot-segment-spi,pinot-segment-local,pinot-perfgit diff --check