Apache Spark · Part 2 of 4
  1. Handling Nulls in Spark
  2. Under the hood of a Spark job
  3. Spark Execution Modes
  4. The Life Cycle of a Spark Application ( Outside )

Under the hood of a Spark job

On this page
  1. Every Action Creates a Job
  2. Every Shuffle Starts a New Stage
  3. Every Partition Becomes a Task
  4. The Whole Picture in One Table
  5. References

Here's a small Spark program: two DataFrames, one repartitioned into 5 partitions and the other into 6, joined and summed. Open the Spark UI and one of its stages has 200 tasks. Nothing in the code says 200.

Where that number comes from is the key to reading the Spark UI:

One action creates a job. Every shuffle starts a new stage. Every partition becomes a task.

This post walks through each of those three rules with the Spark UI open, and ends with where the 200 came from.

Every Action Creates a Job#

Each application is made up of one or more Spark jobs, and a job is created for each action (count, collect, write, …). Jobs triggered from the same thread run one after another. They only run in parallel if you submit them from separate threads.

Since Spark evaluates lazily, transformations alone do nothing. If there are no actions, there are no jobs:

no-action-no-job

Call an action on the DataFrame, and a job appears:

action-job

Each job breaks down into a series of stages, and the number of stages depends on how many shuffles the job needs.

Every Shuffle Starts a New Stage#

A stage is a group of tasks that Spark can run together to perform the same operation across multiple machines. Spark tries to fit as much work as possible into one stage, but it has to start a new one whenever there's a shuffle.

A shuffle happens when Spark redistributes data across partitions or nodes in the cluster. Sorting a DataFrame, or grouping by key (which sends all records with the same key to the same node), both need one. Data physically moves between executors, so everything before the shuffle has to finish before anything after it can start. That boundary is the stage boundary.

scala> val df1 = spark.range(2, 10000000, 2)
val df1: org.apache.spark.sql.Dataset[Long] = [id: bigint]

scala> val step1 = df1.repartition(5)
val step1: org.apache.spark.sql.Dataset[Long] = [id: bigint]

scala> step1.count()
val res0: Long = 4999999

stages

One action (count), so one job, split into three stages:

  • Stage 0 is the range that creates the DataFrame. On this machine, range produced 8 partitions by default.
  • Stage 1 is the repartition. It shuffles the data into 5 partitions.
  • Stage 2 is the count itself.

Now the program from the start of this post:

Scala
val df1 = spark.range(2, 10000000, 2)
val df2 = spark.range(2, 10000000, 4)
val step1 = df1.repartition(5)
val step12 = df2.repartition(6)
val step2 = step1.selectExpr("id * 5 as id")
val step3 = step2.join(step12, ["id"])
val step4 = step3.selectExpr("sum(id)")

shuffle-partitions

shuffle-partition-viz

Stages 3 and 4 work on each DataFrame separately, and each ends at the join, which is another shuffle. The stage after the join is the one with 200 tasks.

Every Partition Becomes a Task#

A task is a unit of computation applied to a unit of data (the partition). Each task runs a stage's transformations on one partition, on a single executor. So a stage has exactly as many tasks as its data has partitions.

That's where the 200 comes from. When a DataFrame shuffle happens, the number of output partitions isn't taken from your input; it's taken from spark.sql.shuffle.partitions, which defaults to 200. The join shuffled both sides into 200 partitions, so the next stage ran 200 tasks, whether the data needed them or not.

You can change it:

Scala
spark.conf.set("spark.sql.shuffle.partitions", 50)

Here is the whole job, one row per stage and one square per task. Change the setting and run it again:

Tasks run: 0 / 228
  1. Create df1

    range() made 8 partitions on this machine

    8 tasks

  2. Create df2

    range() again, 8 partitions

    8 tasks

  3. After repartition(5)

    the shuffle wrote 5 partitions

    5 tasks

  4. After repartition(6)

    the shuffle wrote 6 partitions

    6 tasks

  5. After the join

    the join shuffle wrote spark.sql.shuffle.partitions = 200

    200 tasks

  6. Final sum

    everything is combined into 1 partition

    1 task

One action, so one job. Each row below is a stage, and each square is a task.

Change spark.sql.shuffle.partitions at the top and run it again. Only the stage after the join changes.

The Whole Picture in One Table#

Topic Key Points
App Decomposition - 1 Spark Application can have 1 or more Jobs.
- Each Job is broken into 1 or more Stages.
Stages & Tasks - Each Stage is subdivided into Tasks.
- Shuffle boundaries (data rearrangements) define when Spark moves from one Stage to the next.
Tasks & Executors - One Task runs on one Executor (it can’t move mid-task).
- An Executor can run multiple Tasks (depending on its available cores).
Partitions & Tasks - Processing one partition = one Task.
- The number of Tasks in a Stage often equals the number of partitions for that data.
Partitions & Executors - A partition stays on one Executor while it’s being processed.
- Each Executor can hold 0 or more partitions in memory or on disk.
Executors & Nodes - 1 Executor corresponds to 1 JVM running on 1 physical/virtual Node.
- Each Node can host 0 or more Executors.

Or in one line: Application → Jobs → Stages → Tasks, where each task processes one partition on one executor.

References#

  1. Spark: The Definitive Guide
  2. Apache Spark Documentation

The next time the Spark UI shows a number you didn't write, look for the shuffle before it. That's where the 200 came from.

More in the Apache Spark series.

Vibhavari Bellutagi

Vibhavari Bellutagi

Data & Full-Stack Engineer. 10+ years in software, seven of them building data platforms (Spark, Kafka, Databricks) at Deloitte, AgileLab and Mews. On the side, I build full-stack projects and write up the hard parts here.