collect() и toPandas(): когда весь датасет едет в Driver

Самый частый способ уронить Spark-приложение - собрать миллиарды строк на Driver. Разбираем collect(), toPandas(), broadcast-переменные и безопасные альтернативы.

core optimization

Что происходит при collect()

collect() переносит все данные из всех Executor'ов через сеть на Driver-узел в виде Python-списка:

Driver по умолчанию имеет spark.driver.memory = 1–2 ГБ. Таблица на 100 ГБ убьёт Driver с java.lang.OutOfMemoryError.

Паттерны-убийцы

# ❌ Паттерн 1: collect() всего датасета
all_rows = df.collect()           # 1 TB → Driver → OOM
for row in all_rows:
    process(row)

# ❌ Паттерн 2: toPandas() без фильтрации
pd_df = df.toPandas()             # весь Spark DF → одна pandas таблица в памяти Driver

# ❌ Паттерн 3: collect() в цикле
for date in dates:
    result = df.filter(f"date='{date}'").collect()  # N запросов × collect()

# ❌ Паттерн 4: collect() для проверки "есть ли данные"
if df.collect():   # полный скан → Driver ради одного boolean
    ...

Безопасные альтернативы

# ✅ Вместо collect() - write напрямую в хранилище
df.write.parquet("s3://bucket/output/")

# ✅ Если нужен sample - limit() + collect()
sample = df.limit(1000).collect()   # только 1000 строк

# ✅ Вместо collect() для проверки - isEmpty() или count()
df.isEmpty()       # O(1) - только проверяет есть ли партиции
df.count() == 0    # полный скан, но безопасен для Driver

# ✅ Вместо toPandas() - Arrow + limit
spark.conf.set("spark.sql.execution.arrow.pyspark.enabled", "true")
pd_df = df.limit(100_000).toPandas()   # Arrow + ограниченный объём

# ✅ Вместо цикла с collect() - используйте Spark трансформации
df.groupBy("date").agg(...)   # весь расчёт внутри кластера

Driver bottlenecks помимо collect()

Broadcast-переменные

# ❌ Огромная broadcast-переменная
lookup = load_giant_dict()    # 2 ГБ словарь
bc = spark.sparkContext.broadcast(lookup)
# Driver держит копию + шлёт на каждый Executor → 2 ГБ × N Executors

# ✅ Альтернатива - Broadcast Join через DataFrame
small_df = spark.createDataFrame(...)
df.join(broadcast(small_df), "key")   # Catalyst управляет размером
# Лимит broadcast через конфиг (default: spark.driver.maxResultSize = 1g)
spark.conf.set("spark.driver.maxResultSize", "2g")
# Превышение → SparkException: Job aborted due to stage failure

Аккумуляторы - только для счётчиков

# ❌ Аккумулятор как хранилище данных
acc = spark.sparkContext.accumulator([], ListParam)
df.foreach(lambda r: acc.add(r))   # Все строки → Driver через accumulator

# ✅ Аккумулятор только для метрик
error_count = spark.sparkContext.accumulator(0)
def process(row):
    if row.value is None:
        error_count.add(1)
df.foreach(process)
print(f"Errors: {error_count.value}")

Диагностика в Spark UI

Симптомы Driver OOM:

  • В логах: java.lang.OutOfMemoryError: Java heap space в Driver
  • Spark UI: Task stage завершилась, но Job завис
  • ERROR TaskSetManager: Lost task 0.0 in stage 2.0 (TID 15): java.lang.OutOfMemoryError

Безопасный лимит:

# Настроить Driver memory (только при spark-submit, не в коде)
# spark-submit --driver-memory 8g

# Лимит результата, который Driver принимает:
spark.conf.set("spark.driver.maxResultSize", "2g")  # default 1g

Правило

Если в коде есть .collect() или .toPandas() без предшествующего .limit() или .filter() с известной кардинальностью - это red flag. В production-коде эти вызовы должны иметь явное обоснование.