Optimize consuming MAP key access - #19168
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #19168 +/- ##
============================================
+ Coverage 66.94% 66.95% +0.01%
Complexity 1423 1423
============================================
Files 3452 3452
Lines 218534 218581 +47
Branches 34741 34751 +10
============================================
+ Hits 146288 146356 +68
+ Misses 60559 60533 -26
- Partials 11687 11692 +5
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) { |
15c0fff to
acbae9a
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>
acbae9a to
041ffa6
Compare
|
|
||
| /// Returns a view of the value at the given index without copying it. | ||
| /// The returned buffer must not be used after this store is closed. | ||
| public ByteBuffer getByteBuffer(int index) { |
There was a problem hiding this comment.
(minor) Move this before getValueSize()
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (3)
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/forward/VarByteSVMutableForwardIndex.java:106
- Place
@Nullableabove@Overrideto follow Pinot's annotation ordering convention (kb/code-review-principles.md:1023-1025).
@Override
@Nullable
pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/map/MapKeyIndexReaderTest.java:67
- Place
@Nullableabove@Overrideto follow Pinot's annotation ordering convention (kb/code-review-principles.md:1023-1025).
@Override
@Nullable
pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkMapKeyAccess.java:112
- This reuses an already-created
_directBuffer, but the production path creates a direct view and then a read-only view through_byteArrayStore.getByteBuffer(docId)on every lookup. The selective score therefore omits newly introduced per-row allocation and lookup overhead, so it is not the isolated forward-index lookup described in the PR. BenchmarkVarByteSVMutableForwardIndex#getMapValue(or bothMutableOffHeapByteArrayStore#getandgetByteBuffer) directly so the comparison covers the actual old and new paths.
public Object selectiveMapValue() {
_directBuffer.position(0);
return MapUtils.deserializeMapValue(_directBuffer, _targetKey);
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