perf(array): hoist per-row work out of append_varbinview#8994
perf(array): hoist per-row work out of append_varbinview#8994robert3005 wants to merge 1 commit into
Conversation
Merging this PR will degrade performance by 20.83%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
bf985c1 to
6607a9d
Compare
`DynVarBinBuilder::append_varbinview` is the fallback every string encoding without a direct branch reaches through canonicalization, including Dict, Sparse, RunEnd, and anything on the default `VTable::append_to_builder`. Converting views to offsets has to visit each value, but the work around that walk does not have to be per-row: - `bytes_at` re-resolved the views slice and returned an owned `ByteBuffer` on every call. Resolve the views and data buffer slices once and borrow from them instead. Inlined values now come straight out of the view rather than round-tripping the views handle through `into_byte_buffer().slice_ref(..)`. - Validity was walked a bit at a time with `bit_buffer().iter().enumerate()`. Use `for_each_set_index`, which processes a `u64` word at a time, and fill the gaps between set bits with `append_n_nulls`. - The byte total is a sum over the fixed-width views and needs no value access, so the data buffer is sized once via a new `reserve_data` rather than reallocating as it grows. `reserve_exact` takes a row count and cannot serve this purpose. The accompanying benchmark is sized to fit the CI time budget, and at those sizes the change is within run-to-run noise, so it stands as a regression guard rather than as evidence of a speedup. Output is unchanged, which the extended tests assert against the source array. Signed-off-by: Robert Kruszewski <robert@spiraldb.com>
6607a9d to
8110b99
Compare
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 varbin_append_varbinview |
Rationale for this change
Follow-up to #8902.
DynVarBinBuilder::append_varbinviewis the fallback every string encoding without a direct branch reaches through canonicalization: Sparse, RunEnd, Dict, and anything on the defaultVTable::append_to_builder. It was written per-row.AGENTS.mdnames both anti-patterns it hit. Converting views to offsets does have to visit each value — that part is irreducible, andAGENTS.mdexplicitly carves it out ("Gather over arbitrary indices → you cannot amortize a per-element decode, but you can still materialize the backing buffer once"). The work around the walk is what this removes.What changes are included in this PR?
bytes_atre-resolved the views slice and returned an ownedByteBufferon every call. The views slice and data buffer slices are now hoisted and borrowed from. Inlined values (≤ 12 bytes, the common short-string case) come straight out of the view rather than round-tripping the views handle throughinto_byte_buffer().slice_ref(..).bit_buffer().iter().enumerate()paid a branch per element. Nowfor_each_set_index, with gaps filled byappend_n_nulls— the same shape the Zstd path uses.reserve_datasizes the buffer up front rather than letting it reallocate as it grows.reserve_exacttakes a row count and so cannot serve this purpose, hence the separate method.Net effect: the function no longer allocates per row, and the data buffer no longer reallocates during an append.
append_varbinview_matches_sourcecovers inlined-only, heap-only, mixed, all-null, leading/trailing nulls, and empty, each across both offset widths and both sliced and unsliced. Slicing matters because it shifts the validity bit offset, which the word-at-a-time walk must respect.for_each_set_indexneeds no bounds check here:iter_paddedzero-pads the remainder word, so it never yields an index pastlen.On the benchmark
Sizes are 4096 and 16384, chosen to keep every case well under 1ms (medians ~110–180µs). At those sizes 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 — the run-to-run spread on the runner used here is wide enough that small deltas are not defensible either way.
Reviewing this on structural grounds: fewer allocations per append, and conformance with the hot-loop guidance in
AGENTS.md.What APIs are changed? Are there any user-facing changes?
No public API changes.
reserve_datais private to the module. Output is unchanged.Verification
cargo test -p vortex-array -p vortex-arrow -p vortex-fsst -p vortex-onpair -p vortex-sparse -p vortex-runend -p vortex-zstd— 3177 + 201 + 85 + 36 + 70 + 109 + 28 + 72 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 #8995; all three branch from
developand touch disjoint code.