Skip to content

perf(reader): filter dictionary columns by their values and codes in bulk - #576

Merged
dfa1 merged 1 commit into
mainfrom
perf/dict-filter
Oct 19, 2026
Merged

dfa1 merged 1 commit into
mainfrom
perf/dict-filter

Conversation

@dfa1

@dfa1 dfa1 commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

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:

  1. Bulk row mask. u8/u16 codes are mapped to a row bitmap in one pass over their segment, Rust's values.take(codes). This replaces a per-row VarBinDictArray.code(i) call (216 of 616 samples). The loop is chosen once per chunk, from three shapes:

    • no value matches: only the largest code is checked, with the vectorized max kernel;
    • exactly one matches: each code is compared with that value's code, a branch taken only on matching rows;
    • several match: a branch-free lookup table.

    A code outside the dictionary falls back to the per-row path, which reports it.

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

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

  4. Prune when no value matches. ScanIterator skips 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.

    • Deliberate divergence: Rust always reads the codes, so here a corrupt codes buffer under such a filter goes unreported. Recorded in docs/compatibility.md.

100M-row file through the jdbc:vortex: driver, warm JVM, median of the last 8 of 15 runs:

query before after
count(*) where ISIN = 'ZZYRS9DBOAM3' 293 ms 145 ms
count(*) where ISIN = 'NOPE' 292 ms 9 ms (nothing scanned)
select eventIdentifier where ISIN = … 386 ms 222 ms
select * where ISIN = … 790 ms 633 ms
count(*) where taxCountry = 'XX' 107 ms 9 ms

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

  • Negative codes: a negative 64-bit code into a nullable pool now fails with VortexException. Skipping the code scan had exposed a raw IndexOutOfBoundsException here, and flat pools already hit it on main.
  • Pruning test count: pruning now tests every value that actually decodes, not the count the layout claims. A malformed file could otherwise prune a chunk with matching rows.

Tests

  • ComputeTest:

    • matcher cases over U8, U16 and I32 codes
    • a u8 dictionary longer than 256 entries
    • the memo reusing a shared dictionary
  • ScanIteratorDictPruningTest covers the pruning rules:

    • AND / OR / IS NULL / IS NOT NULL / <>
    • null dictionary values
    • a pool decoding longer than it claims
    • the size guard
    • malformed values
  • DictLayoutDecoderTest:

    • the negative code
    • a code past a pool walked whole
  • DictFilterInteropIntegrationTest filters four files against a plain Java check of the written rows:

    • vortex-jni, 4 values
    • vortex-jni, 300 values
    • vortex-java, default write
    • vortex-java per-chunk dictionary, which is the case with u8 codes

    A missing value must skip every chunk of the three dict layouts; that test fails with pruning disabled.

  • ./mvnw verify passes (9188 tests).

🤖 Generated with Claude Code

@dfa1
dfa1 force-pushed the perf/dict-filter branch from c8833fc to 58a92a5 Compare October 19, 2026 19:55
…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
dfa1 force-pushed the perf/dict-filter branch from 58a92a5 to e56a50f Compare October 19, 2026 20:15
@dfa1
dfa1 merged commit 3e9719e into main Oct 19, 2026
6 of 9 checks passed
@dfa1
dfa1 deleted the perf/dict-filter branch October 19, 2026 20:28
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>
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