sunchao commented on code in PR #6336:
URL: https://github.com/apache/datafusion-comet/pull/6336#discussion_r4368030211


##########
native/shuffle/src/writers/local/local_partition_writer.rs:
##########
@@ -372,14 +452,45 @@ impl PartitionWriter for LocalPartitionWriter {
 
         // The shuffle output is complete; nothing else encodes through this 
context.
         self.codec_context.release_zstd();
+        self.sync_buffer_reservation();
 
         Ok(())
     }
 
     fn write_burst_complete(&mut self) {
-        // A spill burst just ended and the next encode may be a long time 
coming; the zstd
-        // workspace is native memory no reservation tracks, so don't sit on 
it.
+        // A spill burst just ended and the next encode may be a long time 
coming, so don't
+        // sit on the zstd workspace.
         self.codec_context.release_zstd();
+        self.sync_buffer_reservation();
+        // A pressure spill opened its file while the batches still held the 
pool; now they don't.
+        if let (Some(buffers), DataOutput::Multi { spill, .. }) =
+            (&self.buffers, &mut self.data_output)
+        {
+            spill.restore_buffer(buffers);
+        }
+    }
+
+    fn attach_buffer_reservation(
+        &mut self,
+        reservation: MemoryReservation,
+    ) -> datafusion::common::Result<()> {
+        let DataOutput::Multi { output_writer, .. } = &mut self.data_output 
else {
+            return Ok(());
+        };
+        // Attached once, before any write: the rebuild below must not discard 
buffered bytes.
+        debug_assert!(self.buffers.is_none() && 
output_writer.buffer().is_empty());
+        // The data file buffer was allocated before the consumer existed; 
shrink it if refused.
+        let capacity = super::reserve_buffer(&reservation, 
self.write_buffer_size);

Review Comment:
   [P2] The empty output writer can take the entire available pool before its 
input produces a row. A hash join feeding the shuffle then fails its first 
allocation, even though this buffer receives its first bytes during 
finalization. Defer allocating and charging it until the first 
`finish_partition`.
   
   Evidence: With two output partitions, a 16-row `HashJoinExec`, and the 
default 1 MiB write buffer, the base wrote all rows with a 1 MiB pool. The head 
denied `HashJoinInput[0]` its first 128 bytes. Reducing the pool by one byte 
made the head succeed because the writer took the fallback. With eight 
partitions and 256 batches of 1,024 integers, the same eager allocation caused 
256 spills versus four on the base. Deferring it restored four spills and 
preserved all 262,144 rows.



##########
native/shuffle/src/writers/local/local_partition_writer.rs:
##########
@@ -372,14 +452,45 @@ impl PartitionWriter for LocalPartitionWriter {
 
         // The shuffle output is complete; nothing else encodes through this 
context.
         self.codec_context.release_zstd();
+        self.sync_buffer_reservation();
 
         Ok(())
     }
 
     fn write_burst_complete(&mut self) {
-        // A spill burst just ended and the next encode may be a long time 
coming; the zstd
-        // workspace is native memory no reservation tracks, so don't sit on 
it.
+        // A spill burst just ended and the next encode may be a long time 
coming, so don't
+        // sit on the zstd workspace.
         self.codec_context.release_zstd();
+        self.sync_buffer_reservation();

Review Comment:
   [P2] After a pressure spill, `recycled_buffer` is empty but its full 
capacity stays charged. One wide row can leave that charge larger than the 
whole pool, forcing every subsequent narrow batch to spill. Shrink or drop 
drained scratch that the pool cannot fund, then synchronize the reservation 
before restoring the spill buffer.
   
   Evidence: With LZ4, a 512 KiB pool, and one 768 KiB incompressible value, 
the head held 1,062,608 bytes after the first spill, including 1,046,224 bytes 
of empty scratch. A separate 16 KiB request failed. Adding 128 one-row batches 
produced 129 spills versus one on the base. Releasing only the empty scratch 
restored the grant and the one-spill result. All 129 rows decoded.



##########
native/shuffle/src/writers/local/local_partition_writer.rs:
##########
@@ -245,88 +313,100 @@ impl PartitionWriter for LocalPartitionWriter {
         let write_buffer_size = self.write_buffer_size;
         let batch_size = self.batch_size;
 
-        match &mut self.data_output {
-            DataOutput::Single { writer, scratch } => {
-                // Single-partition data was already streamed via `write`, 
starting at
-                // offset 0 (already recorded in `self.offsets[0]`). Stream 
any trailing
-                // batches (normally none) without flushing; the long-lived 
writer is
-                // flushed once in `finish_all`.
-                for batch in iter.by_ref() {
-                    let batch = batch?;
-                    writer.write(
-                        &batch,
-                        scratch,
-                        &mut self.codec_context,
-                        &metrics.encode_time,
-                        &metrics.write_time,
-                    )?;
-                }
-            }
-            DataOutput::Multi {
-                output_writer,
-                shuffle_block_writer,
-                spill,
-                spill_reader,
-                recycled_buffer,
-                ..
-            } => {
-                self.offsets[pid] = output_writer.stream_position()?;
-
-                let mut flush_timer = metrics.write_time.timer();
-                spill.flush()?;
-                flush_timer.stop();
-                let ranges = spill.ranges(pid)?;
-                if !ranges.is_empty() {
-                    if spill_reader.is_none() {
-                        let path = spill.path()?.ok_or_else(|| {
-                            DataFusionError::Internal(
-                                "shuffle spill ranges recorded without a spill 
file".to_string(),
-                            )
-                        })?;
-                        *spill_reader = Some((File::open(path)?, vec![0; 
write_buffer_size]));
-                    }
-                    let (spill_file, buffer) = spill_reader.as_mut().unwrap();
-                    let mut write_timer = metrics.write_time.timer();
-                    for range in ranges {
-                        copy_spill_range(spill_file, buffer, range, 
output_writer)?;
-                    }
-                    write_timer.stop();
-                }
-
-                // Write in memory batches to output data file. Each partition 
uses its
-                // own writer so coalescing does not cross partition 
boundaries, but the
-                // scratch buffer is shared so its capacity carries over to 
the next one.
-                let mut buf_batch_writer = BufBatchWriter::new(
-                    shuffle_block_writer,
-                    output_writer,
-                    write_buffer_size,
-                    batch_size,
-                );
-                let codec_context = &mut self.codec_context;
-                let result: datafusion::common::Result<()> = (|| {
+        // Synced on every exit, errors included, so it matches what a failed 
partition left.
+        let result: datafusion::common::Result<()> = (|| {
+            match &mut self.data_output {
+                DataOutput::Single { writer, scratch } => {
+                    // Single-partition data was already streamed via `write`, 
starting at
+                    // offset 0 (already recorded in `self.offsets[0]`). 
Stream any trailing
+                    // batches (normally none) without flushing; the 
long-lived writer is
+                    // flushed once in `finish_all`.
                     for batch in iter.by_ref() {
                         let batch = batch?;
-                        buf_batch_writer.write(
+                        writer.write(
                             &batch,
-                            recycled_buffer,
-                            codec_context,
+                            scratch,
+                            &mut self.codec_context,
                             &metrics.encode_time,
                             &metrics.write_time,
                         )?;
                     }
-                    buf_batch_writer.flush(
-                        recycled_buffer,
-                        codec_context,
-                        &metrics.encode_time,
-                        &metrics.write_time,
-                    )
-                })();
-                // An errored partition must hand back a drained buffer, or 
its bytes
-                // leak into the next partition's block.
-                result.inspect_err(|_| recycled_buffer.clear())?;
+                }
+                DataOutput::Multi {
+                    output_writer,
+                    shuffle_block_writer,
+                    spill,
+                    spill_reader,
+                    recycled_buffer,
+                    ..
+                } => {
+                    self.offsets[pid] = output_writer.stream_position()?;
+
+                    let mut flush_timer = metrics.write_time.timer();
+                    spill.flush()?;
+                    flush_timer.stop();
+                    let ranges = spill.ranges(pid)?;
+                    if !ranges.is_empty() {
+                        if spill_reader.is_none() {
+                            let path = spill.path()?.ok_or_else(|| {
+                                DataFusionError::Internal(
+                                    "shuffle spill ranges recorded without a 
spill file"
+                                        .to_string(),
+                                )
+                            })?;
+                            // Open first, so a failed open leaves nothing 
charged.
+                            let spill_file = File::open(path)?;
+                            let buffer_size =
+                                
self.buffers.as_ref().map_or(write_buffer_size, |buffers| {
+                                    super::reserve_buffer(buffers, 
write_buffer_size)

Review Comment:
   [P2] During final replay, the flushed spill writer still holds its charged 
buffer when the reader asks for another. A refused read buffer sends 
medium-sized ranges through `seek` and `io::copy` even though a full-size 
allocation is sitting idle. Transfer the drained spill buffer to the reader, or 
release its capacity and charge before choosing the read-buffer size.
   
   Evidence: On Linux, replaying 2,048 identical 12,828-byte ranges with a 2 
MiB + 128 KiB pool took a median 31.8 ms on the base and 64.3 ms on the head 
over seven interleaved rounds. The head held a 1 MiB spill-write buffer and 
received an 8 KiB read buffer. Reusing the existing allocation took 35.8 ms at 
the same budget. A full repartitioner run produced 256 spills, 1,024 ranges, 
and output CRC `01ffa3c0` on both revisions.



##########
native/shuffle/src/writers/local/spill.rs:
##########
@@ -214,17 +217,63 @@ impl PartitionedSpill {
     fn ensure_spill_file_created(
         &mut self,
         runtime: &RuntimeEnv,
+        buffers: Option<&MemoryReservation>,
     ) -> datafusion::common::Result<()> {
         if self.spill_file.is_none() {
             let temp_file = runtime
                 .disk_manager
                 .create_tmp_file("shuffle writer spill")?;
-            let writer = BufWriter::with_capacity(self.write_buffer_size, 
temp_file.open_writer()?);
+            // Open before charging, so a failed open leaves nothing on the 
reservation.
+            let file = temp_file.open_writer()?;
+            let capacity = buffers.map_or(self.write_buffer_size, 
|reservation| {
+                super::reserve_buffer(reservation, self.write_buffer_size)
+            });
+            let writer = BufWriter::with_capacity(capacity, file);
             self.spill_file = Some(ActiveSpillFile { temp_file, writer });
         }
         Ok(())
     }
 
+    /// Grows a fallback-sized buffer back to the configured size if `buffers` 
grants it.
+    /// Buffered bytes move to the new buffer; nothing is written to the file.
+    pub(crate) fn restore_buffer(&mut self, buffers: &MemoryReservation) {
+        if self.failed {
+            return;
+        }
+        let Some(spill_file) = self.spill_file.take() else {
+            return;
+        };
+        let capacity = spill_file.writer.capacity();
+        if capacity >= self.write_buffer_size
+            || buffers.try_grow(self.write_buffer_size - capacity).is_err()

Review Comment:
   [P2] A successful full-size restoration can leave too little memory for even 
the next input batch. The writer then spills on every subsequent batch while 
retaining the enlarged buffer. Account for the input headroom needed after a 
pressure spill when choosing the restore size. An intermediate capacity can 
retain buffered I/O without forcing immediate re-spills.
   
   Evidence: With eight hash partitions, LZ4, `maxBufferBytes=0`, and 256 
batches of 1,024 integers, raising the head's pool from 2,096 to 2,112 KiB 
increased spills from four to 198. The restored 1 MiB spill buffer left just 
4,066 bytes free. The base spilled twice at 2,112 KiB. Capping only restoration 
at 256 KiB produced six spills and reduced median time from 284 to 177 ms over 
five interleaved runs. All rows decoded. The same 198-to-six difference 
persisted with the data-output buffer deferred at a 1,088 KiB pool.



##########
docs/source/user-guide/latest/tuning/memory.md:
##########
@@ -182,11 +187,16 @@ alongside the JVM's own non-heap memory. To size the 
overhead from it:
    non-heap memory, and add the most untracked memory seen on any executor.
 3. Add a margin on top. The log can miss the true peak between samples, and 
none of the figures
    includes the allocator's fragmentation and retained pages, or memory 
allocated by native C
-   libraries such as zstd. With native Arrow UDFs, budget the embedded Python 
interpreter and
-   PyArrow allocations in addition to the untracked memory calculated from the 
log. A PyArrow
-   result buffer later imported into the JVM can appear in the `imported from 
native` figure that
-   the estimate subtracts, even though Rust's `allocated` does not count it. 
Use the executor's
-   peak resident memory to size this additional overhead.
+   libraries such as zstd. The zstd context of the native shuffle writer is 
the exception: when
+   `spark.comet.shuffle.compression.codec` is `zstd`, a shuffle task writing 
to more than one local
+   partition reserves its context while spilling or writing its output, so 
that memory is in

Review Comment:
   [P2] The sizing guide says the local zstd workspace appears in `reserved` 
while writing. When a context exceeds the 8 MiB retention cap, 
`release_zstd_if_oversized()` drops it before the writer samples it. That peak 
appears in neither `reserved` nor Rust's `allocated` figure and can reach tens 
of MiB per concurrent task. Distinguish retained contexts from transient 
compression workspace here and in the contributor guide.
   
   Evidence: The same batch round-tripped at zstd levels 1, 9, and 19. At level 
1 the context measured 1,369,625 bytes both after compression and at the 
accounting boundary. Levels 9 and 19 measured 15,459,865 and 93,848,215 bytes 
after compression, respectively, and zero at the accounting boundary.



##########
native/shuffle/src/writers/local/local_partition_writer.rs:
##########
@@ -372,14 +452,45 @@ impl PartitionWriter for LocalPartitionWriter {
 
         // The shuffle output is complete; nothing else encodes through this 
context.
         self.codec_context.release_zstd();
+        self.sync_buffer_reservation();
 
         Ok(())
     }
 
     fn write_burst_complete(&mut self) {
-        // A spill burst just ended and the next encode may be a long time 
coming; the zstd
-        // workspace is native memory no reservation tracks, so don't sit on 
it.
+        // A spill burst just ended and the next encode may be a long time 
coming, so don't
+        // sit on the zstd workspace.
         self.codec_context.release_zstd();
+        self.sync_buffer_reservation();
+        // A pressure spill opened its file while the batches still held the 
pool; now they don't.
+        if let (Some(buffers), DataOutput::Multi { spill, .. }) =
+            (&self.buffers, &mut self.data_output)
+        {
+            spill.restore_buffer(buffers);
+        }
+    }
+
+    fn attach_buffer_reservation(
+        &mut self,
+        reservation: MemoryReservation,
+    ) -> datafusion::common::Result<()> {
+        let DataOutput::Multi { output_writer, .. } = &mut self.data_output 
else {
+            return Ok(());
+        };
+        // Attached once, before any write: the rebuild below must not discard 
buffered bytes.
+        debug_assert!(self.buffers.is_none() && 
output_writer.buffer().is_empty());
+        // The data file buffer was allocated before the consumer existed; 
shrink it if refused.
+        let capacity = super::reserve_buffer(&reservation, 
self.write_buffer_size);
+        if capacity != output_writer.capacity() {
+            let output_file = output_writer
+                .get_ref()
+                .try_clone()

Review Comment:
   [P2] `try_clone()` requires a spare file descriptor solely to resize an 
empty `BufWriter`. If opening the data file used the executor's last descriptor 
and the pool refuses the configured buffer, attachment now fails with `EMFILE` 
before processing a row. Move the original `File` into the resized writer or 
wrap it once at the granted capacity.
   
   Evidence: A child process with `RLIMIT_NOFILE=32` left one descriptor for 
the output file. With a 64 KiB pool, a 1 MiB write buffer, and a two-partition, 
16-row input requiring no spill, the base published `[0, 262, 536]`. The head 
opened the file, failed in `try_clone()` with OS error 24, and published no 
offsets.



##########
native/shuffle/src/writers/local/mod.rs:
##########
@@ -17,3 +17,18 @@
 
 pub(crate) mod local_partition_writer;
 mod spill;
+
+use datafusion::execution::memory_pool::MemoryReservation;
+
+const FALLBACK_BUFFER_SIZE: usize = 8 * 1024;
+
+/// Returns the capacity to allocate. A refused size falls back to a small 
buffer charged with
+/// `grow`, so the task carries on and the reservation still matches what is 
held.
+fn reserve_buffer(reservation: &MemoryReservation, size: usize) -> usize {
+    if reservation.try_grow(size).is_ok() {

Review Comment:
   [P2] `try_grow(size).is_ok()` treats a Spark exception as permission to use 
the 8 KiB fallback. If a peer's `spill()` throws `IOException`, the map can 
publish offsets while Spark still holds a partial grant. `restore_buffer` 
discards the same error, and `sync_buffer_reservation` can hide it through 
infallible `resize` even after the file-buffer request succeeds. Use fallible 
growth for reusable capacity, recover only from `ResourcesExhausted`, and 
propagate other errors through attachment, writing, and the post-spill hook.
   
   Evidence: With Spark 4.1.3, a 2 MiB off-heap pool, and a peer holding 1.5 
MiB that threw on its first spill, both `fair_unified` and `greedy_unified` 
published `[0, 262, 536]` and left 524,288 bytes charged until task cleanup. 
The base made no peer-spill call and left zero. In a separate run the 1 MiB 
file grant and 256-byte input grant both succeeded. Spark threw on the 516-byte 
scratch charge, yet the head finished and stranded 256 bytes. Injecting 
`External` only during spill-buffer restoration also allowed publication.



-- 
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]

Reply via email to