Summary
I did a read-through audit of the entire shuffle surface looking for performance
opportunities, and want to record the candidates somewhere before they're lost.
Nothing here has been benchmarked. These are code-reading findings: places where the
implementation does per-row, per-batch, or per-partition work that looks avoidable. Each one
needs to be measured before it's worth acting on, and some may turn out to be noise next to
the surrounding I/O and encode costs. Treat this as a work list for investigation, not a list
of agreed changes.
Scope covered:
native/shuffle/ — partitioners, writers, IPC block encoding, row-to-Arrow conversion
native/core/src/execution/operators/shuffle_scan.rs — native shuffle read
spark/src/main/java/org/apache/spark/shuffle/ and
spark/src/main/{java,scala}/org/apache/spark/sql/comet/execution/shuffle/ — JVM shuffle
read and write paths
The native multi-partition writer has had recent attention (#5006,
#5003, #5004), so most of what's left is
concentrated in the JVM columnar path and the read path.
Candidates
1. process_sorted_row_partition rebuilds the schema and IPC writer once per batch
native/shuffle/src/spark_unsafe/row.rs:1422
let block_writer = ShuffleBlockWriter::try_new(batch.schema().as_ref(), codec.clone())?;
written += block_writer.write_batch(&batch, &mut cursor, &ipc_time)?;
This sits inside the while current_row < row_num loop, so every batch pays for:
make_batch (row.rs:1490): one format!("c{i}") String allocation per column, plus a
fresh Schema
ShuffleBlockWriter::try_new: a flattened_fields() walk and a full IPC schema flatbuffer
encode
This is the same per-block cost that #5006 removed from the native
writer, still present on the JVM-columnar encode path.
Complication: builder_to_array (row.rs:1448-1485) decides per batch whether a Utf8/Binary
column is emitted as Dictionary or plain, so the schema genuinely can vary between batches
when preferDictionaryRatio > 1.0. A plain hoist is only safe when no column is
dictionary-capable; otherwise the writer would need caching keyed on the produced schema.
Measurable with native/shuffle/benches/row_columnar.rs.
2. The shuffle reader discards its thread-local direct buffer once per block
NativeBatchDecoderIterator.scala:193-197, called from :178
close() calls resetDataBuf(), which throws away the thread-local ByteBuffer whenever it
has grown past 128 KB. CometBlockStoreShuffleReader.read() (:109-118) constructs and
closes one NativeBatchDecoderIterator per shuffle block, so for any shuffle whose
compressed blocks exceed 128 KB we appear to get two allocateDirect calls per block: the
discard, then the regrow at :143. allocateDirect zeroes the region and goes through
Bits.reserveMemory, which can force a System.gc() under direct-memory pressure.
If that reading is right, the thread-local isn't buying anything on this path. Worth noting
that the sibling class on the ShuffleScan path,
CometShuffleBlockIterator.java:103-106, keeps its buffer across blocks and never resets, so
the two paths are inconsistent.
Needs an end-to-end shuffle-read benchmark; the existing benches don't cover this.
3. RowPartition boxes two objects per shuffled row
spark/src/main/scala/org/apache/spark/shuffle/sort/RowPartition.scala:25-31
private var rowAddresses: ArrayBuffer[Long] = new ArrayBuffer[Long](initialSize)
private var rowSizes: ArrayBuffer[Int] = new ArrayBuffer[Int](initialSize)
Scala's ArrayBuffer is not specialized — it's backed by Array[AnyRef]. So each addRow
should box a java.lang.Long (addresses are always outside the Long cache range, so always
an allocation) and a java.lang.Integer (row sizes are usually >127, so usually an
allocation). getRowAddresses/getRowSizes then unbox element-by-element into primitive
arrays for the JNI call.
That's on the order of two allocations plus an unboxing pass per row, across the whole JVM
columnar shuffle write path. Manually grown Array[Long]/Array[Int] with a count would
remove the allocations and reduce toArray to an Arrays.copyOf memcpy — or eliminate it, if
the JNI signature took an explicit length.
Of everything here this looks like the largest steady-state win, but it touches a JNI
boundary, so it needs the most care.
4. Per-partition setup cost in the bypass-merge writer
CometBypassMergeSortShuffleWriter.java:180-199 allocates one CometDiskBlockWriter per
partition, and each one's ArrowIPCWriter constructor calls serializeSchema(schema)
(CometDiskBlockWriter.java:298 → SpillWriter.java:88-104). That's
numPartitions × numColumns protobuf serializations per task, with numPartitions up to
spark.shuffle.sort.bypassMergeThreshold (200 by default), all producing the identical
result. Same shape for the per-partition new Native() (:135) and the four CometConf
lookups in the constructor (:144-152).
Separately, partitioner.getPartition(key) is called twice per record on that path:
int partition_id = partitioner.getPartition(key);
partitionWriters[partitioner.getPartition(key)].insertRow((UnsafeRow) record._2(), partition_id);
CometBypassMergeSortShuffleWriter.java:214-216 — partition_id is already in hand.
5. Index-file parsing allocates two objects per partition
CometNativeShuffleWriter.scala:158-171
Files.readAllBytes(tempIndexFilePath).grouped(OFFSET_LENGTH).drop(1).map(indexBytes =>
ByteBuffer.wrap(indexBytes).order(ByteOrder.LITTLE_ENDIAN).getLong ...)
grouped allocates an intermediate array per partition and the map allocates a ByteBuffer
per partition. Wrapping the whole byte array in a single little-endian ByteBuffer and
looping getLong into a pre-sized Array[Long] would drop 2 × numPartitions allocations
per map task. Only likely to matter at high partition counts.
6. PartitionedBatchIterator widens the whole partition's index array up front
native/shuffle/src/partitioners/partitioned_batch_iterator.rs:85-89
let record_batches = buffered_batches.iter().collect::<Vec<_>>();
let current_indices = indices.iter().map(|(i_batch, i_row)| (*i_batch as usize, *i_row as usize)).collect::<Vec<_>>();
interleave_record_batch takes &[(usize, usize)] (arrow-select 58.4, interleave.rs:562),
so the u32 → usize widening is unavoidable — but it doesn't have to happen for the whole
partition at once. As written, every spill and every shuffle_write allocates
rows_in_partition × 16 bytes summed across all partitions, roughly a second full copy of the
index array, and record_batches is rebuilt per partition.
Building record_batches once in PartitionedBatchesProducer and widening indices
batch_size at a time into a single reusable scratch buffer would bound this to one
allocation per spill.
Related, in the caller: multi_partition.rs:497-500 replaces partition_indices with
vec![vec![]; num_output_partitions] on every spill, discarding all per-partition Vec
capacity so each inner Vec re-grows from zero after each spill. Recycling the drained Vecs
would avoid that.
Measurable with native/shuffle/benches/shuffle_writer.rs / src/bin/shuffle_bench.rs.
7. Config lookup inside the spill loop
SpillWriter.java:199
int batchSize = (int) CometConf.COMET_SHUFFLE_JVM_BATCH_SIZE().get();
doSpilling runs once per partition per spill, so SpillSorter.writeSortedFileNative
(:247, :283) hits this numPartitions times per spill file. CometDiskBlockWriter
already caches the identical value in columnarBatchSize (:144) and then doesn't use it.
8. Smaller items
ArrowIPCWriter.spill comparator recomputes memory usage.
CometDiskBlockWriter.java:382-391 sorts the static currentWriters list with a comparator
calling getActiveMemoryUsage() twice per comparison; each call is synchronized and walks
the allocatedPages linked list (SpillWriter.java:249-258). Precompute the sort keys.
- Two JNI round-trips per block on the ShuffleScan path.
shuffle_scan.rs:151-171 calls
has_next() then get_buffer(). The returned DirectByteBuffer is the same object unless
it was regrown (CometShuffleBlockIterator.java:105), so caching a global ref and
re-fetching only on capacity change would halve the JNI calls per block. Probably small next
to decode cost.
count_new_buffers calls to_data() per column per batch. multi_partition.rs:166
allocates a Vec<Buffer> per array on the insert hot path just to read buffer pointers and
capacities.
- Dead initial fetch in the decoder.
NativeBatchDecoderIterator.scala:48 runs
fetchNext() before channel is assigned at :52, so it always returns None via the
channel == null guard at :93. Harmless today because hasNext re-fetches, but it
silently depends on field declaration order.
Suggested next steps
Roughly in order of effort-to-impact:
- Benchmark and fix item 1 and item 2 — both are localized, and both re-do per-item work the
codebase already avoids elsewhere.
- Items 4 through 7 are mostly hoisting and should be quick to confirm.
- Item 3 is likely the biggest win but needs care at the JNI boundary; worth its own issue and
its own before/after numbers.
Benchmark coverage: native/shuffle/benches/shuffle_writer.rs,
native/shuffle/benches/row_columnar.rs, and native/shuffle/src/bin/shuffle_bench.rs cover
items 1, 6, and indirectly 3. Item 2 has no coverage and would need an end-to-end shuffle-read
benchmark.
Happy for anyone to pick individual items off into their own issues/PRs. Please post numbers
either way, including for the ones that turn out not to matter — a measured "no effect" is
useful information to record here.
Summary
I did a read-through audit of the entire shuffle surface looking for performance
opportunities, and want to record the candidates somewhere before they're lost.
Nothing here has been benchmarked. These are code-reading findings: places where the
implementation does per-row, per-batch, or per-partition work that looks avoidable. Each one
needs to be measured before it's worth acting on, and some may turn out to be noise next to
the surrounding I/O and encode costs. Treat this as a work list for investigation, not a list
of agreed changes.
Scope covered:
native/shuffle/— partitioners, writers, IPC block encoding, row-to-Arrow conversionnative/core/src/execution/operators/shuffle_scan.rs— native shuffle readspark/src/main/java/org/apache/spark/shuffle/andspark/src/main/{java,scala}/org/apache/spark/sql/comet/execution/shuffle/— JVM shuffleread and write paths
The native multi-partition writer has had recent attention (#5006,
#5003, #5004), so most of what's left is
concentrated in the JVM columnar path and the read path.
Candidates
1.
process_sorted_row_partitionrebuilds the schema and IPC writer once per batchnative/shuffle/src/spark_unsafe/row.rs:1422This sits inside the
while current_row < row_numloop, so every batch pays for:make_batch(row.rs:1490): oneformat!("c{i}")String allocation per column, plus afresh
SchemaShuffleBlockWriter::try_new: aflattened_fields()walk and a full IPC schema flatbufferencode
This is the same per-block cost that #5006 removed from the native
writer, still present on the JVM-columnar encode path.
Complication:
builder_to_array(row.rs:1448-1485) decides per batch whether a Utf8/Binarycolumn is emitted as
Dictionaryor plain, so the schema genuinely can vary between batcheswhen
preferDictionaryRatio > 1.0. A plain hoist is only safe when no column isdictionary-capable; otherwise the writer would need caching keyed on the produced schema.
Measurable with
native/shuffle/benches/row_columnar.rs.2. The shuffle reader discards its thread-local direct buffer once per block
NativeBatchDecoderIterator.scala:193-197, called from:178close()callsresetDataBuf(), which throws away the thread-localByteBufferwhenever ithas grown past 128 KB.
CometBlockStoreShuffleReader.read()(:109-118) constructs andcloses one
NativeBatchDecoderIteratorper shuffle block, so for any shuffle whosecompressed blocks exceed 128 KB we appear to get two
allocateDirectcalls per block: thediscard, then the regrow at
:143.allocateDirectzeroes the region and goes throughBits.reserveMemory, which can force aSystem.gc()under direct-memory pressure.If that reading is right, the thread-local isn't buying anything on this path. Worth noting
that the sibling class on the ShuffleScan path,
CometShuffleBlockIterator.java:103-106, keeps its buffer across blocks and never resets, sothe two paths are inconsistent.
Needs an end-to-end shuffle-read benchmark; the existing benches don't cover this.
3.
RowPartitionboxes two objects per shuffled rowspark/src/main/scala/org/apache/spark/shuffle/sort/RowPartition.scala:25-31Scala's
ArrayBufferis not specialized — it's backed byArray[AnyRef]. So eachaddRowshould box a
java.lang.Long(addresses are always outside theLongcache range, so alwaysan allocation) and a
java.lang.Integer(row sizes are usually >127, so usually anallocation).
getRowAddresses/getRowSizesthen unbox element-by-element into primitivearrays for the JNI call.
That's on the order of two allocations plus an unboxing pass per row, across the whole JVM
columnar shuffle write path. Manually grown
Array[Long]/Array[Int]with acountwouldremove the allocations and reduce
toArrayto anArrays.copyOfmemcpy — or eliminate it, ifthe JNI signature took an explicit length.
Of everything here this looks like the largest steady-state win, but it touches a JNI
boundary, so it needs the most care.
4. Per-partition setup cost in the bypass-merge writer
CometBypassMergeSortShuffleWriter.java:180-199allocates oneCometDiskBlockWriterperpartition, and each one's
ArrowIPCWriterconstructor callsserializeSchema(schema)(
CometDiskBlockWriter.java:298→SpillWriter.java:88-104). That'snumPartitions × numColumnsprotobuf serializations per task, withnumPartitionsup tospark.shuffle.sort.bypassMergeThreshold(200 by default), all producing the identicalresult. Same shape for the per-partition
new Native()(:135) and the fourCometConflookups in the constructor (
:144-152).Separately,
partitioner.getPartition(key)is called twice per record on that path:CometBypassMergeSortShuffleWriter.java:214-216—partition_idis already in hand.5. Index-file parsing allocates two objects per partition
CometNativeShuffleWriter.scala:158-171groupedallocates an intermediate array per partition and themapallocates aByteBufferper partition. Wrapping the whole byte array in a single little-endian
ByteBufferandlooping
getLonginto a pre-sizedArray[Long]would drop2 × numPartitionsallocationsper map task. Only likely to matter at high partition counts.
6.
PartitionedBatchIteratorwidens the whole partition's index array up frontnative/shuffle/src/partitioners/partitioned_batch_iterator.rs:85-89interleave_record_batchtakes&[(usize, usize)](arrow-select 58.4,interleave.rs:562),so the
u32 → usizewidening is unavoidable — but it doesn't have to happen for the wholepartition at once. As written, every spill and every
shuffle_writeallocatesrows_in_partition × 16bytes summed across all partitions, roughly a second full copy of theindex array, and
record_batchesis rebuilt per partition.Building
record_batchesonce inPartitionedBatchesProducerand widening indicesbatch_sizeat a time into a single reusable scratch buffer would bound this to oneallocation per spill.
Related, in the caller:
multi_partition.rs:497-500replacespartition_indiceswithvec![vec![]; num_output_partitions]on every spill, discarding all per-partitionVeccapacity so each inner
Vecre-grows from zero after each spill. Recycling the drainedVecswould avoid that.
Measurable with
native/shuffle/benches/shuffle_writer.rs/src/bin/shuffle_bench.rs.7. Config lookup inside the spill loop
SpillWriter.java:199doSpillingruns once per partition per spill, soSpillSorter.writeSortedFileNative(
:247,:283) hits thisnumPartitionstimes per spill file.CometDiskBlockWriteralready caches the identical value in
columnarBatchSize(:144) and then doesn't use it.8. Smaller items
ArrowIPCWriter.spillcomparator recomputes memory usage.CometDiskBlockWriter.java:382-391sorts the staticcurrentWriterslist with a comparatorcalling
getActiveMemoryUsage()twice per comparison; each call issynchronizedand walksthe
allocatedPageslinked list (SpillWriter.java:249-258). Precompute the sort keys.shuffle_scan.rs:151-171callshas_next()thenget_buffer(). The returnedDirectByteBufferis the same object unlessit was regrown (
CometShuffleBlockIterator.java:105), so caching a global ref andre-fetching only on capacity change would halve the JNI calls per block. Probably small next
to decode cost.
count_new_bufferscallsto_data()per column per batch.multi_partition.rs:166allocates a
Vec<Buffer>per array on the insert hot path just to read buffer pointers andcapacities.
NativeBatchDecoderIterator.scala:48runsfetchNext()beforechannelis assigned at:52, so it always returnsNonevia thechannel == nullguard at:93. Harmless today becausehasNextre-fetches, but itsilently depends on field declaration order.
Suggested next steps
Roughly in order of effort-to-impact:
codebase already avoids elsewhere.
its own before/after numbers.
Benchmark coverage:
native/shuffle/benches/shuffle_writer.rs,native/shuffle/benches/row_columnar.rs, andnative/shuffle/src/bin/shuffle_bench.rscoveritems 1, 6, and indirectly 3. Item 2 has no coverage and would need an end-to-end shuffle-read
benchmark.
Happy for anyone to pick individual items off into their own issues/PRs. Please post numbers
either way, including for the ones that turn out not to matter — a measured "no effect" is
useful information to record here.