Part III — Batch processing · Chapter 8

Tuning Spark: partitioning, shuffling and caching

~40 min read9 interactive widgets5 plates

In this chapter

  1. Partitioning: the two levers
  2. The number of partitions
  3. The partitioning criteria
  4. Repartitioning: pay once, shuffle less
  5. Shuffling: mechanism and cost
  6. Three generations of shuffle
  7. Caching: persist and storage levels
  8. Shared variables: broadcast and accumulators
  9. Tuning the cluster: CPU
  10. Tuning the cluster: memory
  11. Test your knowledge

1. Partitioning: the two levers

Chapter 6 introduced the RDD as a resilient distributed dataset, and glossed over the word “distributed”. This chapter pays that word its due: the lab deck on Spark optimization starts where any tuning conversation has to start, with partitioning.

Data in RDDs are split into multiple partitions. One property holds unconditionally and explains most of what follows:

Key idea — the hard constraint

A single partition can never span multiple workers. A partition is the atomic unit of both storage and computation: it lives entirely on one worker, and one task processes it entirely. Everything you will tune in this chapter is a way of shaping these indivisible pieces of data.

Given that constraint, partitioning serves two purposes, and the lab deck is careful to attach a separate control knob to each:

The two knobs answer two different questions. How many pieces is the data cut into? decides how much parallelism you get. Which piece does each key go to? decides how much data has to travel between executors when operations that group by key run.

The two levers of RDD partitioning RDD partitions are laid out over two workers; each RDD has partitions on both workers, and a note states that one partition never spans two workers. Two levers are listed below: the number of partitions controls parallelization, the partitioning criteria controls network traffic. RDD PARTITIONING — data is split into partitions; one partition never spans two workers WORKER A RDD 1 Partition 1 Partition 2 Partition 3 RDD 2 Partition 1 WORKER B RDD 1 Partition 4 Partition 5 Partition 6 RDD 3 Partition 1 a partition cannot be split across the two workers LEVER 1 — THE NUMBER OF PARTITIONS parallelize computation across workers and CPU cores — one task runs per partition LEVER 2 — THE PARTITIONING CRITERIA (PARTITIONER) minimize network traffic for data exchange between executors — same key, same partition no control over which worker node a partition goes to — only over how keys are distributed among partitions
Plate 8.1 — The two levers. Lever 1 (how many partitions) buys concurrency; lever 2 (which partition a key lands in) buys cheaper joins and aggregations. Both are constrained by the one rule in vermilion: the pieces cannot be split.
Editor’s note — why this chapter is Chapter 7’s continuation

The cost model of Chapter 7 could not see the cluster: it planned from cardinality and size, and explicitly ignored “allocation of resources” and “allocation of tasks”. This chapter is precisely about those two blind spots. Partitioning and shuffling decide how many tasks exist, where they run, and how much data crosses the network — the costs Catalyst had to leave out of its formula.

2. The number of partitions

If you do not specify it, Spark sets the number of partitions automatically. The lab deck gives the exact rules, and they are a mini-review of everything Chapter 6 said about where RDDs come from:

Why does the number matter so much? Because of a one-line equation the deck states plainly: one task is run for each partition. Partition count is task count. Everything that follows is the consequence of that mapping.

Too few partitions

Too many partitions

The deck then gives the rules of thumb, and they are worth memorising as a ladder:

BoundRuleRationale
Lower bound2 partitions for each coreKeeps every core busy even when a task ends early and the scheduler must fill the gap.
Lower boundEach task should process no more than a block-worth of dataA task is the unit of scheduling and of failure; a task bigger than a block recomputes more than necessary on retry.
Upper boundEach task should take at least 100–200ms to executeBelow that, scheduling overhead dominates the actual work.
When in doubtMore partitions is usually better than fewerThe skew and concurrency risks of “too few” are worse than the overhead of “too many”.

The partition count, live

A fixed scenario — 8 cores, a dataset of 100 blocks. Slide the partition count and watch the consequences change.

For the exam

Three default rules and four tuning rules. Defaults: in-memory → available cores; external → one partition per block; transformations → max of the parents. Tuning: at least 2 per core; at most a block per task; at least 100–200ms per task; when in doubt, more. And the equation underneath everything: one task per partition — so “too few partitions” means idle cores plus a skew-stuck straggler, and “too many” means scheduling overhead.

