Спарк производительности для Scala против Python

109

Я предпочитаю Python над Scala. Но поскольку Spark изначально написан в Scala, я ожидал, что мой код будет работать быстрее в Scala, чем версия Python по понятным причинам.

С этим предположением я решил изучить и написать версию Scala некоторого очень распространенного кода предварительной обработки для некоторых 1 GB данных. Данные выбираются из конкурса SpringLeaf на Kaggle. Просто чтобы дать обзор данных (он содержит 1936 размеров и 145232 строки). Данные состоят из различных типов, например. int, float, string, boolean. Я использую 6 ядер из 8 для обработки Spark; поэтому я использовал minPartitions=6, чтобы каждое ядро ​​что-то обрабатывало.

Scala Код

val input = sc.textFile("train.csv", minPartitions=6)

val input2 = input.mapPartitionsWithIndex { (idx, iter) => 
  if (idx == 0) iter.drop(1) else iter }
val delim1 = "\001"

def separateCols(line: String): Array[String] = {
  val line2 = line.replaceAll("true", "1")
  val line3 = line2.replaceAll("false", "0")
  val vals: Array[String] = line3.split(",")

  for((x,i) <- vals.view.zipWithIndex) {
    vals(i) = "VAR_%04d".format(i) + delim1 + x
  }
  vals
}

val input3 = input2.flatMap(separateCols)

def toKeyVal(line: String): (String, String) = {
  val vals = line.split(delim1)
  (vals(0), vals(1))
}

val input4 = input3.map(toKeyVal)

def valsConcat(val1: String, val2: String): String = {
  val1 + "," + val2
}

val input5 = input4.reduceByKey(valsConcat)

input5.saveAsTextFile("output")

Код Python

input = sc.textFile('train.csv', minPartitions=6)
DELIM_1 = '\001'


def drop_first_line(index, itr):
  if index == 0:
    return iter(list(itr)[1:])
  else:
    return itr

input2 = input.mapPartitionsWithIndex(drop_first_line)

def separate_cols(line):
  line = line.replace('true', '1').replace('false', '0')
  vals = line.split(',')
  vals2 = ['VAR_%04d%s%s' %(e, DELIM_1, val.strip('\"'))
           for e, val in enumerate(vals)]
  return vals2


input3 = input2.flatMap(separate_cols)

def to_key_val(kv):
  key, val = kv.split(DELIM_1)
  return (key, val)
input4 = input3.map(to_key_val)

def vals_concat(v1, v2):
  return v1 + ',' + v2

input5 = input4.reduceByKey(vals_concat)
input5.saveAsTextFile('output')

Scala Производительность Стадия 0 (38 минут), этап 1 (18 секунд) Изображение 452

Производительность Python Этап 0 (11 минут), этап 1 (7 сек) Изображение 453

Оба создают разные графики визуализации DAG (из-за которых оба изображения показывают разные функции этапа 0 для Scala (map) и Python (reduceByKey))

Но, по сути, оба кода пытаются преобразовать данные в (size_id, string of list of values) RDD и сохранять на диск. Выход будет использоваться для вычисления различных статистических данных для каждого измерения.

Показатель производительности, Scala код для этих реальных данных вроде этого, кажется, работает в 4 раза медленнее, чем версия Python. Хорошей новостью для меня является то, что она дала мне хорошую мотивацию остаться с Python. Плохая новость: я не совсем понял, почему?

Теги:
performance
apache-spark
rdd

1 ответ

239

Оригинальный ответ, обсуждающий код, можно найти ниже.


Прежде всего, вам нужно различать различные типы API, каждый из которых имеет свои собственные соображения производительности.

API RDD

(чистые структуры Python с оркестровкой на основе JVM)

Это компонент, на который больше всего повлияет производительность кода Python и детали реализации PySpark. Хотя производительность Python вряд ли будет проблемой, необходимо учитывать как минимум несколько факторов:

  • Накладные расходы на передачу JVM. Практически все данные, поступающие от исполнителя Python, должны проходить через сокет и рабочий JVM. Хотя это относительно эффективная локальная связь, она по-прежнему не бесплатна.
  • Исполнители на основе процессов (Python) по сравнению с потоковыми (одиночные JVM-потоки) исполнителями (Scala). Каждый исполнитель Python работает в своем собственном процессе. В качестве побочного эффекта он обеспечивает более сильную изоляцию, чем его коллега JVM, и некоторый контроль над жизненным циклом исполнителя, но потенциально значительно более высокий уровень использования памяти:

    • объем памяти интерпретатора
    • отпечаток загруженных библиотек
    • менее эффективное вещание (каждый процесс требует собственной копии широковещательной передачи)
  • Выполнение самого кода Python. Вообще говоря, Scala быстрее, чем Python, но он будет варьироваться в зависимости от задачи. Кроме того, у вас есть несколько опций, включая JIT, такие как Numba, расширения C (Cython) или специализированные библиотеки, такие как Theano. Наконец, , если вы не используете ML/MLlib (или просто стек NumPy), рассмотрите возможность использования PyPy как альтернативный переводчик. См. SPARK-3094.

  • Конфигурация PySpark предоставляет опцию spark.python.worker.reuse, которая может использоваться для выбора между обработкой Python-процесса для каждой задачи и повторным использованием существующего процесса. Последний вариант, по-видимому, полезен, чтобы избежать дорогостоящей сборки мусора (это скорее впечатление, чем результат систематических тестов), а первый (по умолчанию) является оптимальным для дорогостоящих трансляций и импорта.
  • Подсчет ссылок, используемый в качестве метода сбора мусора первой строки в CPython, хорошо работает с типичными рабочими нагрузками Spark (потоковая обработка, без ссылочных циклов) и снижает риск длительных пауз GC.

