[ 
https://issues.apache.org/jira/browse/SPARK-61258?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Purushotham Pushpavanthar updated SPARK-61258:
----------------------------------------------
    Description: 
h3. SPIP: Ratio-constrained heterogeneous executors on Kubernetes

Full design doc (comments open): 
https://docs.google.com/document/d/1a5zFhMnTz7Tftb7fZgnri58RdmixQcHamEQieva5l5s/edit

h4. Q1. What are you trying to do?

I want a Spark job on Kubernetes to run executors of different sizes, as long 
as every executor keeps the same memory per core. Today one job means one pod 
shape. On a cluster with mixed node sizes, or where spot capacity comes and 
goes, that single shape either strands capacity or pushes everyone down to 
small executors.

The idea: instead of one fixed size the user gives a ladder, e.g. 
{{spark.kubernetes.executor.shape.ladder=16,8,4}}. Memory per core still comes 
from {{spark.executor.memory}} / {{spark.executor.cores}}. The driver asks for 
the biggest shape first. If Kubernetes leaves that pod Unschedulable for 
insufficient cpu/memory past a timeout, the driver swaps it for pods one rung 
down that add up to the same number of cores, and later probes upward again 
with backoff.

The task scheduler does not see any of this. {{spark.task.cpus}}, memory per 
core and per-task custom resources stay uniform; only cores per executor and 
total memory per executor float. Everything outside the k8s allocator keeps 
counting executors in units of the reference shape, so 
{{spark.dynamicAllocation.*}} and {{SparkContext.requestTotalExecutors}} mean 
exactly what they mean today.

h4. Q2. What problem is this NOT designed to solve?

* Different memory/core ratios inside one ResourceProfile. That is what a 
second profile is for.
* Resizing a running executor. Heap and registered cores are fixed at launch.
* Picking node types, zones, or spot vs on-demand. I deliberately leave that 
with the cluster (GKE compute classes, Karpenter NodePools, cluster-autoscaler).
* Reading node capacity from the driver. Possible later as an optional 
strategy, but it needs node RBAC and I do not want that in Phase 1.
* YARN / Standalone. The core model is cluster-manager neutral, but only the 
Kubernetes {{direct}} pods allocator gets an implementation. StatefulSet and 
Deployment allocators reject the config.

h4. Q3. How is it done today, and what are the limits?

Reading the code, homogeneity is an accident of the allocation path, not 
something the scheduler needs. {{ExecutorAllocationManager}} targets an 
executor count per profile. {{ExecutorPodsAllocator}} reconciles counts per 
rpId and never looks at pod size, nor at a Pending pod's {{PodScheduled=False}} 
condition. {{BasicExecutorFeatureStep}} derives the pod shape purely from the 
profile. Meanwhile {{RegisterExecutor}} already carries per-executor cores, so 
the task scheduler copes with mixed cores right now. What does not work is 
faking it with one sibling profile per size, because 
{{ResourceProfileManager.canBeScheduled}} needs an exact rpId match.

So people run small homogeneous executors and eat the per-JVM cost: the 384 MiB 
overhead floor on each one, smaller unified memory pools, N x M shuffle 
connections, one broadcast copy per JVM, slower decommission on spot.

h4. Q4. What is new and why will it succeed?

* The invariant is memory per core, not pod size. That makes it an allocator 
and profile change, not a scheduler change.
* One ResourceProfile carries the ladder under one rpId, so {{canBeScheduled}} 
is untouched. Phase 1 is config-only: no ResourceProfile API change, no 
scheduler backend contract change. The k8s backend converts the executor-count 
target to cores on its own.
* Kubernetes tells the driver what fits through the pod watch Spark already 
runs (new {{PodUnschedulable}} state). No new RBAC.
* On clusters with compute classes the two decisions compose: the platform 
picks node and capacity type, Spark picks pod size. I looked at node-aware 
packing in the driver (approach B in the doc) and dropped it. It duplicates the 
platform's job, needs cluster-scoped node RBAC, and fights consolidation.

h4. Q5. Who cares?

Platform teams running Spark on mixed or spot fleets: better node fill, better 
spot obtainability. Job owners: tune one ratio instead of a size per cluster, 
and per-task memory stays comparable across clusters. Operators of autoscaled 
clusters: this cooperates with the autoscaler instead of going around it.

h4. Q6. Risks

The ones I know about, and what the design does about each:

* Downshift fires only on an insufficient cpu/memory scheduler message, never 
on taint, affinity or PVC causes.
* The upshift probe backs off exponentially and runs under a low PriorityClass, 
so it cannot trigger an autoscaler scale-up on its own.
* Downshift timeout racing autoscaler provisioning: read the 
{{TriggeredScaleUp}} / {{NotTriggerScaleUp}} / {{Nominated}} pod events before 
giving up on a rung.
* Pod churn on downshift: bounded by {{maxPendingPods}}, reuses the existing 
excess-pending deletion path.
* Executor-side conf readers seeing the app-level {{spark.executor.memory}} / 
{{cores}}: the executor backend writes the pod's real values into its local 
SparkConf.
* Overhead floor squeezing the small rungs: per-shape split plus a warning.
* Idle removal in {{ExecutorAllocationManager}} counts executors, so under a 
ladder it would never release an idle big one. Weighting removal by reference 
units is the one core change in Phase 1.
* UI / history server: additive event fields only.

h4. Q7. How long will it take?

Roughly two months for Phase 1, in four PRs: ladder allocation and per-rung 
sizing (prototyped, see the comment below); scheduler backend accounting 
(registered-cores gate, kill-path weights, executor cores from the launch 
argument, metrics) together with the unit-weighted removal in EAM; upshift 
probe and autoscaler events; docs. Apart from that one EAM change everything 
lives in the Kubernetes module. Prerequisites SPARK-61267 and SPARK-61268 are 
already open. A ResourceProfile API for ladders on custom profiles is Phase 2 
and not part of this vote.

h4. Q8. Exams

Mid-term: a single-rung ladder behaves byte-for-byte like today in 
{{TaskSchedulerImplSuite}} and {{ExecutorAllocationManagerSuite}}, and 
executors report pod values in SparkEnv.conf and the Executors tab. Final: 
{{ExecutorPodsAllocatorSuite}} downshift and upshift scenarios; a k8s 
integration test on a cluster with two node sizes where total cores land on 
target and every executor has memory/cores equal to the ratio; StatefulSet and 
Deployment allocators fail fast.

Design baseline: apache/spark master at 4686647b3a1 (2026-11-09).


  was:
h3. SPIP: Ratio-constrained heterogeneous executors on Kubernetes

Full SPIP document: 
https://docs.google.com/document/d/1a5zFhMnTz7Tftb7fZgnri58RdmixQcHamEQieva5l5s/edit

h4. Q1. What are you trying to do?
Let a Spark job on Kubernetes run executors of different sizes as long as every 
executor keeps the same memory per CPU core. The user states a ladder of 
executor sizes ({{spark.kubernetes.executor.shape.ladder=16,8,4}}) instead of 
one fixed size; heap per core is derived from spark.executor.memory / 
spark.executor.cores. The driver requests the largest shape first; when 
Kubernetes reports the pod Unschedulable past a timeout, the driver replaces it 
with pods one rung down that sum to the same slot count, and periodically 
probes upward again. Nothing visible to the task scheduler changes: 
{{spark.task.cpus}}, memory per core and custom resources per task stay 
uniform; only cores per executor and total memory per executor float. 
Everything outside the Kubernetes allocator keeps counting executors in 
reference-shape units, so {{spark.dynamicAllocation.*}} and 
{{SparkContext.requestTotalExecutors}} keep their meaning.

h4. Q2. What problem is this NOT designed to solve?
* Different memory-per-core ratios inside one ResourceProfile (use two 
profiles).
* Growing or shrinking a running executor (heap and registered cores are fixed 
at start).
* Choosing node types, zones or spot vs on-demand. That stays with the cluster 
(GKE compute classes, Karpenter NodePools, cluster-autoscaler).
* Reading node capacity from the driver (optional later strategy, needs node 
RBAC).
* YARN / Standalone. Core model is cluster-manager neutral; only the Kubernetes 
{{direct}} pods allocator is implemented. StatefulSet and Deployment allocators 
reject the config.

h4. Q3. How is it done today, and what are the limits?
Homogeneity is an accident of the allocation path, not a scheduler requirement. 
{{ExecutorAllocationManager}} targets an executor count per profile; 
{{ExecutorPodsAllocator}} reconciles counts per rpId and never looks at pod 
size or a Pending pod's {{PodScheduled=False}} condition; 
{{BasicExecutorFeatureStep}} derives pod shape purely from the profile. 
{{RegisterExecutor}} already carries per-executor cores, so the task scheduler 
tolerates heterogeneous cores today. {{ResourceProfileManager.canBeScheduled}} 
requires exact rpId match, so sibling profiles per size would not work. Users 
work around it with small homogeneous executors and pay per-JVM overhead (384 
MiB overhead floor, smaller unified memory pools, N x M shuffle connections, 
broadcast per JVM, slower spot decommission).

h4. Q4. What is new and why will it succeed?
* The invariant is memory per core, not pod size, so this is an allocator and 
profile change, not a scheduler change.
* One ResourceProfile carries a ladder of shapes under one rpId; 
{{canBeScheduled}} is unchanged. Phase 1 is configuration-only: no change to 
the ResourceProfile API or the scheduler backend contract; the Kubernetes 
backend converts the executor-count target to cores itself.
* Kubernetes tells the driver what fits through the pod watch Spark already has 
(new {{PodUnschedulable}} state); no new RBAC.
* On clusters with compute classes the two decisions compose: the platform 
picks node type and capacity type, Spark picks pod size. Node-aware packing in 
the driver (approach B) would duplicate the platform's job, needs 
cluster-scoped node RBAC and fights consolidation.

h4. Q5. Who cares?
Platform teams running Spark on mixed or spot fleets (higher node fill, higher 
spot obtainability), job owners (one ratio replaces per-cluster size tuning, 
per-task memory stays comparable), and operators of autoscaled clusters (the 
feature cooperates with the autoscaler instead of bypassing it).

h4. Q6. Risks
Downshift fires only on an insufficient cpu/memory scheduler message, not on 
taint, affinity or PVC causes; the upshift probe backs off exponentially and 
carries a low PriorityClass so it never triggers an autoscaler scale-up. 
Downshift timeout vs autoscaler provisioning latency (mitigated by reading 
{{TriggeredScaleUp}} / {{NotTriggerScaleUp}} / {{Nominated}} pod events); pod 
churn on downshift (bounded by {{maxPendingPods}}, reuses the excess-pending 
deletion path); executor-side conf readers seeing app-level 
{{spark.executor.memory}} / {{cores}} (executor backend writes pod values into 
its local SparkConf); overhead floor squeezing small shapes (per-shape split 
and warning); idle removal under-shooting the slot target (subtract real 
slots); UI and history server (additive event fields).

h4. Q7. How long will it take?
About two months for Phase 1 across four PRs, all inside the Kubernetes module 
plus one backend method: ladder allocation and per-rung sizing (prototyped); 
scheduler backend accounting (registered-cores gate, kill-path weights, 
executor cores from the launch argument, metrics); upshift probe and autoscaler 
events; docs. Prerequisites SPARK-61267 and SPARK-61268 are already open. The 
ResourceProfile API for ladders on custom profiles is Phase 2 and not part of 
this vote.

h4. Q8. Exams
Mid-term: a single-rung ladder behaves byte-for-byte like today in 
{{TaskSchedulerImplSuite}} and {{ExecutorAllocationManagerSuite}}; executors 
report pod values in SparkEnv.conf and the Executors tab. Final: 
{{ExecutorPodsAllocatorSuite}} downshift and upshift scenarios; k8s integration 
test on a two-node-size cluster where total slots match the target and every 
executor has memory / cores equal to the ratio; StatefulSet and Deployment 
allocators fail fast.

Design baseline: apache/spark master, commit 4686647b3a1 (2026-11-09).



> SPIP: Ratio-constrained heterogeneous executors on Kubernetes
> -------------------------------------------------------------
>
>                 Key: SPARK-61258
>                 URL: https://issues.apache.org/jira/browse/SPARK-61258
>             Project: Spark
>          Issue Type: New Feature
>          Components: Kubernetes
>    Affects Versions: 5.0.0
>            Reporter: Purushotham Pushpavanthar
>            Priority: Major
>
> h3. SPIP: Ratio-constrained heterogeneous executors on Kubernetes
> Full design doc (comments open): 
> https://docs.google.com/document/d/1a5zFhMnTz7Tftb7fZgnri58RdmixQcHamEQieva5l5s/edit
> h4. Q1. What are you trying to do?
> I want a Spark job on Kubernetes to run executors of different sizes, as long 
> as every executor keeps the same memory per core. Today one job means one pod 
> shape. On a cluster with mixed node sizes, or where spot capacity comes and 
> goes, that single shape either strands capacity or pushes everyone down to 
> small executors.
> The idea: instead of one fixed size the user gives a ladder, e.g. 
> {{spark.kubernetes.executor.shape.ladder=16,8,4}}. Memory per core still 
> comes from {{spark.executor.memory}} / {{spark.executor.cores}}. The driver 
> asks for the biggest shape first. If Kubernetes leaves that pod Unschedulable 
> for insufficient cpu/memory past a timeout, the driver swaps it for pods one 
> rung down that add up to the same number of cores, and later probes upward 
> again with backoff.
> The task scheduler does not see any of this. {{spark.task.cpus}}, memory per 
> core and per-task custom resources stay uniform; only cores per executor and 
> total memory per executor float. Everything outside the k8s allocator keeps 
> counting executors in units of the reference shape, so 
> {{spark.dynamicAllocation.*}} and {{SparkContext.requestTotalExecutors}} mean 
> exactly what they mean today.
> h4. Q2. What problem is this NOT designed to solve?
> * Different memory/core ratios inside one ResourceProfile. That is what a 
> second profile is for.
> * Resizing a running executor. Heap and registered cores are fixed at launch.
> * Picking node types, zones, or spot vs on-demand. I deliberately leave that 
> with the cluster (GKE compute classes, Karpenter NodePools, 
> cluster-autoscaler).
> * Reading node capacity from the driver. Possible later as an optional 
> strategy, but it needs node RBAC and I do not want that in Phase 1.
> * YARN / Standalone. The core model is cluster-manager neutral, but only the 
> Kubernetes {{direct}} pods allocator gets an implementation. StatefulSet and 
> Deployment allocators reject the config.
> h4. Q3. How is it done today, and what are the limits?
> Reading the code, homogeneity is an accident of the allocation path, not 
> something the scheduler needs. {{ExecutorAllocationManager}} targets an 
> executor count per profile. {{ExecutorPodsAllocator}} reconciles counts per 
> rpId and never looks at pod size, nor at a Pending pod's 
> {{PodScheduled=False}} condition. {{BasicExecutorFeatureStep}} derives the 
> pod shape purely from the profile. Meanwhile {{RegisterExecutor}} already 
> carries per-executor cores, so the task scheduler copes with mixed cores 
> right now. What does not work is faking it with one sibling profile per size, 
> because {{ResourceProfileManager.canBeScheduled}} needs an exact rpId match.
> So people run small homogeneous executors and eat the per-JVM cost: the 384 
> MiB overhead floor on each one, smaller unified memory pools, N x M shuffle 
> connections, one broadcast copy per JVM, slower decommission on spot.
> h4. Q4. What is new and why will it succeed?
> * The invariant is memory per core, not pod size. That makes it an allocator 
> and profile change, not a scheduler change.
> * One ResourceProfile carries the ladder under one rpId, so 
> {{canBeScheduled}} is untouched. Phase 1 is config-only: no ResourceProfile 
> API change, no scheduler backend contract change. The k8s backend converts 
> the executor-count target to cores on its own.
> * Kubernetes tells the driver what fits through the pod watch Spark already 
> runs (new {{PodUnschedulable}} state). No new RBAC.
> * On clusters with compute classes the two decisions compose: the platform 
> picks node and capacity type, Spark picks pod size. I looked at node-aware 
> packing in the driver (approach B in the doc) and dropped it. It duplicates 
> the platform's job, needs cluster-scoped node RBAC, and fights consolidation.
> h4. Q5. Who cares?
> Platform teams running Spark on mixed or spot fleets: better node fill, 
> better spot obtainability. Job owners: tune one ratio instead of a size per 
> cluster, and per-task memory stays comparable across clusters. Operators of 
> autoscaled clusters: this cooperates with the autoscaler instead of going 
> around it.
> h4. Q6. Risks
> The ones I know about, and what the design does about each:
> * Downshift fires only on an insufficient cpu/memory scheduler message, never 
> on taint, affinity or PVC causes.
> * The upshift probe backs off exponentially and runs under a low 
> PriorityClass, so it cannot trigger an autoscaler scale-up on its own.
> * Downshift timeout racing autoscaler provisioning: read the 
> {{TriggeredScaleUp}} / {{NotTriggerScaleUp}} / {{Nominated}} pod events 
> before giving up on a rung.
> * Pod churn on downshift: bounded by {{maxPendingPods}}, reuses the existing 
> excess-pending deletion path.
> * Executor-side conf readers seeing the app-level {{spark.executor.memory}} / 
> {{cores}}: the executor backend writes the pod's real values into its local 
> SparkConf.
> * Overhead floor squeezing the small rungs: per-shape split plus a warning.
> * Idle removal in {{ExecutorAllocationManager}} counts executors, so under a 
> ladder it would never release an idle big one. Weighting removal by reference 
> units is the one core change in Phase 1.
> * UI / history server: additive event fields only.
> h4. Q7. How long will it take?
> Roughly two months for Phase 1, in four PRs: ladder allocation and per-rung 
> sizing (prototyped, see the comment below); scheduler backend accounting 
> (registered-cores gate, kill-path weights, executor cores from the launch 
> argument, metrics) together with the unit-weighted removal in EAM; upshift 
> probe and autoscaler events; docs. Apart from that one EAM change everything 
> lives in the Kubernetes module. Prerequisites SPARK-61267 and SPARK-61268 are 
> already open. A ResourceProfile API for ladders on custom profiles is Phase 2 
> and not part of this vote.
> h4. Q8. Exams
> Mid-term: a single-rung ladder behaves byte-for-byte like today in 
> {{TaskSchedulerImplSuite}} and {{ExecutorAllocationManagerSuite}}, and 
> executors report pod values in SparkEnv.conf and the Executors tab. Final: 
> {{ExecutorPodsAllocatorSuite}} downshift and upshift scenarios; a k8s 
> integration test on a cluster with two node sizes where total cores land on 
> target and every executor has memory/cores equal to the ratio; StatefulSet 
> and Deployment allocators fail fast.
> Design baseline: apache/spark master at 4686647b3a1 (2026-11-09).



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to