SPARK

Tuning memory and executors

Spark performance often comes down to one thing, the way how you configure your executors and memory. The same code can run in 5 minutes or 50 minutes depending on how many executors you have, how much memory each one gets, and how memory is split between execution and storage.

What we're doing

You'll learn how executors, cores, and memory work together to determine job performance, understand Spark's internal memory model, and see how different configurations impact a real workload.

Step 1: Executors, cores, memory

Every Spark job's performance comes down to three settings you control:

  • Number of executors — how many worker processes run your job
  • Cores per executor — how many tasks each executor can run in parallel
  • Memory per executor — how much RAM each executor has to work with

Step 2: How executors and cores relate to parallelism

Parallelism in Spark is determined by two multiplied numbers:

total parallel tasks = number of executors × cores per executor

Rules of thumb:

  • Number of partitions should be at least 2x to 4x the total parallel task capacity, so the scheduler always has work ready
  • Cores per executor above 5 often hits diminishing returns because JVM garbage collection gets slower on very wide executors
  • More small executors give better parallelism and easier failure recovery. Fewer large executors give better memory efficiency for wide operations

Step 3: The memory model — where each executor's RAM actually goes

Every executor has a fixed amount of memory, but that memory is split into regions with specific purposes.

The default breakdown of an executor's memory:

  • Reserved memory (~300 MB) — for Spark internal metadata. Untouchable.
  • User memory (~40% of the rest) — for your Python code, UDFs, and any data structures you create outside DataFrames
  • Execution + storage memory (~60% of the rest) — the "unified region" that Spark uses for shuffles, joins, sorts, and cached DataFrames

Execution and storage share a single pool called the unified memory region:

  • Execution memory — used for in-flight operations like shuffles, joins, sorts. Non-cached, gets released after each operation.
  • Storage memory — used for cached DataFrames (from .cache() or .persist())

Step 4: Common sizing patterns

There are two main strategies for sizing executors, each with tradeoffs.

Small and many — lots of executors, each with modest resources (say 2-3 cores, 4 GB memory).

Large and few — fewer executors, each big (say 5-6 cores, 20+ GB memory).

Step 5: Off-heap and overhead memory

Executor overhead memory — RAM used by the JVM outside the heap, for things like network buffers, VM internals, and native code. Default is around 10% of executor memory, minimum 384 MB. When you request 8 GB per executor, YARN or Kubernetes actually reserves ~8.8 GB. If you don't have enough overhead, containers get killed during heavy shuffles.

Off-heap memory — memory allocated outside the JVM heap for storing serialized objects. Can be enabled with:

  • spark.memory.offHeap.enabled = true
  • spark.memory.offHeap.size = 2g

Step 6: See it on a real job

Let's actually change executor settings and watch the impact.

pyspark --master spark://localhost:7077 \
  --executor-cores 1 \
  --executor-memory 512m

Now build a workload:

from pyspark.sql.functions import col, rand, sum as spark_sum
import time

df = spark.range(0, 5000000).select(
    col("id"),
    (rand() * 100).cast("int").alias("category"),
    (rand() * 1000).alias("value")
).groupBy("category").agg(spark_sum("value").alias("total"))

start = time.time()
df.count()
print(f"With 1 core / 512m: {time.time() - start:.2f} seconds")

Now exit and restart PySpark with different settings:

exit()
pyspark --master spark://localhost:7077 \
  --executor-cores 2 \
  --executor-memory 2g

Run the same workload:

from pyspark.sql.functions import col, rand, sum as spark_sum
import time

df = spark.range(0, 5000000).select(
    col("id"),
    (rand() * 100).cast("int").alias("category"),
    (rand() * 1000).alias("value")
).groupBy("category").agg(spark_sum("value").alias("total"))

start = time.time()
df.count()
print(f"With 2 cores / 2g: {time.time() - start:.2f} seconds")

Compare the two timings. You should see meaningful differences — sometimes 2x or more depending on your cluster.

Step 7: Rules of thumb for production tuning

Practical starting points when you're tuning a real job:

  • Cores per executor: Start with 4. Go to 5 max. Below 3 wastes coordination. Above 5 hits GC issues.
  • Memory per executor: 4-8 GB is a safe range for typical jobs. Go higher only for jobs with heavy caching or large joins.
  • Overhead memory: Leave the default unless you see container-killed errors. Then bump spark.executor.memoryOverhead up.
  • Number of partitions: Aim for 2-4x total parallel task capacity. Use .repartition() or .coalesce() to adjust.
  • Off-heap: Enable for jobs over 20 GB per executor to reduce GC pauses.

What's next

Now go and try this out in a live environment — boot a fresh cluster and play with the manifests above.

Start Spark
Spec 2 CPU / 4 GiB ·Disk 25 GiB
Sign in to launch this environment
Required 1 VM · 2 CPU · 4 GB · 25 GiB disk
Available 1 VM · 1 CPU · 2 GB · 10 GiB disk
Sign in