Repository navigation
perf(reader): filter dictionary-encoded strings on the dictionary values, not per row #552
Description
A comparison filter on a dictionary-encoded string column decodes and compares every row. Rust compares the constant against the dictionary values once and keeps the codes, so this is a parity gap as well as a speedup.
Found while analyzing #551.
Today
PredicateEvaluator.evaluatereads each row throughValues.valueAt, which callsVarBinArray.getString(i), then compares withCompare.values. On aVarBinDictArraythat is one copy, one UTF-8 decode and one comparison per row, for a column with a handful of distinct values.DictFilteralready has an encoded-domain lane, but only for primitive dictionaries withu8codes (pool ≤ 256) and only inside the fused filter-aggregate kernels. String dictionaries never reach it.
Rust
vortex-array/src/arrays/dict/compute/compare.rs: when the right-hand side is a constant, it runs the comparison over values() and returns DictArray(codes, boolean_result). It declines (returns None, generic path) when values().len() > codes().len(), because comparing a pool larger than the rows costs more than comparing the rows. like.rs does the same for LIKE.
Calcite benefits directly
- Row path:
VortexEnumeratorcallsCompute.matches(chunk, exactFilter, row)per row (VortexEnumerator.java:104), which lands inPredicateEvaluator→getStringper row. EveryWHERE sym = 'X',sym IN (...)orsym <> 'X'pushed into the scan pays it. - Aggregate path:
VortexTable→Compute.filteredAggregate→FusedFilterAggregate, where a string filter column missesDictFilterand falls back toPredicateEvaluator(FusedFilterAggregate.java:435).
Proposal
- For a comparison leaf (
Eq,Neq,Lt,Gt,Lte,Gte,Between, and theINlists from 783d7a6) over aVarBinDictArray, evaluate the predicate once per dictionary entry into a boolean per code, then select rows by code lookup. No string is decoded per row. - Same guard as Rust: decline when the pool is larger than the rows being filtered.
- Null handling stays as today: codes-side validity and invalid pool slots both mean a null row.
Compute.matchestakes one row at a time, so the per-entry table has to be built once per chunk (memoized per chunk array) or the row path moves to a per-chunk selection. Pick whichever keeps the public API smaller.- Extending
DictFilterto string dictionaries covers the aggregate path; the row path needs the per-chunk table above.
Relation to #551
Independent. #551 caches decoded strings for projection reads; this removes the decode from filtering altogether. With both, SELECT ... WHERE sym = 'X' decodes each distinct value at most once per chunk.
Check
- A vortex-jni cross-check: Rust writes a dict-encoded Utf8 column (with nulls), Java filters it with each comparison and
IN, results match Rust's filtered scan. - Unit cases: pool larger than the rows (declines, same results), all codes match, none match, null codes, invalid pool slot.
- Before/after numbers on the Calcite adapter: close push-down and type-mapping gaps #550 SQL workload with a
WHERE sym = ...filter, and the filteredGROUP BY.
Activity
perf(performance): benchmark reads of a dictionary-encoded string column