Pandas UDF с Arrow: @pandas_udf, Series-батч вместо строки, скорость vs Row-at-a-time UDF

Как Apache Arrow превращает медленные Python UDF в векторизованные вычисления: @pandas_udf, batch execution, ArrowEvalPython, tuning maxRecordsPerBatch.

optimization

Архитектурный кризис классических Python UDF

Прежде чем изучать решение, необходимо точно понять, что именно ломается при использовании обычного Python UDF в Spark. Это не просто «медленнее, потому что Python» - здесь несколько независимых проблем, каждая из которых вносит свой вклад в катастрофическую деградацию производительности.

Как Spark вообще выполняет код

Чтобы понять, почему Python UDF так дорого обходится, нужно сначала понять, как Spark выполняет код без Python.

Когда вы пишете df.filter(F.col("amount") > 1000).groupBy("region").agg(F.sum("amount")), Spark делает следующее:

  1. Catalyst optimizer строит Logical Plan и оптимизирует его (predicate pushdown, column pruning, join reordering)
  2. Physical Planner выбирает конкретные алгоритмы (HashAggregate vs SortAggregate)
  3. Whole-Stage Code Generation (WSCG) генерирует специализированный Java-байткод для всей цепочки операторов - без вызовов виртуальных функций, без интерпретатора, напрямую
  4. JIT компилирует этот байткод в нативный машинный код
  5. Executor выполняет нативный код над данными в UnsafeRow-формате прямо в памяти

Всё это происходит целиком внутри JVM executor. Данные не покидают JVM. Оптимизатор видит полный граф вычислений и может применять правила оптимизации на любом уровне.

Что ломает Python UDF: разрыв исполнения

Когда вы регистрируете обычный Python UDF через @udf, Spark встречает функцию, которую он не может анализировать. Catalyst видит её как непрозрачный «чёрный ящик» и вынужден:

  • Прекратить Whole-Stage Code Generation на этом месте
  • Вставить специальный физический оператор BatchEvalPython
  • Передать данные в внешний Python-процесс
  • Получить результаты обратно
  • Продолжить JVM-выполнение

Этот разрыв - граница между JVM и Python-процессом - и есть источник всех проблем.

Проблема 1: Row-at-a-time сериализация через Pickle

Классический Python UDF обрабатывает одну строку за один вызов функции. Механизм передачи данных выглядит так:

Для каждой строки в батче (например, 8192 строк):
  1. Executor берёт значения нужных колонок из UnsafeRow
  2. Конвертирует JVM-типы в Python-типы (int → PyLong, str → PyUnicode, ...)
  3. Сериализует Python-объекты через Pickle (бинарный формат)
  4. Записывает pickled байты в pipe / socket к Python-воркеру
  5. Python-воркер читает байты, десериализует через Pickle
  6. Вызывает вашу функцию с одним значением
  7. Получает результат, сериализует обратно через Pickle
  8. Отправляет обратно в JVM
  9. JVM десериализует результат, конвертирует в JVM-тип
  10. Записывает в выходной UnsafeRow

Для DataFrame из 10 миллионов строк этот цикл выполняется 10 миллионов раз. Каждый раз - аллокация Python-объектов, Pickle сериализация (CPU-интенсивная операция), системный вызов write(), контекстный переключатель, системный вызов read() на стороне Python-воркера.

Pickle - это не быстрый формат. Он универсален (сериализует любые Python-объекты), но работает в интерпретируемом Python-коде и создаёт бинарные структуры с метаданными для каждого объекта. Сериализация числа 3.14 через Pickle - это ~20 байт и несколько микросекунд. Умножьте на 10 миллионов строк.

Проблема 2: Python-воркер как отдельный процесс

Python-воркер в Spark - это отдельный OS-процесс, а не поток JVM executor'а. Spark запускает его через subprocess.Popen, и общение происходит через Unix pipe или TCP socket.

Каждый системный вызов write() / read() - это переключение контекста между JVM-процессом и Python-процессом. Переключение контекста стоит 1–10 микросекунд (сброс CPU pipeline, переключение MMU, инвалидация кэша). При 10 миллионах строк и нескольких переключениях на строку - это секунды только на переключения контекста, без учёта самих вычислений.

Проблема 3: Catalyst - слепая зона

Catalyst optimizer видит Python UDF как непрозрачную функцию. Он не знает, что происходит внутри, поэтому:

  • Нет predicate pushdown через UDF: если ваш UDF возвращает bool и вы делаете .filter(my_udf(col)), Catalyst не может протолкнуть этот фильтр в Parquet reader - он вынужден прочитать все данные, передать в Python UDF, и только потом отфильтровать
  • Нет оптимизации порядка операций: если после UDF идёт JOIN, Catalyst не может переставить UDF и JOIN местами даже если это было бы выгодно
  • Нет constant folding: если аргументы UDF известны на этапе планирования - Catalyst всё равно будет вызывать UDF для каждой строки

Это принципиальное ограничение: Python - не Spark SQL, компилятор Catalyst не понимает Python-код.

Измерим проблему: простой бенчмарк

from pyspark.sql import SparkSession
from pyspark.sql import functions as F
from pyspark.sql.types import DoubleType
import time
import math

spark = SparkSession.builder \
    .master("local[4]") \
    .config("spark.driver.memory", "4g") \
    .getOrCreate()

# Создаём тестовый DataFrame: 5 миллионов строк
df = spark.range(5_000_000) \
    .withColumn("lat1", (F.rand() * 180 - 90).cast("double")) \
    .withColumn("lon1", (F.rand() * 360 - 180).cast("double")) \
    .withColumn("lat2", (F.rand() * 180 - 90).cast("double")) \
    .withColumn("lon2", (F.rand() * 360 - 180).cast("double")) \
    .cache()

df.count()  # форсируем кэширование

# ──────────────────────────────────────────────────────────
# ВАРИАНТ 1: классический @udf - row-at-a-time
# ──────────────────────────────────────────────────────────
# Формула гаверсинуса вычисляет расстояние между двумя
# точками на сфере по их географическим координатам.
# Это типичная "нестандартная математика" - нет встроенной
# функции Spark SQL для этого расчёта.
@F.udf(returnType=DoubleType())
def haversine_udf(lat1, lon1, lat2, lon2):
    """Расстояние в км между двумя точками на Земле."""
    R = 6371.0  # радиус Земли в км
    phi1, phi2 = math.radians(lat1), math.radians(lat2)
    dphi = math.radians(lat2 - lat1)
    dlambda = math.radians(lon2 - lon1)
    a = math.sin(dphi/2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda/2)**2
    return R * 2 * math.asin(math.sqrt(a))

start = time.time()
result_udf = df.withColumn(
    "distance_km",
    haversine_udf("lat1", "lon1", "lat2", "lon2")
)
count = result_udf.count()  # форсируем выполнение
elapsed_udf = time.time() - start

print(f"Классический @udf: {elapsed_udf:.1f} сек на {count:,} строк")
# Ожидаемо: 45–120 секунд в зависимости от железа

Для 5 миллионов строк классический UDF с формулой гаверсинуса занимает от 45 до 120 секунд на типичном кластере. Это неприемлемо для production-пайплайнов, где данных обычно на порядки больше.


Спасение векторизацией: роль Apache Arrow

Apache Arrow решает проблему UDF принципиально иначе: вместо передачи одной строки за один вызов, он передаёт целый батч строк за один вызов, причём в columnar формате, который Python-библиотеки (NumPy, Pandas) понимают нативно.

Концепция: от строк к колонкам батчами

