Skip to content

perf: write every partition of a JVM shuffle spill in one native call - #7065

Open
andygrove wants to merge 4 commits into
apache:mainfrom
andygrove:andygrove/5905-j3-local-benchmark
Open

andygrove wants to merge 4 commits into
apache:mainfrom
andygrove:andygrove/5905-j3-local-benchmark

Conversation

@andygrove

@andygrove andygrove commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #7061.

Part of #5905 (item J3).

Rationale for this change

Above spark.shuffle.sort.bypassMergeThreshold partitions (200 by default), the JVM columnar shuffle uses the sort-based writer. It wrote each spill, and the final file, with one writeSortedFileNative JNI call per partition holding rows. Each call paid a fixed cost before it converted a row. It decoded every column's protobuf data type, copied the row address and size arrays that Java had built row by row in a boxed ArrayBuffer, and opened the spill file. It also allocated batch-size builders, including a dictionary builder per string column, pre-encoded the IPC schema, created a compression context, and issued its own write(2). A map task pays that partitions x (spills + 1) times, so at high partition counts it dominates the write. In the native benchmark below it comes to about 20 µs per partition before any JNI or Java overhead, and this PR removes about three quarters of it.

What changes are included in this PR?

  • SpillSorter.writeSortedFileNative makes one native call per spill. The new Native.writeSortedPartitionsNative takes the address of the sorter's sorted packed record pointers and a page table holding the base address of each data page by page number. Native code resolves each record where it lies, writes every partition in partition order, and returns each partition's length through SpillInfo.partitionLengths and its checksum through the sorter's checksum array. Java no longer walks the records or builds a RowPartition on this path.
  • In row.rs, the state that does not depend on the rows moves into a SortedRowEncoder that one call reuses for every partition: the builders, the block writer with its pre-encoded schema, the compression context and the open file. Encoded blocks are buffered up to 1 MiB before they are written, so a spill of many small partitions is no longer one write per partition. Like the builders, the buffer is native memory that no memory pool reserves. It holds at most 1 MiB plus one block. The builders are sized for the rows a call encodes, at most one batch, so a call with a few rows no longer allocates batch-size builders. process_sorted_partitions is the new per-spill entry point. process_sorted_row_partition, which the bypass merge writer still calls once per partition, runs on the same encoder.
  • Checksum::update takes the bytes of one block, since the buffer can now hold several.
  • Benchmarks: a sorted_spill group in native/shuffle/benches/row_columnar.rs writes one sorted spill both ways, and CometColumnarShuffleWriteBenchmark runs the shuffle end to end at 1,000 and 10,000 partitions, with and without spills.
  • jvm_shuffle.md describes the two native write calls.

This replaces the loop in SpillSorter that #6361 fixes for spark.shuffle.checksum.enabled=false. With checksums disabled the new call gets an empty checksum array and algorithm -1, and never indexes the array. Whichever of the two lands second has a conflict in SpillSorter.java to resolve.

The output is byte-identical to main: the same blocks in the same order, with the same partition lengths and checksums. I checked this locally against main's per-partition calls for lz4, zstd, snappy and no compression, with dictionary-encoded and plain strings, partitions spanning several batches, and up to 2,000 partitions. The bypass merge writer's output is also unchanged.

How are these changes tested?

  • New Rust tests next to the existing write_sorted_file tests in jni_api.rs. One compares the per-spill output byte for byte with writing each partition on its own, for every codec and checksum algorithm and without checksums, over records in two pages. A second does the same for a string column whose batches switch between dictionary-encoded and plain, within a partition and from one partition to the next, with lz4 and with zstd storing offsets as lengths, and decodes the strings back. Another checks that checksums continued across two spills match CRC32 and Adler-32 computed independently over each partition's bytes in both files. The rest cover an empty spill and the error cases: a page the sorter does not hold, a partition id beyond the partition count, unsorted pointers, and an unsupported record length size.
  • A unit test of the record pointer decoding in spark_unsafe::row, which the nightly Miri job also runs.
  • A new SpillSorterSuite test drives a SpillSorter through a spill and a final write over 1 KiB pages, so the records span pages. It runs with both allocators, whose page tables differ, and with checksums disabled. It decodes every partition of both files back to its rows, and checks the partition lengths against the files, the Adler-32 checksums continued across both files using java.util.zip.Adler32, and the write and spill metrics.
  • Existing suites: CometShuffleSuite and DisableAQECometShuffleSuite, which run 201 partitions with a spill threshold of 10 so the sort path spills constantly, plus SpillSorterSuite, CometDiskBlockWriterSuite, CometShuffle4_0Suite, CometShuffleEncryptionSuite, CometShuffleManagerSuite and the two allocator suites.

Benchmarks

Run locally on an Apple M3 Ultra. Other work was running on the machine, so cases were interleaved and repeated.

Native, one spill of 100,000 rows (Int64, Int32, Float64, Utf8, Date32), lz4, Adler-32 checksums, sorted_spill. Median of three interleaved runs:

Partitions Rows per partition One call per partition (main) One call per spill (this PR) Change
100 1,000 10.6 ms 9.0 ms -15%
1,000 100 31.8 ms 15.8 ms -50%
10,000 10 213 ms 62 ms -71%

CometColumnarShuffleWriteBenchmark, 1M rows, local[1], best time of each run, two runs per side:

Partitions Case main This PR Change
1,000 Spark shuffle, for reference 471 / 440 ms 451 / 441 ms
1,000 Comet JVM shuffle 385 / 343 ms 277 / 271 ms -21% to -28%
1,000 Comet JVM shuffle, spill every 100,000 rows 1,032 / 1,096 ms 566 / 557 ms -45% to -49%
10,000 Spark shuffle, for reference 733 / 731 ms 761 / 736 ms
10,000 Comet JVM shuffle 1,132 / 1,123 ms 688 / 683 ms -39%
10,000 Comet JVM shuffle, spill every 100,000 rows 6,591 / 7,890 ms 3,186 / 3,122 ms -52% to -60%

The repro from #7061: TPC-H SF2 orders (3M rows), SELECT /*+ REPARTITION(n) */ * FROM orders with a noop sink, local[8], 6 GB off-heap and spark.executor.cores=8, on Spark 4.1.1. Median of 5 warm runs, two sessions per side:

n main This PR Spark, all four sessions
400 741 / 698 ms 630 / 636 ms 599 to 662 ms
2,000 1,500 / 1,559 ms 1,043 / 1,002 ms 949 to 1,136 ms

At 2,000 partitions the map stage's total task time drops from 6.8 s to 3.0 s, against 3.7 to 4.8 s for Spark. The reduce stage is unchanged.

