Skip to content

perf(reader): filter dictionary-encoded strings on the dictionary values, not per row #552

Closed

Description

@dfa1

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.evaluate reads each row through Values.valueAt, which calls VarBinArray.getString(i), then compares with Compare.values. On a VarBinDictArray that is one copy, one UTF-8 decode and one comparison per row, for a column with a handful of distinct values.
  • DictFilter already has an encoded-domain lane, but only for primitive dictionaries with u8 codes (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: VortexEnumerator calls Compute.matches(chunk, exactFilter, row) per row (VortexEnumerator.java:104), which lands in PredicateEvaluator → getString per row. Every WHERE sym = 'X', sym IN (...) or sym <> 'X' pushed into the scan pays it.
  • Aggregate path: VortexTable → Compute.filteredAggregate → FusedFilterAggregate, where a string filter column misses DictFilter and falls back to PredicateEvaluator (FusedFilterAggregate.java:435).

Proposal

  • For a comparison leaf (Eq, Neq, Lt, Gt, Lte, Gte, Between, and the IN lists from 783d7a6) over a VarBinDictArray, 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.matches takes 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 DictFilter to 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 filtered GROUP BY.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions