Skip to content

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

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

peterxcli wants to merge 8 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. Takes the same route as #5909, which @andygrove opened for the same problem: this revision replaces the earlier materialize-and-probe fast path with a message loop of the kind #5909 proposed, keyed on the raw schema bytes, so the two are no longer competing designs.

Rationale for this change

Every shuffle block is a self-contained Arrow IPC stream, so the reader built a fresh StreamReader per block. StreamReader::try_new verifies the schema flatbuffer and allocates a Schema with one Arc<Field> and String per column every time, even though ShuffleBlockWriter writes the same pre-encoded schema message into every block and the reducer sees the same schema thousands of times. For the small blocks that high partition counts produce, that fixed cost is about half of the decode.

The earlier revision of this PR materialized the whole block and probed it against the cache. Review found it re-parsed the schema message twice on a hit, built the block through a growing Vec that kept its spare capacity alive under every array, and made dictionary blocks pay for a probe that could never succeed. All three are gone.

What changes are included in this PR?

  • read_ipc_compressed walks each block message by message, the way StreamReader does: schema, any dictionary batches, one record batch, end of stream. StreamReader is no longer used on this path.
  • A per-thread cache maps the raw bytes of a schema message to its parsed SchemaRef, four entries deep so a reduce task interleaving blocks from several shuffles does not thrash. A hit is one memcmp; the schema message is neither verified nor parsed. A miss parses it, exactly as StreamReader did, and caches the result.
  • Message bodies are read into exactly sized buffers: for compressed blocks the same MutableBuffer::from_len_zeroed(bodyLength) StreamReader used, for uncompressed blocks a copy of the body slice with no zero fill first. No growth slack, so a decoded batch reports the same get_array_memory_size as before, and the whole-block materialization is gone.
  • Dictionary batches are decoded in the same loop with read_dictionary_impl, scoped to their own block, so the JVM columnar shuffle's dictionary-encoded strings decode from the cached schema too.
  • Metadata read from a decompressor lands in a reusable per-thread scratch. A corrupt length that grows it past 1 MiB releases it after the block rather than pinning it for the thread's life.
  • Every existing error is preserved: empty stream, more than one record batch, trailing bytes after the IPC stream or after the compressed stream, the LZ4 end mark, and full validation for remote blocks versus skip_validation for local ones. One malformed case is stricter than before on every codec: one to three stray bytes where a message length should be were previously swallowed as an end of stream, and are now an error.

Benchmark

shuffle_reader on an idle 16-core x86_64 Linux host, main at 481aefe versus this branch (the decoder as of a5290ca; fe93ff2 changes only tests and the benchmark source), alternating base/new/base/new with the same benchmark source on both sides (main gets a no-op reset_schema_cache). Criterion defaults. decode_block holds the cache across iterations; decode_block_uncached clears it every iteration, so on main it equals decode_block. parse_schema_only is untouched by this change and serves as a control.

Cached decode (decode_block, the steady state of a shuffle read):

shape codec base (two rounds) this PR (two rounds) change
5 col x 64 row lz4 7.99 us / 7.86 us 5.48 us / 5.68 us -29.6%
5 col x 512 row lz4 21.62 us / 22.15 us 17.55 us / 17.53 us -19.8%
5 col x 8192 row lz4 297.35 us / 296.67 us 289.69 us / 292.65 us -2.0%
50 col x 64 row lz4 60.46 us / 61.51 us 37.06 us / 35.78 us -40.3%
50 col x 512 row lz4 202.76 us / 202.74 us 156.26 us / 153.91 us -23.5%
50 col x 8192 row lz4 3.28 ms / 3.35 ms 3.13 ms / 3.14 ms -5.4%
5 col x 64 row (dictionary strings) lz4 13.17 us / 13.00 us 9.35 us / 9.42 us -28.3%
5 col x 8192 row (dictionary strings) lz4 261.89 us / 264.85 us 249.66 us / 253.26 us -4.5%
5 col x 64 row none 4.24 us / 4.28 us 1.99 us / 2.05 us -52.7%
5 col x 512 row none 5.35 us / 4.99 us 2.71 us / 2.59 us -48.7%
5 col x 8192 row none 25.64 us / 25.16 us 14.95 us / 14.98 us -41.1%
50 col x 64 row none 37.97 us / 38.73 us 17.66 us / 17.11 us -54.7%
50 col x 512 row none 51.64 us / 49.65 us 24.75 us / 25.66 us -50.2%
50 col x 8192 row none 423.89 us / 436.50 us 253.46 us / 264.24 us -39.8%
5 col x 64 row (dictionary strings) none 8.11 us / 8.07 us 5.02 us / 5.10 us -37.5%
5 col x 8192 row (dictionary strings) none 21.20 us / 21.94 us 13.00 us / 13.12 us -39.4%

