sunchao commented on code in PR #57329:
URL: https://github.com/apache/spark/pull/57329#discussion_r3606333887


##########
core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala:
##########
@@ -125,6 +126,30 @@ private[spark] class TaskSetManager(
   val successful = new Array[Boolean](numTasks)
   private val numFailures = new Array[Int](numTasks)
 
+  // For each task, tracks the number of times it has failed due to 
out-of-memory. Used to grow
+  // the number of CPUs allocated to a retry (see 
spark.task.oomRetryCpusIncrement), which lowers
+  // the executor's concurrent task count and thus increases the retry's 
execution-memory share.
+  // Reset to 0 when the task succeeds, mirroring numFailures.
+  private[scheduler] val numOomRetries = new Array[Int](numTasks)
+  private val oomRetryCpusIncrement = conf.get(config.OOM_RETRY_CPUS_INCREMENT)
+  // Executor exit codes treated as OOM (see 
spark.task.oomRetryExecutorExitCodes); when an
+  // executor exits with one of these, the tasks it was running have their OOM 
retry count bumped.
+  private val oomRetryExecutorExitCodes = 
conf.get(config.OOM_RETRY_EXECUTOR_EXIT_CODES).toSet
+  // The executor total cores of this TaskSet's ResourceProfile. A 
TaskSetManager is tied to a
+  // single ResourceProfile, so this is constant for its lifetime and caps the 
OOM retry cpus.
+  private val executorCoresLimit: Int = {
+    val rp = 
sched.sc.resourceProfileManager.resourceProfileFromId(taskSet.resourceProfileId)
+    rp.getExecutorCores.getOrElse(conf.get(EXECUTOR_CORES))

Review Comment:
   [P1] Preserve the ResourceProfile's base CPU request when executor cores are 
unknown
   
   For Standalone and local-cluster without an explicit `spark.executor.cores`, 
`getExecutorCores` is intentionally empty because the executor can use the 
worker's available cores, while `conf.get(EXECUTOR_CORES)` returns the generic 
default of 1. Once the increment is enabled, `effectiveCpusFor` is called for 
every attempt, including when `numOomRetries(index) == 0`, so a base 
`spark.task.cpus=2` is reduced to one CPU. `TaskSchedulerImpl` prechecks the 
offer against the two-CPU ResourceProfile request but later debits the one CPU 
in `TaskDescription`, so an eight-core executor can launch seven nominally 
two-CPU tasks instead of four; with the default one CPU per task, OOM retries 
never grow at all. Please derive this cap from the executor's actual capacity 
(or represent an unknown cap explicitly) and ensure `effectiveCpusFor` can 
never return less than `baseCpus`.



##########
core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala:
##########
@@ -329,13 +354,19 @@ private[spark] class TaskSetManager(
       execId: String,
       host: String,
       list: ArrayBuffer[Int],
-      speculative: Boolean = false): Option[Int] = {
+      speculative: Boolean = false,
+      taskCpus: Int = sched.CPUS_PER_TASK,
+      availCpus: Int = Int.MaxValue): Option[Int] = {
     var indexOffset = list.size
     while (indexOffset > 0) {
       indexOffset -= 1
       val index = list(indexOffset)
+      // Strict wait for OOM retries: if a task that previously failed with 
OOM needs more CPUs
+      // than this offer currently has free, leave it in the queue and look 
for another task,
+      // rather than launching it with the same resources that already caused 
the OOM.
       if (!isTaskExcludededOnExecOrNode(index, execId, host) &&
-          !(speculative && hasAttemptOnHost(index, host))) {
+          !(speculative && hasAttemptOnHost(index, host)) &&
+          !oomRetryNeedsMoreCpus(index, taskCpus, availCpus)) {

Review Comment:
   [P1] Reserve capacity for enlarged retries across offer rounds
   
   This condition leaves an enlarged retry queued when the current offer is too 
small, but then continues scanning and may launch a normal one-CPU task. On a 
saturated executor, `CoarseGrainedSchedulerBackend` makes a new offer 
immediately after each individual task completion; each released core can 
therefore be backfilled before two or more cores ever accumulate. The retry is 
delayed until the normal backlog drains, and continuing work from competing 
task sets can keep it waiting indefinitely. Dynamic allocation also derives 
executor demand from the ResourceProfile's base `maxTasksPerExecutor`, so it 
need not add an executor for this larger pending task. Please add a 
reservation/draining mechanism across offer rounds and make allocation aware of 
the enlarged task shape.



##########
core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala:
##########
@@ -125,6 +126,30 @@ private[spark] class TaskSetManager(
   val successful = new Array[Boolean](numTasks)
   private val numFailures = new Array[Int](numTasks)
 
+  // For each task, tracks the number of times it has failed due to 
out-of-memory. Used to grow
+  // the number of CPUs allocated to a retry (see 
spark.task.oomRetryCpusIncrement), which lowers
+  // the executor's concurrent task count and thus increases the retry's 
execution-memory share.
+  // Reset to 0 when the task succeeds, mirroring numFailures.
+  private[scheduler] val numOomRetries = new Array[Int](numTasks)
+  private val oomRetryCpusIncrement = conf.get(config.OOM_RETRY_CPUS_INCREMENT)
+  // Executor exit codes treated as OOM (see 
spark.task.oomRetryExecutorExitCodes); when an
+  // executor exits with one of these, the tasks it was running have their OOM 
retry count bumped.
+  private val oomRetryExecutorExitCodes = 
conf.get(config.OOM_RETRY_EXECUTOR_EXIT_CODES).toSet
+  // The executor total cores of this TaskSet's ResourceProfile. A 
TaskSetManager is tied to a
+  // single ResourceProfile, so this is constant for its lifetime and caps the 
OOM retry cpus.
+  private val executorCoresLimit: Int = {
+    val rp = 
sched.sc.resourceProfileManager.resourceProfileFromId(taskSet.resourceProfileId)
+    rp.getExecutorCores.getOrElse(conf.get(EXECUTOR_CORES))
+  }
+
+  // The number of CPUs a retry of the given task should request, given the 
ResourceProfile's
+  // base taskCpus. For a task that has failed with OOM, this grows by 
oomRetryCpusIncrement per
+  // OOM failure, capped at the executor total cores.
+  private def effectiveCpusFor(index: Int, baseCpus: Int): Int = {
+    val requested = baseCpus + oomRetryCpusIncrement * numOomRetries(index)

Review Comment:
   [P2] Compute retry CPU growth without `Int` overflow
   
   The new configuration accepts every nonnegative `Int`, so with `baseCpus = 
1` and `oomRetryCpusIncrement = Int.MaxValue`, the first OOM retry wraps 
`requested` to `Int.MinValue` before this clamp. `oomRetryNeedsMoreCpus` then 
accepts the negative value, and `prepareLaunchingTask` mutates `copiesRunning`, 
`taskInfos`, and the running-task set before `TaskDescription` asserts that 
`cpus > 0`. The launch fails with scheduler state already recording a running 
copy. Please calculate in `Long` and clamp safely before converting to `Int`, 
or impose a configuration bound that makes the arithmetic provably safe.



##########
core/src/main/scala/org/apache/spark/scheduler/TaskSchedulerImpl.scala:
##########
@@ -426,23 +426,27 @@ private[spark] class TaskSchedulerImpl(
             val prof = 
sc.resourceProfileManager.resourceProfileFromId(taskSetRpID)
             val taskCpus = 
ResourceProfile.getTaskCpusOrDefaultForProfile(prof, conf)
             val (taskDescOption, didReject, index) =
-              taskSet.resourceOffer(execId, host, maxLocality, taskCpus, 
taskResAssignments)
+              taskSet.resourceOffer(execId, host, maxLocality, taskCpus, 
taskResAssignments,
+                availableCpus(i))
             noDelayScheduleRejects &= !didReject
             for (task <- taskDescOption) {
-              val (locality, resources) = if (task != null) {
+              // The CPUs actually used may exceed the ResourceProfile's 
taskCpus when the task is
+              // an OOM retry (see spark.task.oomRetryCpusIncrement); read it 
from the
+              // TaskDescription so the availableCpus bookkeeping matches what 
was launched.
+              val (locality, resources, cpusUsed) = if (task != null) {
                 tasks(i) += task
                 addRunningTask(task.taskId, execId, taskSet)
-                (taskSet.taskInfos(task.taskId).taskLocality, task.resources)
+                (taskSet.taskInfos(task.taskId).taskLocality, task.resources, 
task.cpus)
               } else {
                 assert(taskSet.isBarrier, "TaskDescription can only be null 
for barrier task")
                 val barrierTask = taskSet.barrierPendingLaunchTasks(index)
                 barrierTask.assignedOfferIndex = i
                 barrierTask.assignedCores = taskCpus
-                (barrierTask.taskLocality, barrierTask.assignedResources)
+                (barrierTask.taskLocality, barrierTask.assignedResources, 
taskCpus)
               }
 
               minLaunchedLocality = minTaskLocality(minLaunchedLocality, 
Some(locality))
-              availableCpus(i) -= taskCpus
+              availableCpus(i) -= cpusUsed

Review Comment:
   [P2] Keep `LocalSchedulerBackend` free-core accounting in sync
   
   This scheduler-side change correctly charges `TaskDescription.cpus`, but 
`LocalSchedulerBackend` still subtracts and restores the fixed 
`scheduler.CPUS_PER_TASK`. For example, in `local[4]` with 
`spark.executor.cores=4`, base CPUs 1, and a two-CPU retry, launching that 
retry plus two normal tasks consumes four CPUs here while the local endpoint 
subtracts only three and advertises a phantom core. Another revive or task-set 
submission can then oversubscribe the local executor. Please track each 
launched task's actual CPU count in the local backend and restore that count on 
completion.



##########
core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala:
##########
@@ -1247,6 +1318,11 @@ private[spark] class TaskSetManager(
           // that the task is not running, and it is NetworkFailure rather 
than TaskFailure.
           case _ => !info.launching
         }
+        // Grow the CPUs of the retry when a running task's executor died of a 
JVM heap OOM. See
+        // the SparkOutOfMemoryError branch in handleFailedTask for the 
non-fatal counterpart.
+        if (oomRetryCpusIncrement > 0 && !isBarrier && isOomExit && 
exitCausedByApp) {

Review Comment:
   [P1] Preserve JVM-OOM classification when FAILED arrives before executor loss
   
   `Executor.TaskRunner` sends an `ExceptionFailure` status before invoking the 
fatal exception handler that exits with code 52. If the driver processes that 
FAILED update first, `handleFailedTask` marks the attempt finished; its OOM 
branch recognizes only `SparkOutOfMemoryError`, not 
`java.lang.OutOfMemoryError`. When the subsequent `ExecutorExited(52, ...)` 
reaches this block, `info.running` is already false, so the counter is never 
incremented and the retry keeps its original CPU count. This is a normal 
reachable ordering for the advertised JVM-heap-OOM path. Please classify fatal 
`OutOfMemoryError` in the `ExceptionFailure` path or preserve enough attempt 
state for the later definitive loss reason, and add a test with FAILED 
preceding executor loss.



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