3. The partitioning criteria

The second lever is the partitioning criteria: the rule that decides, for a key/value RDD, which key goes to which partition. The deck gives it three jobs:

Where does the criteria come from? It depends on how the key/value RDD is built:

And the crucial pricing note: forcing a partitioning criteria on an RDD requires shuffling the data — it costs. A criteria is not a free annotation; it is a redistribution, and redistribution is the expensive operation that sections 5 and 6 will dissect.

There are three ways of partitioning:

MethodHow it distributes keys
Hash partitioningHashing using the Java hashCode (i.e., a 32-bit signed integer); the hash value decides the partition.
Range partitioningDistributes data into uniform ranges; the ranges are determined by sampling the data.
Custom partitioningUser-defined partitioning — any rule you can code.

Hash vs. range on the slide’s own numbers

The same five keys, distributed by two criteria over three partitions. In both cases the values with the same key stay together — that is what a criteria is for.

Repartitioning by n mod 3 Three source partitions containing values 4, 8, 40, 80; 15, 16, 4, 90; and 23, 42, 83, 93 are redistributed by the remainder of n modulo 3 into three new partitions containing multiples of three, numbers congruent to one, and numbers congruent to two. FORCING A PARTITIONING CRITERIA — repartitioning by n mod 3 before — no criteria Partition 1 48 4080 (2 tasks’ worth) Partition 2 1516 490 Partition 3 2342 8393 (skewed: 4 values) repartition (mod 3) requires a shuffle — it costs after — forced criteria new P1 — n mod 3 = 0 15429093 new P2 — n mod 3 = 1 441640 new P3 — n mod 3 = 2 8238083 the same keys are now grouped by remainder — and no key went to a worker of our choosing: placement is out of our control
Plate 8.2 — Repartitioning by n mod 3, worked on the deck’s own numbers. Note where the values travel: 15, 42, 90, 93 to P1; 4, 4, 16, 40 to P2; 8, 23, 80, 83 to P3. The redistribution is a shuffle — a full pass of data across the cluster — which is why forcing a criteria “costs”.

4. Repartitioning: pay once, shuffle less

Given that a criteria costs a shuffle, when is it worth paying? The deck gives two answers:

The second case gets a full worked example, and it is the canonical tuning story of this whole chapter. An application keeps in memory a large table of user information, where UserInfo contains the list of topics the user is subscribed to:

val sc = new SparkContext(...)
val userData = sc.sequenceFile[UserID, UserInfo]("hdfs://...").persist()

Every 5 minutes, userData is combined with a smaller file containing the websites visited by the users; LinkInfo is a single website:

def processNewLogs(logFileName: String) {
  val events = sc.sequenceFile[UserID, LinkInfo](logFileName)
  val joined = userData.join(events) // RDD of (UserID, (UserInfo, LinkInfo)) pairs
  val offTopicVisits = joined.filter {
    case (userId, (userInfo, linkInfo)) => // Expand the tuple into its components
      !userInfo.topics.contains(linkInfo.topic)
  }.count()
  println("Number of visits to non-subscribed topics: " + offTopicVisits)
}
Watch out — the default is quietly terrible

By default, this operation will hash all the keys of both datasets; elements with the same key hash are sent across the network to the same partition. That is very inefficient: userData is expected to be much larger than the small log of events, and nonetheless userData is hashed and shuffled across the network every time — every 5 minutes, the large table is redistributed from scratch, while the small file is the one that actually changed.

The fix is a one-liner, and it is the whole lesson of the chapter in miniature: partition userData before persisting it.

The fix, annotated

Select each line. The two code blocks are the same program; the only difference is where the partitioning is paid.

After the fix, only the events RDD is shuffled, sending events with a certain UserID to the machine that contains the corresponding hash partition of userData. Fewer network communications, and — in the deck’s words — a significant boost in performance.

The 5-minute join, quantified

Step through the rounds and compare the network traffic of the default plan against the partitioned plan. userData: 100 GB, persisted. events: 1 GB, new every round.

Repartitioning advantages, in full

The deck then generalises, and the generalisation is a ladder of three cases:

