Latest     About     Archive

Is My Spark App Over-Provisioned? Core Busyness and Peak Memory (Spark App Doctor, Part 1)

Is My Spark App Over-Provisioned? Core Busyness and Peak Memory (Spark App Doctor, Part 1)

Spark over-provisioning cheat sheet: the two ratios (slot utilisation and peak memory headroom), where each lives in the Spark UI and the REST API, how executor memory is laid out, three shapes of idle time, a verdict matrix for what to cut, the measuring script, and common mistakes

Click to open full size.

Two ratios tell you whether a Spark app is over-provisioned. How much of the core time you paid for ran tasks, and how much of the memory you asked for did the executors actually touch. Both are already recorded by Spark, in the UI and in the REST API, and you can compute them for any finished app in a few seconds.

This is part 1 of a three-part series on judging a Spark app from its own metrics:

Part Lens Question
1 (this post) Waste Did we pay for resources we didn’t use?
2 Pathology Why is it slow, and would more executors even help?
3 Regression Is it getting worse, and how do I check this automatically?

The Scenario

A nightly batch job that nobody has looked at since it was first sized.

Assumption Value
Executors 40, fixed (dynamic allocation off)
Per executor 4 cores, spark.executor.memory=16g
Wall time 60 min
Allocated core time 40 × 4 × 1 h = 160 core-hours
Sum of task time 28.8 core-hours
Time with no stage running 14 min
GC time 3% of task time
Peak heap per executor 4.3 GiB median, 5.3 GiB on the busiest

Illustrative figures: assumed, not measured.

From those numbers: 18% of the core time ran tasks, and the busiest executor used about a third of its heap. This post shows where each number comes from, and what to cut.


The Two Numbers

Number Formula What it tells you
Slot utilisation Σ task time ÷ Σ (executor uptime × executor cores) How much of the core time you held actually ran tasks
Peak heap headroom max peak heap used ÷ configured heap (spark.executor.memory) How much of the heap you asked for was ever touched

Everything you need is in three REST endpoints. They are served by the live UI on port 4040 while the app runs, and by the History Server on port 18080 after it finishes:

APP=http://localhost:18080/api/v1/applications/<app-id>   # add /<attempt-id> on YARN cluster mode

curl -s $APP                  # start/end time, completed flag
curl -s $APP/allexecutors     # active AND dead executors: cores, task time, peaks
curl -s $APP/environment      # spark.executor.memory and every other setting

Use /allexecutors, not /executors. The second one lists only active executors, so any executor removed during the run (by dynamic allocation or a failure) is missing from it.


How Each Number Is Built

1. Slot utilisation: core time held vs core time used

An executor with 4 cores holds 4 task slots from the moment it is added until it is removed. Each slot either runs a task or sits idle. Slot utilisation is the share of slot-time that ran tasks.

exec 1 exec 2 no stage running (driver work) addTime removeTime / app end task running idle slot (you still pay)
Each row is one task slot. Utilisation = blue area ÷ total box area.

The fields, all on each /allexecutors entry:

Field Meaning (from the Spark docs) Used for
totalCores Number of cores available in this executor Slots per executor
addTime, removeTime When the executor joined and left (no removeTime if it lived to the end) Uptime
totalDuration Elapsed time the JVM spent executing tasks, in ms Busy slot-time
totalGCTime Elapsed time spent in GC, summed, in ms Sanity check on memory pressure

totalDuration is the sum of task durations on that executor. Four tasks running at once for 10 seconds add 40 seconds, which is why you divide it by uptime × cores and not by uptime alone.

Skip the entry with "id": "driver". It reports the driver, not a worker.

2. Where idle slot time comes from

A low utilisation number does not tell you why the slots were idle. There are three common shapes, and only the first two are fixed by sizing.

1. Too many slots for the work         2. Long tail                           3. Driver gaps
   (every stage)                          (end of a stage)                       (between stages)

slot 1 |██████░░░░░░░░░░|              slot 1 |████████░░░░░░░░|              slot 1 |████░░░░░░░░████|
slot 2 |████░░░░░░░░░░░░|              slot 2 |███████░░░░░░░░░|              slot 2 |███░░░░░░░░░████|
slot 3 |░░░░░░░░░░░░░░░░|              slot 3 |█████████░░░░░░░|              slot 3 |████░░░░░░░░███░|
slot 4 |░░░░░░░░░░░░░░░░|              slot 4 |████████████████|              slot 4 |██░░░░░░░░░░████|
        fewer tasks than slots                 one slow task holds the stage          collect, listing files,
                                                                                      planning, Python on driver
