viirya commented on code in PR #7096:
URL: https://github.com/apache/datafusion-comet/pull/7096#discussion_r4321652975


##########
native/core/src/execution/memory_pools/fair_pool.rs:
##########
@@ -153,22 +209,32 @@ impl MemoryPool for CometFairMemoryPool {
     ) -> Result<(), DataFusionError> {
         if additional > 0 {
             let mut state = self.state.lock();
-            let num = state.consumers.len();
-            let limit = self
-                .pool_size
-                .checked_div(num)
-                .expect("overflow in checked_div");
-            let consumer_used = *state.consumer_used(reservation);
-            if limit < consumer_used.saturating_add(additional) {
-                return resources_err!(
-                    "Failed to acquire {additional} bytes where this consumer 
already holds {consumer_used} bytes and the fair limit is {limit} bytes, {num} 
registered ({} bytes overcommitted)",
-                    self.spark.overcommit()
-                );
-            }
-            // The shares alone do not bound the pool's total, because a 
consumer keeps what it
-            // reserved before another consumer registered.
+            let can_spill = reservation.consumer().can_spill();
             let used = state.used;
-            if self.pool_size < used.saturating_add(additional) {
+            if can_spill {
+                let (task_share, tasks) = self.task_share();
+                // `num_spill` counts this consumer.
+                let limit = task_share.saturating_sub(state.unspillable) / 
state.num_spill.max(1);

Review Comment:
   This ceiling, `(task_share - unspillable) / num_spill`, can be well below 
what Spark would grant the task. DataFusion 55.2's `ExternalSorter` fails 
instead of spilling in a few spots:
   
   - `reserve_memory_for_batch_and_maybe_spill` when `in_mem_batches` is empty
   - the same function's retry after a spill
   - the `try_resize` in `sort_batch_stream`
   
   Each returns "Not enough memory to continue external sort". The description 
mentions hitting exactly this in a suite ("five tasks left a sort a share 
smaller than one of its batches"). Keeping the fraction as a per-task cap fixed 
the suite, but the same thing can happen in production. Take 16 cores, 1 GiB of 
off-heap and three or four spillable operators in a task. Each one gets tens of 
MiB at most, and one batch of wide string rows can be bigger than that. On 
main, that sort could use the task's whole Spark grant.
   
   Is this a trade-off you have weighed? If it is accepted, could the 1.2.0 
migration note say that a sort can now fail when a single batch exceeds its 
share, and point to `greedy_unified` or a larger off-heap size as the way out?



##########
native/core/src/execution/operators/iceberg_write.rs:
##########
@@ -851,8 +851,7 @@ impl ExecutionPlan for IcebergWriteExec {
         context: Arc<TaskContext>,
     ) -> DFResult<SendableRecordBatchStream> {
         let metrics = WriteMetrics::new(&self.metrics, partition);
-        // One consumer for the whole task, however many files it opens: a 
consumer per file
-        // would shrink every other consumer's share of the fair pool as a 
fanout write widened.
+        // One consumer for the whole task, however many files it opens.

Review Comment:
   The fanout path does respond to a refusal. `reserve()` writes out and closes 
the partitions holding the most until the resize fits. With this change an 
unspillable consumer is held to no share, so a fanout write grows until Spark 
refuses, and every byte it holds is subtracted from the shares of the task's 
spillable operators. In a plan that sorts and writes in the same task, the sort 
ends up spilling where the writer could have closed files instead.
   
   Should the fanout writer register `with_can_spill(true)`? The unpartitioned 
and clustered writers would stay unspillable, since a refusal is fatal for 
them. Either way, the new sentence at `memory_management.md:454` ("They cannot 
spill") contradicts the paragraph right above it, which describes the fanout 
writer closing files on refusal.



##########
spark/src/main/scala/org/apache/spark/Plugins.scala:
##########
@@ -322,7 +322,7 @@ object CometDriverPlugin extends Logging {
 
   // spark.comet.exec.memoryPool.fraction was documented as holding back part 
of the off-heap pool
   // for the native memory that Comet does not reserve. It cannot: Spark hands 
out the whole pool
-  // to the tasks that ask for it, and the fraction only caps each task's 
consumers under
+  // to the tasks that ask for it, and the fraction only limits the operators 
that can spill under

Review Comment:
   "the fraction only limits the operators that can spill" is not quite 
accurate. `pool_size` also caps the task's total for unspillable consumers, 
through the `else if self.pool_size < used.saturating_add(additional)` branch 
in `try_grow`. Something like "the fraction only caps each task's total under 
fair_unified" would match the code.



##########
docs/source/contributor-guide/memory_management.md:
##########
@@ -311,34 +312,50 @@ JNI, which goes through Spark's ordinary 
`TaskMemoryManager`. That means:
   is refused unless Spark can cover both, so operators spill until the debt is 
repaid. Both pools'
   `Display` output and their `try_grow` errors report the current overcommit.
 
-`CometFairMemoryPool` additionally applies two local checks before it asks 
Spark, and refuses the
+`CometFairMemoryPool` exists because Spark cannot ask a native operator to 
spill. Spark limits each
+task to an even share of its off-heap pool among the tasks that hold memory 
from it. Once one
+operator holds its task's share, Spark refuses the task's other operators, and 
one that cannot
+spill, such as a sort's merge or the build side of a hash join, fails the 
task. The pool keeps
+memory for those operators by limiting the ones that can spill to a share of 
the task's, as
+DataFusion's `FairSpillPool` does over its whole pool
+([issue #7088](https://github.com/apache/datafusion-comet/issues/7088)).
+
+The task's share is `off_heap_size` divided by the number of tasks on the 
executor that are running
+native plans, that is, that hold a task-shared pool of either type, or 
`pool_size` if that is
+smaller, which it is only while the deprecated fraction is below `1 / N`. Like 
Spark's own divisor,
+the number follows tasks as they start and finish, so a task that runs alone 
has the whole pool.
+Spark's count differs in two ways: it includes tasks that run no native plan, 
and it counts a task

Review Comment:
   Could this also say what each difference does? Spark's `ExecutionMemoryPool` 
counts a task from its first acquire until its balance returns to zero 
(`memoryForTask.remove`).
   
   - When tasks that run no native plan hold off-heap memory, Comet divides by 
fewer tasks than Spark does. The spillable shares then add up to more than 
Spark will grant, so #7088 can still happen in mixed JVM and native stages.
   - When tasks hold a native pool but no memory, for example a native scan 
feeding JVM shuffle, Comet divides by more. Sorts then spill earlier than Spark 
requires.
   
   That would tell the next reader which way to look when a mixed workload 
misbehaves.



##########
native/core/src/execution/memory_pools/fair_pool.rs:
##########
@@ -31,14 +32,31 @@ use parking_lot::Mutex;
 
 /// A DataFusion fair `MemoryPool` implementation for Comet. Internally this is
 /// implemented via delegating calls to 
[`crate::jvm_bridge::CometTaskMemoryManager`].
+///
+/// Spark limits each task to an even share of its off-heap pool among the 
tasks that hold memory
+/// from it, and it cannot ask a native consumer to spill. So, as DataFusion's 
`FairSpillPool` does
+/// over its whole pool, this pool limits each consumer that can spill to an 
even share of what the

Review Comment:
   Could this doc comment, and the matching paragraph in the guide, state the 
limit as a formula as well? For example:
   
   ```text
   task_share = min(off_heap_size / running_tasks, pool_size)
   limit      = (task_share - unspillable) / num_spill
   ```
   
   The prose version nests "an even share of what ... leave of the task's 
share, which is an even share of ..." and takes a few reads to parse. The guide 
already uses a formula block for `memory_limit`.



##########
native/core/src/execution/delta_dv.rs:
##########
@@ -1837,21 +1808,16 @@ mod tests {
     }
 
     /// One `attach_access_plans` call must add exactly one consumer to the 
task's pool no
-    /// matter how many DV'd files it attaches. `CometFairMemoryPool` divides 
the pool by the
-    /// number of registered consumers, and the reservations live in the 
returned files until
-    /// the task ends, so one consumer per file would lower every other native 
operator's fair
-    /// limit for the whole task even when the DV bytes themselves are tiny. 
Three DV'd files
-    /// must leave a later consumer (a hash join build, say) its half of the 
pool. Each file's
-    /// bytes must still return to the pool when that file alone drops, while 
the shared
-    /// registration stays until the last file is gone.
+    /// matter how many DV'd files it attaches. Each file's bytes must still 
return to the pool

Review Comment:
   Now that the fair-share reason is gone, is there still a reason to pin one 
consumer per call rather than one per file? Keeping `TrackConsumersPool`'s 
top-10 list readable, or keeping the pool's per-consumer map small, might be 
one. If so, could this comment and the one on `attach_access_plans` say so? 
Otherwise the test asserts "not one per file" without anything explaining why 
that matters.



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