Key idea — partitioning is an investment

The ladder is one idea: a partitioner you already paid for keeps paying. The first shuffle (the partitionBy) is capital expenditure; every subsequent key-oriented operation is operational expenditure that the partitioner reduces or eliminates. That is why the example persists the partitioned RDD — the investment must survive across actions, which is exactly what persist() (section 7) guarantees.

Partitioning heritage: Spark knows what it created

The last piece of the partitioning model is bookkeeping: Spark knows internally how each of its operations affects partitioning, and records it on the result RDD. The partitioner is automatically set on RDDs created by operations that partition the data:

cogroup(), groupWith(), join(), leftOuterJoin(), rightOuterJoin(),
groupByKey(), reduceByKey(), combineByKey(), partitionBy(), sort()

And if the parent RDD has a partitioner, also these narrow transformations keep it:

mapValues(), flatMapValues(), filter()

Everything else — notably map()produces a result with no partitioner. The reason is semantic: map() can change the key arbitrarily, so the old criteria would be a lie. The heritage rules are exactly the deck’s way of saying the partitioner survives only where the key survives unchanged.

For the exam

The userData/events example is the exam’s favourite story: default join = both datasets hashed and shuffled every round; fixed join = the large table partitioned once and persisted, only the small file shuffled afterwards. Then the ladder: single RDD → fully local; one partitioned RDD → only the other shuffles; same partitioner → targeted sends; same partitioner and co-located → no shuffle. And the heritage list: join and friends set the partitioner; mapValues, flatMapValues, filter keep it; map() drops it.

5. Shuffling: mechanism and cost

Everything so far has been circling one word. Time to name it: shuffling is the mechanism used to re-distribute data across partitions. The deck’s definition comes with two qualifiers that set the tone of the whole chapter:

First, why it is necessary at all. Values for a key live scattered across partitions; all partitions must be read in order to find values for the key k1:

The shuffle pipeline and its three costs On the map side, three map tasks each produce a spill file on disk; the data crosses a network band to reach reduce tasks, which aggregate it. Three cost components are listed: disk I/O, network I/O, and data serialization. A note states that all partitions must be read to find values for a key. SHUFFLING — re-distributing data across partitions: necessary, complex, costly MAP SIDE — sets of map tasks organize the data map task M1 map task M2 map task M3 spill file spill file spill file disk — data spilled on the map side, later read by reduce tasks NETWORK I/O data crosses the wire between executors REDUCE SIDE — reduce tasks aggregate it reduce task R1 — k1 reduce task R2 reduce task R3 each key’s values must end up in one task to find values for the key k1, all partitions must be read — the reduce task for k1 has no idea where its values are THE THREE COSTS 1. DISK I/O data on the map side is spilled to disk, later read by reduce tasks 2. NETWORK I/O every redistributed record crosses the wire between executors 3. DATA SERIALIZATION increases CPU workload to reduce the cost of I/O Spark generates sets of map tasks (to organize the data) and reduce tasks (to aggregate it) — nomenclature taken from MapReduce (Chapter 4)
Plate 8.3 — The shuffle pipeline. Map tasks organise, spill to disk, and push across the network; reduce tasks pull and aggregate. The three cost centres are the same three resources Chapters 2–5 taught you to watch: disk, network, and the CPU that serialization burns to save the other two.

Which operations trigger all this? The deck’s taxonomy is short and examinable:

For the exam

Three facts to keep separate: (1) the definition — shuffling re-distributes data across partitions; it is necessary and it is costly; (2) the mechanism — map tasks organize, reduce tasks aggregate (MapReduce nomenclature); (3) the three costs — disk I/O (spill on the map side), network I/O, and data serialization (extra CPU to save I/O). And the two exceptions that surprise people: already-partitioned-by-key → no shuffle; countByKey → no shuffle, counts go to the driver.

6. Three generations of shuffle

Shuffling is costly enough that Spark has spent its whole history making it cheaper. The deck tells the story as three generations: hash shuffle (the very first technique), sort shuffle (default with Spark 1.2), and Tungsten sort (an evolution of the sort shuffle).

Hash shuffle: one file per (mapper, reducer)

Each map task creates a file for every reducer. The deck’s example is famous for a reason — a real Yahoo workload with 46,000 mappers and 46,000 reducers:

Watch out — the arithmetic

46,000 × 46,000 = over 2,000,000,000 intermediate files. Two billion files on the filesystem, for one shuffle. That is the baseline the whole history of Spark shuffles is trying to escape.

An evolved version pooled the files per executor: each executor holds a pool of files, one group containing a file for every reducer, with as many groups as the mappers that can run in parallel in the executor itself. With 100 executors, 10 cores each, 1 core per task:

Better than two billion, still absurd.

Pros: fast — no sorting is required at all, no hash table maintained; no memory/CPU overhead for sorting the data. Cons: when the amount of partitions is big, performance starts to degrade due to the big amount of output files — a big amount of files written to the filesystem causes IO skew towards random IO, which is in general up to 100× slower than sequential IO.

Sort shuffle: one spill file per mapper

The second generation inverts the file structure. Each mapper keeps output in memory, spills to disk if necessary; each mapper spills to its own file(s); each file is sorted by reducer. When a reducer asks for its data, the “pieces” from each file are collected, sorted in memory, and sent to the reducer.

Same Yahoo example: ~46,000 intermediate files. Not two billion, not forty-six million — one per mapper.

Pros: smaller amount of files created on the map side; smaller amount of random IO operations, mostly sequential writes and reads. Cons: sorting is slower than hashing — although in practice sort shuffle is usually better; and the deck notes the one environment where the trade flips: in case SSD drives are used for the temporary data of Spark shuffles, hash shuffle might work better.

Tungsten sort: shuffle on serialized bytes

The third generation attacks serialization, the third cost of Plate 8.3. Tungsten sort directly works on serialized records: records can be sorted, merged, concatenated, spilled to disk without de-serializing and re-serializing, using a cache-efficient sorter. The pros are one line: it improves performances of the sort shuffle technique. The cons are the interesting part — it can be adopted only under specific conditions:

The file-count story, calculated

Same workload — 46,000 mappers, 46,000 reducers. Pick a technique and see how many intermediate files it produces.

Three generations of shuffle: intermediate files on the Yahoo workload Hash shuffle produces one file per mapper-reducer pair, over two billion files; evolved hash shuffle produces forty-six million; sort shuffle produces about forty-six thousand, one per mapper. A note explains that today a single SortShuffleManager picks among the techniques. THREE GENERATIONS — Yahoo workload: 46,000 mappers · 46,000 reducers HASH SHUFFLE — 1st technique each map task creates a file for every reducer: 46,000 × 46,000 > 2,000,000,000 files now obsolete — random IO up to 100× slower than sequential IO HASH SHUFFLE EVOLVED pool of files per executor: 100 executors × 10 concurrent mappers × 46,000 reducers 46,000,000 files each executor holds 10 groups of 46,000 files SORT SHUFFLE — default since 1.2 one spill file per mapper, each file sorted by reducer: 46,000 mappers ~46,000 files mostly sequential IO; sorting slower than hashing TUNGSTEN SORT — evolution of the sort shuffle: works directly on serialized records (no de/re-serialization), cache-efficient sorter; adopted only when conditions allow (no map-side combining, serializer supports relocation, < 2^24 output partitions, no record > 128 MB serialized) TODAY — one single implementation is provided: SortShuffleManager. If conditions allow it, tungsten sort is adopted; if there are too few partitions, hash shuffling is adopted; otherwise, sort shuffle is adopted.
Plate 8.4 — The file-count story. The ordering matters: hash’s flat structure (file per reducer) explodes with scale; the pool only rescales it; sort’s sorted spill files collapse it to one file per mapper. The current manager is a decision tree over the three techniques.
For the exam

Know the three techniques by their file structure: hash = one file per (mapper, reducer) (>2 billion on the Yahoo example); sort = one spill file per mapper (~46,000), pieces sorted in memory when the reducer pulls; Tungsten = the same sort, but directly on serialized records. And know the current state: one manager, SortShuffleManager, which picks tungsten if conditions allow, hash if there are too few partitions, sort otherwise. The SSD caveat (hash can win on SSDs) is a favourite exam detail.

7. Caching: persist and storage levels