fix: fewer executors                   fix: Part 2 (skew, partitioning)       fix: code, not cluster size

The fastest way to tell them apart is the Event Timeline on the Jobs page. It shows executors being added and removed alongside each job and stage. Gaps between stage bars are shape 3. The script below also measures shape 3 directly: wall time minus the time covered by at least one stage.

3. Peak memory: what the executor actually touched

An executor’s memory is several nested regions, and Spark reports a peak for most of them. Picking the right denominator is where most over-provisioning analysis goes wrong.

Container = spark.executor.memory + memoryOverhead (+ offHeap.size + pyspark.memory) 300 MiB user memory(1 − memory.fraction) storage(cache) execution(shuffle, join, sort) overheadJVM native, Python unified region M ≈ maxMemory JVM heap = spark.executor.memory JVMHeapMemory ............. whole heap ← divide by spark.executor.memory OnHeapStorageMemory ....... storage part of M OnHeapExecutionMemory ..... execution part of M JVMOffHeapMemory .......... JVM non-heap (in overhead) ProcessTreePythonRSSMemory Python workers (needs processTreeMetrics)
Region sizes are not to scale. M is a fraction (default 0.6) of heap minus 300 MiB.

Unless you set spark.executor.memoryOverhead directly, overhead is executor memory × spark.executor.memoryOverheadFactor, with a minimum. The factor defaults to 0.10, or 0.40 for non-JVM jobs on Kubernetes.

Peak metric (peakMemoryMetrics.*) Covers Compare against
JVMHeapMemory Everything on the heap spark.executor.memory
OnHeapExecutionMemory Shuffle, join and sort buffers maxMemory (the unified region)
OnHeapStorageMemory Cached blocks maxMemory
JVMOffHeapMemory JVM non-heap memory memoryOverhead
ProcessTreeJVMRSSMemory Resident memory of the executor JVM process The container size
ProcessTreePythonRSSMemory Resident memory of PySpark workers memoryOverhead or spark.executor.pyspark.memory

The two ProcessTree* rows report 0 unless spark.executor.processTreeMetrics.enabled=true and the node has a /proc filesystem. Turn it on for PySpark jobs; without it, Python worker memory is invisible.

In the scenario, the unified region is about (16 GiB − 300 MiB) × 0.6 ≈ 9.4 GiB. A 5.3 GiB heap peak is 33% of the heap but 56% of maxMemory. Dividing by the wrong number makes the heap look much fuller than it is.

4. Where each number lives

What Spark UI REST (/api/v1/applications/<id>/…)
Cores per executor Executors tab → Cores allexecutors[].totalCores
Executor lifetime Executors tab → Add Time, Remove Time; Jobs → Event Timeline addTime, removeTime
Task time and GC Executors tab → Task Time (GC Time) totalDuration, totalGCTime
Peak heap Executors tab → Show Additional Metrics → Peak JVM Memory OnHeap / OffHeap peakMemoryMetrics.JVMHeapMemory, .JVMOffHeapMemory
Peak execution memory Same menu → Peak Execution Memory OnHeap / OffHeap; per task on the stage page .OnHeapExecutionMemory
Peak cache memory Same menu → Peak Storage Memory OnHeap / OffHeap .OnHeapStorageMemory
Why an executor left Same menu → Exec Loss Reason removeReason
Configured memory and cores Environment tab → Spark Properties environment.sparkProperties
Stage start and end Stages tab stages[].submissionTime, .completionTime

The peak memory columns are hidden by default. Tick them under Show Additional Metrics at the top of the Executors tab.

The Executors tab already flags one problem for you: the Task Time (GC Time) cell is shaded red when GC time is over 10% of task time.

What about metrics.json? The driver also serves /metrics/json (the MetricsServlet sink, on by default). It holds the driver’s own metric sources, such as the DAG scheduler, the block manager and the dynamic allocation targets. It is not the place to read per-executor busyness or peaks. Those come from the REST API above, or from the Prometheus-format endpoint /metrics/executors/prometheus, which the docs mark experimental.


Measuring It

This script reads a finished or running app and prints both numbers, plus how much of the wall time had no stage running.

