perf(array): gather dict byte values straight into the offset builder#8995
Open
robert3005 wants to merge 1 commit into
Open
perf(array): gather dict byte values straight into the offset builder#8995robert3005 wants to merge 1 commit into
robert3005 wants to merge 1 commit into
Conversation
Merging this PR will not alter performance
Performance Changes
Comparing Footnotes
|
robert3005
force-pushed
the
claude/dict-varbin-append
branch
from
July 27, 2026 01:59
120e4f1 to
513a680
Compare
`Dict::append_to_builder` took the dictionary values to full logical length via `take_canonical` and then appended that result, so exporting a dictionary-encoded string column allocated a full-length intermediate array which was immediately copied into the builder a second time. Dictionaries are usually far smaller than the arrays they encode, so when the target is a `DynVarBinBuilder` and the dtype is Utf8 or Binary, materialize only the values and gather by code. Resolving a value is then an O(1) read out of the views slice and data buffers, both hoisted out of the loop, and null codes are filled a run at a time with `for_each_set_index`. One fewer full-length allocation and copy. The new branch keeps the same guards as the generic path it precedes -- non-empty, primitive codes, not definitely-all-null codes -- and adds an explicit dtype check, so anything it does not handle still falls through unchanged. A code pointing at a null dictionary entry appends a null, matching the canonical route. The accompanying benchmark is sized to fit the CI time budget, and at that size the change is within run-to-run noise, so it stands as a regression guard rather than as evidence of a speedup. Correctness is asserted against `execute::<Canonical>` so the gather and canonical routes are compared directly. Signed-off-by: Robert Kruszewski <robert@spiraldb.com>
robert3005
force-pushed
the
claude/dict-varbin-append
branch
from
July 27, 2026 02:04
513a680 to
3e48f58
Compare
Contributor
Author
Local before/after benchmarkCompared this commit ( Machine: Apple M4 Max, 16 cores, 128 GB RAM, macOS 26.5.2, Rust 1.91.0. Divan reported 100 samples × 100 iterations per case; values below are medians.
Command: NO_COLOR=1 CARGO_TARGET_DIR=/private/tmp/vortex-append-bench-target cargo bench -p vortex-array --bench dict_append_varbin |
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.
Rationale for this change
Follow-up to #8902. That PR originally had a Dict fast path, which was removed in review rather than fixed — reasonably, since the version being removed did three full copies of the expanded data, bypassed
TakeExecute::precondition, and dropped theis_empty/definitely_all_nullguards the neighbouring path has.The gap it left is worth closing: dictionary encoding is the common representation for low-cardinality string columns, so a
pa.string()export of such a column falls all the way through to the canonical route. That route takes the values to full logical length viatake_canonical, allocating an intermediate array which is then copied into the builder a second time. The intermediate is proportional to the logical length, while the dictionary is usually far smaller.Note the description of #8902 still claims Dict as a direct-export encoding and its benchmark list includes three Dict cases — worth correcting separately, since the headline "500,000 repeated URL strings" figure was likely measuring a Dict column.
What changes are included in this PR?
When the target is a
DynVarBinBuilderand the dtype is Utf8 or Binary, materialize only the dictionary values and gather by code. Resolving a value is then anO(1)read out of the views slice and data buffers, both hoisted out of the loop. Runs of null codes are filled withfor_each_set_indexplusappend_nulls.Net effect: one fewer full-length allocation and copy per append.
Deliberately keeping the guards the removed version dropped:
matches!(array.dtype(), DType::Utf8(_) | DType::Binary(_))check, rather than relying on the builder downcast alone to imply the source is byte-typed.TakeExecuteis not called at all, so there is no precondition to bypass.A code pointing at a null dictionary entry appends a null, matching the canonical route. This is the case worth reviewing most closely: null-ness can come from either the codes or the values, and the test asserts against
execute::<Canonical>rather than a hand-written expectation, so the two routes are compared directly.dict_byte_gather_matches_canonicalcovers all-valid, null codes, null values, both together, heap values, mixed inlined/heap, leading and trailing null codes, and a single-value dictionary — each across both offset widths and both sliced and unsliced.On the benchmark
Shapes are 4096 rows across three cardinalities (16, 256, 2048), reduced 16x at review request to fit the CI time budget. At that size the change is within run-to-run noise, so the benchmark is a regression guard, not evidence of a speedup. Performance validation is happening separately, outside this CI environment.
Reviewing this on structural grounds: one fewer full-length allocation and copy, and the guards the earlier attempt dropped are back.
What APIs are changed? Are there any user-facing changes?
No API changes. No behaviour change — output matches the canonical route, which the tests assert directly.
Note for reviewers
The view-resolution closure here overlaps with #8994, which hoists the same resolution in
append_varbinview. The two are kept separate so the PRs stay independent — this one gathers by arbitrary code (random access) while #8994 scans sequentially. If both land, factoring the resolution into one shared helper would be a tidy follow-up.Verification
cargo test -p vortex-array— 3177 + 72 passed, 0 failed.cargo test -p vortex-arrow -p vortex-fsst -p vortex-onpair -p vortex-sparse -p vortex-runend -p vortex-zstd— 529 passed, 0 failed.cargo clippy -p vortex-array --all-targets --all-features— clean.cargo +nightly fmt --all,git diff --check— clean.Independent of #8993 and #8994; all three branch from
developand touch disjoint code.