Task Scheduling: FIFO vs FAIR, locality levels
Полный разбор планирования задач в Spark: иерархия Job→Stage→Task, TaskSetManager, режимы FIFO и FAIR, пулы и веса, иерархия Data Locality с Delay Scheduling, Dynamic Allocation и Speculative Execution. Как читать метрики в Spark UI.
Когда Spark запускает groupBy().count(), он не просто «отправляет задачи». За кадром работает двухуровневая система планирования: Cluster Manager решает, сколько ресурсов выделить приложению, а Task Scheduler - как распределить конкретные Task по уже выделенным Executor-ам. Именно Task Scheduler определяет, почему одна задача запускается раньше другой, почему некоторые Task ждут своего Executor, и что происходит когда Executor падает.
Двухуровневая иерархия планирования¶
Cluster Manager работает на уровне приложений: он выделяет контейнеры/ресурсы для Driver и Executor-ов. Он не знает о конкретных Task - только о том, сколько CPU и памяти нужно каждому Executor.
Task Scheduler работает внутри Driver. Он получает TaskSet от DAG Scheduler и решает: какую Task запустить на каком Executor, в каком порядке, и что делать при сбоях. Это чисто логическая операция - Task Scheduler не управляет ресурсами кластера.
Иерархия Job → Stage → TaskSet → Task¶
Job создаётся при каждом Action. Один df.show() = один Job, df.count() = ещё один Job.
Stage - это граф вычислений без shuffle-границ. Порядок выполнения Stage определяется зависимостями: Stage 1 начинается только после завершения Stage 0.
TaskSet - набор Task для одного Stage. TaskSetManager управляет жизненным циклом TaskSet: отслеживает выполненные и упавшие Task, применяет retry-логику.
Task - минимальная единица выполнения. Содержит:
- Сериализованный байткод пользовательского кода
- Информацию о партиции (какой файл/блок читать)
- Список preferred locations (где хранятся данные для этой партиции)
TaskSetManager: управление TaskSet¶
TaskSetManager - внутренний компонент Task Scheduler, отвечающий за один TaskSet. Он отвечает на вопросы:
- Какую Task назначить на предложенный Executor?
- Что делать если Task упала?
- Как долго ждать Task на "предпочтительном" Executor-е?
Ключевой момент: когда Executor освобождается, он не спрашивает «что мне делать?» - наоборот, он сообщает о свободном слоте через reviveOffers(), и TaskScheduler решает, какую Task туда отправить.
Режимы планирования: FIFO и FAIR¶
Task Scheduler поддерживает два режима планирования нескольких Job в одном приложении. Это важно в случаях, когда несколько потоков (threads) одновременно запускают Action на одном SparkSession - например, в многопоточном сервере или Jupyter-ноутбуке с параллельными ячейками.
FIFO (First In, First Out)¶
Принцип: первый пришёл - первый обслуживается. Job 1 получает все ресурсы кластера. Job 2 (быстрая проверка, 5 секунд работы) ждёт 30 минут, пока завершится Job 1.
Приоритеты внутри FIFO: у Job есть числовой приоритет (по умолчанию равен jobId, меньше = выше). Можно задать явно:
spark.sparkContext.setJobGroup("analytics", "Daily metrics", interruptOnCancel=True)
spark.sparkContext.setLocalProperty("spark.scheduler.pool", "analytics")
Плюсы FIFO:
- Предсказуемость - первый Job всегда завершится максимально быстро
- Нет накладных расходов на перераспределение ресурсов
- Подходит для batch pipeline без конкурирующих Job
Минусы FIFO:
- Starvation - маленькие быстрые Job ждут завершения больших
- Не подходит для интерактивных запросов рядом с batch-задачами
FAIR (Fair Scheduling)¶
Принцип: ресурсы кластера делятся между всеми активными Job через round-robin. Быстрый Job не ждёт большой - он получает свою долю немедленно.
Включение FAIR:
spark = SparkSession.builder \
.config("spark.scheduler.mode", "FAIR") \
.getOrCreate()
Пулы в FAIR-планировании¶
Пулы - это именованные группы Job с настраиваемыми параметрами распределения ресурсов.
Конфиг fairscheduler.xml:
<?xml version="1.0"?>
<allocations>
<!-- Пул для production pipeline -->
<pool name="production">
<schedulingMode>FIFO</schedulingMode> <!-- внутри пула - FIFO -->
<weight>3</weight> <!-- получает в 3× больше ресурсов чем default -->
<minShare>10</minShare> <!-- минимум 10 Task всегда -->
</pool>
<!-- Пул для аналитических запросов -->
<pool name="analytics">
<schedulingMode>FAIR</schedulingMode> <!-- внутри пула - FAIR -->
<weight>1</weight>
<minShare>2</minShare>
</pool>
</allocations>
# Подключить конфиг
spark = SparkSession.builder \
.config("spark.scheduler.mode", "FAIR") \
.config("spark.scheduler.allocation.file", "/path/to/fairscheduler.xml") \
.getOrCreate()
# Назначить Job в пул (thread-local property)
spark.sparkContext.setLocalProperty("spark.scheduler.pool", "production")
result = df.groupBy("city").count() # этот Job идёт в production-пул
result.show()
# Сбросить пул (вернуться в default)
spark.sparkContext.setLocalProperty("spark.scheduler.pool", None)
Параметры пула: weight и minShare¶
| Параметр | Что делает | Пример |
|---|---|---|
weight |
Пропорция ресурсов относительно других пулов | production weight=3, analytics weight=1 → production получает 75% |
minShare |
Минимум Task, гарантированных пулу даже если мало работы | minShare=10 → пул всегда получит хотя бы 10 слотов |
schedulingMode |
FIFO или FAIR внутри пула | production: FIFO (один большой Job не прерывается) |
Алгоритм выбора пула в FAIR: Spark сравнивает «дефицит» каждого пула - насколько текущее потребление ресурсов ниже его weight-доли. Пул с наибольшим дефицитом получает следующий свободный слот. Если пул ниже minShare - он имеет абсолютный приоритет.
Data Locality: данные к коду, а не код к данным¶
Философия Spark: дешевле переместить код к данным, чем данные к коду. Task - это несколько килобайт сериализованного байткода. Партиция данных - это сотни мегабайт. Перемещение данных по сети на порядки дороже.
Поэтому Task Scheduler старается запустить Task там, где физически хранятся данные для её партиции.
Иерархия уровней локальности¶
PROCESS_LOCAL - данные находятся в памяти именно того Executor-а, на котором будет запущена Task. Это происходит когда:
- DataFrame закэширован (
.cache()/.persist()) на этом Executor - Broadcast-переменная уже доставлена на этот узел
- Предыдущая Task на этом же Executor создала данные в памяти
NODE_LOCAL - данные на том же физическом сервере, но в другом процессе. Например:
- HDFS DataNode хранит реплику блока на этом хосте
- ОС-кэш (page cache) после предыдущего чтения файла
- Другой Executor на том же хосте уже читал эти данные
RACK_LOCAL - данные на другом сервере, но в той же сетевой стойке. Сеть внутри rack значительно быстрее (обычно 10 Gbit/s против 1 Gbit/s между rack).
NO_PREF - данным безразлично, где выполняться. Это типично для данных, созданных в памяти без привязки к конкретному узлу (например, spark.range(1000)).
ANY - данные могут быть на любом узле, и у Task нет предпочтений. Возникает когда:
- Данные из внешней системы (S3, JDBC), где нет блочного размещения
- Все реплики HDFS-блока находятся на узлах без свободных Executor-ов
Стоимость каждого уровня¶
| Уровень | Передача данных | Типичная задержка |
|---|---|---|
| PROCESS_LOCAL | Нет (память) | нс |
| NODE_LOCAL | IPC / loopback | мкс |
| RACK_LOCAL | Сеть внутри rack (10G) | мс |
| ANY | Сеть между rack (1G) | десятки мс |
Delay Scheduling: как Spark ждёт лучшей локальности¶
Если идеальный Executor (PROCESS_LOCAL) занят - Task Scheduler не сразу опускается к следующему уровню. Он ждёт:
Параметры Delay Scheduling¶
| Параметр | Default | Что контролирует |
|---|---|---|
spark.locality.wait |
3s | Базовое время ожидания (применяется ко всем уровням если частные не заданы) |
spark.locality.wait.process |
= locality.wait |
Ожидание PROCESS_LOCAL |
spark.locality.wait.node |
= locality.wait |
Ожидание NODE_LOCAL |
spark.locality.wait.rack |
= locality.wait |
Ожидание RACK_LOCAL |
Когда менять Delay Scheduling¶
Увеличить locality.wait (до 10–30 сек) если:
- Данные закэшированы - PROCESS_LOCAL критически важна
- Сеть медленная - ANY очень дорого
- Кластер большой и Executor-ы быстро освобождаются
Уменьшить или поставить 0 если:
- Данные из S3/JDBC - всё равно ANY, локальность не имеет смысла
- Cluster под нагрузкой - ожидание только тормозит Job
- Много маленьких Task - overhead от ожидания больше выгоды от локальности
# Отключить ожидание для S3-данных (всё равно ANY)
spark.conf.set("spark.locality.wait", "0")
# Долгое ожидание для кэшированных данных
spark.conf.set("spark.locality.wait.process", "30s")
spark.conf.set("spark.locality.wait.node", "10s")
Жизненный цикл Task: от создания до результата¶
Результат Task передаётся двумя путями:
DirectTaskResult- если результат мал (<spark.task.maxDirectResultSize, default 1 MB): напрямую через DriverIndirectTaskResult- если результат велик: сохраняется в BlockManager Executor-а, Driver получает ссылку и забирает при необходимости
FetchFailed - особый тип ошибки. Возникает когда Task в Stage N не может скачать shuffle-данные у Executor из Stage N-1 (Executor упал после завершения своей Task). В этом случае TaskScheduler помечает Stage N-1 для повторного выполнения - только потерянные shuffle-файлы, не весь Stage.
Retry-механизм и отказоустойчивость¶
| Параметр | Default | Что делает |
|---|---|---|
spark.task.maxFailures |
4 | Максимум попыток одной Task до провала Stage |
spark.stage.maxConsecutiveAttempts |
4 | Максимум повторных попыток Stage |
spark.blacklist.enabled |
false | Исключать ненадёжные Executor-ы из планирования |
spark.blacklist.task.maxTaskAttemptsPerExecutor |
1 | Task retry на одном Executor до его блэклиста |
Speculative Execution: борьба со Straggler-ами¶
Straggler (отстающий) - Task, которая работает значительно дольше медианы по Stage. Причины:
- Медленный диск на узле
- GC-пауза в JVM Executor-а
- Перегрузка сети на конкретном узле
- Data Skew - одна Task получила непропорционально большую партицию
Одна Task из 200 может задержать весь Stage. Именно для этого существует Speculative Execution:
Параметры Speculative Execution:
| Параметр | Default | Что делает |
|---|---|---|
spark.speculation |
false | Включить/выключить |
spark.speculation.interval |
100ms | Как часто проверять отстающие Task |
spark.speculation.multiplier |
1.5 | Task считается straggler если медленнее медианы в X раз |
spark.speculation.quantile |
0.75 | Спекуляция начинается после завершения X% Task Stage |
spark.speculation.minTaskRuntime |
100ms | Минимальное время работы Task до проверки |
Когда НЕ использовать Speculative Execution:
- Запись в базы данных или внешние системы - спекулятивная Task запишет данные дважды
- Не-идемпотентные операции - дублирование вызовет проблемы
- Data Skew - спекуляция не поможет, если задача медленная из-за реально большого объёма данных
spark.conf.set("spark.speculation", "true")
spark.conf.set("spark.speculation.multiplier", "2.0")
spark.conf.set("spark.speculation.quantile", "0.9")
Dynamic Allocation: автомасштабирование Executor-ов¶
Static allocation: задаёте --num-executors 20 и Spark держит 20 Executor-ов всё время Job - даже когда они простаивают между Stage.
Dynamic Allocation: Spark автоматически запрашивает новые Executor-ы когда задач много, и возвращает простаивающие кластер-менеджеру.
Параметры Dynamic Allocation:
spark = SparkSession.builder \
.config("spark.dynamicAllocation.enabled", "true") \
.config("spark.dynamicAllocation.minExecutors", "2") \
.config("spark.dynamicAllocation.maxExecutors", "50") \
.config("spark.dynamicAllocation.initialExecutors", "5") \
.config("spark.dynamicAllocation.executorIdleTimeout", "60s") \
.config("spark.dynamicAllocation.schedulerBacklogTimeout", "1s") \
.getOrCreate()
| Параметр | Что делает |
|---|---|
minExecutors |
Минимум Executor-ов, которые никогда не возвращаются |
maxExecutors |
Максимум Executor-ов (hard limit) |
initialExecutors |
Сколько запросить при старте |
executorIdleTimeout |
Через сколько вернуть простаивающий Executor |
schedulerBacklogTimeout |
Через сколько запросить новый Executor если есть очередь Task |
Требование: Dynamic Allocation требует External Shuffle Service на YARN NodeManager-ах или аналога на K8s. Это отдельный процесс, который хранит shuffle-файлы после остановки Executor-а. Без него возвращённый Executor унесёт с собой свои shuffle-файлы, и следующий Stage не сможет их прочитать.
Анализ Task Scheduling в Spark UI¶
Вкладка Stages → задача по задаче¶
В Spark UI, кликнув на Stage → Task Metrics, видна таблица всех Task с колонками:
| Колонка | Что показывает |
|---|---|
| Locality Level | PROCESS_LOCAL / NODE_LOCAL / RACK_LOCAL / ANY |
| Scheduler Delay | Время от launchTask до начала выполнения на Executor |
| Task Deserialization Time | Время десериализации байткода Task |
| Duration | Полное время выполнения Task |
| GC Time | Время, потраченное на Garbage Collection |
| Shuffle Read/Write | Объём прочитанных/записанных shuffle-данных |
Что ищем:
- Много
ANY→ данные из S3 или отсутствие локальности. Рассмотреть кэширование или выбор правильного partitioner - Большой
Scheduler Delay→ Executor-ы перегружены или cluster manager медленно отвечает - Большой
GC Time(> 10% Duration) → нехватка памяти Executor, объекты слишком часто создаются - Резкий разброс Duration → Data Skew: одна Task обрабатывает намного больше данных
Вкладка Executors¶
Показывает распределение задач по Executor-ам. Если один Executor выполнил значительно больше Task чем другие - возможна проблема с locality (все данные на одном узле) или Speculative Execution не включён.
Практика: сравниваем FIFO и FAIR¶
import threading
from pyspark.sql import SparkSession
from pyspark.sql.functions import count
# Переключаемся на FAIR
spark = SparkSession.builder \
.master("local[4]") \
.config("spark.scheduler.mode", "FAIR") \
.config("spark.sql.shuffle.partitions", "8") \
.getOrCreate()
big_df = spark.range(10_000_000).toDF("id")
small_df = spark.range(100).toDF("id")
results = {}
def run_big():
spark.sparkContext.setLocalProperty("spark.scheduler.pool", "batch")
results["big"] = big_df.groupBy((big_df.id % 1000).alias("bucket")).count().count()
def run_small():
spark.sparkContext.setLocalProperty("spark.scheduler.pool", "interactive")
results["small"] = small_df.count()
# Запустить оба Job одновременно
t1 = threading.Thread(target=run_big)
t2 = threading.Thread(target=run_small)
t1.start()
t2.start()
t1.join()
t2.join()
print(results)
# При FAIR: small завершится намного раньше big, хотя стартовал позже
# При FIFO: small ждёт big
spark.stop()
Откройте http://localhost:4040/jobs/ и посмотрите на временные метки обоих Job - наглядно видна разница между FIFO и FAIR.
Best practices для production¶
Выбор режима планирования:
- FIFO - batch pipeline с одним потоком выполнения. Один Job за раз, предсказуемость
- FAIR - shared кластер с несколькими командами, интерактивные запросы рядом с batch
Настройка Data Locality:
# Для S3/GCS - отключить ожидание, всё равно ANY
spark.conf.set("spark.locality.wait", "0")
# Для HDFS с кэшированием - долгое ожидание
spark.conf.set("spark.locality.wait.process", "10s")
spark.conf.set("spark.locality.wait.node", "5s")
Speculative Execution в production:
# Включить только если операции идемпотентны
spark.conf.set("spark.speculation", "true")
spark.conf.set("spark.speculation.quantile", "0.9") # после 90% задач
spark.conf.set("spark.speculation.multiplier", "3.0") # только если в 3× медленнее
Dynamic Allocation:
# Production YARN
spark.conf.set("spark.dynamicAllocation.enabled", "true")
spark.conf.set("spark.dynamicAllocation.minExecutors", "5") # никогда не ниже
spark.conf.set("spark.dynamicAllocation.maxExecutors", "100") # потолок
spark.conf.set("spark.shuffle.service.enabled", "true") # обязательно с DA
Итог¶
Task Scheduler - это мозг оперативного планирования внутри Spark-приложения. Два ключевых решения, которые он принимает для каждой Task:
Когда запускать (режим планирования):
FIFO- первый пришёл, первый обслужен. Один большой Job занимает весь кластерFAIR- round-robin между Job. Маленькие запросы не голодают рядом с большими
Где запускать (Data Locality + Delay Scheduling):
- Стратегия: PROCESS_LOCAL → NODE_LOCAL → RACK_LOCAL → ANY
- Spark ждёт
locality.waitсекунд на каждом уровне прежде чем понизить требования - Правило: для S3 ставить
locality.wait=0, для HDFS+кэша увеличивать
Поверх этого работают:
- Speculative Execution - дублирование Straggler-ов для устранения "хвостовой задержки"
- Dynamic Allocation - автомасштабирование Executor-ов по реальной нагрузке
Понимание этих механизмов объясняет: почему одни Task запускаются с задержкой, почему кэширование ускоряет повторные Action, и почему locality.wait=0 стоит ставить при работе с объектными хранилищами.