Ключевой сдвиг - переход от Row-at-a-time к Batch-at-a-time. Вместо того чтобы вызывать функцию 10 миллионов раз с одним значением, Spark вызывает её несколько тысяч раз с батчем из тысяч значений (по умолчанию 10 000).

Но само по себе батчирование не решило бы проблему - Pickle всё равно был бы слишком медленным для сериализации батчей. Ключ в том, как именно батч передаётся: через Apache Arrow IPC.

Arrow IPC: данные без копирования

Когда Spark передаёт данные в Python-воркер через Arrow, происходит следующее:

  1. JVM Executor берёт батч строк из ColumnarBatch (это уже colum-ориентированные данные в памяти JVM)
  2. Данные сериализуются в Arrow IPC формат: FlatBuffer с метаданными схемы + бинарные буферы с данными
  3. Arrow IPC отправляется в Python-воркер один раз для целого батча - не построчно
  4. Python-воркер принимает Arrow IPC и создаёт pa.RecordBatch объект, который смотрит на те же бинарные буферы без копирования (zero-copy via shared memory)
  5. Из pa.RecordBatch создаётся pd.Series - это тоже zero-copy через NumPy array, который смотрит на Arrow буфер
  6. Ваша функция получает готовый pd.Series с тысячами значений
  7. Функция возвращает pd.Series с результатами
  8. Arrow IPC отправляется обратно в JVM

Принципиальное различие: вместо 10 миллионов Pickle-сериализаций - несколько тысяч Arrow IPC транзакций. Вместо 10 миллионов системных вызовов - несколько тысяч. Вместо 10 миллионов Python-вызовов - несколько тысяч.

Но самое важное: когда ваша функция получает pd.Series, она может применять NumPy-векторизованные операции - операции, которые выполняются в скомпилированном C-коде NumPy над непрерывными массивами памяти. Это на порядки быстрее, чем цикл Python-интерпретатора.

Почему NumPy - это не Python

Важно развеять заблуждение: «Python - медленный язык». Это верно для интерпретируемого Python-кода с циклами. Но NumPy-операции - это тонкий Python-обёрток над скомпилированным C/Fortran кодом, работающим над непрерывными массивами памяти.

import numpy as np
import time

data = np.random.randn(10_000_000)

# Python-цикл: 10 миллионов Python-вызовов math.sin
start = time.time()
result_loop = [math.sin(x) for x in data]
print(f"Python loop: {time.time() - start:.2f}s")   # ~8–12 секунд

# NumPy: один вызов C-кода над всем массивом
start = time.time()
result_numpy = np.sin(data)
print(f"NumPy vectorized: {time.time() - start:.2f}s")  # ~0.05–0.1 секунды

NumPy в 100–200 раз быстрее для арифметических операций именно потому, что:

  • Работает в скомпилированном C-коде
  • Данные лежат непрерывно в памяти (contiguous array) - оптимально для кэша CPU
  • Использует SIMD-инструкции (SSE/AVX) автоматически через компилятор

Именно этот NumPy-потенциал и раскрывает @pandas_udf: ваша функция получает pd.Series (который внутри - NumPy array), и если вы пишете векторизованные операции, они выполняются в C-коде с SIMD.

На схеме наглядно видна разница: классический UDF создаёт 10 миллионов micro-транзакций (строка → Pickle → Python → Pickle → JVM), каждая с overhead сериализации и системных вызовов. Pandas UDF через Arrow создаёт ~1000 batch-транзакций (10M строк / 10k строк в батче), каждая передаёт тысячи значений одним Arrow IPC пакетом, а NumPy внутри обрабатывает все значения в C-коде.


Инструментарий: декоратор @pandas_udf

Включение Arrow в Spark

Прежде чем использовать @pandas_udf, необходимо убедиться, что Arrow оптимизация включена. В современных версиях Spark (3.x) она включена по умолчанию, но лучше явно проверить:

# ──────────────────────────────────────────────────────────
# Конфигурация Arrow для Pandas UDF
# ──────────────────────────────────────────────────────────
spark = SparkSession.builder \
    .master("local[4]") \
    .config("spark.driver.memory", "4g") \
    # Arrow enabled by default в Spark 3.x, но лучше явно:
    .config("spark.sql.execution.arrow.pyspark.enabled", "true") \
    # Максимальное число строк в одном Arrow-батче.
    # Default: 10000. Можно настраивать (см. раздел tuning).
    .config("spark.sql.execution.arrow.maxRecordsPerBatch", "10000") \
    # Python-воркер переиспользуется между задачами.
    # Выключить только если воркер накапливает состояние.
    .config("spark.python.worker.reuse", "true") \
    .getOrCreate()

# Проверяем, что Arrow действительно включён:
arrow_enabled = spark.conf.get("spark.sql.execution.arrow.pyspark.enabled")
print(f"Arrow enabled: {arrow_enabled}")  # true

Тип 1: Series → Series (Scalar Pandas UDF)

Самый частый тип - трансформация одной или нескольких колонок в новую колонку. Функция принимает одну или несколько pd.Series и возвращает одну pd.Series той же длины.

Тайп-хинты (type hints) здесь не просто документация - Spark использует их для генерации правильной схемы и выбора механизма передачи данных:

import pandas as pd
import numpy as np
from pyspark.sql.functions import pandas_udf

# ──────────────────────────────────────────────────────────
# Простой пример: математическая трансформация
# ──────────────────────────────────────────────────────────
# Тайп-хинт `pd.Series -> pd.Series` говорит Spark:
#   - принять одну колонку как pd.Series
#   - вернуть pd.Series той же длины
# Декоратор @pandas_udf нужен для регистрации в Spark.
@pandas_udf("double")
def normalize_zscore(series: pd.Series) -> pd.Series:
    """
    Z-score нормализация: (x - mean) / std.
    Это вычисляется в NumPy - C-код с SIMD.
    """
    # series.mean() и series.std() - NumPy операции над всем батчем
    # (series - mean) / std - vectorized вычитание и деление
    mean = series.mean()
    std = series.std()
    # Защита от деления на ноль: если std == 0, возвращаем 0
    if std == 0:
        return pd.Series(np.zeros(len(series)))
    return (series - mean) / std

# ──────────────────────────────────────────────────────────
# Pandas UDF с несколькими входными колонками
# ──────────────────────────────────────────────────────────
# Для нескольких колонок нужно использовать F.struct() или
# аргументы в порядке колонок
@pandas_udf("double")
def haversine_pandas_udf(
    lat1: pd.Series,
    lon1: pd.Series,
    lat2: pd.Series,
    lon2: pd.Series
) -> pd.Series:
    """
    Формула гаверсинуса - расстояние в км между точками.

    Ключевое отличие от @udf:
    lat1, lon1, lat2, lon2 - это pd.Series с 10000 значений,
    а не одно float-значение. Все numpy операции работают
    над целым массивом одновременно.
    """
    R = 6371.0  # радиус Земли в км

    # np.radians применяется ко всему Series сразу - C-код
    phi1 = np.radians(lat1)
    phi2 = np.radians(lat2)
    dphi = np.radians(lat2 - lat1)
    dlambda = np.radians(lon2 - lon1)

    # np.sin, np.cos - векторизованные операции с SIMD
    # Каждая операция обрабатывает весь массив батча
    a = (np.sin(dphi / 2) ** 2
         + np.cos(phi1) * np.cos(phi2) * np.sin(dlambda / 2) ** 2)

    # np.arcsin, np.sqrt - тоже векторизованы
    return pd.Series(R * 2 * np.arcsin(np.sqrt(a)))

# Применяем Pandas UDF к DataFrame
result = df.withColumn(
    "distance_km",
    haversine_pandas_udf("lat1", "lon1", "lat2", "lon2")
)