The third tuning target is memory, and the starting observation is blunt: Spark recomputes an RDD each time an action is called on it. The deck’s example is the “trivial” one that everyone hits:

rddCapraKvLength.count()     // action 1: the whole lineage runs
rddCapraKvLength.collect()    // action 2: the whole lineage runs again

Two actions, two full recomputations of the same RDD. Especially expensive for iterative algorithms — the ones Chapter 9 will build on, where the same dataset is re-read on every iteration.

The fix is persist(): when you persist an RDD x, each node stores in memory the partitions of x that it computes and reuses them in other actions on x. Three properties complete the picture:

Watch out — the deck’s own warning

Beware: do not cache unless necessary! A cached RDD occupies memory that could serve shuffle buffers or other computations, and eviction under pressure means recomputation anyway. The rule of thumb: cache when the same RDD is consumed by multiple actions or iterations — the userData of section 4 is the canonical case — and not otherwise.

RDDs can be persisted using the persist() or cache() methods; persist() allows specifying the storage level; cache() uses MEMORY_ONLY. The storage levels form a ladder from pure memory to pure disk:

Storage levelWhat it stores
MEMORY_ONLYDefault using cache() — deserialized objects in memory; if it does not fit, it is simply not cached.
MEMORY_AND_DISKSpills to disk if there is too much data to fit in memory.
MEMORY_ONLY_SER / MEMORY_AND_DISK_SERStore the serialized representation in memory — more efficient space-wise, but more CPU-intensive (and the disk variant spills when full).
DISK_ONLYEverything on disk — no memory footprint, full re-read cost on every action.
[storage_level]_2Replicate the data on 2 machines — any level, with a second copy for fast fault recovery.
Storage levels from memory to disk A horizontal axis from MEMORY_ONLY to DISK_ONLY with the serialized and disk-spill variants in between; labels note that serialization is more space-efficient but CPU-intensive, and that the _2 suffix replicates data on two machines for fast fault recovery. STORAGE LEVELS — a ladder from memory to disk memory disk MEMORY_ONLY default of cache() MEMORY_AND_DISK spills if it does not fit MEMORY_ONLY_SER serialized in memory MEMORY_AND_DISK_SER serialized, spills to disk DISK_ONLY no memory at all the SER trade-off serialization makes the objects much more space-efficient — at the cost of CPU to (de)serialize on every read [level]_2 — replicate the data on 2 machines use replication only if you want fast fault recovery — it doubles the storage cost
Plate 8.5 — The storage-level ladder. Two dimensions move as you walk it: the memory footprint shrinks toward disk, and the CPU cost of serialization grows toward the SER levels. Replication is an orthogonal switch that trades space for recovery speed.

The deck closes the topic with explicit guidance on which storage level is best:

Choosing a storage level

Pick a level and a replication flag; the trade-offs are the same four the deck lists.

For the exam

Five facts: Spark recomputes an RDD at every action; cache() = persist(MEMORY_ONLY); persisting does not trigger computation; unpersist() releases; and the warning “do not cache unless necessary” is itself examinable. The four guidance bullets (stay in memory; serialization saves space; spill only for expensive computations; replicate only for fast recovery) are the answer to any “which level?” question.

8. Shared variables: broadcast and accumulators

Variables used inside closures have a problem that Chapter 6 never mentioned: variables used within functions passed to distributed operations (e.g., map or reduce) are copied to every task. The deck adds the design rationale in one clause: supporting general read/write shared variables across tasks would be inefficient. Keeping every task’s copy coherent with the driver — or with each other — is exactly the distributed-systems nightmare the chapters on consensus would warn you about.

Spark therefore supports two (limited) types of shared variables, each with its own semantics:

Provide each node with a copy of the variable — read-only. Useful for the two situations below, both of which are about paying the shipping cost once instead of per task.

Provide each node with a pointer to the original variable — so that updates from inside tasks land on the single shared instance, not on a private copy that gets discarded.

Broadcast variables

Through broadcast variables it is possible to send read-only copies of variables to every executor. The mechanism matters because Spark already ships the required data to each task: any variable your closure captures travels with every task. Broadcasting is therefore useful exactly when that default is wasteful:

val dictionary = Map(("man"-> "noun"), ("is"->"verb"), ("mortal"->"adjective"))
val broadDictionary = sc.broadcast(dictionary)

