UDF: Python UDF и их цена - когда стоит, когда не стоит
Row UDF, Pandas UDF, mapPartitions: сравнение производительности, NotSerializableException, WARN TaskSetManager и паттерны правильного использования
Когда вообще нужен UDF¶
Встроенные функции Spark (pyspark.sql.functions) покрывают 90% типичных трансформаций. Прежде чем писать UDF, убедитесь, что не пропустили готовую функцию:
from pyspark.sql import functions as F
# Строки
F.upper, F.lower, F.trim, F.regexp_replace, F.split, F.concat_ws
# Числа
F.round, F.abs, F.log, F.sqrt, F.pow, F.greatest, F.least
# Дата/время
F.to_date, F.date_format, F.datediff, F.date_add, F.year, F.month
# Условная логика
F.when, F.coalesce, F.nullif, F.isnull, F.isnan
# Массивы и структуры
F.array_contains, F.explode, F.collect_list, F.struct, F.map_keys
# Высокоуровневые функции над массивами (HOF)
F.transform, F.filter, F.aggregate, F.exists
UDF нужен только когда нет встроенного аналога: нестандартная бизнес-логика, внешняя библиотека (NLP, геолокация, шифрование), сложные Python-объекты. Желание написать UDF для форматирования строки или округления числа - сигнал, что нужно ещё раз посмотреть документацию pyspark.sql.functions.
Как Spark выполняет встроенные функции¶
Чтобы понять, почему Python UDF - это медленно, нужно сначала понять, почему встроенные функции - это быстро. Spark использует два ключевых механизма: Tungsten и WholeStageCodeGen.
Tungsten - низкоуровневый движок Spark, который хранит данные в двоичном формате (UnsafeRow) прямо в оперативной памяти, минуя сборку мусора JVM. Вместо объектов Java - компактные бинарные буферы с фиксированным оффсетом полей. Благодаря этому Spark избегает давления на GC и использует процессорный кэш максимально эффективно.
WholeStageCodeGen (WSCG) - компилятор физических планов. Когда оптимизатор строит план, WSCG объединяет несколько операторов в один Java-метод, который компилируется в байткод и выполняется напрямую JVM. Вместо цепочки вызовов объектов получается один плотный цикл - без виртуальных диспетчеров, без промежуточных объектов.
Встроенные функции (upper(), regexp_replace(), when()) - это Expression-объекты, которые Catalyst понимает и включает в WholeStageCodeGen. Результат: выполнение со скоростью нативного JVM кода без каких-либо копирований данных.
Python UDF разрывает этот конвейер принудительно и безвозвратно.
Типы UDF: иерархия производительности¶
| Тип | Производительность | Накладные расходы | Когда |
|---|---|---|---|
| Встроенные функции | ●●●●● | Нет (JVM, Codegen) | Всегда первый выбор |
| Pandas UDF (Arrow) | ●●●●○ | Arrow serialization, батчи | Векторизованная логика |
| mapPartitions | ●●●○○ | Python worker per partition | Ресурсы (БД, модели, HTTP) |
| Row UDF (pickle) | ●●○○○ | Python worker per row | Крайний случай |
Row UDF: архитектурный барьер¶
Row UDF - это не просто медленный вариант встроенных функций. Это качественно иная архитектура выполнения, которая ломает всё, что Spark научился делать за 10+ лет оптимизаций.
Механика выполнения: что происходит за кулисами¶
Когда JVM executor встречает Python UDF в физическом плане, он не может выполнить его сам. Вместо этого он запускает отдельный Python worker process и налаживает с ним межпроцессное взаимодействие через Unix сокет (pipe).
На каждую строку: две сериализации через pickle + два вызова IPC. На датасете в 100 миллионов строк это 200 миллионов операций сериализации. Именно поэтому Row UDF в 3–10× медленнее встроенных функций даже для простой логики.
Pickle serialization overhead¶
Сериализация через pickle - не бесплатная операция. Python должен:
- обойти граф объекта (строка, число, список) и превратить его в поток байтов
- передать байты через unix pipe (системный вызов)
- на другой стороне раскодировать байты обратно в Python-объект
- выполнить функцию
- закодировать результат обратно
- передать через pipe
- на стороне JVM раскодировать из байтов в тип данных UnsafeRow
Стоимость pickle зависит от типа данных. Строки и числа - относительно быстро. Словари, вложенные структуры, списки - значительно медленнее. Это объясняет, почему UDF на колонке StructType особенно болезненны.
Batch processing внутри Python UDF¶
Технически Spark не передаёт строки по одной: данные передаются небольшими пакетами (batch size ~100 строк по умолчанию). Это снижает накладные расходы на IPC-вызовы, но не устраняет pickle overhead - каждое значение в batch всё равно сериализуется отдельно.
Batch processing - это компромисс уровня Spark runtime, который не виден в коде UDF. Вы пишете функцию f(x) -> y, и Spark сам решает, как группировать строки при передаче.
Memory management: Python process и YARN limity¶
JVM executor работает в управляемой памяти Spark (Unified Memory Model - см. урок про Storage/Execution memory). Python worker работает за пределами этого управления - в отдельном процессе со своей кучей.
Это означает, что память Python worker не видна для Spark и не учитывается при распределении ресурсов. Когда данные обрабатываются в JVM executor + Python worker, суммарное потребление памяти выше, чем настроено в spark.executor.memory. YARN видит только суммарное потребление памяти процесса executor + дочернего python, и при превышении лимита убивает контейнер:
Container killed by YARN for exceeding memory limits.
12.0 GB of 12.0 GB physical memory used.
Consider boosting spark.yarn.executor.memoryOverhead or
disabling yarn.nodemanager.vmem-check-enabled.
Стандартное решение - увеличить spark.executor.memoryOverhead (по умолчанию max(384MB, 10% от executor memory)). Но правильное решение - избегать Python UDF на больших данных.
Python GIL и CPU limitations¶
Python GIL (Global Interpreter Lock) гарантирует, что в один момент времени только один поток выполняет Python байткод. Это означает, что Python worker не может воспользоваться многоядерностью внутри одного процесса.
JVM executor, напротив, может запускать несколько задач параллельно в разных потоках (thread pool), делясь кэшированными данными. Python worker - по одному на executor, и GIL сериализует их выполнение.
На практике: там, где JVM executor обрабатывает 4 партиции параллельно через 4 потока, Python worker обрабатывает их последовательно (если не настроен spark.python.worker.reuse = true и отдельные процессы).
Catalyst blindness: оптимизатор не видит ваш код¶
Одна из главных проблем Python UDF - не скорость выполнения, а то, что происходит до выполнения. Catalyst optimizer не может заглянуть внутрь Python-функции.
Black box problem¶
Catalyst работает с Expression-деревьями. Встроенная функция upper(col("name")) - это объект класса Upper, который Catalyst понимает: знает его семантику, тип возвращаемого значения, может pushdown фильтры, может объединить с соседними выражениями в один CodeGen блок.
Python UDF - это PythonUDF expression с непрозрачным содержимым. Catalyst видит: «здесь будет вызван Python worker, что он сделает - неизвестно». Поэтому оптимизатор применяет консервативную стратегию: не трогать порядок вычислений вокруг UDF.
Потеря Predicate Pushdown¶
Predicate Pushdown - одна из ключевых оптимизаций Catalyst: фильтры (WHERE, filter()) проталкиваются как можно ближе к источнику данных, чтобы сократить количество читаемых данных. Parquet files, например, могут пропускать целые row groups на основе min/max статистики.
Когда после UDF стоит фильтр - он не может быть протолкнут сквозь UDF:
# Catalyst НЕ МОЖЕТ протолкнуть filter через UDF
df.withColumn("score", my_python_udf(col("text"))) \
.filter(col("score") > 0.5)
# Physical plan:
# Filter (score > 0.5)
# ↑
# BatchEvalPython [my_python_udf] ← UDF выполнится для ВСЕХ строк
# ↑
# FileScan parquet (читает ВСЕ данные)
Вместо того, чтобы сначала отфильтровать и потом применить UDF только к нужным строкам, Spark применяет UDF ко всем строкам, и только потом фильтрует. Разница: UDF вызван 100 млн раз вместо 5 млн раз.
Потеря WholeStageCodeGen¶
WholeStageCodeGen работает, объединяя операторы в один codegen pipeline. Когда в цепочке есть Python UDF - pipeline разрывается. Операторы до UDF и после UDF компилируются отдельно, через барьер сериализации.
# Catalyst компилирует как THREE отдельных блока - не один:
# Блок 1 (JVM Codegen): filter + project перед UDF
# ↓
# Barrier: сериализация в Python, выполнение UDF, десериализация
# ↓
# Блок 2 (JVM Codegen): операции после UDF
Это не только замедляет UDF, но и замедляет соседние операции, которые могли бы быть частью единого сгенерированного метода.
UDF в explain plan¶
Как увидеть UDF в физическом плане:
df.withColumn("cleaned", my_udf(col("raw_text"))) \
.filter(col("cleaned").isNotNull()) \
.explain(extended=True)
# Physical Plan:
# *(2) Project [...]
# +- *(2) Filter (isnotnull(cleaned#45))
# +- BatchEvalPython [my_udf(raw_text#12)], [raw_text#12, cleaned#45]
# +- *(1) FileScan parquet [...] PushedFilters: [], ...
BatchEvalPython - маркер Row UDF в физическом плане. Его наличие означает:
- WholeStageCodeGen разрывается на этом узле
- Данные покидают JVM в Python worker и возвращаются обратно
- Фильтры выше по плану не могут быть протолкнуты ниже
explain("cost") покажет оценку размера данных - это полезно, чтобы понять, сколько строк пройдёт через UDF.
Row UDF: регистрация и использование¶
from pyspark.sql.functions import udf
from pyspark.sql.types import StringType, DoubleType, IntegerType
# Способ 1: декоратор
@udf(returnType=StringType())
def normalize_phone(phone: str) -> str:
if phone is None:
return None
digits = "".join(c for c in phone if c.isdigit())
if len(digits) == 11 and digits[0] == "7":
return f"+{digits}"
if len(digits) == 10:
return f"+7{digits}"
return None
# Способ 2: явная регистрация
def parse_category(code: str) -> str:
mapping = {"A": "premium", "B": "standard", "C": "budget"}
return mapping.get(code, "unknown")
parse_category_udf = udf(parse_category, StringType())
# Использование в DataFrame
df.withColumn("phone_clean", normalize_phone("phone")) \
.withColumn("category_name", parse_category_udf("category_code"))
# Регистрация для SQL
spark.udf.register("normalize_phone", normalize_phone)
spark.sql("SELECT normalize_phone(phone) FROM customers")
returnType - обязательный параметр. Если он не указан, Spark по умолчанию использует StringType, что означает: все значения будут конвертированы в строку. Если ваша функция возвращает число, а returnType не указан - получите строку вместо числа, и все последующие арифметические операции упадут с ошибкой типа.
Декларация returnType и type mismatch¶
Spark не проверяет соответствие returnType на этапе регистрации UDF. Несоответствие проявится только при выполнении:
@udf(returnType=IntegerType()) # объявлено: Int
def bad_return(s: str) -> str:
return "not_an_int" # возвращает: str
# При вызове - ошибка в Task:
# pyspark.sql.utils.PythonException: An exception was thrown from the Python
# worker. Please see the stack trace below.
# Traceback (most recent call last):
# ...
# TypeError: an integer is required (got type str)
Правило: returnType должен точно соответствовать реальному возвращаемому типу Python функции. Для сложных типов (struct, array) нужно явно описать схему через StructType/ArrayType.
Null handling в Row UDF¶
Row UDF не обрабатывает None автоматически. Spark передаёт None в функцию, и если функция бросает исключение - Task упадёт, а Job будет повторять его до исчерпания retry лимита.
# Опасно: AttributeError при None
@udf("string")
def bad_upper(s: str) -> str:
return s.upper() # AttributeError: 'NoneType' object has no attribute 'upper'
# Безопасно: явная проверка на None
@udf("string")
def safe_upper(s: str) -> str:
return s.upper() if s is not None else None
# Ещё лучше: встроенная функция без UDF вообще
from pyspark.sql.functions import upper
df.withColumn("name_upper", upper(col("name")))
Когда None особенно опасны - при работе с составными типами. Spark передаёт вложенную структуру как Python Row:
from pyspark.sql.types import StructType, StructField, StringType, DoubleType
@udf(DoubleType())
def extract_amount(payment):
# payment - это Row объект или None
if payment is None:
return None
# payment.amount тоже может быть None!
return float(payment.amount) if payment.amount is not None else 0.0
Рекомендация: всегда добавляйте null-guard первой строкой UDF и проверяйте вложенные поля отдельно.
Deterministic vs non-deterministic UDF¶
По умолчанию Spark считает UDF детерминированными: один и тот же входной набор значений всегда возвращает одинаковый результат. Это позволяет оптимизатору свободно переупорядочивать вычисления, кэшировать результаты и переиспользовать их.
Если UDF недетерминирован (использует random, текущее время, внешний API), нужно явно объявить это:
import random
from pyspark.sql.functions import udf
from pyspark.sql.types import DoubleType
# Без объявления non-deterministic: Spark может выполнить UDF один раз
# и переиспользовать результат - нарушая ожидаемое поведение
@udf(DoubleType())
def random_score(user_id: int) -> float:
return random.random()
# Правильно: явно объявить non-deterministic
random_score_nd = udf(lambda uid: random.random(), DoubleType()).asNondeterministic()
df.withColumn("score", random_score_nd(col("user_id")))
Недетерминированный UDF имеет дополнительные ограничения:
- Spark не может переместить его в
filterbranch (нельзя знать, будет ли он нужен) - Нельзя кэшировать результаты вычисления
- При повторном чтении (например, при
count()+collect()) UDF будет вызван дважды с разными результатами
Если вы обращаетесь во внешний API внутри UDF - это тоже non-deterministic UDF, и нужно декларировать это явно.
UDF reuse и repeated computation¶
Если один и тот же UDF вызывается дважды в разных местах плана - Spark может выполнить его дважды для одних и тех же строк. Это называется repeated UDF computation.
# Опасно: UDF вызывается дважды в одном плане
expensive_udf_result = expensive_udf(col("raw"))
df.withColumn("a", expensive_udf_result) \
.withColumn("b", expensive_udf_result * 2)
# Physical plan может содержать два BatchEvalPython узла
# - Spark не всегда умеет понять, что это один и тот же вызов
Решение: сначала материализовать результат UDF через withColumn, потом переиспользовать колонку:
# Безопасно: UDF вызывается один раз, результат сохраняется в колонке
df_with_score = df.withColumn("score_raw", expensive_udf(col("raw")))
df_with_score \
.withColumn("score_norm", col("score_raw") / 100) \
.withColumn("score_flag", col("score_raw") > 80)
Если результат UDF нужен в нескольких местах - cache() DataFrame после вычисления UDF, чтобы избежать повторного выполнения при последующих actions.
Spark UI и диагностика UDF bottleneck¶
Когда Job работает медленно из-за Python UDF, это можно увидеть в Spark UI.
Признаки Python UDF bottleneck в Spark UI:
- Task duration непропорционально велики по сравнению с размером данных
- Executor CPU близко к 100%, но Shuffle Read/Write - нули (данные не перемещаются, но CPU занят)
- Python time в Task metrics - если отображается отдельно, показывает, сколько времени задача провела в Python worker
Диагностика через explain:
# Ищем BatchEvalPython в физическом плане
df.withColumn("result", my_udf(col("input"))).explain()
# BatchEvalPython [my_udf(input#5)], [input#5, result#12]
# Это сигнал - здесь Python worker
На уровне ОС (на воркере): если Job медленный, можно найти Python worker процессы:
# На executor node
ps aux | grep python
# pyspark.worker - отдельный Python process на каждый executor
Что смотреть в Spark UI при отладке UDF:
- Stage details → Task metrics: время задачи в разбивке по фазам
- Event timeline: есть ли долгие фазы
computingпри минимальном shuffle - SQL tab: в физическом плане найти
BatchEvalPythonузлы - они и есть bottleneck
NotSerializableException: объект не сериализуется¶
Самая распространённая ошибка при работе с UDF - попытка захватить в closure объект, который нельзя сериализовать через pickle (соединение с БД, HTTP-клиент, ML-модель, файловый дескриптор).
Диагностика¶
org.apache.spark.SparkException: Task not serializable
Caused by: java.io.NotSerializableException: org.postgresql.jdbc.PgConnection
at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1184)
at ...
Механика ошибки: когда Spark сериализует Task для отправки на executor, он сериализует весь closure UDF-функции - включая все переменные, которые функция захватывает из внешней области видимости. Если среди них есть объект, который не поддерживает pickle (большинство connection объектов, файловых дескрипторов, генераторов) - сериализация провалится.
Неправильный подход: создаём соединение на Driver¶
# ОШИБКА: conn создан на Driver, не может быть сериализован в Task
import psycopg2
conn = psycopg2.connect("host=pg-host dbname=mydb user=etl password=secret")
@udf("string")
def enrich_from_db(user_id: int) -> str:
cursor = conn.cursor() # conn захвачен в closure → NotSerializableException
cursor.execute("SELECT name FROM users WHERE id = %s", (user_id,))
return cursor.fetchone()[0]
Правильный подход: создаём ресурс на Executor¶
# РЕШЕНИЕ: создать ресурс внутри UDF (на каждую строку - неэффективно)
@udf("string")
def enrich_from_db(user_id: int) -> str:
import psycopg2 # импорт внутри UDF гарантирует, что он доступен на executor
if user_id is None:
return None
conn = psycopg2.connect("host=pg-host dbname=mydb user=etl password=secret")
cursor = conn.cursor()
cursor.execute("SELECT name FROM users WHERE id = %s", (user_id,))
result = cursor.fetchone()
conn.close()
return result[0] if result else None
Это открывает новое соединение на каждую строку - катастрофа для производительности и для PostgreSQL (лимиты соединений). Правильное решение - mapPartitions.
mapPartitions: один ресурс на партицию¶
mapPartitions позволяет создать ресурс (соединение с БД, ML-модель) один раз на партицию и переиспользовать его для всех строк. Это принципиально другой паттерн по сравнению с Row UDF.
Технически mapPartitions работает на уровне RDD: функция получает итератор по всем строкам одной партиции и должна вернуть итератор результатов. Python worker запускается один раз на партицию, а не на каждую строку.
def enrich_partition(rows):
import psycopg2
# Ресурс создаётся ОДИН РАЗ на партицию (не на строку!)
conn = psycopg2.connect("host=pg-host dbname=mydb user=etl password=secret")
cursor = conn.cursor()
try:
for row in rows:
user_id = row["user_id"]
if user_id is None:
yield row.asDict() | {"user_name": None}
continue
cursor.execute("SELECT name FROM users WHERE id = %s", (user_id,))
result = cursor.fetchone()
yield row.asDict() | {"user_name": result[0] if result else None}
finally:
conn.close() # Соединение закрывается после обработки всей партиции
from pyspark.sql.types import StructType, StructField, StringType, LongType, DoubleType
result_schema = StructType([
StructField("user_id", LongType()),
StructField("amount", DoubleType()),
StructField("user_name", StringType()),
])
enriched_df = df.rdd.mapPartitions(enrich_partition).toDF(result_schema)
Важный момент: функция в mapPartitions получает итератор и должна возвращать итератор. Использование yield вместо return - это генераторный паттерн, который не загружает всю партицию в память сразу. Это важно для больших партиций.
mapPartitions для ML-модели¶
Загрузка ML-модели (scikit-learn, XGBoost, transformers) - дорогая операция, занимающая секунды. При Row UDF загрузка происходила бы на каждую строку. С mapPartitions модель загружается один раз на партицию:
def predict_partition(rows):
import joblib
import numpy as np
# Модель загружается ОДИН РАЗ на партицию
model = joblib.load("/shared/models/fraud_detector_v2.pkl")
batch = list(rows)
if not batch:
return
features = np.array([[r["amount"], r["hour"], r["merchant_code"]] for r in batch])
predictions = model.predict_proba(features)[:, 1]
for row, score in zip(batch, predictions):
yield row.asDict() | {"fraud_score": float(score)}
fraud_df = transactions.rdd.mapPartitions(predict_partition).toDF(result_schema)
Обратите внимание на batch = list(rows): мы материализуем весь итератор, чтобы передать в model.predict_proba() весь набор данных сразу (батч-инференс обычно эффективнее строчного). Это требует, чтобы одна партиция помещалась в память Python worker.
Сравнение подходов для ML inference:
| Подход | Загрузок модели | Время на 10M строк |
|---|---|---|
| Row UDF с загрузкой в каждой строке | 10 000 000 | ~2 часа |
| mapPartitions (200 партиций) | 200 | ~5 минут |
| Pandas UDF с batch=1000 | 10 000 | ~15 минут |
WARN TaskSetManager: большая closure¶
Если UDF или код захватывает в closure крупный Python-объект (словарь, список, датасет), Spark сериализует его и рассылает каждому Task. При большом объекте Driver генерирует предупреждение:
WARN TaskSetManager: Stage 3 contains a task of very large size (1.2 MB).
The maximum recommended task size is 100 KB. If init time is short,
consider using a broadcast variable to reduce the task size.
Это предупреждение означает: каждый из, например, 200 Tasks в этом Stage получит 1.2 MB данных только для closure. При 200 tasks это 240 MB лишнего трафика от Driver к Executors - каждый раз при запуске Stage.
Проблема¶
# Плохо: большой словарь захвачен в closure - копируется в каждый Task
city_population = {city: pop for city, pop in spark.table("cities").collect()}
# Допустим: 500 городов × 1 KB = 500 KB на Task
# 200 Tasks × 500 KB = 100 MB трафика от Driver к Executor'ам
@udf("long")
def get_population(city: str) -> int:
return city_population.get(city, 0) # city_population → в closure!
Решение: Broadcast Variable¶
Broadcast переменная рассылается один раз на executor (не на task) через BitTorrent-подобный протокол. После этого все tasks на одном executor читают её из локальной памяти executor - без дополнительного трафика.
from pyspark.sql.functions import udf
from pyspark.sql.types import LongType
# Broadcast variable: рассылается ОДИН РАЗ на Executor, не на Task
city_pop_dict = {city: pop for city, pop in spark.table("cities").collect()}
city_pop_bv = spark.sparkContext.broadcast(city_pop_dict)
@udf(LongType())
def get_population(city: str) -> int:
# .value получает объект на Executor без повторной сериализации
return city_pop_bv.value.get(city, 0)
df.withColumn("population", get_population(col("city")))
# Освободить когда больше не нужен
city_pop_bv.unpersist()
Разница между closure и broadcast:
- Closure: объект сериализуется на Driver и включается в каждый Task → N tasks × M MB трафика
- Broadcast: объект рассылается один раз на каждый executor → K executors × M MB трафика, где K << N
Когда использовать Broadcast variable вместо closure:
- Объект > 100 KB
- Объект используется в нескольких Tasks (что обычно и есть)
- Объект не меняется в процессе Job'а
Практика: бенчмарк встроенных функций vs Python UDF¶
Наглядный пример: та же логика, реализованная через встроенные функции и через Python UDF.
Задача: нормализовать email-адрес (привести к нижнему регистру, обрезать пробелы, извлечь домен).
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
from pyspark.sql.functions import udf
from pyspark.sql.types import StringType
import time
spark = SparkSession.builder \
.appName("UDF-Benchmark") \
.config("spark.sql.shuffle.partitions", "8") \
.getOrCreate()
# Генерируем тестовые данные: 10 миллионов строк
df = spark.range(10_000_000).select(
F.concat(
F.lit(" User_"),
F.col("id").cast("string"),
F.lit("@Example.COM ")
).alias("email_raw")
).cache()
df.count() # материализуем кэш
# ── Вариант 1: Python UDF ─────────────────────────────────────────────────────
@udf(StringType())
def extract_domain_udf(email: str) -> str:
if email is None:
return None
clean = email.strip().lower()
if "@" not in clean:
return None
return clean.split("@")[1]
start = time.time()
df.withColumn("domain_udf", extract_domain_udf(F.col("email_raw"))).count()
print(f"Python UDF: {time.time() - start:.1f}s")
# ── Вариант 2: встроенные функции ─────────────────────────────────────────────
start = time.time()
df.withColumn("domain_builtin",
F.split(F.lower(F.trim(F.col("email_raw"))), "@").getItem(1)
).count()
print(f"Built-in functions: {time.time() - start:.1f}s")
Ожидаемые результаты на типичном кластере:
Python UDF: 45.3s (BatchEvalPython, pickle overhead)
Built-in functions: 4.1s (WholeStageCodeGen, JVM native)
Разница - ~11×. На реальных production кластерах разница может быть от 3× до 20× в зависимости от сложности логики и объема данных.
Что смотреть в Spark UI после этого бенчмарка:
- Для UDF: найдите Stage с
BatchEvalPython- task duration будет намного длиннее - Для built-in: увидите один сгенерированный
WholeStageCodegenблок в SQL tab
Практика: удаление UDF из legacy ETL pipeline¶
Реальный паттерн - рефакторинг legacy PySpark кода, написанного «потому что не знали про built-in функции».
До рефакторинга (типичный legacy code):
# Всё через UDF - типичная ошибка junior разработчика
@udf("string")
def extract_year(date_str: str) -> str:
if date_str is None:
return None
return date_str[:4]
@udf("string")
def clean_phone(phone: str) -> str:
if phone is None:
return None
return "".join(c for c in phone if c.isdigit())
@udf("string")
def categorize_amount(amount) -> str:
if amount is None:
return "unknown"
if amount < 100:
return "small"
if amount < 10000:
return "medium"
return "large"
@udf("string")
def upper_name(name: str) -> str:
return name.upper() if name else None
result = df \
.withColumn("year", extract_year(col("created_at"))) \
.withColumn("phone_clean", clean_phone(col("phone"))) \
.withColumn("amount_cat", categorize_amount(col("amount"))) \
.withColumn("name_upper", upper_name(col("name")))
# Physical plan: 4× BatchEvalPython - 4 барьера JVM↔Python
После рефакторинга (встроенные функции):
from pyspark.sql import functions as F
result = df \
.withColumn("year",
F.year(F.col("created_at").cast("date"))
) \
.withColumn("phone_clean",
F.regexp_replace(F.col("phone"), "[^0-9]", "")
) \
.withColumn("amount_cat",
F.when(F.col("amount") < 100, "small")
.when(F.col("amount") < 10000, "medium")
.otherwise("large")
) \
.withColumn("name_upper",
F.upper(F.col("name"))
)
# Physical plan: один WholeStageCodeGen блок - всё вместе
Не только производительность: рефакторинговая версия понятнее, тестируется стандартными Spark тестами, и Catalyst понимает её семантику.
Полный пример: правильный vs неправильный UDF¶
from pyspark.sql import SparkSession
from pyspark.sql.functions import udf, col, upper, when, regexp_replace
from pyspark.sql.types import StringType
spark = SparkSession.builder.appName("UDF-Demo").getOrCreate()
df = spark.createDataFrame([
(1, "john doe", "+7 (999) 123-45-67", "A"),
(2, None, "8 800 555 3535", "B"),
(3, "Jane Smith", None, "C"),
], ["id", "name", "phone", "tier"])
# ── ПЛОХО: UDF для того, что встроенные функции делают лучше ──────────────────
@udf("string")
def bad_upper(s):
return s.upper() if s else None # встроенный upper() быстрее в 5–10×
# ── ХОРОШО: встроенные функции ───────────────────────────────────────────────
df_good = df.withColumn("name_upper", upper(col("name"))) # JVM, Codegen
# ── ХОРОШО: UDF только для нестандартной логики (нормализация телефона) ───────
# Такой логики нет в встроенных функциях - UDF оправдан
@udf("string")
def normalize_phone(phone: str) -> str:
if phone is None:
return None
digits = "".join(c for c in phone if c.isdigit())
if len(digits) == 11:
return f"+{digits}"
if len(digits) == 10:
return f"+7{digits}"
return phone
df_phones = df.withColumn("phone_clean", normalize_phone(col("phone")))
# ── ХОРОШО: broadcast для справочника ────────────────────────────────────────
tier_labels = {"A": "Premium", "B": "Standard", "C": "Budget"}
tier_bv = spark.sparkContext.broadcast(tier_labels)
@udf("string")
def tier_label(code: str) -> str:
return tier_bv.value.get(code, "Unknown") if code else None
df_result = df_phones.withColumn("tier_name", tier_label(col("tier")))
df_result.show()
tier_bv.unpersist()
Anti-patterns Python UDF¶
1. UDF для операций, которые есть в pyspark.sql.functions
# ПЛОХО
@udf("string")
def my_upper(s): return s.upper() if s else None
# ХОРОШО
F.upper(col("name"))
2. UDF вместо F.when().otherwise()
# ПЛОХО: UDF для условной логики
@udf("string")
def categorize(score):
if score is None: return None
if score > 0.8: return "high"
if score > 0.5: return "medium"
return "low"
# ХОРОШО: встроенная условная логика
F.when(col("score") > 0.8, "high") \
.when(col("score") > 0.5, "medium") \
.otherwise("low")
3. UDF вместо regexp_replace/regexp_extract
# ПЛОХО: regex через Python UDF
@udf("string")
def extract_code(text):
import re
m = re.search(r"CODE-(\d+)", text or "")
return m.group(1) if m else None
# ХОРОШО: встроенный regexp_extract
F.regexp_extract(col("text"), r"CODE-(\d+)", 1)
4. UDF вместо Higher-Order Functions над массивами
# ПЛОХО: UDF для преобразования массива
@udf("array<double>")
def normalize_scores(scores):
if scores is None: return None
total = sum(scores) or 1
return [s / total for s in scores]
# ХОРОШО: transform (HOF) - работает в JVM
from pyspark.sql.functions import transform, aggregate
total = aggregate(col("scores"), F.lit(0.0), lambda acc, x: acc + x)
F.transform(col("scores"), lambda x: x / total)
5. Соединение с БД создаётся на каждую строку
# ПЛОХО: 10M строк = 10M соединений с PostgreSQL
@udf("string")
def lookup(user_id):
import psycopg2
conn = psycopg2.connect(...) # новое соединение на каждую строку!
...
# ХОРОШО: mapPartitions - одно соединение на партицию
df.rdd.mapPartitions(enrich_partition).toDF(schema)
6. Большой справочник в closure вместо broadcast
# ПЛОХО: 10MB dict в closure каждого Task
big_dict = load_mapping() # 10MB
@udf("string")
def lookup(key): return big_dict.get(key) # big_dict → closure
# ХОРОШО: broadcast
bv = spark.sparkContext.broadcast(big_dict)
@udf("string")
def lookup(key): return bv.value.get(key)
Checklist: выбор подхода¶
| Задача | Рекомендация |
|---|---|
| Строковая операция (upper, trim, split) | F.upper, F.trim, F.split |
| Условная логика | F.when().otherwise() |
| Математика | F.log, F.sqrt, F.round |
| Работа с датами | F.to_date, F.datediff |
| Регулярные выражения | F.regexp_extract, F.regexp_replace |
| Трансформации массивов | HOF: F.transform, F.filter, F.aggregate |
| Нестандартная бизнес-логика без внешних ресурсов | Row UDF с null-guard и явным returnType |
| Нестандартная логика + numpy/scipy | Pandas UDF (следующий урок) |
| Обращение к БД / HTTP / файлам | mapPartitions - один ресурс на партицию |
| ML inference | mapPartitions (загрузка модели раз на партицию) |
| Большой справочник в UDF | broadcast() + .value в теле UDF |
| Объект не сериализуется | Создать внутри UDF или в mapPartitions |
| Random/time-dependent логика | Объявить asNondeterministic() |
| Медленный Job с UDF | Проверить explain() на BatchEvalPython; искать аналог в F.* |