Skip to content

perf: decode shuffle blocks against a cached schema instead of re-parsing per block - #5809

Draft
peterxcli wants to merge 5 commits into
apache:mainfrom
peterxcli:perf/shuffle-reader-schema-cache
Draft

perf: decode shuffle blocks against a cached schema instead of re-parsing per block#5809
peterxcli wants to merge 5 commits into
apache:mainfrom
peterxcli:perf/shuffle-reader-schema-cache

Conversation

@peterxcli

@peterxcli peterxcli commented Sep 9, 2026

Copy link
Copy Markdown
Member

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_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 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 StreamReader allocates a MutableBuffer::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:

shape base cached change
5 col x 64 row 4.31 us / 4.25 us 5.07 us / 4.76 us +14.9%
5 col x 512 row 5.26 us / 5.17 us 5.44 us / 5.39 us +3.8%
5 col x 8192 row 24.92 us / 25.88 us 18.17 us / 18.01 us -28.8%
50 col x 64 row 38.78 us / 38.14 us 37.66 us / 37.53 us -2.2%
50 col x 512 row 50.33 us / 50.48 us 46.44 us / 45.22 us -9.1%
50 col x 8192 row 443.9 us / 405.0 us 302.6 us / 287.9 us -30.4%

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?

  • A per-thread cache keyed on the raw schema message, so a hit costs one memcmp. It holds four schemas: a reduce task can interleave blocks from several shuffles, and a single entry would thrash.
  • On a hit, the block is decoded in place with RecordBatchDecoder over the already-decompressed buffer, so arrays borrow it instead of being copied into a fresh zeroed buffer.
  • On a miss, the original StreamReader path runs unchanged and its parsed schema is cached for later blocks.
  • arrow-data becomes an explicit dependency for UnsafeFlag, which the trusted-local path needs to keep skipping validation. Already in the tree via arrow, 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 None and 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_message reports 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_stream checks 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_decode decodes 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_cache covers a schema that never takes the fast path.
  • trailing_data_still_fails_with_a_warm_cache covers the end-of-stream gap above.
  • truncated_block_fails_with_a_warm_cache checks 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-shuffle 129 passed, datafusion-comet --lib 333 passed, CometNativeShuffleSuite 53, CometShuffleSuite 44, clippy clean.

The benchmark gains a decode_block_uncached arm 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

peterxcli and others added 2 commits September 9, 2026 22:05
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>
peterxcli and others added 3 commits September 10, 2026 10:35
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:shuffle Shuffle (JVM and native) enhancement New feature or request performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reuse the decoded schema across shuffle blocks instead of re-parsing it per block

1 participant