Cache miss (decode_block_uncached, cleared every iteration; on main this is the same code as decode_block). This is the cost of the new reader with no help from the cache:

shape codec base (two rounds) this PR (two rounds) change
5 col x 64 row lz4 8.13 us / 7.97 us 8.09 us / 7.71 us -1.8%
5 col x 512 row lz4 23.00 us / 21.58 us 19.59 us / 20.75 us -9.5%
5 col x 8192 row lz4 296.73 us / 298.59 us 296.21 us / 293.15 us -1.0%
50 col x 64 row lz4 61.88 us / 63.91 us 58.72 us / 59.14 us -6.3%
50 col x 512 row lz4 201.14 us / 211.23 us 175.41 us / 175.89 us -14.8%
50 col x 8192 row lz4 3.24 ms / 3.31 ms 3.17 ms / 3.22 ms -2.5%
5 col x 64 row (dictionary strings) lz4 12.90 us / 12.71 us 12.49 us / 11.97 us -4.4%
5 col x 8192 row (dictionary strings) lz4 267.11 us / 260.50 us 251.59 us / 258.55 us -3.3%
5 col x 64 row none 4.22 us / 4.18 us 4.04 us / 4.11 us -3.1%
5 col x 512 row none 5.32 us / 5.18 us 4.71 us / 4.76 us -9.8%
5 col x 8192 row none 25.04 us / 25.43 us 18.00 us / 17.85 us -29.0%
50 col x 64 row none 38.32 us / 39.29 us 36.80 us / 36.48 us -5.6%
50 col x 512 row none 51.94 us / 49.66 us 45.43 us / 45.15 us -10.8%
50 col x 8192 row none 428.88 us / 458.75 us 280.78 us / 306.94 us -33.8%
5 col x 64 row (dictionary strings) none 8.09 us / 8.29 us 8.35 us / 8.01 us -0.1%
5 col x 8192 row (dictionary strings) none 21.80 us / 21.45 us 16.59 us / 16.80 us -22.8%

Validated decode (decode_block_validated, the remote entry point, schema from the cache):

shape codec base (two rounds) this PR (two rounds) change
5 col x 64 row lz4 9.71 us / 9.15 us 6.56 us / 6.72 us -29.6%
5 col x 512 row lz4 25.31 us / 26.36 us 21.18 us / 20.84 us -18.7%
5 col x 8192 row lz4 341.45 us / 341.73 us 335.25 us / 335.59 us -1.8%
50 col x 64 row lz4 72.82 us / 72.26 us 45.98 us / 47.36 us -35.7%
50 col x 512 row lz4 244.81 us / 247.56 us 191.44 us / 198.29 us -20.8%
50 col x 8192 row lz4 3.76 ms / 3.83 ms 3.73 ms / 3.68 ms -2.3%
5 col x 64 row (dictionary strings) lz4 14.60 us / 14.51 us 10.04 us / 9.77 us -31.9%
5 col x 8192 row (dictionary strings) lz4 283.60 us / 281.65 us 265.88 us / 269.28 us -5.3%
5 col x 64 row none 5.09 us / 5.03 us 2.98 us / 2.90 us -41.9%
5 col x 512 row none 8.44 us / 8.46 us 5.90 us / 5.93 us -30.0%
5 col x 8192 row none 67.79 us / 66.41 us 57.49 us / 59.16 us -13.1%
50 col x 64 row none 48.92 us / 47.37 us 26.70 us / 26.73 us -44.5%
50 col x 512 row none 89.48 us / 88.57 us 64.11 us / 66.84 us -26.5%
50 col x 8192 row none 1.08 ms / 1.11 ms 905.54 us / 884.60 us -18.3%
5 col x 64 row (dictionary strings) none 9.24 us / 9.49 us 6.65 us / 5.97 us -32.6%
5 col x 8192 row (dictionary strings) none 40.48 us / 38.39 us 30.70 us / 30.27 us -22.7%

parse_schema_only, the control, moved -1.5% to +3.1% across the six shapes.

No shape regresses. The narrow small blocks that the previous revision made 4 to 15 percent slower are now the largest wins, because a hit no longer verifies the schema flatbuffer at all. Under LZ4, the default, the gain shrinks with block size because decompression dominates: a 50 column, 8192 row block takes 3 ms to decompress and decode, of which the schema parse was 20 us. The large-block gain on uncompressed blocks, 40 percent at 50 col x 8192 rows, holds even on a cache miss: it comes from copying the body without zero-filling it first, which the compressed path cannot do because Read::read_exact needs an initialized destination.

Memory

Bodies are exactly sized, so nothing changes in what a batch holds: decoded_arrays_report_the_same_memory_size_as_stream_reader decodes a 100,000-row block on every codec, cold and warm, and asserts get_array_memory_size equals a plain StreamReader decode of the same bytes.

warm_decode_allocates_no_more_than_stream_reader compares one warm decode against the StreamReader path this PR replaced, on the same bytes in the same test, using the allocation observer the RSS writer tests already had, and asserts the new path never allocates more in count, bytes, or peak live memory. Rust allocations only; zstd's C-side workspace is outside the observer, which is why ZSTD reads like NONE:

block codec old: allocations / bytes / peak live this PR: allocations / bytes / peak live
3 rows, 3 columns none 22 / 2,451 / 1,675 12 / 1,248 / 920
3 rows, 3 columns lz4 24 / 133,523 / 132,747 14 / 132,320 / 131,992
3 rows, 3 columns zstd 22 / 2,451 / 1,675 12 / 1,248 / 920
3 rows, 3 columns snappy 24 / 144,477 / 143,701 14 / 143,274 / 142,946
8192 rows, 2 columns none 18 / 150,402 / 149,554 9 / 149,032 / 148,816
8192 rows, 2 columns lz4 20 / 674,690 / 673,842 11 / 673,320 / 673,104
8192 rows, 2 columns zstd 18 / 150,402 / 149,554 9 / 149,032 / 148,816
8192 rows, 2 columns snappy 20 / 292,428 / 291,580 11 / 291,058 / 290,842

The difference is the schema parse and the metadata buffer, about ten allocations and 1.2 KiB per block; the body allocation is identical. On LZ4 and Snappy the decompressors' own working buffers dominate both columns.

I have not measured executor resident size across a Spark shuffle read; with per-body allocations identical to the previous path there is nothing left for it to show, but I am happy to run one if wanted.

How are these changes tested?

Under cfg(test) the cache counts hits and misses, and reset_schema_cache clears both, so each test states its cold and warm phases explicitly rather than relying on nextest process isolation:

  • warm_decodes_hit_the_cache_and_match_the_cold_one: on every codec and both entry points, the second decode is a hit and equals the first and the original batch, for a plain schema and a dictionary schema.
  • dictionaries_are_scoped_to_their_block_under_a_cached_schema: two blocks with the same schema but different dictionaries decode to their own values with the schema served from the cache, validated and not, on every codec.
  • distinct_schemas_miss_once_and_recent_ones_stay_cached: alternating schemas stay resident; one more than the capacity evicts only the least recently used.
  • decoded_arrays_report_the_same_memory_size_as_stream_reader and warm_decode_allocates_no_more_than_stream_reader, as above.
  • invalid_array_offsets_fail_validation_cold_and_warm: the corrupt-offsets block fails validation both when its schema is parsed and when it is served from the cache by an earlier valid block.
  • oversized_metadata_length_is_an_error_and_releases_the_scratch: a forged 2 MiB metadata length fails cleanly on both the streamed and in-place readers and leaves no oversized scratch behind.
  • partial_length_prefix_is_an_error: the stricter malformed case above, on every codec.
  • trailing_data_still_fails_with_a_warm_cache and truncated_block_fails_with_a_warm_cache now assert which path they failed on.
  • All previous malformed-input tests pass unchanged against the new reader.

datafusion-comet-shuffle 134 passed, datafusion-comet --lib 414 passed, clippy clean with -D warnings on all targets. CometNativeShuffleSuite and CometShuffleSuite (101 tests) pass on Spark 4.1 against the rebuilt native library.

The red rust-test job on the earlier push failed in iceberg_write::tests::cancelling_abort_keeps_the_guard_armed, which this PR does not touch; #5919 makes that test deterministic. On a5290ca the full rust-test job completed green.

🤖 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>
@peterxcli
peterxcli marked this pull request as ready for review September 13, 2026 05:34
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 andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@andygrove

Copy link
Copy Markdown
Member

I opened #5909 for the same problem before finding this PR, sorry for the overlap. It is now a draft, and this PR has priority since it came first. Posting the comparison here because the two take different routes to the same cache and the numbers suggest they are complementary rather than competing.

Where the cost is. Both PRs agree the schema parse is a fixed cost per block. On my machine (Apple Silicon, codec None, the same shuffle_reader bench shapes) parse_schema_only is 0.8 µs at 5 columns and 6 µs at 50 columns, which is about half of a 64-row block's decode and under 10 percent of an 8192-row block's. So the parse matters most for small blocks, and the body zero-fill plus copy that this PR removes matters most for large ones.

What #5909 does differently. It keeps the streaming Read-based path and replaces StreamReader with a message-level loop over the same framing (schema, dictionary batches, one record batch, end of stream, plus the existing trailing-data and single-batch checks). The decoder keeps the raw bytes of the last schema message next to the parsed SchemaRef; each block's schema message is compared with == on the bytes and reused on a match, parsed and cached otherwise. It does not materialize the block, so it never pays for a block the old path did not pay for. The body is still read into a MutableBuffer as before, so it does not get the large-block win this PR measures.

Relative change of a decoder held across blocks versus a fresh decoder per block, same branch, same machine:

shape #5909 cached vs fresh this PR (from the description)
5 col x 64 row -54% +15%
5 col x 512 row -33% +4%
5 col x 8192 row -8% -29%
50 col x 64 row -54% -2%
50 col x 512 row -39% -9%
50 col x 8192 row -7% -30%

The fresh-decoder numbers in #5909 are unchanged from main, so the message loop is free on a cache miss.

Suggestion. Both PRs key the cache on the raw schema message bytes, so the cache itself is the same idea. The difference is what happens to the body: this PR materializes the block and decodes in place, #5909 streams it. The two stack naturally: the streaming loop for blocks under a size threshold, where materialization does not pay for itself, and your in-place decode above it. That would give the small-block gain without the regression you flagged and keep the large-block gain, which is the size gate you offered in the description. Happy to close #5909 once that lands, or to rebase #5909 on top of this if you would rather keep the in-place decode as the base. Whichever you prefer.

One more thing worth a test if it is not already covered: the JVM columnar shuffle writes dictionary-encoded strings, so blocks on that path carry a dictionary batch before the record batch, and the dictionary must be scoped to its own block rather than to the cached schema.

…ema without parsing it

Replaces the materialize-then-probe fast path with one message loop that
mirrors StreamReader. A cached schema is matched on its raw bytes and never
verified or parsed again; the record batch and any dictionary batches are
parsed once each. Bodies are read into exactly sized buffers, so a decoded
batch reports the same memory as before, and dictionary blocks decode from
the cache with dictionaries scoped to their own block.

A #[cfg(test)] hit/miss counter proves which path each decode took; the
tests reset the cache so cold and warm phases are explicit. The benchmark
adds Lz4Frame, the default codec, and a dictionary-encoded string column.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness

Summary

Reviewed 0f61f3510f588c1a80969239b1e81571d1f269b0 against f69c4c81b9429e327ea95658530ae4ed4ed19635, including the existing discussion.

Prior state and problem

Each shuffle block contains a complete Arrow IPC stream. The previous reader built a new StreamReader per block, reparsed its schema, and allocated a zero-filled body buffer. Repeating that work can matter when a reduce task reads many blocks with the same schema.

Design approach and implementation

This change fully materializes the IPC payload, keeps up to four raw-schema-message/SchemaRef pairs per thread, and uses RecordBatchDecoder for a cache hit with exactly one record batch. Unsupported layouts and failed probes return to StreamReader. The wire format and writer are unchanged; the behavioral change is in reader allocation, parsing, and ownership.

I found no additional correctness defect in the paths inspected. The cache compares the complete schema-message bytes, so a different field type, nullability, nested layout, dictionary identity, or metadata cannot silently reuse a different schema. The cached state contains no batch data or dictionary values. Dictionary messages use a fresh general reader for each block, preserving block-scoped dictionaries.

The fast decoder uses the same batch metadata version and validation choice as Arrow's general reader. Remote reads still call read_ipc_compressed_validated, then validate logical types before normalization; the JNI failure callback remains in place. A schema previously cached by a trusted local read does not disable validation on a later remote read. Owned, reference-counted buffers also survive reuse of the JVM input buffer and schema-cache eviction.

The one-batch/end checks reject the fast path for additional messages, trailing data, or truncated bodies; errors then come from the general decoder. Legacy clean message-boundary termination remains accepted. This is a source-level assessment, not a new malformed-input execution run.

The existing request to prove fast-path execution is still relevant: output equality also passes if the optimized path always declines. Add explicit cold reset/hit assertions, dictionary-decline assertions, alternating schemas and eviction, and a warm-cache validated corrupt-array case before relying on these tests as optimization coverage.

Validation and disposition

The lockfile and native-build log resolve Arrow 59.3.0, despite the manifest's 59.2.0 lower bound. I checked the relevant 59.3.0 reader/buffer sources against the tagged upstream files.

The Rust job failed at cancelling_abort_keeps_the_guard_armed, whose source is unchanged by this PR. It ran 143 passing tests and one failure, then skipped 1,300 remaining tests because of fail-fast. That does not identify a shuffle failure, and it does not verify the new Rust shuffle tests. Resolve the failure and obtain a completed relevant run.

The Spark 3.5 shuffle job passed 442 tests overall: CometShuffleSuite lists 44 passes; CometNativeShuffleSuite lists 51 passes and six cancellations. This includes dictionary, direct-read and multiple-shuffle cases. Its checkout and the native-build checkout were 4e2296af, whose entire source tree equals the reviewed head.

COMMENT: the existing performance concern and validation requests remain; no duplicate inline finding. No local native build, benchmark, or new runtime probe was run for this review. Maintained Spark 3.4 and 4.1 source branches were unavailable, so no source-compatibility claim is made for those versions.

Performance

P2 — the existing whole-block materialization/fallback concern remains. I independently confirmed the mechanism described in the earlier review: NONE now copies the complete payload into a new Buffer; compressed blocks first grow a full decoded Vec. Dictionary blocks then always run StreamReader, which still allocates and copies its message bodies. They pay for the extra full-block buffer without taking the optimized decode path. Cold misses have the same additional materialization cost. Please address that fallback cost and qualify the resulting implementation before merging.

The checked-in benchmark only covers uncompressed, non-dictionary Int64/Utf8 blocks. LZ4 is the default. The PR reports small-block regressions of 14.9% and 3.8%, alongside larger-block gains; I have not reproduced those timings or established that their binaries came from this exact head. Clearing the cache on every iteration measures the new cold path, including insertion, rather than the previous implementation.

