Skip to content

Read MAP keys without deserializing the whole frame, and read every projected key in one traversal - #19273

Open
xiangfu0 wants to merge 1 commit into
apache:masterfrom
xiangfu0:xiangfu0/map-consuming-perf-followup
Open

Read MAP keys without deserializing the whole frame, and read every projected key in one traversal#19273
xiangfu0 wants to merge 1 commit into
apache:masterfrom
xiangfu0:xiangfu0/map-consuming-perf-followup

Conversation

@xiangfu0

@xiangfu0 xiangfu0 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

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

This overrides the two selective hooks on VarByteChunkForwardIndexReaderV4 (inherited by V5 and V6) and VarByteChunkSVForwardIndexReader, and takes 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 never read this way behaves exactly as before.

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.

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

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#getString would have; and it keys its cache on a block id stamped by DataFetcher#initNewBlock rather 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):

path upstream/master this PR speedup
sealed 16423.0 µs 427.9 µs 38.4x
consuming 682.1 µs 422.5 µs 1.61x

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, values flat, target key last, µs/op):

benchmark entries before after speedup
sealed selective key 16 2.216 0.184 12.0x
sealed selective key 64 8.663 0.630 13.7x
whole-map projection 16 1.046 0.526 2.0x
whole-map projection 64 4.078 2.012 2.0x

The whole-map row is the SELECT attributes and LASTWITHTIME(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 FreqInlineSize recovers part of it, so it reads as an inlining cliff plus benchmark layout noise rather than a real regression.

Tests

MapFieldTypeTest, MapFieldTypeRealtimeTest and MapFieldTypeMixedValueIngestingIntegrationTest cover the wiring end to end, including the multi-key projection query and absent keys, on both the sealed and consuming paths.

VarByteChunkV4Test gains a MAP case that runs across every compression type and, through VarByteChunkV5Test / VarByteChunkV6Test, every reader version — cross-checking the selective and one-pass reads against deserializing the whole frame.

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

MutableOffHeapByteArrayStoreTest pins that views sliced from the shared region stay independent.

Follow-ups not in this PR

  • Numeric map values (getInt / getLong / getDouble on MapKeyIndexReader) still bind through Jackson. A direct byte-level decode carries real semantics around precision and overflow, so it belongs in its own change.
  • Grouping is not applied to the filter path, so attributes['a'] = x AND attributes['b'] = y still scans the frame once per predicate.

🤖 Generated with Claude Code

@codecov-commenter

codecov-commenter commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 47.91667% with 75 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.17%. Comparing base (84becf5) to head (6eee6a4).

Files with missing lines Patch % Lines
...rg/apache/pinot/core/common/MapKeyGroupReader.java 0.00% 32 Missing ⚠️
...a/org/apache/pinot/core/common/DataBlockCache.java 12.50% 13 Missing and 1 partial ⚠️
...java/org/apache/pinot/core/common/DataFetcher.java 18.75% 10 Missing and 3 partials ⚠️
...ders/forward/VarByteChunkSVForwardIndexReader.java 0.00% 4 Missing ⚠️
...ime/impl/forward/VarByteSVMutableForwardIndex.java 25.00% 3 Missing ⚠️
...ent/local/segment/index/map/MapKeyIndexReader.java 50.00% 3 Missing ⚠️
...t/segment/spi/index/reader/ForwardIndexReader.java 0.00% 3 Missing ⚠️
...main/java/org/apache/pinot/spi/utils/MapUtils.java 94.23% 1 Missing and 2 partials ⚠️
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     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 67.17% <47.91%> (-0.02%) ⬇️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 67.17% <47.91%> (-0.02%) ⬇️
unittests 67.16% <47.91%> (-0.02%) ⬇️
unittests1 57.81% <37.50%> (-0.03%) ⬇️
unittests2 39.23% <43.75%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@xiangfu0 xiangfu0 changed the title Read a single MAP key without deserializing the whole frame on sealed segments Read MAP keys without deserializing the whole frame, and read every projected key in one traversal Aug 16, 2026
@xiangfu0
xiangfu0 requested review from Jackie-Jiang and a lite review from Copilot August 17, 2026 06:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 through DataFetcher via a new MapKeyGroupReader to 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.

@xiangfu0
xiangfu0 force-pushed the xiangfu0/map-consuming-perf-followup branch from eeef6b6 to c85b9dc Compare August 17, 2026 09:03
@Jackie-Jiang Jackie-Jiang added enhancement Improvement to existing functionality query Related to query processing performance Related to performance optimization labels Aug 17, 2026
Comment thread pinot-core/src/main/java/org/apache/pinot/core/common/DataFetcher.java Outdated
/// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a standalone optimization. Suggest separating it out as a separate PR. Same for other optimizations around bytes read

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@xiangfu0
xiangfu0 force-pushed the xiangfu0/map-consuming-perf-followup branch from 9d852a8 to fe9481d Compare August 18, 2026 09:04
xiangfu0 added a commit to xiangfu0/pinot that referenced this pull request Aug 18, 2026
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>
@xiangfu0
xiangfu0 force-pushed the xiangfu0/map-consuming-perf-followup branch from a6104f6 to 6eee6a4 Compare August 19, 2026 06:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improvement to existing functionality performance Related to performance optimization query Related to query processing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants