Streaming chunked decompression via a composable vtable method - #9614
Streaming chunked decompression via a composable vtable method#9614joseph-isaacs wants to merge 11 commits into
Conversation
Merging this PR will degrade performance by 2.79%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | WallTime | mul_u64_nonnull_neon |
15.2 µs | 19.6 µs | -22.85% |
| ❌ | Simulation | decompress[for_bp_u64] |
47.7 µs | 58.3 µs | -18.28% |
| ❌ | WallTime | multiply_shapes_neon[(16384, PerRowPerRow)] |
17.3 µs | 20.2 µs | -14.75% |
| ❌ | Simulation | bitpacked_decompress_u32 |
55.7 µs | 64.9 µs | -14.26% |
| ❌ | WallTime | mul_i64_nonnull_neon |
17.2 µs | 19.9 µs | -13.77% |
| ❌ | Simulation | baseline_lt[4, 1024] |
82.7 µs | 93 µs | -11.06% |
| ❌ | Simulation | cold_misaligned[(16, 64)] |
345.6 µs | 388.6 µs | -11.05% |
| ❌ | Simulation | take[all_null_len65536_run4_take2048] |
99.7 µs | 111.1 µs | -10.2% |
| ❌ | Simulation | decompress_rd[f32, (2000, 0.0)] |
92.3 µs | 102.8 µs | -10.2% |
| ❌ | WallTime | mul_i32_nonnull_avx512 |
7.2 µs | 8.1 µs | -10.19% |
| ⚡ | Simulation | compact_sliced[(2048, 10)] |
250.8 µs | 180.7 µs | +38.83% |
| ⚡ | Simulation | compact_sliced[(1024, 10)] |
149.6 µs | 113.8 µs | +31.42% |
| ⚡ | WallTime | subtract_shapes_neon[(16384, PerRowNullableConstant)] |
12.3 µs | 10.9 µs | +12.47% |
| ⚡ | WallTime | sub_i64_constant_neon |
11.3 µs | 10.1 µs | +12.13% |
| ⚡ | WallTime | subtract_shapes_neon[(128, PerRowNullableConstant)] |
3.9 µs | 3.5 µs | +10.39% |
| ⚡ | Simulation | compact[(1024, 10)] |
279.4 µs | 254 µs | +10.01% |
| 🆕 | Simulation | chunked_decompress_into_patched_constant |
N/A | 18.5 ms | N/A |
| 🆕 | Simulation | chunked_vtable_decompress_into |
N/A | 22.9 ms | N/A |
| 🆕 | Simulation | chunked_vtable_sum |
N/A | 8.8 ms | N/A |
| 🆕 | Simulation | chunked_vtable_sum_generic_compose |
N/A | 10.9 ms | N/A |
| ... | ... | ... | ... | ... | ... |
ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/decompression-chunked-iteration-u3kcns (377d029) with develop (68e2aee)
Footnotes
-
106 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. ↩
-
4 benchmarks were run, but are now archived. If they were deleted in another branch, consider rebasing to remove them from the report. Instead if they were added back, click here to restore them. ↩
Introduce VTable::decompress_chunks: a push-based streaming decompression API that walks an array's decompressed values in cache-resident ~1024 element chunks without materializing the full array. Chunks flow through a ChunkSink chain: leaf encodings stream their existing unpack scratch buffers, and wrapper encodings compose by interposing a stack-allocated sink adapter and recursing into their child through the erased ArrayRef entry point, so the mechanism composes across arbitrary encoding trees with no per-element dynamic dispatch and no heap state on the way down. The default implementation executes to canonical and streams the result (the two-pass baseline), so the method is always available. BitPacked overrides it to stream FastLanes blocks straight from its unpack scratch (applying patches per block via a cursor), and FoR overrides it by adding the reference value in place per chunk before forwarding. On 4Mi u32 FoR-over-BitPacked, a streaming sum through the vtable path runs ~1.7x faster than fused decompress-then-iterate, within ~22% of a fully monomorphized loop doing identical per-chunk work; the pure dynamic dispatch cost measures ~0.06ns per element. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk> Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
When a FoR array's child is BitPacked (and the reference is unsigned, mirroring the dispatch in decompress), FoR::decompress_chunks now streams each FastLanes block through the fused FoRStrategy unchecked_unfor_pack kernel instead of wrapping the child's chunk stream with an in-place add pass. Patch values get the reference applied when the patch cursor list is built. The generic sink-composition path remains for any other child. The block streaming and patch-cursor loop is shared between BitPacked's plain path and the fused FoR path via stream_unpacked_chunks, generic over the UnpackStrategy. On 4Mi u32 FoR-over-BitPacked, a streaming sum through the vtable path now matches the hand-written monomorphized fused loop within noise (1.35ms vs 1.36ms median), i.e. the same speed as the unpack_map-style kernels, while the generic composition (measured via a signed reference) stays available for arbitrary children. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk> Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Constant streams by filling a single stack-resident 1024-element scratch chunk with the constant and re-emitting it across the array (refilled per emission, since sinks may mutate chunks in place), so the array is never materialized. Patched streams its inner child through the erased decompress_chunks entry point and interposes a sink adapter that overwrites patched rows in each chunk before forwarding. The lane-transposed patch layout is flattened once up front into row-sorted (row, value) pairs so the per-chunk work is a single cursor advance. This composes with any inner encoding: Patched(Constant) patches each re-emitted constant chunk, Patched(BitPacked) patches each unpacked FastLanes block. On 4Mi u32 Patched-over-Constant with sparse patches, a streaming sum runs 3.6x faster than execute-then-iterate (0.59ms vs 2.12ms median) since the 16MB base buffer is never written. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk> Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Add benches comparing the chunked decompress_chunks path on Patched(Constant) against the sparse decompression baseline (execute: canonicalize the constant into a full buffer, then scatter patches), for both pure materialization and materialize-then-sum. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk> Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
The default VTable::decompress_chunks previously fell back to executing the array to canonical and streaming the materialized result, which silently defeated the purpose of the API: decompressing and transforming blocks while they are L1-resident. Streaming is now advertised via VTable::supports_decompress_chunks (default false); wrapper encodings propagate the check through the children they stream from, so support of the whole tree is decided before any chunk is emitted. ArrayRef::decompress_chunks errors on unsupported trees without emitting anything, and the materializing two-pass fallback moves to the explicitly named ArrayRef::decompress_chunks_or_materialize. Primitive gains a real streaming implementation (it is already decompressed; chunks are copied through one reusable L1 scratch buffer), Constant and BitPacked advertise support directly, FoR advertises support when its fused BitPacked path applies or its child supports streaming, and Patched advertises support when its inner array does. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk> Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Add a stream-to-canonical shortcut to the iterative executor: when the whole encoding tree supports chunked decompression (the capability cascades — each encoding only advertises support when the children it streams from do) and the tree is at least two streaming levels deep, execute_until decompresses blocks straight into the canonical builder via execute_via_chunks while each block is L1-resident, instead of materializing a full intermediate buffer per encoding level. A lone streaming leaf keeps its normal execute path, which already decodes directly into the output. Benchmarked on 4Mi-element trees via execute::<PrimitiveArray> with the shortcut toggled on/off in one process (medians): - FoR(signed ref) over BitPacked: 3.87ms -> 3.05ms (-21%) - Patched(FoR(BitPacked)) with 0.1% exceptions: 4.72ms -> 3.30ms (-30%) - FoR(unsigned ref) over BitPacked (fused execute baseline): 4.41ms -> 3.01ms (-32%) A process-global toggle (set_chunked_execute_enabled, doc-hidden) exists so benchmarks and tests can compare both executor paths; an equivalence test checks the shortcut against level-wise execution on a nullable multi-level tree. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk> Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Honor VORTEX_CHUNKED_EXECUTE=0 at process start so benchmark binaries can compare the executor with and without the stream-to-canonical shortcut without recompiling; set_chunked_execute_enabled still overrides at runtime. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk> Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
VORTEX_CHUNKED_EXECUTE_TRACE=1 prints, for every non-canonical array reaching the executor's step 2c check, whether the stream-to-canonical shortcut fires along with the tree's length, root encoding, and child encodings. Used to establish which TPC-H scan trees are streaming-capable; to be removed (or demoted to tracing) once the investigation concludes. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk> Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
The TPC-H trace established its conclusion: on Q1/Q6 the shortcut fires 288 times, almost entirely on 65536-row FoR(BitPacked) trees, but that is only ~6% of non-canonical executor work. The dominant trees are wrapped in vortex.filter (1656, including 736x Filter(BitPacked)) and vortex.decimal_byte_parts (828), neither of which implements decompress_chunks yet — which explains the SQL-neutral benchmark result as an encoding-coverage gap rather than a batch-size effect. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk> Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
2d9d407 to
9e5874f
Compare
Filter now streams: the child streams its blocks, each block is compacted in place against the mask slice covering it (output index is always <= input index within a block), and the surviving prefix is forwarded. The mask's selection is resolved once up front and walked with a cursor, with a mean-run-length heuristic choosing between run-copying and per-index gathering, so no per-chunk mask slicing or allocation happens. Benchmarking this answered the question it was meant to answer: streaming a filter is NOT faster than the existing path. On Filter(BitPacked) at 65,536 rows (the dominant TPC-H scan tree), streaming matches level-wise for materialization and is 10-35% slower for consumption except at very high selectivity. The reason is structural and now understood: the level-wise path decodes the child directly into the output buffer via decode_into and compacts in place within it, so there is no intermediate for streaming to eliminate -- streaming only adds a scratch-to-output copy. Two consequences: - should_execute_via_chunks now requires a cardinality-preserving child, which keeps the executor from picking the streaming path for Filter trees (it would have regressed them by up to 2.6x). - The executor shortcut is disabled by default and enabled with VORTEX_CHUNKED_EXECUTE=1. Re-measuring on a quiet machine (three rounds) shows it is only a reliable win where the level-wise path does an extra full-buffer pass: signed FoR(BitPacked), which runs a separate wrapping-add pass, is 10-25% faster streaming, while fused FoR(BitPacked) and Patched(FoR(BitPacked)) are ~5-10% slower. The earlier 21-32% figures were measured on a loaded machine and did not reproduce. The streaming consumption win (sum without materializing: 1.29ms vs 3.36ms two-pass on 4Mi rows) is unaffected and remains the API's value, along with Patched(Constant) where the base buffer is never written. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk> Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Deeper stacks are where streaming pays: each additional encoding level costs level-wise execution a full-length intermediate buffer, but costs streaming only one more pass over an L1-resident block. Measured on 4Mi-row nested FoR-over-BitPacked stacks (medians, repeated rounds; marginal cost per added level 0.93ms level-wise vs 0.51ms streaming): chain len 2: 2.57ms vs 2.58ms parity chain len 3: 3.14ms vs 2.74ms 1.15x chain len 5: 5.26ms vs 4.00ms 1.32x chain len 9: 9.07ms vs 6.16ms 1.47x Streaming is also far more predictable: p100/median is ~1.2x versus 2.5-5x for level-wise, which repeatedly allocates full-length buffers. So instead of a blanket on/off default, the executor now measures the tree: streaming_chain_len counts consecutive streaming-capable nodes from the root, following only children that stream, are not already canonical, and preserve row count, and the shortcut fires at MIN_STREAMING_CHAIN nodes. The cardinality rule keeps selection encodings such as Filter out of the executor path without a special case, since their level-wise kernels already decode into the output and compact in place. VORTEX_CHUNKED_EXECUTE=0 remains as a kill switch. Equivalence tests now drive execute_via_chunks directly so they exercise streaming at every depth rather than depending on the heuristic, and a new test pins the depth rule (including that filter push-down beneath a deep stack still streams the levels above it). Also rewrites the chunk_iter module docs to state the vtable API contract and the cost model in one place. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk> Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
9e5874f to
377d029
Compare
Summary
Adds
decompress_chunks, a vtable method that streams an encoding tree's decompressed values through aChunkSinkin cache-resident ~1024-element chunks. Leaf encodings stream blocks straight out of their decompression kernel; wrapper encodings compose by interposing a stack-allocated sink adapter and recursing into their child, so a whole tree decompresses and transforms one L1-resident block at a time instead of materializing a full-length buffer per level.Two things this buys:
FoR(BitPacked)runs 1.29 ms vs 3.36 ms for decompress-then-iterate, matching a hand-written monomorphized loop within noise.The executor uses it where that second effect pays, decided by measuring the tree rather than by a blanket default.
The vtable API
Design points:
ArrayRef::decompress_chunksrejects an unsupported tree without emitting a partial stream.ArrayRef::decompress_chunks_or_materialize.ChunkMutis a type-erased&mut [T]; sinks recover the typed slice once per chunk and all per-element work stays monomorphized (~0.06 ns/element of dispatch).0..lenexactly (debug-checked), may be short; validity is not streamed; primitive dtypes only.Changes
vortex-array/src/chunk_iter.rs(new):ChunkSink,ChunkMut,DECOMPRESS_CHUNK_LEN, theArrayRefentry points,execute_via_chunks, a debug-build coverage checker, and the explicit materializing fallback.VTable::{supports_decompress_chunks, decompress_chunks}, wired throughDynArrayData.execute_untilstep 2c: canonicalize by streaming whenstreaming_chain_len(array) >= MIN_STREAMING_CHAIN(4). The chain counts consecutive streaming-capable nodes from the root, following only children that stream, are not already canonical, and preserve row count — that last rule keeps selection encodings such asFilterout of the executor path without a special case.VORTEX_CHUNKED_EXECUTE=0is a kill switch.FoRStrategykernel when the child is BitPacked with an unsigned reference, otherwise generic in-place add composition), Patched (patches each streamed block in place; lane-transposed layout flattened once), Filter (compacts each block in place against the mask, with a mean-run-length heuristic choosing run-copy vs gather), Constant (re-emits one scratch chunk), Primitive (streams its buffer through one L1 scratch chunk).encodings/fastlanes/benches/chunked_decompress.rswith hand-written monomorphized baselines to isolate each overhead.Benchmarks
Divan on a 4-core cloud VM, 4Mi rows unless noted. Methodology note: an earlier revision of this PR reported executor wins that did not reproduce — they were measured while other builds were running. Everything below is from repeated rounds on a quiet machine, and results that were not stable across rounds are called out as such.
Depth sweep — nested FoR over BitPacked, canonicalize:
Marginal cost per added level: 0.93 ms level-wise vs 0.51 ms streaming. Tail latency is the other story: p100/median is ~1.2× streaming versus 2.5–5× level-wise, which repeatedly allocates full-length intermediates. This is what
MIN_STREAMING_CHAIN = 4is calibrated against; chain 3 is borderline (1.08–1.15× across rounds) and excluded conservatively.Streaming consumption — sum without materializing, FoR(ref=1M) over BitPacked(bw=10):
hand_fused_sum(monomorphized upper bound)chunked_vtable_sum(this API, fused FoR+BitPacked)hand_chunked_add_pass_sum(monomorphized, extra in-place pass)chunked_vtable_sum_generic_compose(signed ref, generic composition)two_pass_sum(decompress then re-read)Patched(Constant), 1 patch per 1000 — the 16 MB base buffer is never written: streaming sum 0.955 ms vs 1.953 ms decompress-then-read.
Where streaming does not win, and why. When an encoding's level-wise path already decodes straight into the destination (
decode_intofor fused FoR/Patched; the in-place compaction kernel for Filter), there is no intermediate to eliminate and streaming only adds a scratch→output copy.Filter(BitPacked)at 65,536 rows (the dominant TPC-H scan tree) measures at parity for materialization and 10–35% slower for consumption except at high selectivity. Hence the cardinality rule in the executor:Filterstreams for consumers, never through the executor.SQL (TPC-H SF-1, DataFusion, Q1 + Q6). Neutral within noise. A trace showed why: streaming-capable trees are ~6% of non-canonical executor invocations there, and the trees that dominate (
Filter,decimal_byte_parts) are shallow — chains of 2–3, below the depth where streaming pays. The mechanism is confirmed non-regressing on the real scan path; SQL-level wins needChunkSinkconsumers in the aggregate kernels, where the consumption win above would reach query time.Next step. The remaining structural overhead is destination-blindness: a producer cannot see the consumer's output buffer, so it always decodes into scratch. Letting a sink offer a destination slice per chunk would remove that copy, which should turn the parity/loss cases into wins and lower
MIN_STREAMING_CHAIN.Checks run:
cargo nextest runon vortex-array (3439), vortex-fastlanes (327), vortex-file (144), vortex-alp, vortex-btrblocks — all passing;cargo test --doc -p vortex-array;cargo clippy --all-targetson vortex-array/vortex-fastlanes (clean);cargo +nightly fmt --all; TPC-H SF-1 Q1/Q6 A/B viadatafusion-bench. Not run: workspace-wide--all-featuresclippy (CUDA feature stack untouched).API Changes
New public API in
vortex-array: thechunk_itermodule (ChunkSink,ChunkMut,DECOMPRESS_CHUNK_LEN,execute_via_chunks),ArrayRef::{supports_decompress_chunks, decompress_chunks, decompress_chunks_or_materialize}, and two defaultedVTablemethods. Behavior change:execute_untilcanonicalizes streaming-capable primitive trees of chain length ≥ 4 via chunk streaming (same logical results, covered by equivalence tests; disable withVORTEX_CHUNKED_EXECUTE=0).🤖 Generated with Claude Code
https://claude.ai/code/session_01LNgQat1UYMr3pjJrdhPJuh