A concrete comparison should run the actual base and revised head alternately on the same idle host, with matching release settings and dependency locks: 5/50 columns × 64/512/8192 rows, at least NONE and LZ4, both plain and dictionary string/binary data, and both validation entry points. Assert decoded values and schema equality; record cache hits, allocation/copy volume, peak live memory, and retained capacity while batches remain live. Include interleaved schemas exceeding the four-entry cache. Report commit/binary identity with the results.

Design

Raw-byte schema identity and retaining Arrow as the fallback keep schema reuse easy to audit. Keeping dictionary values out of this cache is the right lifetime boundary. The full-block allocation strategy, however, couples schema caching to a separate memory tradeoff. A cached schema alone does not establish that this allocation strategy benefits ordinary compressed or dictionary shuffles.

On successful in-place decoding, array slices share the full block allocation, including spare Vec capacity; that allocation survives while a referencing array is retained. The previous reader also shared body buffers, so this is not a new per-column duplication of the entire batch. The additional capacity and metadata retention still need the memory measurement already requested in the existing review.

Abstraction & complexity

The helpers are localized, but the successful fast path currently verifies schema metadata twice and record-batch metadata twice. Carry the already parsed message through the helpers, as requested in the earlier review, to remove that repeated work before adding a size threshold. Keep one explicit fallback boundary and make the hit/decline behavior observable in tests; a second decoder path needs evidence that it is actually exercised.

@peterxcli

Copy link
Copy Markdown
Member Author

Replying to #5809 (review)

Thanks for the careful read. Pushed a5290ca, which restructures the decoder rather than patching the probe, and reworked the description to match. Point by point:

CI. The red rust-test is execution::operators::iceberg_write::tests::cancelling_abort_keeps_the_guard_armed panicking on "expected the deletes to yield so the abort can be cancelled mid-flight". It is scheduling-sensitive and nothing in this PR touches iceberg_write; it went red on the merge commit because that is the run it happened to land on. Your #5919 is the fix. The new push re-runs the job.

Lz4Frame in the matrix. Added, alongside None, and re-run on the same idle 16-core host with base/new alternated. Table in the description. Under LZ4 the cached decode is 30 to 40 percent faster at 64 rows, 20 to 24 percent at 512, and 2 to 5 percent at 8192 rows where decompression dominates. The miss path is flat to slightly faster on every LZ4 shape, so the cache costs nothing on the default codec. The read_to_end growth you described went with the materialization: compressed bodies are now read straight into an exactly sized buffer, as before this PR.

Tests that would survive removing the fast path. There is no separate fast path any more, but the equivalent gap is covered: under cfg(test) the cache counts hits and misses, reset_schema_cache clears both, and every cache test now asserts the exact counts it expects, so a decode that silently re-parsed the schema would fail warm_decodes_hit_the_cache_and_match_the_cold_one. The dictionary test asserts a hit rather than a declined probe, because dictionary blocks now decode from the cache too (below), and it checks that each block's record batch is decoded against that block's dictionary and not the previous block's.

Four root_as_message calls per block. On a hit the schema message is now not parsed at all: the reader locates it by its length prefix, memcmps it against the cache, and moves on. The record batch message is parsed once, dictionary messages once each. IpcMessage is gone; the loop keeps the parsed Message for the duration of the message it describes. This is what turned the small-block regression into a gain, so no body-size threshold.

Dictionary blocks. They take the same loop: dictionary batches decode with read_dictionary_impl into a map scoped to the block, then the record batch decodes against it. No failed probe and no materialization. The benchmark gained 5col_64row_dict and 5col_8192row_dict arms on both codecs. Cached dictionary blocks are 38 percent faster uncompressed and 28 percent under LZ4 at 64 rows, 39 and 4.5 percent at 8192 rows. On a miss the 64-row shapes are within noise, so no probe cost remains.

Memory. The whole-block read_to_end is gone. Compressed bodies use the same MutableBuffer::from_len_zeroed(bodyLength) StreamReader used; uncompressed bodies are an exactly sized copy of the body slice without the zero fill. decoded_arrays_report_the_same_memory_size_as_stream_reader asserts get_array_memory_size equals a plain StreamReader decode on every codec, cold and warm, so what DataFusion reserves for a shuffle-read batch is unchanged. I also bounded the per-thread metadata scratch: a corrupt length that grows it past 1 MiB releases it after the block. I have not measured executor RSS across a Spark shuffle read; with per-body allocations identical to the previous path I do not expect it to show anything, but I can run one if you would like the number.