val result = words
  .map( word => getElementsCount(word, broadDictionary) )
  .reduceByKey(_+_)

This is also the mechanism behind Chapter 7’s Broadcast Hash join: the small table is shipped to every executor once — the same reasoning, at the engine level.

Accumulators

Accumulators are shared variables that can be updated from inside tasks. From the point of view of tasks they are write-only — reading the current value from inside a task would require keeping all tasks up to date, which is the inefficiency the design avoids. They are useful for debugging, computing metrics or commutative and associative functions — i.e., updates whose order does not matter, exactly like the combiners of Chapter 4.

val emptyLines = sc.accumulator(0)

val tokens = textFile.flatMap( line => {
    if (line == "")
       emptyLines += 1
    line.split(" ")
  }
)

Two properties matter for the exam, and the second one is a genuine trap:

The re-execution pitfall, simulated

Run the job, then simulate a failed task being re-executed, and watch the counter lie.

Finally, a visibility detail: accumulators will be displayed in the web UI for the stage that modifies that accumulator — the UI is where you actually see the double-counting happening.

For the exam

The one-line contrast: broadcast = a copy per node; accumulator = a pointer to the original. Broadcast is for large or multi-stage read-only data (shipped once per executor, not per task); accumulators are write-only from the tasks’ view, for debugging, metrics, and commutative/associative functions, and they do not break laziness. The trap: re-execution (failures, stragglers) double-counts accumulator updates.

9. Tuning the cluster: CPU

The lab deck on Spark optimization ends with caching; a companion deck on cluster configuration turns the same knobs at the level of the whole application. This section and the next summarise that deck, because partitioning, shuffling and caching all happen inside an executor budget you get to choose.

Two main resources: CPU and memory. Disk and network I/O play an important part too, but with a decisive caveat: neither Spark nor YARN currently do anything to actively manage them — everything the chapters so far called “IO cost” is yours to tune indirectly. Every executor in an application has the same fixed:

The number of executors is also fixed — unless dynamic allocation is enabled.

Tuning CPU is done by setting two options of spark-submit: --num-executors (how many executors) and --executor-cores (how many cores each). The deck adds four things to bear in mind:

These constraints collapse into a small recipe, and the recipe is examinable arithmetic:

--executor-cores = 3 to 5
                  (such that all cores in a node (-1 for daemons) are employed)

executors-per-node = ⌊ ( available cores - 1 ) / --executor-cores ⌋
--num-executors    = number-of-nodes × executors-per-node - 1
                     (leave out 1 executor for the application master)
PropertyExampleCluster (old)Cluster (new)
Number of nodes1199
Available cores (per node)8416
--executor-cores335
Executors per node2 = ⌊7/3⌋1 = ⌊3/3⌋3 = ⌊15/5⌋
--num-executors21 = 11·2 −18 = 9·1 −126 = 9·3 −1
Used cores (%)63/88 = 72%24/40 = 60%130/144 = 90%
Editor’s note — the deck’s own arithmetic

The “old cluster” row uses 24/40 where the formulas give 9 nodes × 4 cores = 36 available; the extra node’s worth of cores is the application master (the −1 in the recipe, applied to a node count of 10). Trust the formulas: they are what the deck states, and they reproduce the “Example” and “new” columns exactly.

CPU tuning calculator

Set the cluster and the executor core count, and the recipe computes the rest. Warning levels follow the deck: 3–5 cores per executor, one executor left for the AM.

10. Tuning the cluster: memory

Tuning memory is done with one main option: --executor-memory, the amount of memory per executor. The cautions are the mirror of the CPU ones:

The off-heap default is small but not negligible: max(384 MB, 10% of spark.executor.memory). The recipe, with off-heap size = 0.1 (10%):

--executor-memory = ( available memory × 0.75 ) × ( 1 - 0.1 ) / executors-per-node
                    (leave out 25% of RAM for running daemons)
PropertyExampleCluster (old)Cluster (new)
Available memory (per node) in GB321632
Executors per node213
--executor-memory11 = (32·0.75)·(1−0.1)/211 = (16·0.75)·(1−0.1)/17 = (32·0.75)·(1−0.1)/3
Used memory24.2/32 = 76%11.1/16 = 69%23.1/32 = 72%