#!/usr/bin/env python3
"""Usage: python3 overprov.py http://localhost:18080/api/v1/applications/<app-id>"""
import json, sys, urllib.request
from datetime import datetime, timezone
from statistics import median

BASE = sys.argv[1].rstrip("/")
UNITS = {"k": 2**10, "m": 2**20, "g": 2**30, "t": 2**40}


def get(path=""):
    with urllib.request.urlopen(BASE + path) as r:
        return json.load(r)


def ts(s):  # Spark REST dates look like 2026-10-28T09:00:00.000GMT
    return datetime.strptime(s.replace("GMT", "+0000"), "%Y-%m-%dT%H:%M:%S.%f%z")


def to_bytes(s):  # "16g", "512m"; a bare number means MiB
    s = s.strip().lower().removesuffix("b")
    return int(float(s[:-1]) * UNITS[s[-1]]) if s[-1] in UNITS else int(s) * 2**20


attempt = get()["attempts"][-1]
start = ts(attempt["startTime"])
end = ts(attempt["endTime"]) if attempt["completed"] else datetime.now(timezone.utc)

execs = [e for e in get("/allexecutors") if e["id"] != "driver"]
slot_ms = busy_ms = gc_ms = 0
for e in execs:
    gone = ts(e["removeTime"]) if e.get("removeTime") else end
    slot_ms += (gone - ts(e["addTime"])).total_seconds() * 1000 * e["totalCores"]
    busy_ms += e["totalDuration"]
    gc_ms += e["totalGCTime"]

# Wall time covered by at least one stage; the rest is driver-side work.
spans = sorted((ts(s["submissionTime"]), ts(s["completionTime"]))
               for s in get("/stages") if "submissionTime" in s and "completionTime" in s)
covered, cur_s, cur_e = 0.0, None, None
for s, e in spans:
    if cur_e is None or s > cur_e:
        if cur_e is not None:
            covered += (cur_e - cur_s).total_seconds()
        cur_s, cur_e = s, e
    else:
        cur_e = max(cur_e, e)
if cur_e is not None:
    covered += (cur_e - cur_s).total_seconds()
wall = (end - start).total_seconds()

props = dict(get("/environment")["sparkProperties"])
heap = to_bytes(props.get("spark.executor.memory", "1g"))
peaks = [e.get("peakMemoryMetrics", {}).get("JVMHeapMemory", 0) for e in execs]
py = max(e.get("peakMemoryMetrics", {}).get("ProcessTreePythonRSSMemory", 0) for e in execs)

print(f"executors (all time)      {len(execs)}")
print(f"slot utilisation          {busy_ms / slot_ms:.0%}")
print(f"idle core-hours           {(slot_ms - busy_ms) / 3.6e6:,.1f}")
print(f"wall time with no stage   {1 - covered / wall:.0%}")
print(f"GC / task time            {gc_ms / busy_ms:.0%}")
print(f"peak heap / heap  max     {max(peaks) / heap:.0%}")
print(f"peak heap / heap  median  {median(peaks) / heap:.0%}")
print(f"peak Python RSS           {py / 2**30:.1f} GiB" if py else
      "peak Python RSS           n/a (processTreeMetrics off, or not PySpark)")

For the scenario it would print something like:

executors (all time)      40
slot utilisation          18%
idle core-hours           131.2
wall time with no stage   23%
GC / task time            3%
peak heap / heap  max     33%
peak heap / heap  median  27%
peak Python RSS           n/a (processTreeMetrics off, or not PySpark)

Illustrative output: computed from the assumed scenario, not from a real run.

It needs a real cluster. In local mode the driver is the only executor, so the list is empty after the driver is filtered out.


How the Pieces Fit