@peterxcli

Copy link
Copy Markdown
Member Author

Replying to #5809 (comment)

Thanks for the comparison, and for #5909. You were right that the streaming loop is the better base, so this revision (a5290ca) is built on it rather than stacked beside it: each block is walked message by message, the schema message is served from the cache on a byte match without being parsed, and bodies are read into exactly sized buffers. There is no whole-block materialization left to gate behind a threshold, so the "streaming below, in-place above" split turned out not to be needed.

What remains of the earlier design: the cache is per thread and holds four schemas, so a reduce task that interleaves blocks from two shuffles does not thrash and no decoder handle has to be threaded through ShuffleScanExec or the JNI entry points; uncompressed blocks are walked in place, so their bodies are copied once without a zero fill; and the metadata scratch is reused across blocks.

Re-measured against main on the idle host, base/new alternated, None and Lz4Frame, plain and dictionary-encoded strings. Table in the description. Against main, 5 col x 64 row is now -53% uncompressed and -30% under LZ4, and 50 col x 64 row -55% and -40%, in line with the -54% you measured for #5909's cached decoder on the uncompressed shape. The large-block gain from the earlier revision survives on uncompressed blocks (-40% at 50 col x 8192 rows, even on a cache miss, from copying the body without a zero fill) and is small under LZ4 (-2% to -5%) where decompression dominates.

The dictionary case you flagged is covered by dictionaries_are_scoped_to_their_block_under_a_cached_schema: two blocks that share a schema but carry different dictionaries decode to their own values with the schema served from the cache, on every codec, validated and not.

…ys on a warm cache, bench the validated entry point

The RSS tests' allocation observer is shared with the reader tests, which
compare one warm decode against the StreamReader path this change replaced:
no more allocations, bytes or peak live memory on any codec. The corrupt
offsets test now also fails validation with the schema served from the
cache, and the benchmark times read_ipc_compressed_validated as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@peterxcli

Copy link
Copy Markdown
Member Author

Replying to #5809 (review)

Thanks for the thorough pass. That review is against 0f61f35; a5290ca (pushed shortly before it landed) replaced the design it describes, and fe93ff2 adds what was still missing. Mapping the findings to the current head:

P2, whole-block materialization and the dictionary fallback. Gone. The reader walks each block message by message the way StreamReader does; there is no full-block Buffer, no growing Vec, and no fallback decoder. Dictionary batches decode in the same loop with read_dictionary_impl, scoped to their block, from the cached schema. Cold misses parse the schema exactly as StreamReader did and read the same exactly sized bodies.

Schema and record-batch metadata verified twice. Gone. A hit does not parse the schema message at all (length prefix, memcmp, move on); every other message is parsed once and the parsed Message is carried through the decode.

Spare Vec capacity under live arrays. Gone with the materialization. Compressed bodies use MutableBuffer::from_len_zeroed(bodyLength) as before; uncompressed bodies are an exactly sized copy. decoded_arrays_report_the_same_memory_size_as_stream_reader asserts get_array_memory_size equals a plain StreamReader decode on every codec.

Tests that prove the optimized path runs. Under cfg(test) the cache counts hits and misses and reset_schema_cache clears both, so every cache test states its cold and warm phases and asserts exact counts: cold reset then hit, dictionary blocks served from the cache with per-block dictionaries, alternating schemas staying resident, eviction past the four-entry capacity, and (fe93ff2) the corrupt-offsets block still failing validation when the schema is served from the cache by an earlier valid block.

Allocation and copy volume, peak live memory, retained capacity. fe93ff2 shares the RSS tests' allocation observer with the reader tests. warm_decode_allocates_no_more_than_stream_reader runs the old StreamReader path and the cached decode on the same bytes and asserts the new one never allocates more, in count, bytes, or peak:

