Skip to content

Streaming chunked decompression via a composable vtable method - #9614

Draft
joseph-isaacs wants to merge 11 commits into
developfrom
claude/decompression-chunked-iteration-u3kcns
Draft

Streaming chunked decompression via a composable vtable method#9614
joseph-isaacs wants to merge 11 commits into
developfrom
claude/decompression-chunked-iteration-u3kcns

Conversation

@joseph-isaacs

@joseph-isaacs joseph-isaacs commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds decompress_chunks, a vtable method that streams an encoding tree's decompressed values through a ChunkSink in 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:

  1. Consumers that never materialize fold values directly out of L1 — a sum over FoR(BitPacked) runs 1.29 ms vs 3.36 ms for decompress-then-iterate, matching a hand-written monomorphized loop within noise.
  2. Deep encoding stacks canonicalize faster, because each level costs level-wise execution a whole intermediate buffer but costs streaming only one more L1 pass. Marginal cost per added level: 0.93 ms level-wise vs 0.51 ms streaming.

The executor uses it where that second effect pays, decided by measuring the tree rather than by a blanket default.

The vtable API

pub trait VTable {
    /// Can this encoding (recursively) stream without materializing? Default: false.
    fn supports_decompress_chunks(array: ArrayView<'_, Self>) -> bool { false }

    /// Stream decompressed values through `sink` in cache-resident chunks.
    /// Default: reports unsupported — never a silent materializing fallback.
    fn decompress_chunks(
        array: ArrayView<'_, Self>,
        ctx: &mut ExecutionCtx,
        sink: &mut dyn ChunkSink,
    ) -> VortexResult<()>;
}

pub trait ChunkSink {
    fn accept(&mut self, chunk: ChunkMut<'_>, row_range: Range<usize>) -> VortexResult<()>;
}

Design points:

  • Two methods, not one. Support must be answerable before any work happens, and it cascades: a wrapper only advertises support when the children it streams from do. So the whole tree is validated up front and ArrayRef::decompress_chunks rejects an unsupported tree without emitting a partial stream.
  • No silent fallback. Callers who want one ask by name: ArrayRef::decompress_chunks_or_materialize.
  • Dispatch is per chunk, never per value. ChunkMut is 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).
  • Chunks are mutable producer scratch, which is what lets wrappers transform in place (FoR adds its reference, Patched overwrites patched rows, Filter compacts) with no extra buffer.
  • No heap state descending the tree — the sink chain is one stack frame per level.
  • Contract: chunks are in-order, contiguous, cover 0..len exactly (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, the ArrayRef entry points, execute_via_chunks, a debug-build coverage checker, and the explicit materializing fallback.
  • VTable::{supports_decompress_chunks, decompress_chunks}, wired through DynArrayData.
  • execute_until step 2c: canonicalize by streaming when streaming_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 as Filter out of the executor path without a special case. VORTEX_CHUNKED_EXECUTE=0 is a kill switch.
  • Implementations: BitPacked (streams FastLanes blocks from its unpack scratch, patches applied per block via a cursor), FoR (fused FoRStrategy kernel 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).
  • Tests: patches, slicing/offset, fused and generic FoR, Patched-over-Constant, Filter-over-BitPacked across selectivities plus all-true/all-false, non-chunk-aligned lengths, null constants, empty arrays, the error-without-emitting contract, streaming-vs-levelwise equivalence at three stack depths on nullable trees, and the executor depth rule (including that filter push-down beneath a deep stack still streams the levels above it).
  • New divan bench encodings/fastlanes/benches/chunked_decompress.rs with 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:

streaming chain level-wise streaming speedup level-wise p100 streaming p100
2 2.57 ms 2.58 ms parity 10.9 ms 3.1 ms
3 3.14 ms 2.74 ms 1.15× 13.6 ms 3.8 ms
5 5.26 ms 4.00 ms 1.32× 17.0 ms 4.6 ms
9 9.07 ms 6.16 ms 1.47× 23.4 ms 6.5 ms

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 = 4 is 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):

bench median
hand_fused_sum (monomorphized upper bound) 1.014 ms
chunked_vtable_sum (this API, fused FoR+BitPacked) 1.291 ms
hand_chunked_add_pass_sum (monomorphized, extra in-place pass) 1.411 ms
chunked_vtable_sum_generic_compose (signed ref, generic composition) 1.886 ms
two_pass_sum (decompress then re-read) 3.360 ms

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_into for 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: Filter streams 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 need ChunkSink consumers 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 run on vortex-array (3439), vortex-fastlanes (327), vortex-file (144), vortex-alp, vortex-btrblocks — all passing; cargo test --doc -p vortex-array; cargo clippy --all-targets on vortex-array/vortex-fastlanes (clean); cargo +nightly fmt --all; TPC-H SF-1 Q1/Q6 A/B via datafusion-bench. Not run: workspace-wide --all-features clippy (CUDA feature stack untouched).

API Changes

New public API in vortex-array: the chunk_iter module (ChunkSink, ChunkMut, DECOMPRESS_CHUNK_LEN, execute_via_chunks), ArrayRef::{supports_decompress_chunks, decompress_chunks, decompress_chunks_or_materialize}, and two defaulted VTable methods. Behavior change: execute_until canonicalizes streaming-capable primitive trees of chain length ≥ 4 via chunk streaming (same logical results, covered by equivalence tests; disable with VORTEX_CHUNKED_EXECUTE=0).

🤖 Generated with Claude Code

https://claude.ai/code/session_01LNgQat1UYMr3pjJrdhPJuh

@codspeed-hq

codspeed-hq Bot commented Aug 25, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 2.79%

⚠️ Unknown Walltime execution environment detected

Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data.

For the most accurate results, we recommend using CodSpeed Macro Runners: bare-metal machines fine-tuned for performance measurement consistency.

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 6 improved benchmarks
❌ 10 regressed benchmarks
✅ 2141 untouched benchmarks
🆕 41 new benchmarks
⏩ 106 skipped benchmarks1
🗄️ 4 archived benchmarks run2

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

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)

Open in CodSpeed

Footnotes

  1. 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.

  2. 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.

@joseph-isaacs joseph-isaacs changed the title Add composable chunked decompression via a vtable method Streaming chunked decompression via a composable vtable method Aug 27, 2026
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>
@joseph-isaacs
joseph-isaacs force-pushed the claude/decompression-chunked-iteration-u3kcns branch from 2d9d407 to 9e5874f Compare August 28, 2026 11:29
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>
@joseph-isaacs
joseph-isaacs force-pushed the claude/decompression-chunked-iteration-u3kcns branch from 9e5874f to 377d029 Compare August 28, 2026 11:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant