Pandas UDF с Arrow: @pandas_udf, Series-батч вместо строки, скорость vs Row-at-a-time UDF
Как Apache Arrow превращает медленные Python UDF в векторизованные вычисления: @pandas_udf, batch execution, ArrowEvalPython, tuning maxRecordsPerBatch.
Архитектурный кризис классических Python UDF¶
Прежде чем изучать решение, необходимо точно понять, что именно ломается при использовании обычного Python UDF в Spark. Это не просто «медленнее, потому что Python» - здесь несколько независимых проблем, каждая из которых вносит свой вклад в катастрофическую деградацию производительности.
Как Spark вообще выполняет код¶
Чтобы понять, почему Python UDF так дорого обходится, нужно сначала понять, как Spark выполняет код без Python.
Когда вы пишете df.filter(F.col("amount") > 1000).groupBy("region").agg(F.sum("amount")), Spark делает следующее:
- Catalyst optimizer строит Logical Plan и оптимизирует его (predicate pushdown, column pruning, join reordering)
- Physical Planner выбирает конкретные алгоритмы (HashAggregate vs SortAggregate)
- Whole-Stage Code Generation (WSCG) генерирует специализированный Java-байткод для всей цепочки операторов - без вызовов виртуальных функций, без интерпретатора, напрямую
- JIT компилирует этот байткод в нативный машинный код
- 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, происходит следующее:
- JVM Executor берёт батч строк из ColumnarBatch (это уже colum-ориентированные данные в памяти JVM)
- Данные сериализуются в Arrow IPC формат: FlatBuffer с метаданными схемы + бинарные буферы с данными
- Arrow IPC отправляется в Python-воркер один раз для целого батча - не построчно
- Python-воркер принимает Arrow IPC и создаёт
pa.RecordBatchобъект, который смотрит на те же бинарные буферы без копирования (zero-copy via shared memory) - Из
pa.RecordBatchсоздаётсяpd.Series- это тоже zero-copy через NumPy array, который смотрит на Arrow буфер - Ваша функция получает готовый
pd.Seriesс тысячами значений - Функция возвращает
pd.Seriesс результатами - 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) присутствует для ваших UDFevalType=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 незаменим:
-
ML inference batch scoring: scikit-learn, XGBoost, LightGBM, PyTorch CPU inference - библиотеки принимают NumPy arrays, что идеально совпадает с
pd.Series.values -
Сложная геопространственная математика: формулы, которых нет в Spark SQL (гаверсинус, проекции, буферные зоны через shapely)
-
Нестандартная статистика: scipy.stats дистрибуции, bootstrap sampling, кастомные метрики
-
NLP предобработка: токенизация через spaCy/NLTK, которая работает с батчами эффективно
-
Бизнес-правила с 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")
)
Ваша задача:
-
Проверьте: существует ли встроенная функция Spark SQL для расстояния Левенштейна? (Подсказка: поищите в
pyspark.sql.functions) -
Если встроенная функция есть - используйте её. Если нет - переписать
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
-
Подберите оптимальный
maxRecordsPerBatch. Тестируйте значения: 1000, 5000, 10000, 25000. Для строковых данных размер батча считается иначе, чем для чисел - объясните почему. -
Сравните время выполнения трёх вариантов (если встроенная функция нашлась) или двух (если нет). Оформите мини-отчёт: методология, результаты в таблице, вывод о целесообразности каждого подхода.
Задание 2: Оптимальный размер батча (практическое)¶
Для вашего рабочего DataFrame (или датасета из предыдущих модулей):
- Напишите
@pandas_udfдля любой бизнес-логики (нормализация, георасчёт, текстовая обработка) - Реализуйте бенчмарк из раздела "Управление ресурсами" с 5–6 разными
maxRecordsPerBatch - Замерьте и время выполнения, и пиковую память Python-воркера через
psutil - Постройте график (matplotlib или просто таблицу) throughput vs memory
- Обоснуйте выбор оптимального размера для вашей конфигурации (RAM воркера, размер строк)
Задание 3: Анализ физического плана (аналитическое)¶
Напишите два эквивалентных преобразования - одно через @pandas_udf, одно через нативные Spark SQL функции (для операции, где обе версии возможны, например: нормализация числа в диапазон [0, 1]).
- Запустите
explain(extended=True)для обоих - Найдите в плане
ArrowEvalPythonvs отсутствие Python операторов - Определите, где заканчивается и начинается Whole-Stage Codegen блок (
*(N)) - Объясните своими словами: почему 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 векторизации.