start = time.time()
count = result.count()
elapsed_pandas_udf = time.time() - start

print(f"@pandas_udf: {elapsed_pandas_udf:.1f} сек")
# Ожидаемо: 2–8 секунд - в 10–50× быстрее классического @udf

Обратите внимание на структуру haversine_pandas_udf: весь код написан без единого Python-цикла. Каждая операция (np.radians, np.sin, np.cos, *, +, **) работает над всем pd.Series (который внутри - NumPy array) целиком. NumPy компилирует эти операции в нативный C-код и использует AVX/SSE инструкции на современных процессорах.

Тип 2: Iterator of Series → Iterator of Series

Этот тип решает специфическую проблему: тяжёлая инициализация, которую нужно выполнить один раз на partition, а не на каждый батч.

Представьте, что ваш UDF делает ML-инференс. Модель (например, scikit-learn или PyTorch) весит 500 MB. Если загружать её для каждого Arrow-батча (10 000 строк), то на partition из 5 миллионов строк это 500 загрузок модели - явно неразумно. Нужно загрузить модель один раз, а потом применять к батчам по очереди.

Iterator of Series → Iterator of Series именно это и позволяет: функция получает генератор батчей, инициализируется один раз, и обрабатывает все батчи партиции в одной функции:

from typing import Iterator

# ──────────────────────────────────────────────────────────
# Iterator-паттерн: загрузка модели один раз на partition
# ──────────────────────────────────────────────────────────
# Тайп-хинт Iterator[pd.Series] -> Iterator[pd.Series]
# говорит Spark: эта функция получает итератор батчей
# и возвращает итератор результатов.
@pandas_udf("double")
def ml_score_iterator(
    iterator: Iterator[pd.Series]
) -> Iterator[pd.Series]:
    """
    ML-скоринг с загрузкой модели один раз на partition.

    Структура:
    1. Код ДО цикла for выполняется один раз при старте partition
    2. Цикл for итерирует по батчам
    3. Каждый yield отправляет батч результатов в Spark
    """
    # ── ИНИЦИАЛИЗАЦИЯ (один раз на partition) ──────────────
    # Здесь можно загружать:
    # - ML-модель из файла или object storage
    # - Lookup-словари (тысячи записей)
    # - Тяжёлые коэффициенты для бизнес-правил
    # - Подключения к внешним сервисам (с осторожностью)
    import joblib
    import numpy as np

    # Загрузка модели выполняется ОДИН РАЗ для всей partition.
    # Если partition содержит 1M строк и батч 10k строк,
    # модель загружается 1 раз, а не 100 раз.
    model = joblib.load("/models/fraud_detection_v2.pkl")

    # Любая другая тяжёлая инициализация:
    # lookup_dict = load_large_dictionary()
    # scaler = load_feature_scaler()

    # ── ОБРАБОТКА БАТЧЕЙ ───────────────────────────────────
    # Теперь итерируем по батчам - каждый батч это pd.Series
    for batch in iterator:
        # batch - pd.Series с 10000 значений (или меньше в последнем батче)
        # Преобразуем в формат, который ожидает модель
        X = batch.values.reshape(-1, 1)  # (10000, 1) NumPy array

        # Инференс: модель работает над всем батчем
        # (внутри scikit-learn тоже NumPy/BLAS - быстро)
        scores = model.predict_proba(X)[:, 1]  # вероятность фрода

        # yield отправляет батч результатов в Spark
        # и функция "засыпает" до следующего батча
        yield pd.Series(scores)

# Для нескольких входных колонок:
@pandas_udf("double")
def ml_score_multi_cols(
    iterator: Iterator[tuple[pd.Series, pd.Series, pd.Series]]
) -> Iterator[pd.Series]:
    """ML-скоринг с несколькими признаками."""
    import joblib

    model = joblib.load("/models/risk_model.pkl")

    for amount_batch, merchant_id_batch, hour_batch in iterator:
        # Собираем признаки в матрицу
        X = np.column_stack([
            amount_batch.values,
            merchant_id_batch.values,
            hour_batch.values
        ])
        scores = model.predict_proba(X)[:, 1]
        yield pd.Series(scores)

# Использование с F.struct для множественных колонок
result = df.withColumn(
    "fraud_score",
    ml_score_multi_cols("amount", "merchant_id", "hour_of_day")
)

Паттерн с Iterator критически важен для production ML-пайплайнов. Без него каждый батч потребовал бы загрузки модели, что превратило бы весь IO в узкое место. С Iterator'ом модель загружается один раз на Python-воркер, и эта одна загрузка обслуживает все батчи этой партиции.

Тип 3: Grouped Map - applyInPandas

Третий тип Pandas UDF предназначен для паттерна split-apply-combine: разделить DataFrame по ключу группировки, применить произвольную функцию к каждой группе (как полноценному pd.DataFrame), объединить результаты.

Это принципиально отличается от groupBy().agg(): agg() поддерживает только операции, которые можно выразить через встроенные функции (SUM, COUNT, AVG...). applyInPandas позволяет применить любую Python-логику к каждой группе:

# ──────────────────────────────────────────────────────────
# applyInPandas: произвольная логика для каждой группы
# ──────────────────────────────────────────────────────────
# Пример: нормализация продаж по регионам.
# Для каждого region вычисляем z-score относительно
# среднего и стандартного отклонения ТОЛЬКО этого региона.
from pyspark.sql.types import StructType, StructField, StringType, DoubleType, LongType

# Схема результирующего DataFrame должна быть объявлена явно.
# Это не опционально - Spark не может вывести схему из Python-кода.
output_schema = StructType([
    StructField("region", StringType(), True),
    StructField("order_id", LongType(), True),
    StructField("amount", DoubleType(), True),
    StructField("amount_normalized", DoubleType(), True),
    StructField("amount_rank", DoubleType(), True),
])

def normalize_by_region(pdf: pd.DataFrame) -> pd.DataFrame:
    """
    Функция принимает pd.DataFrame для ОДНОЙ группы (одного region).
    Возвращает pd.DataFrame с той же или другой схемой.

    Здесь мы можем использовать весь арсенал Pandas:
    - произвольные вычисления по всей группе
    - операции, которые требуют видеть все строки группы
    - интеграцию с любыми Python-библиотеками
    """
    # pdf - это обычный pd.DataFrame только для одного region
    # Нормализуем amount внутри группы
    mean = pdf["amount"].mean()
    std = pdf["amount"].std()

    pdf["amount_normalized"] = (pdf["amount"] - mean) / (std if std > 0 else 1)

    # Ранг внутри группы (0.0 = минимум, 1.0 = максимум)
    pdf["amount_rank"] = pdf["amount"].rank(pct=True)

    return pdf

# groupBy указывает ключ разбиения на группы
# applyInPandas применяет функцию к каждой группе
result = df.groupBy("region").applyInPandas(
    normalize_by_region,
    schema=output_schema
)

applyInPandas полезен, но имеет важное ограничение: вся группа должна помещаться в памяти одного Python-воркера. Если у вас группа из миллионов строк, это может вызвать OOM. Планируйте размер групп заранее.

Физический план: ArrowEvalPython

Когда Spark строит физический план для запроса с @pandas_udf, вы увидите специфический оператор - ArrowEvalPython. Это принципиально отличается от BatchEvalPython (классический UDF):

# ──────────────────────────────────────────────────────────
# Анализ физического плана
# ──────────────────────────────────────────────────────────
# Применяем pandas_udf к DataFrame
result = df.withColumn(
    "distance_km",
    haversine_pandas_udf("lat1", "lon1", "lat2", "lon2")
)

