Skip to content

[WIP] Optimize heap usage of dictionary build to support dict on high cardinality STRINGs (fast filtering)#229

Draft
siddharthteotia wants to merge 5 commits into
masterfrom
siddharthteotia/offheap-dict-creation-layer1
Draft

[WIP] Optimize heap usage of dictionary build to support dict on high cardinality STRINGs (fast filtering)#229
siddharthteotia wants to merge 5 commits into
masterfrom
siddharthteotia/offheap-dict-creation-layer1

Conversation

@siddharthteotia

Copy link
Copy Markdown
Collaborator

No description provided.

siddharthteotia and others added 5 commits June 30, 2026 00:52
…ING/BYTES (3a)

For high-cardinality STRING/BYTES columns, the on-heap value->dictId map
(Object2IntOpenHashMap) is a dominant heap cost during offline segment
generation and a frequent OOM source. Above a configurable cardinality
threshold, skip building that map and resolve dictIds by binary-searching the
just-written, mmap-backed dictionary buffer (via the same immutable dictionary
reader used at load time) instead. Trades an O(1) on-heap lookup for an
O(log N) off-heap read per value, removing the map from heap entirely.

- Opt-in: threshold defaults to Integer.MAX_VALUE, so default behavior is
  unchanged. Decision is per-column, against that column's own cardinality.
- Scoped to STRING/BYTES; numeric dictionaries keep their cheap primitive maps.
- Lookup reader + mmap buffer are released in postIndexingCleanup()/close().
- 22 functional tests prove indexOfSV/indexOfMV return identical dictIds in
  map mode vs binary-search mode (STRING/BYTES x var/fixed x SV/MV), and the
  PinotBuffersAfterMethodCheckRule confirms no buffer leaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… columns (3b)

3a removes the on-heap value->dictId map for high-cardinality STRING/BYTES
columns, but the distinct values stay pinned by the sorted unique-values array
held in the stats collector through the whole creation. This frees that array
right after init() (once the dictionary and any value-array-consuming indexes
such as FST are built, before the row-indexing pass), so the GC can reclaim the
distinct values. Together with 3a this drops a high-card column's dictionary
heap to ~0.

- ColumnStatistics gains a default no-op releaseSortedValues(); String/Bytes
  collectors override it to cache min/max/cardinality, then null _sortedValues.
  Getters return the cached scalars once released; numeric collectors keep the
  default no-op (3b is scoped to STRING/BYTES, the OOM-driving types).
- ColumnIndexCreationInfo.getDistinctValueCount() falls back to the cached
  cardinality when the array is null (previously a dead null-branch).
- SegmentColumnarIndexCreator.init() releases the array only for columns in
  binary-search mode (creator.isUsingBinarySearchLookup()).
- Like 3a, dormant until a threshold config flips columns to binary search.
- Tests: collector release keeps min/max/cardinality and ColumnIndexCreationInfo
  reporting; numeric release is a no-op; DictionariesTest regression green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…a/3b)

- BenchmarkDictionaryHeapFootprint: JOL GraphLayout retained size of the
  map-mode structures (sorted String[] + Object2IntOpenHashMap) across
  cardinality x avg length. Confirms the on-heap footprint binary-search mode
  eliminates, and validates the ~(60+L) bytes/value model.
- BenchmarkSegmentDictionaryCreation: JMH lookupMap vs lookupBinarySearch
  per-row dictId cost, plus buildMap/buildBinarySearch, swept over cardinality
  x avg length x var/fixed encoding.
- Adds jol-core (0.17) to pinot-perf.

Measured (JOL): bytes/value tracks 60+L within a few percent (e.g. 1M x L=32 =
88 MB, 3M x L=100 = 455 MB), all eliminated in binary-search mode.
Measured (JMH, indicative): binary search is a ~30-55x per-lookup penalty vs the
hash map (constant factor, no cardinality crossover), i.e. ~2-4 s extra encode
per fat column on a 5M-row segment -- the bounded CPU price for the heap saved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fig)

3a/3b added a per-column binary-search dictId lookup (skipping the on-heap
value->dictId map) plus early release of the sorted unique-values array for
high-cardinality STRING/BYTES columns, but the flip was driven only by
SegmentDictionaryCreator._onHeapLookupMapMaxCardinality (default
Integer.MAX_VALUE), which nothing in production sets -- so the feature was
dormant. This wires it to a real, per-table config so it can fire end-to-end,
gated on an estimated byte budget rather than a flat cardinality count.

New knob IndexingConfig.dictionaryOnHeapLookupMapMaxBytes (a size in bytes;
default Long.MAX_VALUE = effectively disabled, so behavior is unchanged unless
set). A dict-enabled STRING/BYTES column flips to the low-heap path when its
estimated on-heap footprint (60 + maxLen) * cardinality >= the budget, where
cardinality = numValues and maxLen = the column's longest value in bytes (the L
proxy from the (60 + L) per-value model measured in the design doc). A byte
budget (vs a flat cardinality) correctly targets long-string columns, which a
flat count under-targets.

Plumbing: IndexingConfig -> SegmentGeneratorConfig -> IndexCreationContext ->
DictionaryIndexType.createIndexCreator. The gate is applied by converting the
byte budget to the equivalent per-column cardinality threshold
(ceil(budget / (60 + maxLen)) - 1) and feeding the existing, already-tested
setOnHeapLookupMapMaxCardinality(int) setter, so the tested lookup path in
SegmentDictionaryCreator is untouched. No-op for numeric columns (cheap
primitive maps, never the OOM driver) and when the budget is disabled.

Compiles clean (pinot-spi, pinot-segment-spi, pinot-segment-local). Tests
(gate-wiring unit test + end-to-end build/load test) to follow in a subsequent
commit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Covers the byte-budget wiring added in the previous commit, which flips a
high-cardinality STRING/BYTES column to the low-heap binary-search dictId lookup
(3a) + early sorted-array release (3b) when its estimated on-heap footprint
(60 + maxLen) * cardinality >= the configured budget.

DictionaryIndexTypeOnHeapLookupBudgetTest (gate-wiring unit test): asserts the
per-column flip decision made by DictionaryIndexType.createIndexCreator matches
the byte-budget formula exactly at the boundary (one below stays on the on-heap
map, at/above flips), across several (budget, maxLen) points, for STRING and
BYTES and for both single-value (SV) and multi-value (MV) columns -- the switch
is scoped to the stored type, so it must flip identically for SV and MV. Also
verifies that every non-STRING/BYTES stored type (INT, LONG, FLOAT, DOUBLE,
BIG_DECIMAL), SV and MV, never flips even at a tiny budget, and that the
default/disabled budget (Long.MAX_VALUE, <= 0) keeps map mode -- including that
a fresh, unconfigured IndexingConfig defaults to disabled (no behavior change).
This nails the ceil/off-by-one conversion from byte budget to the creator's
strict cardinality > threshold.

SegmentGenerationWithDictionaryOnHeapLookupBudgetTest (end-to-end): builds a
real segment carrying all four in-scope column types -- SV STRING, MV STRING,
SV BYTES, MV BYTES (plus a LONG metric) -- and, because the on-disk segment is
identical whether or not a column flips, directly confirms which path each
column took by reading SegmentDictionaryCreator.isUsingBinarySearchLookup() off
the driver's columnar index creator. It asserts the switch fired for every
STRING/BYTES column when enabled, fired for none under the default/disabled
config, and that in both cases every row round-trips through the
forward-index -> dictionary path (incl. MV element order and BYTES values) with
correct min/max/cardinality metadata. A parity build (ON vs OFF) asserts the two
paths produce identical row values and metadata, so a bug in the binary-search
dictId encoding for any SV/MV STRING/BYTES column would surface. The direct
fired-flag observation is what makes these correctness checks non-vacuous.

All new tests pass (18 gate-wiring + 4 end-to-end); existing dictionary/context
tests (DictionariesTest, SegmentDictionaryCreatorBinarySearchLookupTest,
ColumnStatisticsReleaseSortedValuesTest, DictionaryIndexTypeTest,
InvertedIndexVersionConfigTest) remain green. Spotless + checkstyle clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@siddharthteotia siddharthteotia changed the title [WIP] Reduce memory footprint during dictionary creation to support dictionary on higher cardinality STRINGs for faster filtering [WIP] Reduce memory footprint of dictionary creation to support dict on high cardinality STRINGs (fast filtering) Jul 4, 2026
@siddharthteotia siddharthteotia changed the title [WIP] Reduce memory footprint of dictionary creation to support dict on high cardinality STRINGs (fast filtering) [WIP] Optimize heap usage of dictionary build to support dict on high cardinality STRINGs (fast filtering) Jul 5, 2026
Sign up for free to 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