UDF: Python UDF и их цена - когда стоит, когда не стоит

Row UDF, Pandas UDF, mapPartitions: сравнение производительности, NotSerializableException, WARN TaskSetManager и паттерны правильного использования

core optimization

Когда вообще нужен 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 не может переместить его в filter branch (нельзя знать, будет ли он нужен)
  • Нельзя кэшировать результаты вычисления
  • При повторном чтении (например, при 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.*