feat(btrblocks): compress binary arrays with VarBin offsets and FSST - #9576
feat(btrblocks): compress binary arrays with VarBin offsets and FSST#9576joseph-isaacs wants to merge 4 commits into
Conversation
Canonical binary arrays are VarBinViewArray, which spends a fixed 16 bytes per element on an opaque views buffer. No scheme could compress that buffer, so any binary column that the dictionary scheme declined was written as payload plus 16 B/value, regardless of content. VarBinScheme re-encodes as VarBinArray, replacing the views buffer with an offsets child array that the cascading compressor compresses with the ordinary integer schemes. For fixed-width values the offsets are a constant-stride sequence and collapse to nothing. This mirrors what FSSTScheme already does for strings. Measured at 100k rows (tests/varbin_scheme.rs), compressed nbytes against the same compressor with the scheme excluded: nulls every 7th 2,966,827 -> 1,647,348 (0.56) random 16B (hash) 3,200,000 -> 1,600,000 (0.50) shared prefix 3,200,000 -> 1,600,000 (0.50) random 256B 27,200,000 -> 25,600,000 (0.94) The one golden snapshot that moves also improves: binary_low_cardinality dictionary values go from 96 to 52 bytes as the scheme cascades into the dictionary's values child. Checks: cargo test -p vortex-btrblocks (all pass), cargo test -p vortex-file (144 pass), cargo clippy -p vortex-btrblocks --all-targets --all-features (clean), cargo +nightly fmt --all. Signed-off-by: "Claude" <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJdgPga43u5rXYwv6R5b19
FSSTScheme gated on `is_utf8()`, but its compress path never validates UTF-8 -- it trains and compresses over the raw `array_as_varbinview()` bytes. The gate therefore excluded binary columns from a scheme that already works on them, leaving any binary payload with intra-value structure (shared prefixes, zero padding, common framing) uncompressed once the dictionary scheme declined it. Widening `matches` to accept binary, measured at 100k rows (tests/varbin_scheme.rs), compressed nbytes: shared prefix 1,600,000 -> 734,685 nulls every 7th 1,647,348 -> 690,230 random 16B (hash) 1,600,000 -> 1,600,000 (VarBinScheme still selected) random 256B 25,600,000 -> 25,600,000 (VarBinScheme still selected) Binary now lands byte-identical to the same content stored as Utf8 (734,685 either way), which is what confirms the dtype gate was not protecting anything. The two schemes compose rather than compete: on incompressible payloads FSST alone is worse than VarBinScheme (1,872,028 vs 1,600,000 for random 16B) because the symbol table buys nothing, and scheme selection picks VarBinScheme there. Not measured: symbol-table training cost on write and FSST decode cost on read. The `is_utf8()` restriction may also have had a rationale outside the compress path that this change does not account for, so the gate's history is worth checking before relying on this. Checks: cargo test -p vortex-btrblocks (all pass, including the roundtrip assertions which now exercise FSST on binary), cargo test -p vortex-file (144 pass), cargo clippy -p vortex-btrblocks --all-targets --all-features (clean), cargo +nightly fmt --all. golden_default is unchanged. Signed-off-by: "Claude" <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJdgPga43u5rXYwv6R5b19
The two binary-scheme tests printed their measurements without asserting anything, so a regression would have shown up only on a careful read of the output. Add the two invariants the schemes are meant to hold: - enabling VarBinScheme never grows the output, so a future selection change that makes it lose is a test failure rather than a silent regression; - FSST compresses bytes rather than codepoints, so the same values must compress identically whether typed as binary or utf8. Signed-off-by: "Claude" <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJdgPga43u5rXYwv6R5b19
Merging this PR will degrade performance by 10.53%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | WallTime | words_gather_dispatch_avx2[1024] |
17 ns | 19 ns | -10.53% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/binary-compression-schemes (54fb31b) with develop (e4b3421)
Footnotes
-
54 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
| // loop this hot. | ||
| let mask = view.validity()?.execute_mask(len, exec_ctx)?; | ||
|
|
||
| let varbin = VarBinArray::from_iter( |
There was a problem hiding this comment.
surely this isn't the most efficient way to do this?
…ement copies VarBinScheme::compress built its VarBinArray with VarBinArray::from_iter over bytes_at(i).as_slice().to_vec(). That clones a buffer handle and heap-allocates a Vec for every element, then copies the Vec into the builder and drops it, and sizes the builder by element count so the data buffer reallocates as it grows. Use the existing bulk path instead. VarBinBuilder::append_varbinview resolves the views slice and data buffers once, sums the exact byte total from the fixed-width view headers to size a single allocation, and appends borrowed slices without allocating per value. vortex-arrow's to_arrow_byte_array already converts views to offsets this way. Offsets are built as u64 so a chunk whose values exceed u32::MAX bytes cannot overflow; the existing narrow() call downcasts them before compression, so the compressed output is unchanged. Measured on 500k x 16B binary values, best of 3 after a warm-up, release_debug: 9.0 -> 12.0 Mrows/s for the whole compress call. Signed-off-by: "Claude" <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com>
Summary
Binary columns had exactly one scheme in
ALL_SCHEMES:BinaryDictScheme. Once the dictionary declined a column, nothing else was eligible, so the array fell through to a canonicalVarBinViewArraywritten as payload plus a fixed 16 bytes per element of opaque views buffer — regardless of content.Two consequences, both measurable:
zlib -9shrinks 6.8x was written at exactly the same size as an incompressible one, because no scheme ran on either.For comparison, the same values typed as
Utf8compressed to 713,324 bytes whereBinaryproduced 3,204,100 — a 4.5x gap decided purely by dtype.This PR gives binary two schemes that already existed in spirit elsewhere in the compressor, and adds the tests to pin their behavior.
Changes
VarBinScheme(new,schemes/binary/varbin.rs) re-encodes the canonicalVarBinViewArrayas aVarBinArray, replacing the opaque views buffer with an offsets child array. That child is then compressed by the ordinary integer schemes, so a fixed-width column's constant-stride offsets collapse to asequenceand the per-element overhead disappears. This mirrors whatFSSTSchemealready does for strings, which builds its codes as aVarBinArraywith compressed offsets.FSSTSchemenow matches binary as well as utf8. Itsmatchesgated onis_utf8(), but the compress path never validates UTF-8 — it trains and compresses over the rawarray_as_varbinview()bytes. The gate excluded binary from a scheme that already worked on it, leaving any binary payload with intra-value structure (shared prefixes, zero padding, common framing) uncompressed once the dictionary declined it.The two are complementary rather than competing, and selection picks correctly between them: FSST wins where the payload has structure,
VarBinSchemewins on incompressible data where FSST's symbol table is pure overhead.Measured at 100k rows, compressed
nbytesagainst the same compressor with these schemes excluded:The
random 16Bcase is now at its payload floor (1,600,000 bytes of incompressible data) with no metadata overhead.Tests (
tests/varbin_scheme.rs) cover both schemes with roundtrip assertions throughassert_arrays_eq!, including a nullable case, plus two invariants: enablingVarBinSchememust never grow the output, and identical values must compress identically whether typed binary or utf8 (FSST compresses bytes, not codepoints).One golden snapshot moves, and it improves:
binary_low_cardinalitydictionary values go from 96 to 52 bytes asVarBinSchemecascades into the dictionary's values child.Review notes
VarBinSchemeis registered inALL_SCHEMES, so it affects every binary column on a default write. Worth confirming that blast radius is intended rather than gating it behindwith_compact.compressdemonstrably does not validate UTF-8, but I have not read throughfsst_train_compressor/fsst_compress. The byte-identical binary/utf8 assertion is evidence the widening is safe, not proof. If the original restriction was deliberate for a reason deeper in the FSST implementation, that is the part to push back on.Checks
cargo test -p vortex-btrblocks— all passcargo test -p vortex-file— all passcargo clippy -p vortex-btrblocks --all-targets --all-features— cleancargo +nightly fmt --allNot measured: decode throughput for either scheme, and the file-level (rather than in-memory
nbytes) size effect. Both are worth a follow-up before leaning on these numbers for anything latency-sensitive.🤖 Generated with Claude Code
https://claude.ai/code/session_01GJdgPga43u5rXYwv6R5b19
Generated by Claude Code