Repository navigation
perf(reader): decode and filter dictionary strings once per value, not per row - #553
Merged
Merged
Conversation
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
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.
Closes #551, closes #552.
Three commits, kept separate:
DictStringBenchmarkreads a dictionary-encoded string column through the reader (getStringper 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.VarBinDictArraycaches one decodedStringper dictionary entry, bounded bymin(dictSize, rows, 65536).VarBinConstantArray, which a single-value dictionary collapses to, caches its one value. Both become final classes with the same accessors (Breaking).Compute.matcher(Chunk, RowFilter)lowers a filter once per chunk; the Calcite enumerator uses it instead of per-rowCompute.matches.Results
Single fork, ms/op: before → after #551 → after #552 (measured before rebasing onto #550).
readStringssqlScansqlGroupBysqlFiltersqlFilterSumTests
Compute.matcheragainstCompute.matchesover 7 predicates with nulls, a sliced dictionary, a dictionary larger than the rows, a malformed code.🤖 Generated with Claude Code
https://claude.ai/code/session_01WtuVGQ5h5Lk6Zn4JnkxF1K