Repository navigation
perf(reader): filter dictionary columns by their values and codes in bulk - #576
Merged
Merged
Conversation
dfa1
force-pushed
the
perf/dict-filter
branch
from
October 19, 2026 19:55
c8833fc to
58a92a5
Compare
…bulk A WHERE on a dictionary-encoded string column already evaluated the predicate once per dictionary value (#552), but then: - re-evaluated those values for every chunk, although Rust's global dict layout shares one dictionary across all of them; - tested each row through VarBinDictArray.code(i), a per-row VarHandle read behind a lambda: 216 of 616 samples on a 100M-row file; - read every code once more at decode just to bound the pool walk (checkedPoolExtent, 50 samples); - decoded and tested every code even when no value can match. Now, as Rust's dict reader does: 1. The u8/u16 codes are mapped to a row bitmap in one pass over their segment, Rust's values.take(codes), in the cheapest of three shapes: no value matches (only the codes' maximum is checked, vectorized), one value matches (a compare against its code, taken on the matching rows only), several match (a branch-free lookup table). A code past the dictionary falls back to the per-row path, which reports it. Each loop is its own method: sharing one made the table loop 6% slower. 2. Compute.matcher(RowFilter) carries the per-value results and the code table across the chunks of one scan, as Rust caches its values evaluation per dict reader. 3. A pool no longer than its codes is walked whole, which the codes bound as well as the scan did; only a constant pool or a longer one is scanned. 4. ScanIterator prunes a dict layout's chunks when no value satisfies a null-rejecting filter, decided from the decoded values alone (their length, not the layout's claim), decoded once per column's dictionary. Rust always reads the codes here, so a corrupt codes buffer under such a filter goes unreported: a deliberate divergence, recorded in docs/compatibility.md. Skipping the code scan for short pools exposed a raw IndexOutOfBoundsException on a negative 64-bit code into a nullable pool, which flat pools already hit on main: the pool-validity gather now rejects a negative code too. 100M-row cashflow file, jdbc:vortex: driver, warm JVM (median of the last 8 of 15 runs): count(*) where ISIN = 'ZZYRS9DBOAM3' 293 -> 145 ms count(*) where ISIN = 'NOPE' 292 -> 9 ms (nothing scanned) select eventIdentifier where ISIN = ... 386 -> 222 ms select * where ISIN = ... 790 -> 633 ms count(*) where taxCountry = 'XX' 107 -> 9 ms ISIN in (two values) unchanged vs the table loop YourKit over 15 runs of the first query: the per-row test went from 3,400 ms to a 620 ms bitmap pass; what remains is unpacking the bit-packed codes once (Rust pays it too) and the enumerator's walk over the rows. Tests: ComputeTest runs the matcher cases over u8, u16 and i32 codes, a u8 dictionary longer than 256 entries, a code past the dictionary with no value matching, and the memo's reuse; ScanIteratorDictPruningTest covers the pruning rules (and/or/null, null values, a pool decoding longer than claimed, the size guard, malformed values); DictLayoutDecoderTest covers the negative code and a code past a pool walked whole. DictFilterInteropIntegrationTest filters vortex-jni files with 4 and 300 values, a vortex-java file, and a vortex-java per-chunk dictionary (u8 codes) against a plain Java oracle, and checks a missing value skips every chunk of the layouts (it fails with pruning disabled). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dfa1
force-pushed
the
perf/dict-filter
branch
from
October 19, 2026 20:15
58a92a5 to
e56a50f
Compare
dfa1
added a commit
that referenced
this pull request
Oct 20, 2026
After #576 a dictionary filter's rows are a bitmap, but the SQL enumerator still called the row test once per row: 720 ms of a 3,060 ms YourKit profile of 15 runs of `count(*) where ISIN = ...` over 100M rows, for about 20k matching rows. Compute.matcher now returns a RowSelection, a LongPredicate with nextSelected(from), the BitSet.nextSetBit walk: a selection whose leaves include dictionary bitmaps ANDs them into one and skips a 64-row word at a time with numberOfTrailingZeros, testing any other leaf only on the rows the bitmap selects. A filter with no bitmap leaf keeps testing each row. VortexEnumerator walks by nextSelected. 100M-row cashflow file, jdbc:vortex: driver, warm JVM, median of 5 interleaved rounds of 15 runs (the machine was noisy, so 5 rounds): count(*) where ISIN = 'ZZYRS9DBOAM3' 142 -> 97 ms (-32%) select eventIdentifier where ISIN = ... 236 -> 187 ms (-21%) ISIN in (two values) 215 -> 168 ms (-22%) select * where ISIN = ... 707 -> 661 ms (-7%, noisy) ISIN <> ... (almost every row selected) 1869 -> 1809 ms (neutral) Tests: ComputeTest walks nextSelected over u8, u16 and i32 codes across several 64-row words with empty ones between, with one bitmap leaf, two ANDed, a bitmap with a numeric residual leaf, no bitmap leaf, and a start past the last row, against the row test and Compute.matches. The vortex-jni cross-check now walks its filtered scans by nextSelected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dfa1
added a commit
that referenced
this pull request
Oct 20, 2026
Sonar findings on the dictionary filter of #576 and #580: java:S1168 twice (matchBits and codeTable returned a null array), java:S125 (a test comment read as commented-out code) and java:S9358 (a conditional around two calls of one method in the interop oracle). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
WHERE col = 'x'on a dictionary-encoded string column already evaluated the predicate once per dictionary value (#552). Profiling a 100M-row file showed where the rest of the time went. Four changes follow Rust's dict reader, in one commit:Bulk row mask.
u8/u16codes are mapped to a row bitmap in one pass over their segment, Rust'svalues.take(codes). This replaces a per-rowVarBinDictArray.code(i)call (216 of 616 samples). The loop is chosen once per chunk, from three shapes:A code outside the dictionary falls back to the per-row path, which reports it.
One evaluation per dictionary. The new
Compute.matcher(RowFilter)keeps the per-value results across a scan's chunks, as Rust caches its values evaluation. The Calcite callers now use it.No extra pass over the codes. A pool no longer than its codes is walked whole. Only a constant pool or a longer one still needs the scan (
checkedPoolExtent) that read every code just to bound the walk.Prune when no value matches.
ScanIteratorskips a dict layout's chunks when no dictionary value satisfies a filter that rejects nulls. This is decided from the values alone, once per dictionary.docs/compatibility.md.100M-row file through the
jdbc:vortex:driver, warm JVM, median of the last 8 of 15 runs:count(*) where ISIN = 'ZZYRS9DBOAM3'count(*) where ISIN = 'NOPE'select eventIdentifier where ISIN = …select * where ISIN = …count(*) where taxCountry = 'XX'YourKit, 15 runs of the first query: the per-row test took 3,400 ms before; building the bitmap now takes 620 ms.
What's left is unpacking the bit-packed codes once, which Rust pays too.
Also fixed
VortexException. Skipping the code scan had exposed a rawIndexOutOfBoundsExceptionhere, and flat pools already hit it onmain.Tests
ComputeTest:U8,U16andI32codesu8dictionary longer than 256 entriesScanIteratorDictPruningTestcovers the pruning rules:AND/OR/IS NULL/IS NOT NULL/<>DictLayoutDecoderTest:DictFilterInteropIntegrationTestfilters four files against a plain Java check of the written rows:u8codesA missing value must skip every chunk of the three dict layouts; that test fails with pruning disabled.
./mvnw verifypasses (9188 tests).🤖 Generated with Claude Code