# explain() показывает физический план
result.explain(extended=True)

# Ключевые части вывода:
#
# == Physical Plan ==
# *(2) Project [id#0L, lat1#4, lon1#6, lat2#8, lon2#10, distance_km#42]
# +- ArrowEvalPython [haversine_pandas_udf(lat1#4, lon1#6, lat2#8, lon2#10)#41],
#    [id#0L, lat1#4, lon1#6, lat2#8, lon2#10, distance_km#41],
#    evalType=SQL_SCALAR_PANDAS_UDF
#    +- *(1) Project [id#0L, ...]
#       +- *(1) Range (0, 5000000, step=1, splits=4)
#
# Что означают ключевые части:
#
# ArrowEvalPython - оператор для Pandas UDF через Arrow.
#   Если бы это был классический @udf, было бы BatchEvalPython.
#
# evalType=SQL_SCALAR_PANDAS_UDF - тип Pandas UDF:
#   SQL_SCALAR_PANDAS_UDF = Series → Series
#   SQL_SCALAR_PANDAS_ITER_UDF = Iterator of Series
#   SQL_GROUPED_MAP_PANDAS_UDF = applyInPandas
#
# *(1) и *(2) - номера Whole-Stage Codegen стадий.
# Обратите внимание: ArrowEvalPython разрывает WSCG!
# Стадия *(1) компилируется в JVM bytecode.
# ArrowEvalPython - граница, данные уходят в Python.
# Стадия *(2) - снова JVM bytecode.
#
# Это означает: нативные функции Spark SQL (F.sum, F.filter)
# внутри WSCG работают со скоростью нативного JVM-кода.
# Pandas UDF - всегда разрыв WSCG, всегда slower.

# Для сравнения - классический @udf в explain():
# BatchEvalPython [haversine_udf(...)], evalType=SQL_BATCHED_UDF
# Вместо ArrowEvalPython - BatchEvalPython.
# Принципиальная разница: BatchEvalPython использует Pickle,
# ArrowEvalPython - Arrow IPC.

Понимание EXPLAIN вывода критически важно для диагностики производительности. Если вы видите BatchEvalPython там, где ожидаете ArrowEvalPython, проверьте: включён ли Arrow, правильно ли объявлены тайп-хинты в функции.


Сравнение производительности: бенчмарк

Проведём систематическое сравнение трёх подходов на одном датасете - формула гаверсинуса для 5 миллионов строк:

# ──────────────────────────────────────────────────────────
# Полный бенчмарк: @udf vs @pandas_udf vs native SQL
# ──────────────────────────────────────────────────────────
import time
import math
import numpy as np
import pandas as pd
from pyspark.sql import functions as F
from pyspark.sql.functions import pandas_udf, udf
from pyspark.sql.types import DoubleType

def benchmark(name, df_func, df, n_runs=3):
    """Запускает функцию n_runs раз и возвращает медианное время."""
    times = []
    for i in range(n_runs):
        start = time.time()
        df_func(df).count()  # count() форсирует полное выполнение
        elapsed = time.time() - start
        times.append(elapsed)
        print(f"  Run {i+1}: {elapsed:.2f}s")
    median = sorted(times)[n_runs // 2]
    print(f"  Median: {median:.2f}s\n")
    return median

# ──────────────────────────────────────────────────────────
# Вариант 1: Классический @udf - row-at-a-time
# ──────────────────────────────────────────────────────────
@udf(returnType=DoubleType())
def haversine_row_udf(lat1, lon1, lat2, lon2):
    R = 6371.0
    phi1, phi2 = math.radians(lat1), math.radians(lat2)
    dphi = math.radians(lat2 - lat1)
    dlambda = math.radians(lon2 - lon1)
    a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlambda/2)**2
    return R * 2 * math.asin(math.sqrt(a))

# ──────────────────────────────────────────────────────────
# Вариант 2: @pandas_udf - Arrow batch, Python loops
# ──────────────────────────────────────────────────────────
# Внимание: здесь умышленно написан НЕПРАВИЛЬНЫЙ pandas_udf
# с Python-циклом внутри. Это покажет, что батчирование
# само по себе без векторизации даёт меньший прирост.
@pandas_udf("double")
def haversine_pandas_loop(lat1, lon1, lat2, lon2):
    """Pandas UDF с Python-циклом ВНУТРИ (антипаттерн!)."""
    result = []
    for a, b, c, d in zip(lat1, lon1, lat2, lon2):
        R = 6371.0
        phi1, phi2 = math.radians(a), math.radians(c)
        dphi = math.radians(c - a)
        dlambda = math.radians(d - b)
        v = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlambda/2)**2
        result.append(R * 2 * math.asin(math.sqrt(v)))
    return pd.Series(result)

# ──────────────────────────────────────────────────────────
# Вариант 3: @pandas_udf - Arrow batch + NumPy vectorized
# ──────────────────────────────────────────────────────────
@pandas_udf("double")
def haversine_pandas_numpy(lat1, lon1, lat2, lon2):
    """Pandas UDF с NumPy векторизацией (правильный подход)."""
    R = 6371.0
    phi1 = np.radians(lat1)
    phi2 = np.radians(lat2)
    dphi = np.radians(lat2 - lat1)
    dlambda = np.radians(lon2 - lon1)
    a = np.sin(dphi/2)**2 + np.cos(phi1)*np.cos(phi2)*np.sin(dlambda/2)**2
    return pd.Series(R * 2 * np.arcsin(np.sqrt(a)))

# ──────────────────────────────────────────────────────────
# Вариант 4: Native Spark SQL (ground truth - самый быстрый)
# ──────────────────────────────────────────────────────────
# Spark SQL функции выполняются в Whole-Stage Codegen
# без разрыва WSCG, без Python, в нативном JVM байткоде.
# Формула гаверсинуса на Spark SQL:
def haversine_native_sql(df):
    return df.withColumn(
        "distance_km",
        F.lit(6371.0) * 2 * F.asin(F.sqrt(
            F.pow(F.sin(F.radians(F.col("lat2") - F.col("lat1")) / 2), 2) +
            F.cos(F.radians(F.col("lat1"))) *
            F.cos(F.radians(F.col("lat2"))) *
            F.pow(F.sin(F.radians(F.col("lon2") - F.col("lon1")) / 2), 2)
        ))
    )

# Запускаем бенчмарк
print("=== БЕНЧМАРК: 5M строк, формула гаверсинуса ===\n")

print("1. Классический @udf (row-at-a-time):")
t1 = benchmark("@udf", lambda df: df.withColumn("d", haversine_row_udf("lat1","lon1","lat2","lon2")), df)

print("2. @pandas_udf с Python-циклом ВНУТРИ (антипаттерн):")
t2 = benchmark("@pandas_udf loop", lambda df: df.withColumn("d", haversine_pandas_loop("lat1","lon1","lat2","lon2")), df)

print("3. @pandas_udf с NumPy векторизацией (правильно):")
t3 = benchmark("@pandas_udf numpy", lambda df: df.withColumn("d", haversine_pandas_numpy("lat1","lon1","lat2","lon2")), df)

print("4. Native Spark SQL (ground truth):")
t4 = benchmark("native SQL", haversine_native_sql, df)

print("\n=== ИТОГИ ===")
print(f"@udf (row-at-a-time):       {t1:.1f}s  (baseline)")
print(f"@pandas_udf (loop внутри):  {t2:.1f}s  ({t1/t2:.1f}× vs @udf)")
print(f"@pandas_udf (NumPy):        {t3:.1f}s  ({t1/t3:.1f}× vs @udf)")
print(f"Native Spark SQL:           {t4:.1f}s  ({t1/t4:.1f}× vs @udf)")

Типичные результаты на 4-ядерной машине (5M строк):

Подход Время Ускорение
@udf row-at-a-time 85 сек 1× (baseline)
@pandas_udf с Python-циклом 15 сек ~6× (батчирование, но нет векторизации)
@pandas_udf с NumPy 3 сек ~28×
Native Spark SQL 0.8 сек ~106×

Из этих результатов вытекает важный вывод: батчирование через Arrow само по себе даёт ~6×, а векторизация через NumPy добавляет ещё ~5×. Вместе - ~28× ускорение. Но native Spark SQL - в 3 раза быстрее даже @pandas_udf с NumPy, потому что работает полностью в JVM без Python-границы.

Это задаёт чёткую иерархию выбора, которую мы рассмотрим в разделе Best Practices.


Управление ресурсами: тюнинг размера батча

Параметр spark.sql.execution.arrow.maxRecordsPerBatch - это главный рычаг управления соотношением throughput/memory для Pandas UDF.

Что определяет размер батча

Когда Spark готовит данные для передачи в Python-воркер, он собирает строки в Arrow Record Batch до достижения maxRecordsPerBatch. Размер одного батча в памяти:

Память_батча ≈ maxRecordsPerBatch × sum(sizeof(column) for column in передаваемых колонок)

Например, при 10 000 строк и передаче 4 колонок типа double (8 байт каждая):

10 000 × (4 × 8 байт) = 320 000 байт ≈ 312 KB per batch

Это очень небольшой объём. Но если у вас длинные строки (VARCHAR(1000)) или вложенные структуры, размер батча растёт линейно.

Влияние на производительность

Маленький батч (1 000 строк):

  • Больше вызовов функции на partition
  • Больше системных вызовов для Arrow IPC
  • Лучше memory profile (меньше пиковая память Python-воркера)
  • NumPy operations на маленьких массивах - хуже SIMD utilization (CPU не успевает насытить AVX pipeline)
  • Хуже для ML-моделей: overhead инференса на маленький батч ≈ overhead на большой

Большой батч (100 000 строк):

  • Меньше вызовов функции - меньше overhead на Python function call
  • NumPy operations на больших массивах - отличная SIMD utilization
  • Хуже memory profile: Python-воркер держит 100 000 строк в памяти
  • Риск OOM если строки объёмные или модель требует много памяти

Практические рекомендации по выбору размера:

# ──────────────────────────────────────────────────────────
# Бенчмарк разных размеров батча
# ──────────────────────────────────────────────────────────
import psutil
import os

batch_sizes = [1_000, 5_000, 10_000, 25_000, 50_000, 100_000]
results = []

for batch_size in batch_sizes:
    # Пересоздаём SparkSession с новым размером батча
    spark.stop()
    spark = SparkSession.builder \
        .master("local[4]") \
        .config("spark.sql.execution.arrow.pyspark.enabled", "true") \
        .config("spark.sql.execution.arrow.maxRecordsPerBatch", str(batch_size)) \
        .getOrCreate()

    df = recreate_dataframe(spark)  # ваш тестовый DataFrame

    # Замер памяти Python-воркера
    process = psutil.Process(os.getpid())
    mem_before = process.memory_info().rss / 1024 / 1024

    start = time.time()
    df.withColumn("d", haversine_pandas_numpy("lat1","lon1","lat2","lon2")).count()
    elapsed = time.time() - start

    mem_after = process.memory_info().rss / 1024 / 1024

    results.append({
        "batch_size": batch_size,
        "time_s": elapsed,
        "mem_delta_mb": mem_after - mem_before
    })
    print(f"batch={batch_size:7d}: {elapsed:.2f}s, mem +{mem_after-mem_before:.0f}MB")

# Типичные результаты (5M строк, 4 double колонки):
# batch=  1_000: 8.3s, mem +12MB    - много overhead, мало пользы SIMD
# batch=  5_000: 4.1s, mem +25MB    - хороший баланс
# batch= 10_000: 3.0s, mem +45MB    - default, хорошо для простых UDF
# batch= 25_000: 2.7s, mem +90MB    - лучше для NumPy, больше памяти
# batch= 50_000: 2.5s, mem +175MB   - отличная SIMD, требует памяти
# batch=100_000: 2.4s, mem +340MB   - максимальная SIMD, риск OOM при больших строках

Формула для расчёта безопасного размера батча:

def safe_batch_size(
    python_worker_memory_mb: int,
    row_size_bytes: int,
    safety_factor: float = 0.3
) -> int:
    """
    Рассчитывает максимальный безопасный размер батча.

    safety_factor=0.3 означает использование не более 30%
    памяти Python-воркера для одного батча.
    Оставшиеся 70% нужны для:
    - Python-объектов и pandas overhead
    - Промежуточных результатов вычислений
    - ML-модели (если загружена в память)
    """
    available_bytes = python_worker_memory_mb * 1024 * 1024 * safety_factor
    return int(available_bytes / row_size_bytes)

# Пример:
# Python-воркер имеет 4 GB памяти
# Строка: 4 double колонки = 32 байта
# safe_batch_size(4096, 32) = 4096 * 1024 * 1024 * 0.3 / 32 = 40 миллионов
# Это слишком много - ограничиваем сверху
# На практике 10_000–50_000 - разумный диапазон

# Для ML-инференса с тяжёлой моделью (1 GB):
# Эффективная память = 4096 - 1024 = 3072 MB
# safe_batch_size(3072, 32) → ещё больше, но реальный ограничитель —
# время инференса на батч. Оптимальный размер для GPU-инференса
# определяется пропускной способностью GPU, а не RAM.

Null-значения, типы данных и ловушки совместимости

NaN vs NULL: два разных "ничего"

В Spark SQL NULL означает «отсутствующее значение» - стандартная ANSI SQL семантика. В Pandas/NumPy NaN (Not a Number) - это специальное значение float64, которое используется как суррогат для отсутствующих значений.

Это создаёт тонкую проблему при использовании @pandas_udf:

# ──────────────────────────────────────────────────────────
# NULL vs NaN: важная ловушка
# ──────────────────────────────────────────────────────────
@pandas_udf("double")
def safe_divide(numerator: pd.Series, denominator: pd.Series) -> pd.Series:
    """
    Деление с защитой от NULL и нуля.
    """
    # Когда Spark передаёт NULL через Arrow в Pandas,
    # NULL → float('nan') для числовых типов
    # NULL → None для строковых типов

    # ПРОБЛЕМА: denominator.isna() ловит И NaN И None,
    # но числовой 0 при делении даёт python ZeroDivisionError
    # или numpy RuntimeWarning (inf или nan)

    # ПРАВИЛЬНЫЙ подход: использовать numpy где возможно
    result = numerator / denominator  # numpy: 1/0 → inf, 0/0 → nan

    # Заменяем inf и nan на 0 (или другое sentinel value)
    result = result.replace([np.inf, -np.inf], np.nan)

    # Важно: возвращаем nan, а не None.
    # Arrow преобразует float nan обратно в Spark NULL корректно.
    return result

# Проверка:
test_df = spark.createDataFrame(
    [(1.0, 2.0), (3.0, 0.0), (None, 5.0), (6.0, None)],
    ["num", "den"]
)

test_df.withColumn("ratio", safe_divide("num", "den")).show()
# +----+----+-----+
# | num| den|ratio|
# +----+----+-----+
# | 1.0| 2.0|  0.5|
# | 3.0| 0.0| null|  ← 3/0 → inf → null
# |null| 5.0| null|  ← null numerator
# | 6.0|null| null|  ← null denominator
# +----+----+-----+

