[GLUTEN-12616][CORE] Guard SparkResourceUtil.getTaskSlots against non-positive task cpus - #12617
Conversation
…-positive task cpus getTaskSlots computed executorCores / taskCores with no guard. GlutenDriverPlugin.init reads the slot count and divides by it (and four other callers use it as a denominator), so two invalid configs crash there with an opaque ArithmeticException: - spark.task.cpus > spark.executor.cores makes the quotient 0, so a caller's offHeapSize / taskSlots throws. - spark.task.cpus = 0 makes getTaskSlots itself throw; it reads the value with raw conf.getInt, which bypasses Spark's CPUS_PER_TASK.checkValue(_ > 0). The plugin runs before createTaskScheduler, so Gluten throws before Spark's own validation (validateTaskCpusLargeEnough / CPUS_PER_TASK) can report the real misconfiguration with a clear message. Return a single slot for taskCores <= 0 and floor the quotient at 1 otherwise, deferring to Spark for the error text. Add SparkResourceUtilSuite covering both guarded branches and the normal paths.
|
Run Gluten Clickhouse CI on x86 |
There was a problem hiding this comment.
Pull request overview
This PR hardens SparkResourceUtil.getTaskSlots so Gluten plugin initialization never triggers a divide-by-zero or returns 0 task slots for invalid spark.task.cpus settings, letting Spark’s own validation surface the proper user-facing error.
Changes:
- Guard
getTaskSlotsagainstspark.task.cpus <= 0by returning 1. - Floor
executorCores / taskCoresat 1 to avoid returning 0 whenspark.task.cpus > executor cores. - Add a new
SparkResourceUtilSuitecovering the guarded behaviors and normal cases.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| gluten-core/src/main/scala/org/apache/spark/util/SparkResourceUtil.scala | Adds guards to prevent / by zero and zero-slot results from invalid CPU-per-task configs. |
| gluten-core/src/test/scala/org/apache/spark/util/SparkResourceUtilSuite.scala | Introduces unit tests for getTaskSlots behavior across invalid and typical configurations. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| test("getTaskSlots returns one core per slot by default") { | ||
| val conf = new SparkConf(false).set("spark.master", "local[1]") | ||
| assert(SparkResourceUtil.getTaskSlots(conf) == 1) | ||
| } |
There was a problem hiding this comment.
Good catch. local[1] didn't actually distinguish the default behavior since 1 slot comes out regardless of the logic under test. Changed it to local[8] with spark.task.cpus unset, asserting 8 slots, so it now validates one-slot-per-core when task cpus defaults to 1.
…e master The default-behavior test used local[1], which yields 1 slot regardless of the logic under test. Use local[8] with spark.task.cpus unset and assert 8 slots, so it validates one-slot-per-core when task cpus defaults to 1.
|
Run Gluten Clickhouse CI on x86 |
| val executorCores = SparkResourceUtil.getExecutorCores(conf) | ||
| val taskCores = conf.getInt("spark.task.cpus", 1) | ||
| executorCores / taskCores | ||
| if (taskCores <= 0) { |
There was a problem hiding this comment.
the task cores has been checked while setting the configs, do we need this check again?
There was a problem hiding this comment.
The check isn't redundant, for two reasons.
First, on the Spark versions Gluten targets, spark.task.cpus isn't validated at set time. The checkValue(_ > 0) on CPUS_PER_TASK was only added in SPARK-55757, which ships in Spark 4.2. I decompiled spark-core 3.5.5 to confirm: its CPUS_PER_TASK is ConfigBuilder("spark.task.cpus").version("0.5.0").intConf.createWithDefault(1), with no checkValue. So on Spark 3.3 through 4.1, nothing rejects a non-positive value before we read it.
Second, even on 4.2+ where the check exists, it only fires on a typed conf.get(CPUS_PER_TASK). The first such read is in SparkContext.createTaskScheduler, which runs after PluginContainer init. getTaskSlots is reached through that plugin init (GlutenDriverPlugin.init then setPredefinedConfigs), and it reads the value raw via conf.getInt("spark.task.cpus", 1), which skips the ConfigEntry. So we read the raw value before Spark validates it, on every version.
I verified this on a real driver init (new SparkContext with spark.plugins=org.apache.gluten.GlutenPlugin): spark.task.cpus=0 throws ArithmeticException: / by zero inside setPredefinedConfigs, and a negative value silently produces negative task slots and negative per-task off-heap budgets while the context still starts.
Based on your comment I switched the fix from coercing to 1 to failing fast with require(taskCores > 0, ...). A non-positive value is a real misconfiguration that should surface rather than be silently rewritten, which also matches the direction Spark took in 4.2. Just pushed the update, along with a refreshed PR description and issue rationale.
Coercing a non-positive spark.task.cpus to a single slot hid an invalid configuration. Spark treats a non-positive value as illegal (checkValue(_ > 0) on CPUS_PER_TASK, added in SPARK-55757 for 4.2+), so getTaskSlots should reject it rather than silently substitute 1. getTaskSlots reads spark.task.cpus via raw conf.getInt during driver plugin init, which bypasses Spark's checkValue and runs before Spark validates the value. On Spark < 4.2 that positivity check does not exist at all. Without a guard, a zero throws an opaque "/ by zero" ArithmeticException and a negative silently produces negative task slots and off-heap budgets. require(_ > 0) surfaces the misconfiguration with a clear message on every supported Spark version.
|
Run Gluten Clickhouse CI on x86 |
| // spark.task.cpus is read raw here, which bypasses Spark's own checkValue(_ > 0) (and on | ||
| // Spark < 4.2 that positivity check does not exist at all). getTaskSlots runs during driver | ||
| // plugin init, before Spark validates the value, so fail fast on a non-positive setting rather | ||
| // than dividing by it: a zero would throw an opaque "/ by zero" ArithmeticException and a | ||
| // negative would silently produce negative task slots and off-heap budgets. | ||
| val taskCores = conf.getInt("spark.task.cpus", 1) | ||
| executorCores / taskCores | ||
| require(taskCores > 0, s"spark.task.cpus should be positive, but was $taskCores") |
| test("getTaskSlots fails fast when task cpus is zero") { | ||
| // spark.task.cpus is read via raw conf.getInt, which bypasses Spark's checkValue(_ > 0) (a | ||
| // check that only exists on Spark >= 4.2), so a zero value must not reach the division. Fail | ||
| // fast with a clear message instead of an opaque "/ by zero" ArithmeticException. | ||
| val conf = new SparkConf(false) | ||
| .set("spark.master", "local[8]") | ||
| .set("spark.task.cpus", "0") | ||
| val e = intercept[IllegalArgumentException](SparkResourceUtil.getTaskSlots(conf)) | ||
| assert(e.getMessage.contains("spark.task.cpus should be positive")) | ||
| } |
|
Thank you @jackylee-ch |
What changes were proposed in this pull request?
SparkResourceUtil.getTaskSlotscomputedexecutorCores / taskCoreswith no guard, and callers divide by its result:GlutenDriverPlugin.setPredefinedConfigsdoesoffHeapSize / taskSlots, andMemoryTargets,ColumnarShuffleWriter, and the Celeborn and Uniffle writers use it as a denominator too.spark.task.cpus=0madegetTaskSlotsitself throwArithmeticException: / by zero. A negative value was worse: it produced a negative slot count, so per-task off-heap budgets came out negative and the driver started anyway.spark.task.cpus > spark.executor.coresmade the integer division yield 0, so a caller'soffHeapSize / taskSlotsthrew.Gluten reads this value before Spark can reject it.
getTaskSlotsruns duringPluginContainerinit inSparkContext, beforecreateTaskScheduler, and it reads the value with rawconf.getInt, which skips theConfigEntry. Spark'sCPUS_PER_TASK.checkValue(_ > 0)also only exists from Spark 4.2 (SPARK-55757); on 3.3 through 4.1 nothing validates it at all. There is no later Spark check to defer to, sogetTaskSlotsnow fails fast withrequire(taskCores > 0, ...). Coercing the value to 1 would hide the misconfiguration and size every off-heap budget for a single slot.The
task.cpus > executor.corescase is handled differently. That is a cross-config relationship Spark validates with its own dedicated message (validateTaskCpusLargeEnough), sogetTaskSlotsfloors the quotient at 1 to keep init from dividing by zero first, and leaves the reporting to Spark.How was this patch tested?
Added
SparkResourceUtilSuitewith five tests:task.cpus=0andtask.cpus=-2each fail withIllegalArgumentExceptionnaming the config,task.cpusgreater than executor cores floors to one slot,8 / 2gives 4, and the default is one slot per core (asserted onlocal[8]so the default path is distinguishable). The three non-happy-path tests fail on the unfixed code and pass after the fix.Closes #12616