[SPARK-58192][CORE] Support fractional spark.task.cpus#57332
Conversation
|
cc @HyukjinKwon, as you also raised the same idea in #40730 (comment)
background: we do have observed a large set of such I/O-bound workloads on our cluster, which take a few CPU time but occupy many executor core slots cc @dongjoon-hyun @sunchao @mridulm @tgravescs @wangyum for review, thank you in advance |
sunchao
left a comment
There was a problem hiding this comment.
Summary
This change tackles a real scheduler limitation and the central fixed-scale BigDecimal accounting is thoughtfully carried through offers, task descriptions, completion updates, barrier rollback, and status reconstruction. I reviewed exact head 980835a5d6ff18bb64d47dda605b7dc720a44fc3 with separate scheduler, API/Python, runtime/Kubernetes, and adversarial verification passes.
I found four material gaps at the runtime and resource-manager boundaries. They all arise when fractional CPUs increase task concurrency or when a stage-level resource profile differs from the application-wide spark.task.cpus; I left them as inline P2 findings.
Prior state and problem
Before this patch, task CPU demand was integral, so executor core counts doubled as both a resource quantity and, in several subsystems, an implicit upper bound on concurrent tasks or workers. That prevented useful configurations such as five I/O-bound tasks sharing one core or tasks reserving 1.5 cores.
Supporting fractional CPU demand requires more than changing scheduler slot division: every downstream assumption that equates executor cores with concurrent tasks, and every integration that rereads the global configuration instead of the active task profile, must also be updated.
Design approach
The patch introduces a scale-9 decimal CPU representation, parses spark.task.cpus without binary floating-point drift, and performs slot computation with exact floor division. It threads the exact value through scheduler offers, TaskDescription, executor accounting, status updates, TaskContext, and PySpark task-context serialization.
For APIs that require an integer, such as OMP_NUM_THREADS and Kubernetes executor registration, it rounds the exact amount upward. The Kubernetes recovery path additionally documents the unavoidable global spark.task.cpus <= 0.5 isolation limitation.
Correctness / compatibility analysis
The scheduler's normal launch/finish accounting is internally symmetric: the same normalized amount is reserved and returned, barrier rollback preserves it, and slot sums saturate safely. I also found no same-version RPC serialization problem, and the existing MiMa-exclusion pattern for TaskContext additions has project precedent.
The remaining problems are boundary mismatches. PySpark memory is still divided by raw executor cores even though fractional CPUs can create more workers than cores; Python threading and Kubernetes recovery reread the global CPU value instead of the stage profile being launched; and executorRunTime scales the deserialization interval before subtracting it, which can collapse valid runtimes to zero.
Key design decisions
- Fixed-scale decimal accounting avoids cumulative drift and makes
floor(cores / cpus)deterministic. - The exact fractional value remains the scheduler contract; ceiling is appropriate only when projecting it onto an integral interface.
- Stage-level resource profiles must remain authoritative all the way into executor-side child-process settings and resource-manager pod construction.
- Any executor-wide budget must be divided by maximum concurrent task slots, not by physical cores once sub-core tasks are supported.
Implementation sketch
The configuration reader constructs the default resource profile, while stage APIs construct task-specific profiles. TaskSchedulerImpl resolves the profile CPU amount, reserves it from an offer, and sends it in TaskDescription; the executor returns the same amount in terminal status updates.
At execution time, TaskContext.cpuAmount() exposes that resolved value to JVM and Python paths. Separately, Kubernetes maps a requested resource-profile ID to a ResourceProfile and passes it into BasicExecutorFeatureStep when constructing each executor pod. Those existing handoff points provide the exact values needed to address the inline findings without re-reading global configuration.
Behavioral changes worth calling out
With spark.task.cpus < 1, task concurrency can exceed executor cores. That is the intended scheduler behavior, but it also increases the number of simultaneous Python workers and native-library thread pools, so memory caps and thread counts must be derived from task slots and the active profile.
With spark.task.cpus > 1, CPU-weighted runtime metrics remain useful, but deserialization must be removed from the measured execution interval before multiplying by the CPU reservation. Kubernetes recovery executors also need the task CPU amount from the profile for which the pod is being allocated; otherwise they can be unusable for that stage or defeat single-task recovery isolation.
Suggested improvements
- Split configured PySpark executor memory across the maximum concurrent task slots for the active profile, and add a 4-core / 0.5-CPU regression test.
- Set Python worker thread defaults from
context.cpuAmount()and test a stage profile that differs from globalspark.task.cpus. - Derive recovery-mode executor cores, and its isolation warning, from the resource profile being allocated.
- Compute CPU-weighted executor runtime from
(task interval - in-run deserialization) * task cpus, with a fractional case that currently reports zero.
| val localdir = env.blockManager.diskBlockManager.localDirs.map(f => f.getPath()).mkString(",") | ||
| // If OMP_NUM_THREADS is not explicitly set, override it with the number of task cpus. | ||
| // See SPARK-42613 for details. | ||
| // See SPARK-42613 for details. `spark.task.cpus` may be fractional, so round up to an integer |
There was a problem hiding this comment.
[P2] Split PySpark memory across fractional task slots
The fractional concurrency enabled here invalidates getWorkerMemoryMb's assumption above that the worker pool has at most execCores workers. With 4 executor cores, spark.task.cpus=0.5, and 4 GiB of spark.executor.pyspark.memory, Spark can now run 8 Python tasks concurrently, but each worker still receives 4096 / 4 = 1024 MiB. Their aggregate limits can therefore reach 8 GiB even though YARN/Kubernetes adds only 4 GiB to the executor container request, defeating the configured cap and potentially OOMing the executor. Please divide the executor-wide allocation by the maximum concurrent task slots for the active resource profile (or another conservative concurrency bound), and cover this case.
| if (conf.getOption("spark.executorEnv.OMP_NUM_THREADS").isEmpty) { | ||
| envVars.put("OMP_NUM_THREADS", conf.get("spark.task.cpus", "1")) | ||
| envVars.put("OMP_NUM_THREADS", | ||
| conf.get(CPUS_PER_TASK).setScale(0, BigDecimal.RoundingMode.CEILING).intValue.toString) |
There was a problem hiding this comment.
[P2] Use the task's stage-level CPU amount for OMP_NUM_THREADS
This reads the executor's global spark.task.cpus, but the scheduler may have reserved a different amount from the stage resource profile and already exposes it as context.cpuAmount(). For example, with global CPUs 3, a stage profile requesting 0.5, and a 4-core executor, Spark schedules 8 Python workers while each gets OMP_NUM_THREADS=3, allowing 24 native threads on 4 cores; the inverse mismatch underutilizes larger stage requests. Python worker factories are keyed by the environment map, so using the ceiling of context.cpuAmount() will also keep worker reuse separated correctly by profile.
| (BigDecimal(taskFinishNs - taskStartTimeNs) * | ||
| CpuAmount.stripTrailingZeros(taskDescription.cpus)).toLong | ||
| - task.executorDeserializeTimeNs))) |
There was a problem hiding this comment.
[P2] Subtract deserialization before scaling executorRunTime
This currently computes cpus * (deserialization + execution) - deserialization. With cpus=0.2, 100 ms of in-run deserialization, and 100 ms of execution, it produces -60 ms and the clamp reports zero, although the CPU-weighted execution interval is 0.2 * (200 - 100) = 20 ms. Besides under-reporting stage metrics, a zero runtime makes efficient speculation discard the task's process-rate sample. Please remove the in-run deserialization interval before multiplying by the task CPU amount.
| // When it is 0.5 or less, the single announced core fits more than one task and | ||
| // the single-task-per-recovery-executor guarantee no longer holds; | ||
| // ExecutorPodsAllocator logs a warning for that case. | ||
| math.max(1, kubernetesConf.get("spark.task.cpus", "1.0").toDouble.ceil.toInt).toString |
There was a problem hiding this comment.
[P2] Derive recovery executor capacity from the profile being launched
ExecutorPodsAllocator resolves the requested profile ID and passes that ResourceProfile into this feature step, but this branch ignores it and rounds the global spark.task.cpus. With global CPUs 1 and a stage profile requesting 1.5, a recovery executor for that profile announces one core and can never launch its 1.5-core tasks. With global CPUs 2 and a stage profile requesting 0.5, it announces two cores and accepts four tasks, defeating recovery isolation. Please derive this value (and the corresponding warning) from the resource profile being allocated.
sunchao
left a comment
There was a problem hiding this comment.
One additional issue from the API/configuration pass: stage-level CPU requests can be silently rounded down at the accounting boundary.
| // A positive amount below half of the internal accounting scale (1e-9) would silently | ||
| // round to zero cpus downstream; reject it here where the original value is still visible. | ||
| assert(!amount.isNaN && !amount.isInfinity && | ||
| CpuAmount.normalize(BigDecimal(amount.toString)).signum > 0, |
There was a problem hiding this comment.
[P2] Reject CPU requests that would be rounded down
This predicate validates only the sign after normalization, so the new public cpus(Double) API accepts values with more precision than the 1e-9 accounting scale and can allocate less CPU than requested. For example, cpus(1.0000000004) is accepted, but getTaskCpus normalizes it to 1.000000000; a 10-core executor then gets 10 slots even though floor(10 / 1.0000000004) = 9. The config path already rejects excess precision, so please validate the unnormalized decimal against the same scale and bounds (or round upward, never downward).
|
I did not take a look at the details but I am supportive of this change |
|
cc @tgravescs and @mridulm if you guys find some time to take a look. cc @jiangxb1987 and @Ngone51 |
What changes were proposed in this pull request?
This PR adds support for fractional values of
spark.task.cpus(e.g.0.5,1.5) and forfractional stage-level task cpus requests, so that the number of concurrent tasks on an
executor becomes
floor(executor cores / task cpus)computed exactly.Exact decimal accounting. CPU bookkeeping moves from
Inttoscala.math.BigDecimal:WorkerOffer.cores,ExecutorData.freeCores,TaskDescription.cpus, andStatusUpdate.taskCpusall carry the exact decimal value. A new internalCpuAmountobjectdefines the canonical representation: every value is normalized to a fixed scale of 9
(
1e-9resolution), which keepsBigDecimalon its compact long-backed fast path and makesarithmetic between normalized values stay at scale 9. Slot math
(
ResourceProfile.numTasksBasedOnCores) uses exactFLOORdivision, so e.g.1.0 / 0.1yields 10 slots (naive
Doublearithmetic would floor9.999...to 9), and the result isclamped to
[0, Int.MaxValue](BigDecimal.intValue()wraps modulo 2^32);TaskSchedulerImpl.calculateAvailableSlotssums per-executor slots inLongand saturates.Configuration. A new
ConfigBuilder.decimalConfparses the exact decimal string. Thevalue is validated before normalization: it must be in
[1e-9, Int.MaxValue]with at most9 decimal places, so out-of-range/over-precise values (including extreme exponents like
1e100000000) fail fast with a clear error instead of being silently rounded or triggeringpathological
setScalecosts.API.
TaskContext.cpuAmount(): BigDecimal(new,@Since("4.3.0")) returns the exact amount;TaskContext.cpus(): Intis deprecated and now returns the ceiling of the exact amount.TaskResourceRequests.cpus(Double)overload (theIntoverload delegates to it).amount <= 1.0 || wholeassertion inTaskResourceRequestand theamount <= 0.5 || wholeassertion inResourceProfile.validate()no longer apply tocpus: those rules exist so task amounts map onto discrete resource addresses (GPUs etc.),while cpus is a plain quantity drawn from the executor's core pool. Instead, a cpus amount
must be at least
1e-9(values that would round to zero at the accounting scale arerejected at request construction time).
TaskContext.cpuAmount() -> float(delivered via the task-context JSON sent toPython workers),
TaskResourceRequests.cpusacceptsfloatand no longer truncatesfractional amounts when materializing requests to the JVM.
Executor / runtime.
OMP_NUM_THREADSand the Python worker'scpusdefault to the ceiling of the exact amount.executorRunTimeis scaled by the task's cpus amount and clamped at zero (with cpus < 1 thescaled elapsed time can be smaller than the subtracted in-run deserialization time).
Kubernetes. In allocation recovery mode (SPARK-55639),
SPARK_EXECUTOR_CORESis theceiling of
spark.task.cpus(the executor--coresargument and registration RPC areintegral). When
spark.task.cpus <= 0.5this cannot express a single-task capacity — therecovery executor accepts
floor(1 / spark.task.cpus)concurrent tasks — so the driver logsa prominent one-time warning and the config/docs document the limitation.
Misc.
ResourceProfile.getTaskCpusis cached (lazy val) and the scheduler resolves taskcpus once per (task set, locality) round instead of per slot probe; MiMa excludes are placed
in
v43excludesto match the@Since("4.3.0")annotations so the change can be backportedto
branch-4.x.Why are the changes needed?
spark.task.cpusonly accepted whole numbers, so the finest scheduling granularity was onetask per core. Workloads with many lightweight tasks (I/O-bound tasks, PySpark tasks whose
heavy lifting happens in a shared native library or external service) cannot efficiently use
an executor without oversubscribing memory-per-task, and workloads needing "more than 1 but
less than 2" cores per task (e.g.
1.5) had to round up to 2 and waste capacity. Fractionaltask cpus let users express both:
Does this PR introduce any user-facing change?
Yes.
spark.task.cpusandTaskResourceRequests.cpusnow accept fractional values; whole-numberconfigurations behave exactly as before. Values outside
[1e-9, Int.MaxValue]or with morethan 9 decimal places are rejected with a clear error message.
TaskContext.cpuAmount()(Scala and Python);TaskContext.cpus()is deprecated anddocuments its ceiling behavior.
spark.task.cpus <= 0.5, recovery executors acceptmore than one concurrent task; a warning is logged and the behavior is documented.
How was this patch tested?
New and updated unit tests:
SparkContextSuite: config bounds/precision matrix (accepts1.5,1e-9,Int.MaxValue,trailing zeros; rejects
0,-1,4e-10,Int.MaxValue + 1,1e100000000,1e1000000000, 10 decimal places).ResourceProfileSuite: fractional profiles (0.7/1.5/2.5) are valid, sub-1e-9amounts rejected,
numTasksBasedOnCoresclamp behavior; existing custom-resourcevalidation tests unchanged.
TaskSchedulerImplSuite: fractional scheduling end-to-end (0.5,0.2, and1.5on a4-core offer launching exactly 2 tasks),
calculateAvailableSlotssaturation.BasicExecutorFeatureStepSuite/ExecutorPodsAllocatorSuite(K8s): recovery-mode envceiling (
0.5 -> 1,1.5 -> 2,2.5 -> 3) and the one-time isolation warning.test_taskcontext.py(cpuAmount()for whole and fractional configs),test_resources.py(fractional amounts preserved inTaskResourceRequests).Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Fable 5)