And the mapping table that ties every knob to its spark-submit option — this is the one to memorise because exams love pairing a property with its flag:

Spark conf parameterOption in spark-submitDescription
spark.executor.memory--executor-memoryAmount of memory dedicated to each executor
spark.executor.cores--executor-coresNumber of cores dedicated to each executor
spark.executor.instances--num-executorsTotal number of executors
spark.driver.memory--driver-memoryAmount of memory dedicated to the driver (note: in the local environment, only the driver is created)
spark.driver.cores--driver-coresNumber of cores dedicated to the driver (note: in the local environment, only the driver is created)

Memory tuning calculator

Feed in the executors-per-node from the CPU recipe and the per-node RAM; the memory recipe computes the heap, the off-heap overhead, and the utilisation.

For the exam

Four numbers and two formulas. Numbers: 3–5 cores per executor (Cloudera: at most 5 tasks), ~64GB upper limit per executor (GC), 1 executor reserved for the AM (−1), 25% of node RAM left for daemons (×0.75). Formulas: executors-per-node = ⌊(cores−1)/executor-cores⌋ and executor-memory = (available×0.75)×(1−0.1)/executors-per-node. And the flag pairs: spark.executor.memory--executor-memory, spark.executor.cores--executor-cores, spark.executor.instances--num-executors, spark.driver.memory--driver-memory, spark.driver.cores--driver-cores — in the local environment only the driver exists.

Key idea — how the whole chapter fits together

The cluster recipe fixes how many tasks can run at once (cores) and how much data can sit in memory (heap). Partitioning decides how many tasks there are (section 2) and where their data lands (sections 3–4); shuffling is what a bad partitioning costs (sections 5–6); caching is what makes the memory budget useful across actions (section 7). The four topics are one system: the cluster budget is the frame, and every earlier section is a way of spending it better.

Test your knowledge

What are the two purposes of partitioning, and which knob controls each?

Parallelize computation across workers and CPU cores — controlled by the number of partitions; and minimize network traffic for data exchange between executors — controlled by the partitioning criteria (the partitioner). The hard constraint underneath both: a single partition can never span multiple workers.

How does Spark set the number of partitions by default?

In-memory datasets → the total number of available cores; external datasetsone partition for each block of each file; transformationsthe largest number of partitions in a parent RDD. One task is run for each partition.

What are the consequences of too few or too many partitions, and what are the rules of thumb?

Too few: less concurrency (some cores unused) and skew risk — the job may get stuck on the one task handling most of the keys. Too many: excessive overhead in managing/scheduling many small tasks. Rules of thumb: lower bounds — 2 partitions for each core, and each task should process no more than a block-worth of data; upper bound — each task should take at least 100–200ms; when in doubt, more partitions is usually better than fewer.

What is a partitioning criteria used for, and what can it not do?

It is used to put values that belong to the same key in the same partition and to define how the key should be distributed in different partitions. It cannot control the specific worker node a partition goes to. Forcing a criteria on an RDD requires shuffling the data — it costs.

Name the three partitioning methods.

Hash partitioning (hashing using the Java hashCode, a 32-bit signed integer); range partitioning (uniform ranges determined by sampling the data); custom partitioning (user-defined).

Why is the default behaviour of the userData/events join inefficient, and what is the fix?

By default the join hashes all the keys of both datasets; since userData is much larger than the events log, the large table is hashed and shuffled across the network every 5 minutes even though only the small log changed. The fix: userData.partitionBy(new HashPartitioner(100)).persist() — partition once before persisting, so only the events RDD is shuffled (events with a certain UserID go to the machine holding the corresponding hash partition of userData). Fewer network communications, significant boost in performance.

State the repartitioning-advantage ladder for joins and single-RDD operations.

Single partitioned RDD (e.g., reduceByKey): values for each key are computed locally, no further shuffling. Two RDDs, one partitioned: only the other is shuffled. Both share the same partitioner: partitions of RDD2 are only sent to the correct partition of RDD1. Same partitioner and co-located (e.g., RDD2 from RDD1.mapValues): no shuffling occurs.

Which operations set a partitioner on their result, which keep a parent’s, and which drop it?

