perf: decode shuffle blocks against a cached schema instead of re-parsing per block - #5809
Draft
peterxcli wants to merge 5 commits into
Draft
perf: decode shuffle blocks against a cached schema instead of re-parsing per block#5809peterxcli wants to merge 5 commits into
peterxcli wants to merge 5 commits into
Conversation
Every shuffle block is a self-contained Arrow IPC stream, so read_single_batch builds a fresh StreamReader per block and parses the schema flatbuffer once per block, even though every block in a shuffle carries the same schema. The write side already avoids the mirror image of this, encoding the schema once in ShuffleBlockWriter::try_new and writing the pre-encoded bytes verbatim, but there was no read-side benchmark to say whether the reader's half is worth removing. This adds one, parameterized by column count and rows per block, measuring the schema parse separately from the full block decode. On an M-series laptop: shape decode schema parse share 5 col x 64 row 1.93 us 1.14 us 59% 5 col x 512 row 2.38 us 0.91 us 38% 5 col x 8192 row 10.99 us 0.86 us 8% 50 col x 64 row 12.77 us 6.03 us 47% 50 col x 512 row 17.89 us 6.05 us 34% 50 col x 8192 row 218 us 6.05 us 3% The parse cost is constant per block and independent of row count, so its share is set by how many rows land in a block. That is largest exactly where the issue predicted: wide shuffles, where rows per partition are few, and repeated spilling, where each spill round emits its own block per partition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sing per block Every shuffle block is a self-contained Arrow IPC stream, so read_single_batch built a fresh StreamReader per block and parsed the schema flatbuffer once per block, even though every block in a shuffle carries the same schema. The write side already avoids the mirror image of this, encoding the schema once in ShuffleBlockWriter::try_new and writing the pre-encoded bytes verbatim. Blocks are now decoded against a per-thread cache keyed on the raw schema message, so a hit costs one memcmp. On a hit the block is decoded in place with RecordBatchDecoder; on a miss the original StreamReader path runs unchanged and its parsed schema is cached for later blocks. The cache holds four schemas, since a reduce task can interleave blocks from more than one shuffle and a single entry would thrash. The fast path never reports an error of its own. A cache miss, a dictionary message, more than one record batch, trailing bytes after the end-of-stream marker, or a block that simply fails to decode all fall back to the general decoder, so validation behaviour and every error message are unchanged and the fast path is always safe to skip. The measured win is not where apache#5792 predicted. Comparing this commit against its parent back to back, with the parse_schema_only arm as a control that this change does not touch (it drifted within 5% between the runs): shape before after change 5 col x 64 row 1.663 us 1.775 us +6.7% 5 col x 512 row 2.120 us 1.913 us -9.8% 5 col x 8192 row 11.098 us 7.841 us -29.3% 50 col x 64 row 13.479 us 12.849 us -4.7% 50 col x 512 row 18.606 us 16.090 us -13.5% 50 col x 8192 row 159.49 us 77.03 us -51.7% The issue expected the gain at small blocks, where the constant per-block parse is the largest share of decode. It is the other way round: the parse is worth under a microsecond, while decoding in place avoids the per-body MutableBuffer that StreamReader allocates and zero-fills before copying into it, and that cost scales with body size. Small blocks are marginally slower, since materializing the block and walking its messages is not repaid when the body is tiny. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…chema-cache Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Which issue does this PR close?
Closes #5792. Builds on #5805, which added the benchmark.
Rationale for this change
Every shuffle block is a self-contained Arrow IPC stream, so
read_single_batchbuilt a freshStreamReaderper block and parsed the schema flatbuffer once per block, even though every block in a shuffle carries the same schema and the reducer already knows it from the plan protobuf.The win is not the parse, and not where #5792 predicted. The parse is worth about 2 us; the real cost is that
StreamReaderallocates aMutableBuffer::from_len_zeroed(bodyLength)per message and copies the body into it (reader.rs:1845), so it zero-fills and copies every block body. Decoding in place against a known schema skips that, and the saving scales with body size.Measured on an idle 16-core x86_64 Linux host, alternating branches base/cached/base/cached so drift moves both together, criterion defaults (3s warmup, 5s measurement, 100 samples). Both runs of each branch shown, and
parse_schema_only, which this change does not touch, held within 2 percent across all four rounds:This is a trade, not a free win. Narrow schemas regress until blocks get large: 15 percent slower at 64 rows and still 4 percent slower at 512, crossing over to a 29 percent gain by 8192. Materializing the block and walking its messages is not repaid when the body is small. Wide schemas are neutral to positive throughout, since their bodies are already large enough.
The 8192 row rows are the block size a default 200 partition shuffle produces, which is the case this is aimed at. But a shuffle with many partitions and few rows each lands in the regressing region, and that is also the case #5792 originally expected to benefit.
If reviewers would rather not take the regression, the fast path can be gated on body size, which would keep the large-block gain and leave small blocks on the existing path. I have not implemented that here because it adds a threshold to tune; happy to if preferred.
An earlier revision of this description carried numbers from a loaded laptop that overstated the gains (up to -51.7%) and understated the small-block regression (+6.7%), and had the wrong sign on the 5 col x 512 row row. The table above replaces them.
What changes are included in this PR?
RecordBatchDecoderover the already-decompressed buffer, so arrays borrow it instead of being copied into a fresh zeroed buffer.StreamReaderpath runs unchanged and its parsed schema is cached for later blocks.arrow-databecomes an explicit dependency forUnsafeFlag, which the trusted-local path needs to keep skipping validation. Already in the tree viaarrow, so no build cost.The fast path never reports an error of its own. A cache miss, a dictionary message, more than one record batch, trailing bytes after the end-of-stream marker, or a block that fails to decode all return
Noneand fall back to the general decoder. Validation behaviour and every error message are unchanged, and the fast path is always safe to skip.One subtlety:
read_messagereports both an explicit end-of-stream marker and a clean message boundary as "no more messages". Relying on that alone would have let trailing bytes after the marker pass, which the general decoder rejects.expect_end_of_streamchecks for that specifically.How are these changes tested?
Four new tests in
ipc.rs, all exercising the warm-cache path the existing tests never reached:cached_schema_decode_matches_the_first_decodedecodes each block twice across all four codecs and both entry points, asserting the warm decode equals the cold one and the original batch.dictionary_blocks_keep_decoding_with_a_warm_cachecovers a schema that never takes the fast path.trailing_data_still_fails_with_a_warm_cachecovers the end-of-stream gap above.truncated_block_fails_with_a_warm_cachechecks a body-truncated block fails cold and warm, and that a stream ending on a message boundary without the marker stays valid, as before.datafusion-comet-shuffle129 passed,datafusion-comet --lib333 passed,CometNativeShuffleSuite53,CometShuffleSuite44, clippy clean.The benchmark gains a
decode_block_uncachedarm that clears the cache each iteration. It is not a stand-in for the base branch, since it also pays the cache insert every time, but it bounds what the cache is worth within a single run.🤖 Generated with Claude Code