Py4J Gateway и Python Workers: как Python-код переходит в JVM
Архитектура PySpark изнутри: Py4J мост на Driver, Python Worker-процессы на Executor'ах, Pickle vs Arrow и где возникает performance-налог.
Два мира, один фреймворк¶
Apache Spark написан на Scala и работает в JVM. Python-разработчик пишет PySpark-код. Между ними работает мост, который незаметен до тех пор, пока не начинает тормозить.
PySpark - это не «Python-версия» Spark. Это тонкий Python-слой, который через сетевой сокет управляет JVM-процессом. Большинство операций выполняются полностью внутри JVM без участия Python.
Понять эту модель важно по трём причинам:
- Объясняет, почему встроенные функции (
col,lit,when) работают в разы быстрее Python UDF - Показывает, где возникает performance-налог при UDF и как его снизить
- Помогает отлаживать ошибки вида
Py4JJavaError,worker died unexpectedly,Pickle error
Архитектура на Driver: Py4J Gateway¶
На Driver-узле всегда работают два процесса:
Как Py4J работает изнутри¶
Py4J - библиотека, которая позволяет Python-процессу работать с Java-объектами как с обычными Python-объектами. Механизм:
- При запуске
SparkSessionJVM открываетGatewayServerна TCP-порту 25333 - Python создаёт
JavaGateway- клиент к этому серверу - Каждый Java-объект в JVM получает уникальный
id; Python видит proxy-объект с тем жеid - Любой вызов метода на proxy - это
Commandчерез TCP-сокет в JVM и обратно
# Что вы пишете
df = spark.read.parquet("/data/events")
# Что происходит за кулисами
# spark._jvm.org.apache.spark.sql.SparkSession → proxy SparkSession
# .read → proxy DataFrameReader (новый Java-объект, новый id в JVM)
# .parquet("/data/events") → вызов Java-метода, возвращает proxy DataFrame
Каждый такой вызов - сетевой round-trip через loopback (localhost), поэтому цепочки .filter().groupBy().agg() лучше строить в Python до .collect() - каждый шаг посылает один TCP-запрос, а не тысячи.
Что происходит после построения DataFrame¶
Как только вы вызываете Action (collect, show, count, write):
Python не участвует в выполнении задач на Executor'ах - если не используются Python UDF. Всё вычисление происходит в JVM на скорости Tungsten.
Proxy-объекты и утечки памяти¶
Каждый proxy-объект удерживает reference на соответствующий Java-объект в JVM heap. Py4J отслеживает это через finalize() в Python. Проблема:
# Антипаттерн: создание тысяч промежуточных DataFrame в цикле
for table in all_tables:
df = spark.table(table)
result = df.count()
# df - proxy на JVM-объект, финализируется GC Python'а
# при большом числе таблиц JVM heap заполняется proxy-объектами
Способ обойти - явно останавливать ненужные объекты или работать вне циклов, строя план декларативно.
Python Workers на Executor: когда Python всё-таки нужен¶
При отсутствии Python UDF Executor выглядит так:
Python-процессы на Executor'е не запускаются. Всё выполняется внутри JVM.
Как только появляется Python UDF, каждая партиция запускает отдельный Python Worker:
Жизненный цикл Python Worker¶
fork-модель: Python Worker - это полноценный OS-процесс, созданный через fork(). Он наследует всё окружение родительского Python-процесса на Executor'е, включая импортированные модули и открытые соединения.
Worker reuse: повторное использование процессов¶
По умолчанию (spark.python.worker.reuse = true) Spark не завершает Python Worker после обработки партиции. Вместо этого:
- Рабочий процесс помещается в пул ожидания
- Следующая задача той же JVM-задачи получает готовый процесс
- Сэкономленное время - это
fork()+exec()+ загрузка Python +importвсех библиотек
# В SparkConf или spark-defaults.conf
spark.python.worker.reuse = true # default: true
# Когда отключить worker reuse:
# - UDF использует global state, который нельзя сбрасывать
# - Memory leak в third-party библиотеке
# - Отладка (каждый запуск = чистое окружение)
При worker.reuse = true стоимость fork() оплачивается один раз на партицию в первый раз. Последующие партиции получают уже прогретый процесс.
Сериализация: главный враг производительности¶
Pickle: построчная сериализация¶
Стандартный путь Python UDF - двойная сериализация:
На каждую строку:
- JVM преобразует
UnsafeRow→ Python-объект →pickle.dumps()→ bytes - Python Worker:
pickle.loads()→ UDF →pickle.dumps(result)→ bytes - JVM:
pickle.loads()→UnsafeRow
При 100 млн строк это 400 млн сериализаций/десериализаций. Отсюда разрыв в 5–100× по сравнению со встроенными функциями.
CloudPickle: сериализация замыканий¶
Стандартный pickle не умеет сериализовывать функции, лямбды и замыкания. PySpark использует CloudPickle (от Databricks/cloudpickle):
import cloudpickle
# Этот UDF умеет захватывать замыкание:
threshold = 100.0
@udf("boolean")
def is_large(v):
return v > threshold # threshold попадает в байткод через cloudpickle
# cloudpickle сериализует:
# - байткод функции
# - глобальные переменные, захваченные в замыкании (threshold)
# - вложенные функции и классы
Ограничения CloudPickle:
# НЕ сериализуется:
# - открытые файловые дескрипторы
# - database connections (psycopg2.connection, etc.)
# - thread.Lock
# - объекты C-extension без __reduce__
# Типичная ошибка:
db_conn = psycopg2.connect(...) # глобальная переменная
@udf("string")
def lookup(id):
return db_conn.execute(...) # PicklingError при отправке на executor
Правильный паттерн - инициализировать ресурсы внутри UDF на Executor'е:
@udf("string")
def lookup(id):
# Соединение создаётся на Executor'е при первом вызове (worker reuse кэширует)
import psycopg2
conn = psycopg2.connect(host="...", database="...")
return conn.execute("SELECT name FROM t WHERE id = %s", [id]).fetchone()[0]
Apache Arrow и Pandas UDF¶
Что не так с Pickle-подходом¶
Pickle работает построчно: каждая строка - отдельная (де)сериализация. Это хорошо для произвольных Python-объектов, но катастрофа для числовых данных - numpy и pandas работают с батчами.
Apache Arrow: колоночный формат в памяти¶
Apache Arrow - спецификация колоночного формата данных в памяти (in-memory columnar format). Ключевые свойства:
- Данные хранятся по колонкам, не по строкам: все значения колонки
ageподряд в памяти - Нулевая копия (zero-copy) между JVM и Python через shared memory или mmap
- Нативная поддержка в numpy/pandas:
pd.Seriesможно создать из Arrow-буфера без копирования - Батчевая передача: 1 000–10 000 строк за один вызов
Типы Pandas UDF¶
from pyspark.sql.functions import pandas_udf
import pandas as pd
# Scalar UDF: Series → Series (построчная логика над батчем)
@pandas_udf("double")
def normalize(s: pd.Series) -> pd.Series:
return (s - s.mean()) / s.std()
df.withColumn("norm_age", normalize("age"))
# Grouped Map UDF (applyInPandas): каждая группа → pd.DataFrame → pd.DataFrame
def fill_gaps(key, pdf):
# pdf - весь pandas DataFrame для данной группы
return pdf.fillna(method="ffill")
df.groupBy("user_id").applyInPandas(fill_gaps, schema=df.schema)
# Grouped Aggregate UDF: Series → scalar (кастомные агрегаты)
@pandas_udf("double")
def trimmed_mean(s: pd.Series) -> float:
from scipy.stats import trim_mean
return trim_mean(s.dropna(), 0.1)
df.groupBy("category").agg(trimmed_mean("price"))
Размер Arrow-батча¶
Параметр spark.sql.execution.arrow.maxRecordsPerBatch (default: 10 000) управляет размером батча. Слишком большой батч → OOM в Python Worker; слишком маленький → overhead на создание Arrow RecordBatch:
# Для тяжёлых ML-моделей (много памяти на батч):
spark.conf.set("spark.sql.execution.arrow.maxRecordsPerBatch", "1000")
# Для лёгких операций (накладные расходы важнее):
spark.conf.set("spark.sql.execution.arrow.maxRecordsPerBatch", "50000")
Сравнение подходов: встроенные функции vs UDF vs Pandas UDF¶
Сравнение по характеристикам:
| Характеристика | Built-in | Python UDF | Pandas UDF | applyInPandas |
|---|---|---|---|---|
| Выполнение | JVM Tungsten | Python Worker | Python Worker (Arrow) | Python Worker (Arrow) |
| Сериализация | нет | Pickle построчно | Arrow батч | Arrow батч |
| Скорость | 1× (baseline) | 5–100× медленнее | 2–5× медленнее | зависит от группы |
| Catalyst pushdown | да | нет | нет | нет |
| Поддержка null | автоматически | нужно проверять вручную | через pd.NA |
через pd.NA |
| Memory | executor heap | worker process | worker process | вся группа в памяти |
Почему Catalyst pushdown теряется в UDF¶
# Фильтр сработает ПОСЛЕ UDF (Catalyst не видит внутрь Python)
df.withColumn("result", my_udf("value")).filter(col("result") > 0)
# Если бизнес-логика позволяет, лучше фильтровать ДО UDF:
df.filter(col("value") > 0).withColumn("result", my_udf("value"))
Путь данных: полная картина¶
Диагностика и отладка¶
Находим Python Worker процессы¶
# На каждом Executor'е при работающем job:
ps aux | grep pyspark.worker
# Типичный вывод:
# spark 12345 99.0 2.1 820MB python3 -m pyspark.worker
# spark 12346 98.5 2.0 815MB python3 -m pyspark.worker
# spark 12347 97.2 1.9 800MB python3 -m pyspark.worker
# - один процесс на партицию (при worker.reuse=true - переиспользуются)
Spark UI: выявляем UDF-налог¶
В Spark UI → вкладка SQL → найти план выполнения. Ищите узлы:
ArrowEvalPython- Pandas UDF через ArrowBatchEvalPython- Python UDF через Pickle батчPythonUDF- построчный Python UDF
Если видите BatchEvalPython, смотрите на Task Duration vs Executor Computing Time - большая разница указывает на Python-налог.
Типичные ошибки и их причины¶
Читаем Python Worker логи¶
# Включить детальный лог Worker'а:
spark.sparkContext.setLogLevel("DEBUG")
# Или через SparkConf:
# spark.python.worker.reuse=false # каждый запуск = новый лог
Worker пишет stderr → попадает в логи Executor'а. В YARN:
yarn logs -applicationId application_XXXXX_YYYYY | grep "Python worker"
В Kubernetes:
kubectl logs spark-exec-1 -c spark-kubernetes-driver | grep "PythonRunner"
Типичная диагностика Py4JJavaError¶
try:
df.write.mode("overwrite").parquet("/output")
except Exception as e:
# Py4JJavaError содержит вложенное Java-исключение
# Нужно искать "Caused by:" в трейсбеке
print(str(e)) # первая строка - Python wrapper
# Реальная причина - глубже в стеке
Практика¶
1. Измерить стоимость Python UDF vs built-in¶
from pyspark.sql.functions import col, lower, trim, pandas_udf, udf
from pyspark.sql.types import StringType
import pandas as pd
import time
# Создаём тестовый датасет
df = spark.range(10_000_000).selectExpr(
"CAST(id AS STRING) AS raw_name",
"CONCAT(' USER_', CAST(id AS STRING), ' ') AS name"
)
df.cache()
df.count() # прогрев
# Вариант 1: built-in функции
t0 = time.time()
df.select(lower(trim(col("name")))).count()
t_builtin = time.time() - t0
# Вариант 2: Python UDF (Pickle)
@udf(StringType())
def clean_udf(s):
return s.strip().lower() if s else None
t0 = time.time()
df.select(clean_udf(col("name"))).count()
t_pyudf = time.time() - t0
# Вариант 3: Pandas UDF (Arrow)
@pandas_udf(StringType())
def clean_pandas(s: pd.Series) -> pd.Series:
return s.str.strip().str.lower()
t0 = time.time()
df.select(clean_pandas(col("name"))).count()
t_pandas = time.time() - t0
print(f"Built-in: {t_builtin:.1f}s (1.0×)")
print(f"Python UDF: {t_pyudf:.1f}s ({t_pyudf/t_builtin:.1f}×)")
print(f"Pandas UDF: {t_pandas:.1f}s ({t_pandas/t_builtin:.1f}×)")
Ожидаемые результаты при 10 млн строк:
| Метод | Время | Множитель |
|---|---|---|
| Built-in | ~2–3 с | 1× |
| Pandas UDF | ~6–12 с | 3–5× |
| Python UDF | ~30–120 с | 15–50× |
2. Профилировать через план выполнения¶
# Посмотреть план с указанием типа UDF
df.select(clean_udf(col("name"))).explain(mode="extended")
# Ищем: BatchEvalPython или ArrowEvalPython
df.select(clean_pandas(col("name"))).explain(mode="extended")
# Ищем: ArrowEvalPython - хороший знак (Arrow путь)
3. Замерить стоимость Py4J round-trip¶
import time
# Один вызов через Py4J
t0 = time.time()
for _ in range(1000):
_ = spark.sparkContext.defaultParallelism # одно свойство через TCP
print(f"1000 Py4J calls: {(time.time()-t0)*1000:.1f}ms")
# Обычно 50–200ms для 1000 вызовов - ~0.1ms на вызов
# Вывод: не вызывать Py4J в Python-цикле с большим числом итераций
4. Отладка CloudPickle¶
import cloudpickle
# Проверить, сериализуется ли UDF до отправки на Executor
@udf("string")
def my_udf(s):
return s.upper()
try:
serialized = cloudpickle.dumps(my_udf.func)
print(f"Размер сериализованного UDF: {len(serialized)} bytes")
# Восстановить:
restored = cloudpickle.loads(serialized)
print(f"Тест: {restored('hello')}")
except Exception as e:
print(f"Ошибка сериализации: {e}")
# Это то, что увидит Executor при попытке выполнить задачу
Лучшие практики¶
| Ситуация | Рекомендация |
|---|---|
| Строковые операции | regexp_extract, substring, split - built-in |
| Числовые трансформации | col * 2, log(col), round - built-in |
| ML inference (sklearn, torch) | Pandas UDF, batch=1000–5000 |
| Статистика и scipy | Pandas UDF |
| Сложная бизнес-логика (ветвление, словари) | Pandas UDF или Python UDF с broadcast |
| Внешние API-вызовы | Python UDF + connection pooling внутри UDF |
| Нельзя переписать legacy | Python UDF, мониторить через Spark UI |
Резюме¶
PySpark - двухпроцессная архитектура:
- Driver: Python ↔ Py4J Gateway (TCP :25333) ↔ JVM SparkSession. Python строит логический план; JVM его оптимизирует и выполняет.
- Executor без UDF: только JVM Task Threads на скорости Tungsten. Python не запускается.
- Executor с Python UDF: каждая партиция → Python Worker Process → Pickle (строка за строкой) → 5–100× накладные расходы.
- Executor с Pandas UDF: Python Worker + Apache Arrow (батчи) → zero-copy columnar → 2–5× накладные расходы.
Ключевое правило: чем меньше данных пересекает границу JVM↔Python, тем быстрее. Встроенные функции никогда не пересекают эту границу.