MLlib

(смешанное выполнение Python и JVM)

Основные соображения почти такие же, как и раньше, с несколькими дополнительными проблемами. В то время как базовые структуры, используемые с MLlib, представляют собой простые объекты RDD Python, все алгоритмы выполняются непосредственно с помощью Scala.

Это означает дополнительную стоимость преобразования объектов Python в объекты Scala и наоборот, увеличение использования памяти и некоторые дополнительные ограничения, которые мы рассмотрим позже.

На данный момент (Spark 2.x) API на основе RDD находится в режиме обслуживания и планируется удалить в Spark 3.0.

API DataFrame и Spark ML

(исполнение JVM с кодом Python, ограниченным драйвером)

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

Единственное исключение - использование строковых UDF Python, которые значительно менее эффективны, чем их эквиваленты Scala. Хотя есть некоторые возможности для усовершенствований (в Spark 2.0.0 было существенное развитие), самым большим ограничением является полное переключение между внутренним представлением (JVM) и интерпретатором Python. Если возможно, вы должны одобрить состав встроенных выражений (пример. Поведение Python UDF было улучшено в Spark 2.0.0, но оно по-прежнему субоптимально по сравнению с родным Это может улучшиться в будущем с введением векторизованных UDF (SPARK-21190).

Также избегайте ненужных передаваемых данных между DataFrames и RDDs. Это требует дорогостоящей сериализации и десериализации, не говоря уже о передаче данных в интерпретатор Python и из него.

Стоит отметить, что вызовы Py4J имеют довольно высокую задержку. Сюда входят простые вызовы типа:

from pyspark.sql.functions import col

col("foo")

Как правило, это не имеет значения (накладные расходы являются постоянными и не зависят от объема данных), но в случае мягких приложений реального времени вы можете рассмотреть кэширование/повторное использование Java-оберток.

GraphX ​​и Spark DataSets

Как и сейчас (Spark 1.6 2.1), ни один из них не предоставляет API PySpark, поэтому вы можете сказать, что PySpark бесконечно хуже, чем Scala.

Graphx

На практике разработка GraphX ​​прекратилась почти полностью, и проект в настоящее время находится в режиме обслуживания, при этом связанные билеты JIRA закрылись, так как не будут исправлены. GraphFrames библиотека предоставляет альтернативную библиотеку обработки графов с привязками Python.

Dataset

Субъективно говоря, в Python не так много места для статического ввода Datasets, и даже если бы была реализована текущая реализация Scala слишком простая и не обеспечивала тех же преимуществ производительности, что и DataFrame.

Streaming

Из того, что я видел до сих пор, я настоятельно рекомендую использовать Scala поверх Python. Это может измениться в будущем, если PySpark получит поддержку структурированных потоков, но прямо сейчас Scala API выглядит гораздо более надежным, всеобъемлющим и эффективным. Мой опыт довольно ограничен.

Структурированная потоковая передача в Spark 2.x, по-видимому, уменьшает разрыв между языками, но на данный момент она все еще в первые дни. Тем не менее, API на основе RDD уже упоминается как "устаревшая потоковая передача" в Документация Databricks (дата доступа 2017-03-03)), поэтому он разумный ожидать дальнейших усилий по объединению.

Неэффективные соображения

Паритет функции

Не все функции Spark доступны через API PySpark. Обязательно проверьте, действительно ли требуемые части уже реализованы, и попытайтесь понять возможные ограничения.

Это особенно важно при использовании MLlib и подобных смешанных контекстов (см. вызов функции Java/ Scala из задачи). Чтобы быть справедливым, некоторые части API PySpark, такие как mllib.linalg, предоставляют более полный набор методов, чем Scala.

Дизайн API

API PySpark тесно отражает свою аналогию Scala и, как таковой, не совсем Pythonic. Это означает, что довольно легко сопоставить языки, но в то же время код Python может быть значительно сложнее понять.

Комплексная архитектура

Поток данных PySpark относительно сложный по сравнению с чистым выполнением JVM. Гораздо сложнее рассуждать о программах PySpark или отладке. Более того, по крайней мере, базовое понимание Scala и JVM в общем случае должно быть довольно.

Spark 2.x и выше

Продолжающийся переход к API Dataset, с замороженным API RDD предоставляет как возможности, так и проблемы для пользователей Python. Хотя высокоуровневые части API намного проще раскрывать в Python, более сложные функции практически невозможно использовать напрямую.

Кроме того, родные функции Python по-прежнему остаются гражданами второго сорта в мире SQL. Надеюсь, это улучшится в будущем с сериализацией Apache Arrow (текущими усилиями целевых данных collection, но UDF serde является долгосрочная цель).

Для проектов, сильно зависящих от кодовой базы Python, чистые альтернативы Python (например, Dask или Ray) может быть интересной альтернативой.

Он не должен быть одним против другого

API Spark DataFrame (SQL, Dataset) предоставляет элегантный способ интеграции Scala/Java-кода в приложении PySpark. Вы можете использовать DataFrames, чтобы вывести данные в собственный код JVM и прочитать результаты. Я объяснил некоторые опции где-то еще, и вы можете найти рабочий пример Python- Scala roundtrip в Как использовать a Scala внутри Pyspark.

Он может быть дополнен путем введения пользовательских типов (см. Как определить схему для настраиваемого типа в Spark SQL?).


Что не так с кодом, указанным в вопросе

(Отказ от ответственности: точка зрения Pythonista. Скорее всего, я пропустил некоторые трюки Scala)

Прежде всего, в вашем коде есть одна часть, которая не имеет никакого смысла. Если у вас уже есть пары (key, value), созданные с помощью zipWithIndex или enumerate, какова точка в создании строки, чтобы сразу разбить ее? flatMap не работает рекурсивно, поэтому вы можете просто получить кортежи и пропустить следующий map.

Другая проблема, которую я считаю проблематичной, - reduceByKey. Вообще говоря, reduceByKey полезна, если применение агрегированной функции может уменьшить объем данных, которые нужно перетасовать. Поскольку вы просто объединяете строки, здесь ничего не получится. Игнорирование низкоуровневых материалов, таких как количество ссылок, количество данных, которое вы должны передать, точно такое же, как и для groupByKey.

Обычно я бы не стал останавливаться на этом, но насколько я могу судить об этом, это узкое место в вашем коде Scala. Объединение строк в JVM - довольно дорогостоящая операция (см. например: Является ли конкатенация строк в Scala столь же дорогостоящей, как и в Java?). Это означает, что что-то вроде этого _.reduceByKey((v1: String, v2: String) => v1 + ',' + v2), которое эквивалентно input4.reduceByKey(valsConcat) в вашем коде, не является хорошей идеей.

Если вы хотите избежать groupByKey, вы можете попробовать использовать aggregateByKey с StringBuilder. Нечто похожее на это должно сделать трюк:

rdd.aggregateByKey(new StringBuilder)(
  (acc, e) => {
    if(!acc.isEmpty) acc.append(",").append(e)
    else acc.append(e)
  },
  (acc1, acc2) => {
    if(acc1.isEmpty | acc2.isEmpty)  acc1.addString(acc2)
    else acc1.append(",").addString(acc2)
  }
)

но я сомневаюсь, что это стоит всей суеты.

Помня это, я переписал ваш код следующим образом:

Scala

val input = sc.textFile("train.csv", 6).mapPartitionsWithIndex{
  (idx, iter) => if (idx == 0) iter.drop(1) else iter
}

val pairs = input.flatMap(line => line.split(",").zipWithIndex.map{
  case ("true", i) => (i, "1")
  case ("false", i) => (i, "0")
  case p => p.swap
})

val result = pairs.groupByKey.map{
  case (k, vals) =>  {
    val valsString = vals.mkString(",")
    s"$k,$valsString"
  }
}

result.saveAsTextFile("scalaout")

Python

def drop_first_line(index, itr):
    if index == 0:
        return iter(list(itr)[1:])
    else:
        return itr

def separate_cols(line):
    line = line.replace('true', '1').replace('false', '0')
    vals = line.split(',')
    for (i, x) in enumerate(vals):
        yield (i, x)

input = (sc
    .textFile('train.csv', minPartitions=6)
    .mapPartitionsWithIndex(drop_first_line))

pairs = input.flatMap(separate_cols)

result = (pairs
    .groupByKey()
    .map(lambda kv: "{0},{1}".format(kv[0], ",".join(kv[1]))))

result.saveAsTextFile("pythonout")

Результаты

В режиме local[6] (Intel (R) Xeon (R) CPU E3-1245 V2 @3,40 ГГц) с памятью 4 ГБ на каждого исполнителя требуется (n = 3):

  • Scala - означает: 250.00s, stdev: 12.49
  • Python - означает: 246.66s, stdev: 1.15

Я почти уверен, что большую часть времени тратится на перетасовку, сериализацию, десериализацию и другие второстепенные задачи. Просто для удовольствия, здесь наивный однопоточный код в Python, который выполняет одну и ту же задачу на этой машине менее чем за минуту:

def go():
    with open("train.csv") as fr:
        lines = [
            line.replace('true', '1').replace('false', '0').split(",")
            for line in fr]
    return zip(*lines[1:])
  • 10
    Один из самых ясных, исчерпывающих и полезных ответов, с которыми я столкнулся некоторое время. Спасибо!

Ещё вопросы

Сообщество Overcoder
Наверх
Меню