fix: report native shuffle write metrics accurately - #5370
Conversation
|
Thanks for this. The disk-side fix is a genuine bug with a clear root cause, and I like that I traced the shared-buffer memory bookkeeping through several scenarios and it holds up:
Marking only The Rust tests are well targeted. Comparing one input batch against two in A few things I would like to see addressed. 1. The task metric bridge belongs on
|
|
Thanks for the detailed review, @andygrove — addressed all five points:
The focused successful- and failed-attempt spill regressions pass against both Spark 3.5 and Spark 4.0. |
Why are the changes needed?
When a shuffle becomes too large to keep in memory, Spark spills intermediate data to local disk. Operators use the Spark UI to answer two different questions about that work: how much memory was occupied by the data that spilled, and how much data was actually written to disk. Spark intentionally exposes these as separate task metrics because the on-disk representation may be compressed and can be much smaller than the in-memory representation.
Comet's native shuffle writer did not preserve that distinction. It copied the same on-disk byte count into both
memoryBytesSpilledanddiskBytesSpilled. Worse, its disk counter considered only bytes returned by immediate writes, even though Arrow's batch coalescer can hold those bytes untilflush(). In that common case, a real spill could appear as zero memory spilled and zero disk spilled in the Spark UI.For example, suppose a task spills 256 MiB of in-memory shuffle data and partition indices, which compress to 32 MiB on disk. These numbers are illustrative:
The failure case matters especially for debugging: a task that runs out of disk, encounters bad input, or fails after several spills should not look as though it never spilled at all.
Accurate in-memory accounting also has an Arrow-specific complication. One input batch can be split into many zero-copy slices that all reference the same underlying allocation. The regression test uses 16,384
Int64values, backed by one 128 KiB allocation, and processes them as 16 slices of 1,024 rows. If every slice triggers a spill, simply summing each released memory reservation counts that same 128 KiB allocation 16 times: 2 MiB instead of 128 KiB, before even including the real per-slice partition-index allocations. The underlying allocation remains owned by the original input batch throughout the operation, so it must be counted once for that batch, not once per slice.Finally, native shuffle already measures the time spent interleaving rows into output partitions, but that work was not visible in the SQL exchange metrics. This made it harder to distinguish repartitioning overhead from encoding and compression overhead when investigating slow shuffle stages.
Part of #3996. This PR addresses native shuffle write observability only; shuffle reads and mixed native/JVM scan-input accounting remain outside its scope. Follow-up #5382 tracks spills from native child operators inlined beneath the shuffle writer.
What changes were proposed in this PR?
The change gives native shuffle spill accounting two independent sources of truth and carries them consistently into both of Spark's observability surfaces.
For disk usage, the source of truth is the physical spill file itself. The native writer observes the file position before a spill and again after buffered Arrow data has been flushed. Their difference is the actual number of bytes written to disk, including data produced only during the final flush and data appended to an existing spill file. When shuffle compression is enabled, this is the compressed size; when
spark.shuffle.compress=false, it is the uncompressed on-disk size.For memory usage, the source of truth is the in-memory shuffle state being spilled: Arrow backing allocations plus the partition-index structures that organize its rows. The native writer records the memory released by the spill and also includes the current batch when the memory pool rejected its reservation after that batch had already been buffered. To avoid turning Arrow's zero-copy slicing into fictional memory growth, it remembers which backing allocations have already been counted for the lifetime of the original input batch. Separate input batches still contribute cumulatively, as Spark's spill metrics require.
Those two measurements are exposed separately in the SQL shuffle exchange and copied into their corresponding Spark task metrics only after the native execution plan has published its final values. The copy runs from a task-completion listener registered before the native iterator; Spark invokes completion listeners in reverse registration order, so native cleanup and final metric publication happen first. Because task-completion listeners also run when an attempt fails or is canceled, the same accounting remains available for the attempts that are most useful to diagnose.
The exchange also surfaces the native partition-interleaving timer, making the main stages of native shuffle work visible alongside the existing repartitioning, encoding, and spill metrics. Existing shuffle output semantics, compression behavior, and scan-input accounting are unchanged.
How was this PR tested?
Focused Rust tests exercise both spill triggers: an explicit maximum buffer size and a memory-pool reservation failure. They compare reported disk spill bytes against the actual spill-file sizes, verify zero spill metrics when no spill occurs, and cover the shared-allocation example above. They also verify that separate outer input batches are counted cumulatively even when they reference the same Arrow backing buffer.
The Spark integration tests validate the complete native-to-Spark reporting path on both Spark 3.5 and Spark 4.0:
ShuffleMapTaskstill reports both memory and disk spill bytes.