RDD - Cache Vs Persist
Apache Spark is designed for speed, and its primary mechanism for achieving extreme performance is In-Memory Caching.
By default, RDDs are computed lazily. Every time you trigger an Action (such as count or collect) on an RDD, Spark recomputes the entire lineage graph from the raw source file. If your application executes multiple actions on the same processed RDD, this recomputation causes massive, redundant overhead.
To solve this, Spark allows you to cache or persist intermediate RDDs in the memory or disk of the executor nodes.
This guide details the concepts and differences between cache() and persist(), complete with a timed PySpark script proving the performance benefits.
The Key Difference: cache() vs. persist()
While both operations achieve the same fundamental goal (saving an RDD's computed partitions in executor nodes), they differ in customization options:
1. cache()
- Behavior: Shorthand for calling
persist(StorageLevel.MEMORY_ONLY). - Storage Location: Saves elements in executor JVM memory as deserialized Java objects. If a partition doesn't fit in memory, it is not cachedit will be recomputed on the fly when needed.
- When to use: Quick, standard caching where you are confident the data fits comfortably in RAM.
2. persist(storageLevel=StorageLevel.MEMORY_ONLY)
- Behavior: Fully customizable caching. It allows you to pass a specific StorageLevel to control where and how data is cached (e.g., spilling overflow to disk, serializing objects to save space, or replicating to multiple nodes for fault tolerance).
- When to use: Production jobs where data is large or memory is constrained, requiring disk spillback or serialization.
Setting Up Spark Session (For Code Examples)
import time
from pyspark.sql import SparkSession
from pyspark import StorageLevel
spark = SparkSession.builder \
.appName("Day01 Cache vs Persist") \
.master("local[*]") \
.getOrCreate()
sc = spark.sparkContext
Caching and Performance Demonstration (Timed)
Let's write a comprehensive script that measures the time difference of running multiple actions on a heavy RDDfirst without caching, and then with caching.
Step 1: Create a Heavy Computation Pipeline
# Create an RDD with 1 million elements
raw_data = sc.parallelize(range(10000000), numSlices=4)
# Apply some CPU-heavy transformations (lazy)
heavy_rdd = raw_data.map(lambda x: x * 2).filter(lambda x: x % 3 == 0)
Step 2: Time the Actions WITHOUT Caching
print("--- Running WITHOUT Caching ---")
# Action 1: Count elements
start_time = time.time()
count1 = heavy_rdd.count()
end_time = time.time()
duration_no_cache_1 = end_time - start_time
print(f"Action 1 (Count): {count1} | Duration: {duration_no_cache_1:.4f} seconds")
# Action 2: Sum elements (forces full recalculation from source!)
start_time = time.time()
sum1 = heavy_rdd.reduce(lambda x, y: x + y)
end_time = time.time()
duration_no_cache_2 = end_time - start_time
print(f"Action 2 (Sum): {sum1} | Duration: {duration_no_cache_2:.4f} seconds")
Step 3: Time the Actions WITH Caching
Let's cache the RDD and run the actions again. Notice how the first action builds the cache (takes normal time), while the second action reads directly from RAM (sub-second execution!).
print("
--- Running WITH Caching ---")
# 1. Flag the RDD to be cached (lazy operation!)
heavy_rdd.cache()
# Alternative: heavy rdd.persist(StorageLevel.MEMORY ONLY)
# 2. Action 1: Counts the RDD and builds the cache in executor RAM
start_time = time.time()
count2 = heavy_rdd.count()
end_time = time.time()
duration_with_cache_1 = end_time - start_time
print(f"Action 1 (Build Cache): {count2} | Duration: {duration_with_cache_1:.4f} seconds")
# 3. Action 2: Sum elements (reads directly from cache - lightning fast!)
start_time = time.time()
sum2 = heavy_rdd.reduce(lambda x, y: x + y)
end_time = time.time()
duration_with_cache_2 = end_time - start_time
print(f"Action 2 (Read Cache): {sum2} | Duration: {duration_with_cache_2:.4f} seconds")
print("
--- Time Savings Recap ---")
print(f"Without Cache (Action 2 Duration): {duration_no_cache_2:.4f} seconds")
print(f"With Cache (Action 2 Duration): {duration_with_cache_2:.4f} seconds")
# You will typically see a massive speedup (often 10x to 100x faster)!
4. How to Release Cache: unpersist()
Saves memory across your pipeline by explicitly releasing RDDs from cache when they are no longer needed. Caching consumes RAM; failing to unpersist completed datasets can lead to garbage collection slowdowns or memory constraints.
- Syntax:
rdd.unpersist(blocking=True) - Blocking: If
blocking=True, Spark will pause until the memory is fully cleared before continuing your script.
# Unpersist the heavy RDD to free up RAM
heavy_rdd.unpersist(blocking=True)
print("Cache released successfully.")
Best Practices for Caching RDDs
- Lazy Caching: Remember that
.cache()is lazy. It does not load data into RAM immediately. It only marks the RDD to be saved. The cache is built only when the first action is called. - Checkpoint of Lineage: Caching does not destroy the lineage graph. If a node hosting cached data crashes, Spark will simply fall back to the lineage graph to recompute that partition and cache it again on a different node.
- Avoid Over-Caching: Caching everything is a bad practice. If you cache too many RDDs, the executor JVM memory will saturate. Spark will either spill your data to disk (causing I/O overhead) or drop older partitions from cache, forcing recomputations later anyway.