One warm decode, counted with the crate's test allocation observer (Rust allocations only; zstd's C-side workspace is outside it, which is why ZSTD reads like NONE). Old is the StreamReader path this PR replaced, run in the same test on the same bytes:

block codec old: allocations / bytes / peak live this PR: allocations / bytes / peak live
3 rows, 3 columns none 22 / 2,451 / 1,675 12 / 1,248 / 920
3 rows, 3 columns lz4 24 / 133,523 / 132,747 14 / 132,320 / 131,992
3 rows, 3 columns zstd 22 / 2,451 / 1,675 12 / 1,248 / 920
3 rows, 3 columns snappy 24 / 144,477 / 143,701 14 / 143,274 / 142,946
8192 rows, 2 columns none 18 / 150,402 / 149,554 9 / 149,032 / 148,816
8192 rows, 2 columns lz4 20 / 674,690 / 673,842 11 / 673,320 / 673,104
8192 rows, 2 columns zstd 18 / 150,402 / 149,554 9 / 149,032 / 148,816
8192 rows, 2 columns snappy 20 / 292,428 / 291,580 11 / 291,058 / 290,842

The difference is the schema parse and the metadata buffer, about ten allocations and 1.2 KiB per block; the body allocation is identical. On LZ4 and Snappy the decompressors' own working buffers dominate both columns.

Benchmark. Re-run on an idle 16-core x86_64 host against main at 481aefe, base and head alternated over two rounds, same benchmark source on both sides, commit identity in the description. None and Lz4Frame, 5 and 50 columns by 64, 512 and 8192 rows, plain and dictionary strings, cached and cache-cleared arms, and the untouched parse_schema_only control (within 3%). No shape regresses; the tables are in the description. fe93ff2 adds decode_block_validated for the remote entry point:

Validated decode (decode_block_validated, the remote entry point, schema from the cache):

shape codec base (two rounds) this PR (two rounds) change
5 col x 64 row lz4 9.71 us / 9.15 us 6.56 us / 6.72 us -29.6%
5 col x 512 row lz4 25.31 us / 26.36 us 21.18 us / 20.84 us -18.7%
5 col x 8192 row lz4 341.45 us / 341.73 us 335.25 us / 335.59 us -1.8%
50 col x 64 row lz4 72.82 us / 72.26 us 45.98 us / 47.36 us -35.7%
50 col x 512 row lz4 244.81 us / 247.56 us 191.44 us / 198.29 us -20.8%
50 col x 8192 row lz4 3.76 ms / 3.83 ms 3.73 ms / 3.68 ms -2.3%
5 col x 64 row (dictionary strings) lz4 14.60 us / 14.51 us 10.04 us / 9.77 us -31.9%
5 col x 8192 row (dictionary strings) lz4 283.60 us / 281.65 us 265.88 us / 269.28 us -5.3%
5 col x 64 row none 5.09 us / 5.03 us 2.98 us / 2.90 us -41.9%
5 col x 512 row none 8.44 us / 8.46 us 5.90 us / 5.93 us -30.0%
5 col x 8192 row none 67.79 us / 66.41 us 57.49 us / 59.16 us -13.1%
50 col x 64 row none 48.92 us / 47.37 us 26.70 us / 26.73 us -44.5%
50 col x 512 row none 89.48 us / 88.57 us 64.11 us / 66.84 us -26.5%
50 col x 8192 row none 1.08 ms / 1.11 ms 905.54 us / 884.60 us -18.3%
5 col x 64 row (dictionary strings) none 9.24 us / 9.49 us 6.65 us / 5.97 us -32.6%
5 col x 8192 row (dictionary strings) none 40.48 us / 38.39 us 30.70 us / 30.27 us -22.7%

CI. The failure was iceberg_write::tests::cancelling_abort_keeps_the_guard_armed, untouched by this PR and fixed by #5919. On a5290ca the full rust-test job completed green with the new shuffle tests included.

Not covered: dictionary binary columns are the same code path as dictionary strings and are not benchmarked separately, and interleaved schemas beyond the cache capacity are tested for correctness but not timed.

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

3 participants