flowchart LR
    A["/allexecutors
cores, uptime,
task time, peaks"] --> U["Slot utilisation"] A --> M["Peak heap headroom"] E["/environment
spark.executor.memory"] --> M S["/stages
submission, completion"] --> G["Wall time with
no stage running"] U --> V{"Verdict"} M --> V G --> V V --> C1["Cut executors"] V --> C2["Cut memory per executor"] V --> C3["Fix driver code"] V --> C4["Sized right:
see Part 2 if slow"]

Reading the Result

The thresholds below are starting points that I use, not Spark rules. Tune them on your own jobs.

Slot utilisation Peak heap / heap Verdict What to change
Low (under ~40%) Low (under ~50%) Too many executors, too much memory each Cut executor count first, then memory
Low High (over ~80%) Memory-bound, cores idle Fewer cores per executor at the same memory, or a memory-heavier instance type
High Low Cores busy, heap mostly empty Less memory per executor, or more cores at the same memory
High High About right If it is still slow, the cause is in Part 2
Low, with a large no-stage share any Driver-bound Fix the driver-side code; resizing won’t help

The scenario lands in the first row, with a sizeable driver gap. Cutting executors is safe. The 14 minutes of driver time won’t shrink either way, so expect the wall time to stay close to an hour.


Where It Hurts

Watch-out Why it matters What to do
Dividing heap peak by maxMemory maxMemory is the storage/execution region, not the heap Divide JVMHeapMemory by spark.executor.memory
Peaks are sampled Executor metrics are updated on each heartbeat (spark.executor.heartbeatInterval, default 10s), so short spikes can be missed Set spark.executor.metrics.pollingInterval (ms) for faster polling, and keep a safety margin
Python memory not reported ProcessTree* metrics are 0 unless enabled spark.executor.processTreeMetrics.enabled=true on PySpark jobs
Using app duration as executor uptime With dynamic allocation, executors come and go Use each executor’s addTime and removeTime, as the script does
Using /executors It lists only active executors, so removed ones vanish Use /allexecutors
Old stages missing The UI and REST API keep up to spark.ui.retainedStages stages (default 1000) Read finished apps from the History Server; raise the limit for long apps
Cached data pins executors With dynamic allocation, executors holding cached data are never removed by default Check spark.dynamicAllocation.cachedExecutorIdleTimeout, and unpersist when done
Skew looks like over-provisioning One long task leaves the other slots idle (shape 2) Check the stage’s max vs median task time before cutting (Part 2)

When Not to Cut

Situation Why low utilisation can be fine
Latency-bound streaming Spare slots absorb bursts; the goal is batch latency, not busy cores
Spot or pre-emptible nodes Spare capacity covers lost executors without missing the deadline
Shared cluster you don’t pay for per app Idle slots go to other apps; the waste is smaller than it looks
Wall time is a hard SLA Cut in steps and stop when wall time moves, not when utilisation looks good

Right-Sizing Safely

flowchart TD
    A["Measure baseline:
wall time, utilisation,
peak heap, spill, GC"] --> B{"No-stage share large?"} B -- yes --> B1["Fix driver code first"] B -- no --> C{"Utilisation low?"} C -- yes --> D["Cut executors by a step"] C -- no --> E{"Peak heap low?"} D --> R["Rerun on the same input"] E -- yes --> F["Cut memory per executor by a step"] E -- no --> Z["Done: sized about right"] F --> R R --> Q{"Wall time, spill
and GC still OK?"} Q -- yes --> C Q -- no --> P["Go back one step and stop"]

Change one dimension at a time. If you change executor count and memory together and the job slows down, you won’t know which change caused it.


Checking It Works

  1. Run the script on the baseline and save the output with the input size for that run.
  2. Cut one step (for example, half the executors) and rerun on the same input, or on an input of the same size.
  3. Compare four things: wall time, slot utilisation, peak heap headroom, and disk spill on the Stages tab. Utilisation should rise. Wall time and spill should stay flat.
  4. Check the Executors tab for red GC cells and for Exec Loss Reason entries. Either one means you cut too far.

Sources

Checked on 2026-10-28.

Source Used for
Spark Monitoring and Instrumentation (4.2.0) REST endpoints and ports, /executors vs /allexecutors, executor field meanings, peakMemoryMetrics, heartbeat updates, process tree conditions, MetricsServlet, Prometheus endpoint, logStageExecutorMetrics
Spark Configuration memoryOverhead and container size, metrics.pollingInterval, processTreeMetrics.enabled, heartbeatInterval, ui.retainedStages
Spark Tuning Guide Unified region M, spark.memory.fraction default 0.6, the 300 MiB reserve
Spark Job Scheduling Dynamic allocation idle removal and cached executors
Spark Web UI Event Timeline, stage task metrics
Executors page template and script (v3.5.3) Executors tab column names, Show Additional Metrics options, the 10% GC shading
api.scala and AppStatusListener.scala (v3.5.3) ExecutorSummary fields; totalDuration is the sum of task durations