Read MAP keys without deserializing the whole frame, and read every projected key in one traversal - #19273
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #19273 +/- ##
============================================
- Coverage 67.18% 67.17% -0.02%
Complexity 1424 1424
============================================
Files 3462 3463 +1
Lines 220361 220480 +119
Branches 35147 35164 +17
============================================
+ Hits 148045 148097 +52
- Misses 60506 60571 +65
- Partials 11810 11812 +2
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 projections (e.g., attributes['key']) by avoiding full-map deserialization on sealed segments and by reading multiple projected keys in a single traversal per document/block, reducing repeated frame scans and allocations.
Changes:
- Add selective MAP key read hooks for sealed (chunked) forward indexes and ensure MAP frame readers force big-endian when consuming off-heap views.
- Introduce a multi-key string read API (
ForwardIndexReader#getMapEntryValuesAsString) and wire it throughDataFetchervia a newMapKeyGroupReaderto group projected MAP keys per block. - Reduce per-read allocations on the consuming path by caching a shared read-only region view in
MutableOffHeapByteArrayStore; add/extend tests and JMH benchmarks for coverage and perf validation.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pinot-spi/src/main/java/org/apache/pinot/spi/utils/MapUtils.java | Adds word-at-a-time key matching, forces BIG_ENDIAN for off-heap views, and introduces one-pass multi-key string extraction. |
| pinot-spi/src/test/java/org/apache/pinot/spi/utils/MapUtilsTest.java | Adds tests for BIG_ENDIAN forcing, key matching edge cases (word boundaries/UTF-8), and one-pass multi-key behavior. |
| pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/ForwardIndexReader.java | Adds default getMapEntryValuesAsString API for multi-key MAP key reads. |
| pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/forward/VarByteChunkForwardIndexReaderV4.java | Implements selective MAP key reads and multi-key one-pass reads for sealed chunked raw forward index. |
| pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/forward/VarByteChunkSVForwardIndexReader.java | Implements selective MAP key reads and multi-key one-pass reads for chunk SV reader. |
| pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/forward/VarByteSVMutableForwardIndex.java | Switches MAP reads to off-heap ByteBuffer views and adds multi-key read override. |
| pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/writer/impl/MutableOffHeapByteArrayStore.java | Caches a shared read-only region view and returns sliced value views to cut per-read allocations. |
| pinot-segment-local/src/test/java/org/apache/pinot/segment/local/io/writer/impl/MutableOffHeapByteArrayStoreTest.java | Adds coverage ensuring returned ByteBuffer views are independent (position/order changes don’t leak). |
| pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/map/MapKeyIndexReader.java | Adds accessors to support grouping and caches the default-null substitution string for absent keys. |
| pinot-core/src/main/java/org/apache/pinot/core/common/MapKeyGroupReader.java | New component to group multiple MAP-key “columns” sharing one underlying forward index and cache per-block results. |
| pinot-core/src/main/java/org/apache/pinot/core/common/DataFetcher.java | Groups MAP key readers by underlying forward index identity; adds per-block id stamping; closes group contexts. |
| pinot-core/src/main/java/org/apache/pinot/core/common/DataBlockCache.java | Calls DataFetcher.initNewBlock() to advance the block id for grouped MAP key caching. |
| pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/creator/VarByteChunkV4Test.java | Adds sealed MAP SV coverage across compression types/reader versions, validating selective + multi-key reads vs full-map. |
| pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkMapKeyAccess.java | Extends benchmark to include sealed chunked forward index paths and whole-map JSON rendering. |
| pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkMapMultiKeyProjection.java | New benchmark modeling multi-key MAP projection over blocks (single-key-per-pass vs combined traversal). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
eeef6b6 to
c85b9dc
Compare
| /// Built on first use rather than up front, so a store that is never read this way - every dictionary and every | ||
| /// non-MAP raw column - behaves exactly as before. Racing readers may each build one; they are interchangeable, | ||
| /// and the field is volatile so a reader never sees a half-initialized buffer. | ||
| private volatile ByteBuffer _readOnlyView; |
There was a problem hiding this comment.
This is a standalone optimization. Suggest separating it out as a separate PR. Same for other optimizations around bytes read
There was a problem hiding this comment.
Fair — the PR is doing more than one thing. Flagging that the split is not in this push: the author asked for the branch squashed to a single commit, so it is currently one commit containing the sealed-segment selective read, the byte-read optimizations you pointed at, and the multi-key grouping.
Happy to carve it into three: (A) the sealed selective read, which is the actual defect — sealed MAP columns had no selective path at all and deserialized the whole frame per key per document; (B) the byte-read work here (shared off-heap view, JsonBuilder bulk copy, word-at-a-time key compare); (C) the multi-key grouping. A is the smallest and carries nearly all of the win, so it would go first. Let me know if you would rather review it that way and I will split it.
9d852a8 to
fe9481d
Compare
MapKeyIndexReader#getString substitutes the key's default null value when a
document does not carry the key, and cached it as
_defaultNullValue.toString(). The two agree for every scalar type except
BYTES, whose default renders as an identity string ("[B@1b6d3586") rather
than hex. FieldSpec#getDefaultNullValueString is what the rest of the
codebase uses for exactly this.
Reported by Copilot on apache#19273.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rojected key in one visit A projected `attributes['key']` resolves to a single-key read against the column's forward index. apache#19168 gave the consuming index a scanning extractor for that, but the sealed side never got one: the chunked readers only overrode getMapAsJsonString, so getMapEntryValue fell through to the SPI default - getMap(docId).get(key) - and every key access on a completed segment built a full HashMap and ran Jackson over every entry. Override the two selective hooks on VarByteChunkForwardIndexReaderV4 (inherited by V5 and V6) and VarByteChunkSVForwardIndexReader, and take four allocations and copies off the shared path: - PreparedMapKey pre-packs its UTF-8 into big-endian longs, so the frame scan compares eight key bytes per buffer read instead of one. - JsonBuilder#appendRaw bulk-copies instead of walking the frame one ByteBuffer#get at a time, which off-heap is a separate load per byte. - getMap and getMapAsJsonString on the consuming index read the off-heap view rather than copying the whole frame to a byte[] first. - MutableOffHeapByteArrayStore caches one read-only view per region, so a value view costs a single slice rather than duplicate + slice + asReadOnlyBuffer. Built on first use, so every store that is never read this way behaves exactly as before. Separately, each projected key repeated the whole per-document walk, because each key is a column of its own and the projection reads columns one at a time. ForwardIndexReader#getMapEntryValuesAsString walks a document's frame once, testing every entry against all requested keys and stopping once each has been found; the default keeps the per-key behaviour, which is what a reader holding the map columnar-decomposed wants anyway. DataFetcher groups the key readers that share a forward index and reads them together; DataBlockCache decides when a block's values are still good, which is what it already does for every other column. The grouped read applies each key's default-null substitution, so an absent key still renders what MapKeyIndexReader#getString would have, and that default now comes from FieldSpec#getDefaultNullValueString - Object#toString renders a BYTES default as an identity string rather than hex. deserializeMap(ByteBuffer) now forces BIG_ENDIAN like the other two frame readers already did, since it is fed an off-heap view that inherits its source's order. Isolated JMH, JDK 25, 1024 documents, four projected keys, against the 30-key dotted attribute map from the reported workload: path upstream/master this work speedup sealed 16423.0 us 427.9 us 38.4x consuming 682.1 us 422.5 us 1.61x Sealed carries almost all of it because master has no selective read there at all. Consuming has less headroom because apache#19168 already took that win; what is left is dominated by materializing the four output strings. MapFieldTypeTest, MapFieldTypeRealtimeTest and MapFieldTypeMixedValueIngestingIntegrationTest cover the wiring end to end, including the multi-key projection and absent keys on both the sealed and consuming paths. VarByteChunkV4Test cross-checks the selective and one-pass reads against deserializing the whole frame for every compression type and reader version, and MapUtilsTest does the same over value shapes, absent keys, near-miss keys, word-boundary key lengths, multi-byte UTF-8 keys and a reused output buffer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a6104f6 to
6eee6a4
Compare
Two changes to how a MAP column is read, both aimed at
attributes['key']projection.1. Sealed segments had no selective read at all
A projected
attributes['key']resolves to a single-key read against the column's forward index. #19168 gave the consuming index a scanning extractor for that, but the sealed side never got one: the chunked readers only overrodegetMapAsJsonString, sogetMapEntryValuefell through to the SPI default —getMap(docId).get(key)— and every key access on a completed segment built a fullHashMapand ran Jackson over every entry.This overrides the two selective hooks on
VarByteChunkForwardIndexReaderV4(inherited by V5 and V6) andVarByteChunkSVForwardIndexReader, and takes four allocations and copies off the shared path:PreparedMapKeypre-packs its UTF-8 into big-endianlongs, so the frame scan compares eight key bytes per buffer read instead of one.JsonBuilder#appendRawbulk-copies instead of walking the frame oneByteBuffer#getat a time, which off-heap is a separate load per byte.getMapandgetMapAsJsonStringon the consuming index read the off-heap view rather than copying the whole frame to abyte[]first.MutableOffHeapByteArrayStorecaches one read-only view per region, so a value view costs a singleslicerather thanduplicate+slice+asReadOnlyBuffer. Built on first use, so every store never read this way behaves exactly as before.deserializeMap(ByteBuffer)now forcesBIG_ENDIANlike the other two frame readers already did, since it is fed an off-heap view that inherits its source's order.2. Every projected key repeated the whole per-document walk
Each projected key is a column of its own and the projection reads columns one at a time, so four keys fetched and traversed the same frame four times.
ForwardIndexReader#getMapEntryValuesAsStringwalks a document's frame once, testing every entry against all requested keys and stopping once each has been found. The default keeps the per-key behaviour, which is what a reader holding the map columnar-decomposed wants anyway.It is wired through
DataFetcher, the one object with the right scope: it owns every column reader for one segment of one query on one thread, and closes them explicitly. Key readers sharing an underlying forward index are grouped, and the first read of a block extracts all of them. Grouping covers the STRING path only, which is what a projected key of a string-valued MAP column resolves to.Two details the grouped read reproduces exactly: it applies each key's default-null-value substitution, so an absent key still renders what
MapKeyIndexReader#getStringwould have; and it keys its cache on a block id stamped byDataFetcher#initNewBlockrather than on the doc id array, which the projection reuses across blocks.Benchmarks
Isolated JMH, JDK 25, 1024 documents, four projected keys, against the 30-key dotted attribute map from the reported workload (
BenchmarkMapMultiKeyProjection):Sealed carries almost all of it because master has no selective read there at all. Consuming has less headroom because #19168 already took that win; what is left is dominated by materializing the four output strings.
Blending the two by the share of scanned documents that sit in consuming segments, this clears 5x until consuming holds about 90% of them — so for any scan reaching past the most recent minutes of a table.
Per-read microbenchmarks (
BenchmarkMapKeyAccess, valuesflat, target keylast, µs/op):The whole-map row is the
SELECT attributesandLASTWITHTIME(attributes, ..., 'STRING')path, on top of the 3.2-5.7x #19169 measured.One consuming cell in that second table (selective-as-string at 64 synthetic equal-length keys, 0.516 → 0.538) came out flat rather than faster; raising
FreqInlineSizerecovers part of it, so it reads as an inlining cliff plus benchmark layout noise rather than a real regression.Tests
MapFieldTypeTest,MapFieldTypeRealtimeTestandMapFieldTypeMixedValueIngestingIntegrationTestcover the wiring end to end, including the multi-key projection query and absent keys, on both the sealed and consuming paths.VarByteChunkV4Testgains a MAP case that runs across every compression type and, throughVarByteChunkV5Test/VarByteChunkV6Test, every reader version — cross-checking the selective and one-pass reads against deserializing the whole frame.MapUtilsTestcovers the word-at-a-time comparison directly (key lengths on, just under and just over the word boundary; keys agreeing over whole words and diverging only afterwards; multi-byte UTF-8 keys, whose high-bit bytes must not sign-extend into the packed word; read-only and offset buffers; the byte-order contract) and the one-pass read against the per-key read over value shapes, absent keys, near-miss keys and a reused output buffer.MutableOffHeapByteArrayStoreTestpins that views sliced from the shared region stay independent.Follow-ups not in this PR
getInt/getLong/getDoubleonMapKeyIndexReader) still bind through Jackson. A direct byte-level decode carries real semantics around precision and overflow, so it belongs in its own change.attributes['a'] = x AND attributes['b'] = ystill scans the frame once per predicate.🤖 Generated with Claude Code