Skip to content

perf(reader): decode and filter dictionary strings once per value, not per row - #553

Merged
dfa1 merged 3 commits into
mainfrom
perf/dict-string-cache
Oct 18, 2026
Merged

dfa1 merged 3 commits into
mainfrom
perf/dict-string-cache

Conversation

@dfa1

@dfa1 dfa1 commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Closes #551, closes #552.

Three commits, kept separate:

  1. Benchmark: DictStringBenchmark reads a dictionary-encoded string column through the reader (getString per row) and through Calcite SQL (scan, GROUP BY, filter, filtered sum). Defaults to a 4M-row synthetic fixture; runs on a real file with -p file/column/value/agg.
  2. perf(reader): cache decoded strings per dictionary entry in VarBinDictArray #551: VarBinDictArray caches one decoded String per dictionary entry, bounded by min(dictSize, rows, 65536). VarBinConstantArray, which a single-value dictionary collapses to, caches its one value. Both become final classes with the same accessors (Breaking).
  3. perf(reader): filter dictionary-encoded strings on the dictionary values, not per row #552: a filter over a dictionary string column, or a slice of one, is evaluated once per dictionary value and rows are tested by code, as in Rust's dict compare kernel. New Compute.matcher(Chunk, RowFilter) lowers a filter once per chunk; the Calcite enumerator uses it instead of per-row Compute.matches.

Results

Single fork, ms/op: before → after #551 → after #552 (measured before rebasing onto #550).

4M synthetic sf-salaries (Rust-written, 1.18M rows)
readStrings 43.4 → 10.8 → 10.9 25.2 → 3.45 → 3.47
sqlScan 104.4 → 69.5 → 69.8 43.8 → 21.6 → 21.8
sqlGroupBy 200.3 → 132.4 → 135.3 54.9 → 27.1 → 25.2
sqlFilter 136.6 → 85.7 → 44.2 55.5 → 38.4 → 16.0
sqlFilterSum 132.5 → 85.4 → 37.2 59.9 → 41.1 → 19.4

Tests

  • Unit: cache identity, cache bound, out-of-range codes, constant array; Compute.matcher against Compute.matches over 7 predicates with nulls, a sliced dictionary, a dictionary larger than the rows, a malformed code.
  • vortex-jni cross-checks: Rust-written low-cardinality and single-value string columns read back exactly; a nullable dictionary column filtered with 6 operators × 3 bounds (UTF-8 vs UTF-16 order, nulls) selects the same rows as Rust's filtered scan.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WtuVGQ5h5Lk6Zn4JnkxF1K

dfa1 and others added 3 commits October 18, 2026 08:53
DictStringBenchmark reads a 1M-row, 8-value Utf8 column four ways: a bare
reader scan through getString, and Calcite SQL scan, GROUP BY and filter.
It is the before/after for #551 (string decode per row) and #552 (string
comparison per row). Setup checks the column decodes to VarBinDictArray so
a writer change cannot silently benchmark another representation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
VarBinDictArray.getString copied and UTF-8 decoded the entry on every
call, so an 8-value column over 1M rows decoded 1M strings. It now caches
one String per dictionary entry and returns the same instance for every
row with that code. VarBinConstantArray, which a single-value dictionary
collapses to, caches its one decoded value the same way.

The cache holds at most min(dictSize, rows, 65536) entries: the dictionary
size comes from an untrusted offsets buffer, and a large shared dictionary
over a small chunk must not allocate more than its rows. Codes past the
bound decode per call; out-of-range codes still throw VortexException.
Writes are unsynchronized: String is immutable, so a race only decodes an
entry twice. getBytes still returns a fresh copy.

Both classes become final classes (a record cannot hold the cache) with
the same accessors. Rust has no such cache because its strings are views
into the shared buffer; the cost is JVM-only, so this is no divergence.

DictStringBenchmark, single fork, ms/op:

  readStrings  10.88 -> 2.63  (4.1x)
  sqlScan      26.91 -> 18.84 (-30%)
  sqlGroupBy   56.93 -> 36.07 (-37%)
  sqlFilter    35.03 -> 22.96 (-34%)

Closes #551

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A comparison filter on a dictionary-encoded string column read and
compared every row's string. Rust's dict compare kernel evaluates the
predicate against the dictionary values once and keeps the codes; Java
now does the same. FusedFilterAggregate.leafTest lowers a leaf over a
VarBinDictArray, or a slice of one, to a per-entry match table, so each
row costs a code lookup. Null rows and codes outside the dictionary
still go through the per-row evaluator, so null-aware predicates answer
as before and bad codes still throw VortexException. Like Rust, the
lowering declines when the dictionary has more entries than there are
rows to test.

The slice matters on real files: Rust's global dict layout decodes one
dictionary for the whole column and the scan hands each chunk a
VarBinSlicedArray over it (Raincloud sf-salaries, TPC-H c_mktsegment).

The row path could not use it: Compute.matches tests one row at a time
and has nowhere to keep per-chunk work. Compute.matcher(Chunk, RowFilter)
lowers the filter once per chunk and returns a LongPredicate, and the
Calcite enumerator now builds one per chunk instead of calling matches
per row. The fused aggregate path already lowers through leafTest.

VarBinDictArray gains values() and code(long), after Rust's DictArray.
DictStringBenchmark now defaults to 4M rows and runs on a real file
with -p file/column/value/agg.

Single fork, ms/op, before #551 -> after #551 -> after this:

  4M synthetic rows, 8 symbols
    sqlFilter     136.6 -> 85.7 -> 44.2
    sqlFilterSum  132.5 -> 85.4 -> 37.2
  sf-salaries (Rust-written, 1.18M rows, Organization Group)
    sqlFilter      55.5 -> 38.4 -> 16.0
    sqlFilterSum   59.9 -> 41.1 -> 19.4

Part of the gain is the enumerator no longer resolving the filter
column per row; the two were not measured apart.

Closes #552

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVGQ5h5Lk6Zn4JnkxF1K
@dfa1
dfa1 merged commit 51ee28d into main Oct 18, 2026
9 checks passed
@dfa1
dfa1 deleted the perf/dict-string-cache branch October 18, 2026 07:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant