perf: decode shuffle blocks against a cached schema instead of re-parsing per block - #5809
perf: decode shuffle blocks against a cached schema instead of re-parsing per block#5809peterxcli wants to merge 6 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>
apache#5805 landed on main as a squash, so the benchmark it added conflicted with the original commits on this branch. The conflict was one-sided: resolved to main's file plus this branch's reset_schema_cache import and decode_block_uncached arm, with no main-only content dropped. The auto-merged manifests keep both main's DataFusion 55.1.0 and this branch's arrow-data dependency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
andygrove
left a comment
There was a problem hiding this comment.
The description on this one is unusually careful and I appreciate the retraction of the earlier numbers. I traced the fast path against StreamReader::next_ipc_message in arrow-ipc 59.2.0 and could not find an input that decodes differently. The cache key is the raw schema message bytes compared byte for byte and only Schema messages are inserted, so a hit cannot reuse a schema across blocks that do not share one. Dictionary blocks, multi-batch blocks, trailing bytes, truncation and a second schema message all fall back correctly. My concerns are all on the measurement and cost side.
First, PR Build (Linux) / ubuntu-latest/rust-test is red and is taking Required Checks down with it. The only annotation is exit code 100, which is what cargo nextest returns when a test fails rather than when the build breaks. That job was green on cc6b3d805 before the merge from main, and it is green on main itself, so the red appeared with the merge commit. Could you confirm whether that is the schema cache or an unrelated flake?
The benchmark only encodes with CompressionCodec::None, so every row of the results table uses a codec that is off by default. spark.comet.shuffle.compression.codec defaults to lz4. That matters more than usual because the change has a different shape per codec. On NONE the new code pays a fresh aligned allocation and a full block memcpy in Buffer::from(encoded) that the old path did not pay, which is probably a good part of the small-block regression you measured. On the compressed codecs the block is instead built by read_to_end into a growing Vec, so it reallocates and recopies as it doubles, where the old path had the decompressor write straight into the destination buffers. Could you add Lz4Frame to the matrix and re-run? Whether the small-block regression is worth taking really depends on what the default codec does.
None of the four new tests would fail if the fast path were removed. Changing try_decode_with_cached_schema to return None unconditionally leaves all of them passing, because each only checks that the batch decodes correctly or that the error cases still error, and the general decoder does both. Nothing proves the fast path ever runs, which is the entire change. Would a #[cfg(test)] counter bumped when the fast path returns a batch work? That would also let dictionary_blocks_keep_decoding_with_a_warm_cache assert the opposite, that the fast path was declined, and let the tests call reset_schema_cache so the cold and warm phases are explicit rather than depending on nextest giving each test its own process.
The fast path also calls root_as_message four times per block. read_message parses and verifies the schema message, then try_decode_with_cached_schema parses the same bytes again for header_type, and the record batch message gets the same treatment in read_message and decode_with_known_schema. root_as_message runs the verifier over the whole message, so on a wide schema this repeats most of the parse the cache exists to avoid. Given your numbers that looks like a plausible source of the regression. Could IpcMessage carry the Message it already parsed? On a cache hit the schema message does not need parsing at all, since a hit on the exact bytes already proves it is a schema message. I would rather close the regression that way than gate the fast path on a body-size threshold that then needs tuning.
Dictionary blocks can never take the fast path, because the message after the schema is a DictionaryBatch and expect_end_of_stream then rejects the record batch that follows. Comet's native shuffle does dictionary encode string and binary columns, which is why ShuffleBlockWriter has the SchemaEncoding::Fallback arm and ShuffleScanExec needs unpack_dictionary. Those blocks now pay full block materialization and a failed probe every time with none of the gain, and the benchmark builds only plain Int64 and Utf8 columns so it is not measured. Could you add a dictionary-encoded string column to the matrix? If it is a real regression, recording in the cache that a schema never takes the fast path would let the probe be skipped from the second block onward.
Last one is memory. Buffer::from_vec keeps the vector's allocation as it is, and read_to_end grows geometrically, so the buffer handed to the decoder can be close to twice the decompressed block. Every array in the batch is a slice of that buffer, so the whole allocation including spare capacity stays alive as long as any downstream operator holds the batch. The previous path allocated an exactly sized body buffer per message. There is a knock-on too, since Buffer::capacity returns the layout size and get_array_memory_size sums that per buffer, so what DataFusion reserves for a shuffle-read batch in a sort or join now tracks vector capacity rather than body length. Have you measured resident footprint across a shuffle read before and after? A shrink_to_fit costs a copy so it may not be the answer, but I would like to see the number before this lands.
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