What is left per partition is the cost of one block per partition rather than of the call. With 10,000 partitions the per-spill path still spends about 5 µs per partition with lz4 and about 4 µs without compression. That goes to the per-block lz4 frame encoder (#6916), and to building and framing a small batch, including turning a string column's dictionary back into plain strings (#6597).

The sort-based JVM columnar shuffle writer wrote each spill, and the final
file, with one writeSortedFileNative JNI call per partition. Every call
decoded the schema, copied the row address and size arrays, opened the
spill file, allocated batch-size builders, pre-encoded the IPC schema and
wrote its own blocks, so a map task paid that partitions x (spills + 1)
times.

SpillSorter now makes one writeSortedPartitionsNative call per spill.
Native code reads the sorter's sorted packed record pointers through a
page table, converts every partition with one reused encoder, buffers the
encoded blocks, and returns each partition's length and checksum. The
output is byte-identical to the per-partition calls, which the bypass
merge writer still makes.

Adds a sorted_spill group to the row_columnar bench and
CometColumnarShuffleWriteBenchmark.

Part of apache#5905 (J3).
The per-spill test compared the output byte for byte only for an Int64
column, so the batch schema and block writer that the encoder now keeps
across partitions were never exercised when a string column switches
between a dictionary and a plain array. A new test writes such a spill,
compares it with writing each partition on its own, and decodes it.

The SpillSorterSuite test checked partition lengths and checksums against
the files, which a page table mistake that read valid rows from the wrong
page would still pass. It now decodes every partition back to its rows.

Also fixes the strict Scala warnings build, where the new benchmark
widened an Int row count to Long, and rewords the JVM shuffle guide's
write path, which read as one native call per partition.
With zstd, a block writer built for a plain string column stores its
offsets as lengths and one built for a dictionary column does not, so
the writer the encoder keeps across partitions changes the bytes of a
block. The test now runs that codec as well as lz4.
SortedRowEncoder kept the batch schema twice, once to reuse it and once
next to the block writer to notice that it changed. It now keeps the
schema only with the block writer, compares the column types against it,
and builds both when they change. The encoder also owns the output file:
encode_batch writes the buffered blocks out once they reach 1 MiB, and
finish writes the rest and returns the encode time, so neither caller
has to remember the final write. Its builders are sized for the rows the
call encodes, at most one batch.

process_sorted_partitions splits the record pointers into partitions
with chunk_by, and the benchmark groups its records the same way.

SpillSorter no longer routes a spill's metrics through a throwaway
ShuffleWriteMetrics, which only existed for the per-partition calls. It
reports the same values to the task's metrics directly.

bytes is only used by tests now, so it moves to dev-dependencies. The
tests reuse long_values and createSpillSorter, and SpillSorterSuite reads
each spill file once.
@andygrove andygrove added the run-spark-4.1-tests Run the Spark 4.1 SQL tests on this pull request instead of waiting for the merge queue label Oct 16, 2026
@andygrove
andygrove requested review from comphead and viirya October 17, 2026 13:48
@andygrove andygrove added this to the 1.2.0 milestone Oct 17, 2026

@viirya viirya 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.

Thanks for this. The per-spill call removes a cost that grew with partitions x (spills + 1), and the numbers line up with that, from -15% at 100 partitions to -71% at 10,000. I traced the pointer decoding against both allocators. CometUnboundedShuffleMemoryAllocator and the TaskMemoryManager path both encode the offset relative to the page base, and every record goes into a page in allocatedPages, so the page table covers what getOffsetInPage used to resolve. Moving the shared state into SortedRowEncoder also removes the duplication between the two native paths. The dictionary-change test with zstd offsets as lengths covers the one new risk that keeping the block writer across partitions brings, and the SpillSorterSuite case decodes the rows back, so a wrong page lookup would fail it.

One thing about the description. Rewriting the loop in SpillSorter also fixes the ArrayIndexOutOfBoundsException that sort-based JVM shuffle hits on branch-1.1 with spark.shuffle.checksum.enabled=false (#6256), and the new SpillSorterSuite case covers it. Could the description say this fixes the sort-writer half of #6256? The bypass path, where doSpilling overwrites checksum with Long.MIN_VALUE so the checksum != -1 check no longer works, is still open and left to #6361. This PR is a performance change and not something to backport, so #6361 (or a minimal cherry-pick of its SpillSorter fix) is what would bring the fix to branch-1.1. Saying that here would make it clear what each PR covers once this one lands.

A few inline comments below.

* #setSpillInfo}, every partition in one native call, recording each partition's length and
* checksum.
*/
public void writeSortedFileNative(boolean isLastFile, boolean tracingEnabled) throws IOException {

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.

This method no longer calls Native.writeSortedFileNative. It calls writeSortedPartitionsNative, and the bypass writer, through SpillWriter.doSpilling, is now the only caller of the old native method. Two methods with the same name doing different things is why jvm_shuffle.md needs a paragraph to tell them apart. Would you consider renaming this one, for example to writeSortedPartitions? Then a grep for writeSortedFileNative would only find the per-partition path.


/// Encoded blocks are buffered until they reach this many bytes, so that a spill of many small
/// partitions is not written one partition at a time.
const OUTPUT_BUFFER_BYTES: usize = 1 << 20;

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 says this buffer, like the builders, is native memory that no pool reserves, and that it holds at most 1 MiB plus one block. Could the doc comment here say the same? This is new buffering on this path, and someone reading the code later will want to know whether it is reserved without going back to the PR.

* }}}
* Results will be written to "spark/benchmarks/CometColumnarShuffleWriteBenchmark-**results.txt".
*/
object CometColumnarShuffleWriteBenchmark extends CometBenchmarkBase {

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.

CometShuffleBenchmark already targets the sort-based writer on purpose, through manyPartitions = 201 and spark.comet.shuffle.jvm.spillThreshold=30000. Is there a reason the 1,000 and 10,000 partition cases need their own benchmark object rather than a group in CometShuffleBenchmark? If it is the different session setup (CometShuffleBenchmark runs with spark.comet.exec.onHeap.enabled=true), a sentence in the scaladoc would explain it. Otherwise, keeping all JVM shuffle write benchmarks in one place would make them easier to maintain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:memory Memory pools, reservations, OOM handling area:shuffle Shuffle (JVM and native) enhancement New feature or request performance run-benchmark-check Run the benchmark compile and lint check on this pull request instead of waiting for the merge queue run-spark-4.1-tests Run the Spark 4.1 SQL tests on this pull request instead of waiting for the merge queue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: the sort-based JVM columnar shuffle is 2x slower than Spark at 2,000 partitions because every partition write pays builder, file and codec setup

2 participants