Py4J Gateway и Python Workers: как Python-код переходит в JVM

Архитектура PySpark изнутри: Py4J мост на Driver, Python Worker-процессы на Executor'ах, Pickle vs Arrow и где возникает performance-налог.

core

Два мира, один фреймворк

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-объектами. Механизм:

  1. При запуске SparkSession JVM открывает GatewayServer на TCP-порту 25333
  2. Python создаёт JavaGateway - клиент к этому серверу
  3. Каждый Java-объект в JVM получает уникальный id; Python видит proxy-объект с тем же id
  4. Любой вызов метода на 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 после обработки партиции. Вместо этого:

  1. Рабочий процесс помещается в пул ожидания
  2. Следующая задача той же JVM-задачи получает готовый процесс
  3. Сэкономленное время - это 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 через Arrow
  • BatchEvalPython - 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, тем быстрее. Встроенные функции никогда не пересекают эту границу.