Timestamp и timezone: особая осторожность

Timestamp-значения при передаче через Arrow теряют timezone-информацию, если не настроить явно:

# ──────────────────────────────────────────────────────────
# Timestamp: настройка timezone
# ──────────────────────────────────────────────────────────
# Spark TimestampType → Arrow Timestamp[us, tz=None]
# или Timestamp[us, tz="UTC"] в зависимости от конфига

# Рекомендация: всегда работайте в UTC внутри Pandas UDF
@pandas_udf("long")
def extract_hour_utc(ts: pd.Series) -> pd.Series:
    """Извлекает час из timestamp в UTC."""
    # ts.dt.hour работает с timezone-naive или timezone-aware Series
    # Если Arrow передал без timezone (naive), Pandas считает UTC
    return ts.dt.hour.astype("int64")

# Если нужна конвертация timezone:
@pandas_udf("long")
def extract_hour_moscow(ts: pd.Series) -> pd.Series:
    """Извлекает час в московском времени."""
    # Делаем timestamp timezone-aware (UTC) и конвертируем
    ts_utc = ts.dt.tz_localize("UTC")
    ts_moscow = ts_utc.dt.tz_convert("Europe/Moscow")
    return ts_moscow.dt.hour.astype("int64")

Типы данных: таблица совместимости

Spark SQL тип Pandas/NumPy тип Особенности
IntegerType int32 / int64 NumPy int32, но часто апкастится в int64
LongType int64 Нет проблем
FloatType float32 Может терять точность
DoubleType float64 Нет проблем
StringType object (Python str) Не NumPy - медленнее!
BooleanType bool Нет проблем
TimestampType datetime64[us] Timezone issues (см. выше)
DateType object (datetime.date) Не NumPy, медленнее
ArrayType object (Python list) Потеря векторизации!
MapType ❌ Не поддерживается Arrow не поддерживает
StructType pd.DataFrame колонка Возможно, но медленно

Обратите особое внимание на StringType и ArrayType: они передаются как Python-объекты, а не NumPy arrays. Это означает потерю векторизации - SIMD не применяется. Если ваш UDF работает со строками, подумайте, можно ли заменить его на Spark SQL regexp-функции.

Fallback: когда Arrow автоматически отключается

Spark молча откатывается к non-Arrow режиму в нескольких ситуациях. Это приводит к потере производительности без явных ошибок. Диагностика через логи:

# Настройте WARNING-уровень для Arrow fallback:
import logging
logging.getLogger("pyspark").setLevel(logging.WARNING)

# Или в SparkSession:
spark.sparkContext.setLogLevel("WARN")

# Сообщения, которые сигнализируют о fallback:
# "Arrow optimization failed, falling back to non-optimized method"
# Обычная причина: unsupported data type (MapType, некоторые nested types)

# Проверить, используется ли Arrow в конкретном плане:
result.explain(extended=True)
# Ищите: ArrowEvalPython (хорошо) vs BatchEvalPython (fallback)

Лабораторная практика: скоринг на реальном сценарии

Бизнес-кейс: fraud scoring для транзакций ритейлера

Задача: у ритейлера есть 200 миллионов транзакций в день. Служба безопасности разработала правило скоринга фрода - комбинацию гео-дистанции (формула гаверсинуса), скорости транзакций и аномальной суммы. Правило реализовано в Python. Нужно применить его ко всем транзакциям эффективно.

Шаг 1: Наивное решение через @udf

# ──────────────────────────────────────────────────────────
# Шаг 1: наивный @udf (медленный baseline)
# ──────────────────────────────────────────────────────────
import math
from pyspark.sql.types import DoubleType

@udf(returnType=DoubleType())
def fraud_score_udf(
    amount,
    prev_lat, prev_lon,
    curr_lat, curr_lon,
    time_delta_minutes,
    category_risk  # float: коэффициент риска категории товара
):
    """
    Скоринг фрода: row-at-a-time UDF.
    Вычисляется для каждой строки отдельно.
    """
    if any(x is None for x in [amount, prev_lat, prev_lon,
                                curr_lat, curr_lon, time_delta_minutes]):
        return None

    # 1. Гео-скорость: км/ч между предыдущей и текущей транзакцией
    R = 6371.0
    phi1, phi2 = math.radians(prev_lat), math.radians(curr_lat)
    dphi = math.radians(curr_lat - prev_lat)
    dlambda = math.radians(curr_lon - prev_lon)
    a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlambda/2)**2
    distance_km = R * 2 * math.asin(math.sqrt(a))
    speed_kmh = distance_km / max(time_delta_minutes / 60, 0.001)

    # 2. Компоненты скора
    # Скорость > 900 км/ч подозрительна (быстрее самолёта невозможно)
    speed_score = min(speed_kmh / 900.0, 1.0)

    # Аномальная сумма: нормализуем относительно порога 50 000 руб.
    amount_score = min(amount / 50_000.0, 1.0)

    # Категорийный риск передаётся как коэффициент 0.0–1.0
    category_score = category_risk

    # Итоговый скор: взвешенная сумма компонент
    return 0.4 * speed_score + 0.3 * amount_score + 0.3 * category_score

# Применяем к данным
result_baseline = transactions.withColumn(
    "fraud_score",
    fraud_score_udf(
        "amount", "prev_lat", "prev_lon",
        "curr_lat", "curr_lon",
        "time_delta_minutes", "category_risk"
    )
)

start = time.time()
flagged = result_baseline.filter(F.col("fraud_score") > 0.7).count()
print(f"@udf: {time.time() - start:.1f}s, flagged: {flagged:,}")

Шаг 2: Рефакторинг на @pandas_udf с NumPy

# ──────────────────────────────────────────────────────────
# Шаг 2: pandas_udf с NumPy - правильная векторизация
# ──────────────────────────────────────────────────────────
@pandas_udf("double")
def fraud_score_pandas(
    amount: pd.Series,
    prev_lat: pd.Series, prev_lon: pd.Series,
    curr_lat: pd.Series, curr_lon: pd.Series,
    time_delta_minutes: pd.Series,
    category_risk: pd.Series
) -> pd.Series:
    """
    Тот же скор фрода, но векторизованный.

    Каждый аргумент - pd.Series с 10000 значений (батч).
    Все операции работают над целым батчем через NumPy.

    Обработка None/NaN: np.where и np.nan_to_num
    позволяют избежать Python-циклов для null-handling.
    """
    # ── Гео-расстояние: векторизовано ──────────────────────
    R = 6371.0
    phi1 = np.radians(prev_lat)
    phi2 = np.radians(curr_lat)
    dphi = np.radians(curr_lat - prev_lat)
    dlambda = np.radians(curr_lon - prev_lon)

    a = np.sin(dphi/2)**2 + np.cos(phi1)*np.cos(phi2)*np.sin(dlambda/2)**2
    distance_km = R * 2 * np.arcsin(np.sqrt(np.clip(a, 0, 1)))

    # ── Скорость: векторизовано, защита от деления на 0 ────
    # np.maximum гарантирует denominator >= 0.001 (без if)
    time_hours = np.maximum(time_delta_minutes / 60.0, 0.001)
    speed_kmh = distance_km / time_hours

    # ── Компоненты скора: всё vectorized ───────────────────
    # np.minimum эквивалентно min() но для всего массива
    speed_score = np.minimum(speed_kmh / 900.0, 1.0)
    amount_score = np.minimum(amount / 50_000.0, 1.0)
    category_score = category_risk.values  # NumPy array напрямую

    # ── Финальный скор ─────────────────────────────────────
    result = 0.4 * speed_score + 0.3 * amount_score + 0.3 * category_score

    # ── Null handling: где были NaN во входе → NaN в выходе ─
    # NaN propagation автоматически работает в NumPy:
    # nan + anything = nan, так что строки с null входными
    # данными автоматически получат nan в результате,
    # который Spark преобразует обратно в NULL
    return pd.Series(result)