Set it: cogroup(), groupWith(), join(), leftOuterJoin(), rightOuterJoin(), groupByKey(), reduceByKey(), combineByKey(), partitionBy(), sort(). Keep a parent’s: mapValues(), flatMapValues(), filter(). Drop it: everything else, notably map() — because it can change the key.

What is shuffling, and what are its three costs?

Shuffling is the mechanism used to re-distribute data across partitions; it is necessary to compute some operations and it is complex and costly. Spark generates sets of map tasks (to organize the data) and reduce tasks (to aggregate it) — MapReduce nomenclature. The three costs: disk I/O (data on the map side is spilled to disk, later read by reduce tasks), network I/O, and data serialization (increases CPU workload to reduce the cost of I/O).

Which operations avoid the shuffle, and why?

ByKey operations when data is already partitioned by key — no shuffling occurs because the values for each key are already together. countByKey never shuffles — it just sends the counts to the driver. Joins may also shuffle less when the partitioning of the two RDDs matches (the section 4 ladder).

Compare hash, sort and Tungsten shuffle on the Yahoo workload (46,000 mappers, 46,000 reducers).

Hash shuffle: each map task creates a file for every reducer — 46,000 × 46,000 = over 2,000,000,000 intermediate files (evolved version: pools per executor, 100 executors × 10 concurrent mappers → 46,000,000). Fast (no sorting) but degrades with many partitions; random IO up to 100× slower than sequential IO. Sort shuffle: each mapper keeps output in memory and spills to its own sorted file(s); the reducer collects pieces, sorts them in memory — ~46,000 files; mostly sequential IO, but sorting is slower than hashing (on SSDs hash may win). Tungsten sort: works directly on serialized records with a cache-efficient sorter; only when conditions allow (no map-side aggregation, serializer supports relocation, <16,777,216 partitions, no record >128 MB serialized). Today one implementation, SortShuffleManager, picks tungsten if conditions allow, hash if too few partitions, sort otherwise.

What does persist() do, how does it differ from cache(), and how do you release an RDD?

persist() makes each node store in memory the partitions it computes and reuse them in other actions on the RDD, so future actions are much faster. It does not trigger computation (unlike actions). cache() is persist() with the MEMORY_ONLY level; persist() lets you specify any storage level (MEMORY_AND_DISK, MEMORY_ONLY_SER, MEMORY_AND_DISK_SER, DISK_ONLY, and the _2 replication suffix). Release with unpersist(). And remember the warning: do not cache unless necessary.

Which storage level is best, and when?

Stay in-memory as much as possible; serialization makes the objects much more space-efficient (at CPU cost); spill to disk only if the dataset is computed via expensive functions; use replication only if you want fast fault recovery.

What are broadcast variables and when are they useful?

Broadcast variables send read-only copies of a variable to every executor. Broadcasting is useful when the same variable is used across multiple stages (it is shipped only once) and when a large object is used by every task (it is shipped only to the executor and kept in memory for every task to read, instead of per-task copies).

What is an accumulator, and what is its pitfall?

An accumulator is a shared variable that can be updated from inside tasks; from the tasks’ point of view it is write-only. It is useful for debugging, metrics, or commutative and associative functions. It does not change the lazy evaluation model of Spark. The pitfall: if a transformation gets executed multiple times (failures or stragglers), the accumulator is updated multiple times, so the final value is wrong. Accumulators are displayed in the web UI for the stage that modifies them.

Give the CPU and memory tuning recipes for a cluster, with the numbers that constrain them.

CPU: --executor-cores = 3 to 5 (Cloudera: no more than 5 tasks per executor; single-core executors lose JVM sharing); executors-per-node = ⌊(available cores − 1)/--executor-cores⌋; --num-executors = nodes × executors-per-node − 1 (one executor left for the application master); leave each node enough power for OS and Hadoop daemons; in yarn-cluster the AM runs the driver, so bolster it with --driver-memory/--driver-cores. Memory: --executor-memory = (available memory × 0.75) × (1 − 0.1) / executors-per-node (25% of RAM left for daemons, off-heap = 10%); the full request is heap + off-heap (default overhead max(384 MB, 10% of executor memory)); executors above ~64GB cause excessive GC delays.