andygrove opened a new issue, #5198:
URL: https://github.com/apache/datafusion-comet/issues/5198
## 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
(apache/datafusion-comet#5006,
apache/datafusion-comet#5003, apache/datafusion-comet#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`
```rust
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 apache/datafusion-comet#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`
```scala
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:
```java
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`
```scala
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`
```rust
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 `Vec`s
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`
```java
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:
1. Benchmark and fix item 1 and item 2 — both are localized, and both re-do
per-item work the
codebase already avoids elsewhere.
2. Items 4 through 7 are mostly hoisting and should be quick to confirm.
3. 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.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]