# Применяем pandas_udf
result_vectorized = transactions.withColumn(
    "fraud_score",
    fraud_score_pandas(
        "amount", "prev_lat", "prev_lon",
        "curr_lat", "curr_lon",
        "time_delta_minutes", "category_risk"
    )
)

start = time.time()
flagged = result_vectorized.filter(F.col("fraud_score") > 0.7).count()
print(f"@pandas_udf: {time.time() - start:.1f}s, flagged: {flagged:,}")

Шаг 3: Iterator-паттерн для тяжёлых словарей

# ──────────────────────────────────────────────────────────
# Шаг 3: Iterator - загрузка тяжёлых данных один раз
# ──────────────────────────────────────────────────────────
# Реальный сценарий: категорийный риск хранится в большом
# словаре (50 000 SKU → коэффициент риска).
# Загружать этот словарь для каждого батча было бы неэффективно.

from typing import Iterator

@pandas_udf("double")
def fraud_score_with_lookup(
    iterator: Iterator[tuple[pd.Series, pd.Series, pd.Series,
                              pd.Series, pd.Series, pd.Series, pd.Series]]
) -> Iterator[pd.Series]:
    """
    Fraud score с предзагрузкой категорийного справочника.

    Справочник загружается ОДИН РАЗ при старте partition.
    Затем обрабатывается каждый батч по очереди.
    """
    # ── ИНИЦИАЛИЗАЦИЯ (один раз на partition) ──────────────
    # Загружаем справочник категорийных рисков
    # (в реальности - из S3, Redis, или broadcast переменной)
    import json
    with open("/data/category_risk_dict.json") as f:
        category_risk_dict = json.load(f)  # {sku_id: risk_score}

    # Конвертируем в NumPy-friendly структуру для быстрого lookup
    # defaultdict с 0.5 как риском для неизвестных категорий
    from collections import defaultdict
    risk_lookup = defaultdict(lambda: 0.5, category_risk_dict)

    # ── ОБРАБОТКА БАТЧЕЙ ───────────────────────────────────
    for (amount_s, prev_lat_s, prev_lon_s,
         curr_lat_s, curr_lon_s, time_s, sku_s) in iterator:

        # Lookup категорийного риска для всего батча
        # np.vectorize быстрее Python-цикла, но не так быстро как NumPy ufunc
        # Для справочного lookup это оптимальный подход
        category_risk = np.array([risk_lookup[sku] for sku in sku_s])

        # Остальная логика - та же NumPy векторизация
        R = 6371.0
        phi1 = np.radians(prev_lat_s)
        phi2 = np.radians(curr_lat_s)
        dphi = np.radians(curr_lat_s - prev_lat_s)
        dlambda = np.radians(curr_lon_s - prev_lon_s)
        a = np.sin(dphi/2)**2 + np.cos(phi1)*np.cos(phi2)*np.sin(dlambda/2)**2
        distance_km = R * 2 * np.arcsin(np.sqrt(np.clip(a, 0, 1)))

        time_hours = np.maximum(time_s / 60.0, 0.001)
        speed_kmh = distance_km / time_hours
        speed_score = np.minimum(speed_kmh / 900.0, 1.0)
        amount_score = np.minimum(amount_s / 50_000.0, 1.0)

        result = 0.4 * speed_score + 0.3 * amount_score + 0.3 * category_risk
        yield pd.Series(result)

Шаг 4: Анализ через Spark UI

После запуска скриптов откройте Spark UI (по умолчанию http://localhost:4040) и найдите в разделе Stages задачу с вашим UDF:

  • Task Duration: сравните распределение времени задач между @udf и @pandas_udf вариантами. Для @pandas_udf задачи должны быть значительно короче и равномернее.
  • Shuffle Read/Write: для оба варианта одинаковы - UDF не влияет на shuffle.
  • Python Time: метрика специфичная для Python UDF. Показывает, сколько времени executor провёл, ожидая результатов от Python-воркера. Для @pandas_udf это время должно быть значительно меньше относительно общего времени задачи.

В разделе SQL → конкретный Query → Details найдите физический план. Проверьте:

  • ArrowEvalPython (не BatchEvalPython) присутствует для ваших UDF
  • evalType=SQL_SCALAR_PANDAS_UDF или SQL_SCALAR_PANDAS_ITER_UDF

Антипаттерны: как убить производительность Pandas UDF

Антипаттерн 1: Python-цикл внутри Pandas UDF

Самая частая ошибка - написать @pandas_udf, но внутри использовать Python-цикл. Это частично нивелирует преимущество батчирования:

# ❌ АНТИПАТТЕРН: Python-цикл внутри pandas_udf
@pandas_udf("double")
def bad_pandas_udf(series: pd.Series) -> pd.Series:
    result = []
    for value in series:           # ← Цикл! 10000 Python-итераций на батч
        result.append(value * 2.7183)
    return pd.Series(result)

# ✅ ПРАВИЛЬНО: NumPy операция над всем Series
@pandas_udf("double")
def good_pandas_udf(series: pd.Series) -> pd.Series:
    return series * 2.7183         # ← Одна NumPy операция над всем батчем

Batching даёт ~6× ускорение даже с циклом (меньше системных вызовов). Но NumPy векторизация добавляет ещё ~5×. Потерять эти 5× из-за цикла - расточительство.

Антипаттерн 2: Использование Pandas UDF вместо встроенных функций

# ❌ АНТИПАТТЕРН: pandas_udf для операций, которые Spark умеет нативно
@pandas_udf("string")
def upper_case_udf(s: pd.Series) -> pd.Series:
    return s.str.upper()

result = df.withColumn("name_upper", upper_case_udf("name"))

# ✅ ПРАВИЛЬНО: встроенная Spark SQL функция
# F.upper работает в Whole-Stage Codegen без Python-границы
result = df.withColumn("name_upper", F.upper("name"))

Встроенные Spark SQL функции (F.upper, F.lower, F.regexp_replace, F.date_trunc, F.from_json, практически все агрегаты...) выполняются целиком в JVM без Python. Они в 3–10 раз быстрее даже хорошо написанного @pandas_udf.

Правило: всегда проверяйте pyspark.sql.functions перед написанием UDF. Там около 300 функций, покрывающих огромный спектр задач.

Антипаттерн 3: Маленькие батчи в tight loops

# ❌ АНТИПАТТЕРН: многократный вызов pandas_udf в цикле на маленьких DataFrame
for user_id in user_ids:
    user_df = all_df.filter(F.col("user_id") == user_id)
    scored = user_df.withColumn("score", fraud_score_pandas(...))
    scored.write.mode("append").parquet(f"s3://bucket/scores/{user_id}/")

# Проблема: каждый filter создаёт новый план, каждый write - новый job.
# pandas_udf вызывается N раз для крошечных DataFrame.
# Overhead на инициализацию Python-воркера >> время вычислений.

# ✅ ПРАВИЛЬНО: обрабатываем всех пользователей одним DataFrame
all_scored = all_df.withColumn("score", fraud_score_pandas(...))
all_scored.write.mode("overwrite").partitionBy("user_id").parquet("s3://bucket/scores/")
# Или используем applyInPandas если нужна user-level логика

Антипаттерн 4: Игнорирование fallback предупреждений

# Spark может молча отключить Arrow при unsupported типах.
# Если в логах есть:
# "Arrow optimization failed, ... falling back to non-optimized"
# - ваш pandas_udf работает как обычный @udf по производительности!

# Диагностика: проверьте explain()
df.withColumn("result", my_pandas_udf("col")).explain()
# Ищите: ArrowEvalPython (OK) или BatchEvalPython (fallback!)

Лучшие практики и матрица выбора

Иерархия выбора: когда что использовать

На основе всего изложенного сформулируем чёткую иерархию от самого быстрого к самому медленному:

Уровень 1 (самый быстрый): Native Spark SQL functions

Функции из pyspark.sql.functions. Выполняются в Whole-Stage Codegen, JVM, без Python. Используйте всегда, когда функция существует:

  • Арифметика, строки, даты, JSON, агрегаты, оконные функции
  • F.regexp_extract, F.from_json, F.explode, F.array_*, F.map_*

Уровень 2 (быстро): Spark SQL через spark.sql()

Тот же Whole-Stage Codegen что и level 1, но позволяет писать сложные SQL. Хорош для сложных аналитических запросов с множеством JOIN'ов и подзапросов.

Уровень 3 (приемлемо): @pandas_udf с NumPy/Pandas

Используйте когда:

  • Нужна сложная математика (гео-расчёты, статистика)
  • ML-инференс на scikit-learn / PyTorch
  • Бизнес-правила, которые нельзя выразить SQL
  • Обработка, требующая NumPy/SciPy алгоритмов

Уровень 4 (медленно, последний resort): @udf row-at-a-time

Используйте только если:

  • Функция не поддерживает векторный ввод (некоторые специфические API)
  • Невозможно переписать на @pandas_udf (крайне редко)
  • Данных мало и производительность не критична
# ──────────────────────────────────────────────────────────
# Матрица выбора в коде
# ──────────────────────────────────────────────────────────

# Задача: проверить, является ли email корректным

# Уровень 1 - Native Spark SQL (лучший вариант):
df.withColumn("email_valid",
    F.col("email").rlike(r"^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
)

# Уровень 3 - pandas_udf (если нужна сложная валидация через Python-библиотеку):
import re
@pandas_udf("boolean")
def validate_email_pandas(emails: pd.Series) -> pd.Series:
    pattern = re.compile(r"^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
    return emails.str.match(pattern)

# Уровень 4 - @udf (не нужен здесь, но для примера):
@udf("boolean")
def validate_email_udf(email):
    import re
    if email is None:
        return None
    return bool(re.match(r"^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", email))

Когда @pandas_udf действительно нужен

Реальные production-сценарии, где @pandas_udf незаменим:

  1. ML inference batch scoring: scikit-learn, XGBoost, LightGBM, PyTorch CPU inference - библиотеки принимают NumPy arrays, что идеально совпадает с pd.Series.values

  2. Сложная геопространственная математика: формулы, которых нет в Spark SQL (гаверсинус, проекции, буферные зоны через shapely)

  3. Нестандартная статистика: scipy.stats дистрибуции, bootstrap sampling, кастомные метрики

  4. NLP предобработка: токенизация через spaCy/NLTK, которая работает с батчами эффективно

  5. Бизнес-правила с lookup-словарями: когда правила хранятся в Python dict и меняются ежедневно (не заданы как константы SQL)


Домашнее задание

Задание 1: Левенштейн-дистанция (основное)

Вам дан PySpark-скрипт, который вычисляет расстояние Левенштейна между поисковыми запросами клиентов и эталонными запросами из каталога. Текущая реализация:

from pyspark.sql.functions import udf
from pyspark.sql.types import IntegerType

# Медленная row-at-a-time реализация
@udf(returnType=IntegerType())
def levenshtein_slow(s1, s2):
    """Расстояние Левенштейна: минимальное число правок."""
    if s1 is None or s2 is None:
        return None
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(m + 1):
        dp[i][0] = i
    for j in range(n + 1):
        dp[0][j] = j
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
    return dp[m][n]

# Применяется к 20 миллионам пар запросов
result = searches_df.withColumn(
    "edit_distance",
    levenshtein_slow("query", "reference")
)

Ваша задача:

  1. Проверьте: существует ли встроенная функция Spark SQL для расстояния Левенштейна? (Подсказка: поищите в pyspark.sql.functions)

  2. Если встроенная функция есть - используйте её. Если нет - переписать levenshtein_slow на @pandas_udf, используя библиотеку python-Levenshtein (она реализована на C и работает через batch API):

from Levenshtein import distance as lev_distance
import numpy as np
# Подсказка: np.vectorize или list comprehension внутри pandas_udf
  1. Подберите оптимальный maxRecordsPerBatch. Тестируйте значения: 1000, 5000, 10000, 25000. Для строковых данных размер батча считается иначе, чем для чисел - объясните почему.

  2. Сравните время выполнения трёх вариантов (если встроенная функция нашлась) или двух (если нет). Оформите мини-отчёт: методология, результаты в таблице, вывод о целесообразности каждого подхода.

Задание 2: Оптимальный размер батча (практическое)

Для вашего рабочего DataFrame (или датасета из предыдущих модулей):

  1. Напишите @pandas_udf для любой бизнес-логики (нормализация, георасчёт, текстовая обработка)
  2. Реализуйте бенчмарк из раздела "Управление ресурсами" с 5–6 разными maxRecordsPerBatch
  3. Замерьте и время выполнения, и пиковую память Python-воркера через psutil
  4. Постройте график (matplotlib или просто таблицу) throughput vs memory
  5. Обоснуйте выбор оптимального размера для вашей конфигурации (RAM воркера, размер строк)

Задание 3: Анализ физического плана (аналитическое)

Напишите два эквивалентных преобразования - одно через @pandas_udf, одно через нативные Spark SQL функции (для операции, где обе версии возможны, например: нормализация числа в диапазон [0, 1]).

  1. Запустите explain(extended=True) для обоих
  2. Найдите в плане ArrowEvalPython vs отсутствие Python операторов
  3. Определите, где заканчивается и начинается Whole-Stage Codegen блок (*(N))
  4. Объясните своими словами: почему native Spark SQL работает в одном Codegen блоке, а @pandas_udf разрывает его?

Резюме

@pandas_udf - это мост между Python-экосистемой и высокопроизводительным Spark-движком. Он решает главную проблему классических Python UDF - построчную сериализацию через Pickle - заменяя её на батчевую передачу через Apache Arrow IPC.

Три ключевых вывода этого урока:

Первый: Arrow - это не просто формат, это контракт. Данные передаются от JVM к Python в columnar Arrow формате, и Python-воркер получает их как pd.Series - NumPy array под капотом. Это открывает доступ к C-скорости через NumPy векторизацию.

Второй: @pandas_udf быстрее @udf в 10–30 раз, но медленнее native Spark SQL в 3–10 раз. Иерархия выбора: Native Spark SQL → spark.sql() → @pandas_udf с NumPy → @udf. Нарушать иерархию без причины - расточительство.

Третий: Iterator-паттерн незаменим для ML inference. Загрузка модели один раз на partition, а не на каждый батч - принципиальное требование для production ML-пайплайнов. Без Iterator'а даже vectorized pandas_udf деградирует из-за повторных загрузок модели.

Антипаттерн, которого нужно избегать любой ценой: Python-цикл внутри @pandas_udf. Это сохраняет overhead батчирования (чуть меньше системных вызовов) но теряет весь прирост от NumPy векторизации.