Почему в Scala архивируется быстрее, чем zip?

38

Я написал некоторый код Scala для поэтапной операции над коллекцией. Здесь я определил два метода, которые выполняют одну и ту же задачу. Один метод использует, zipа другой использует zipped.

def ES (arr :Array[Double], arr1 :Array[Double]) :Array[Double] = arr.zip(arr1).map(x => x._1 + x._2)

def ES1(arr :Array[Double], arr1 :Array[Double]) :Array[Double] = (arr,arr1).zipped.map((x,y) => x + y)

Чтобы сравнить эти два метода с точки зрения скорости, я написал следующий код:

def fun (arr : Array[Double] , arr1 : Array[Double] , f :(Array[Double],Array[Double]) => Array[Double] , itr : Int) ={
  val t0 = System.nanoTime()
  for (i <- 1 to itr) {
       f(arr,arr1)
       }
  val t1 = System.nanoTime()
  println("Total Time Consumed:" + ((t1 - t0).toDouble / 1000000000).toDouble + "Seconds")
}

Я вызываю funметод и передаю ESи ES1как ниже:

fun(Array.fill(10000)(math.random), Array.fill(10000)(math.random), ES , 100000)
fun(Array.fill(10000)(math.random), Array.fill(10000)(math.random), ES1, 100000)

Результаты показывают, что названный метод, ES1который использует zipped, быстрее, чем метод, ESкоторый использует zip. Основываясь на этих наблюдениях, у меня есть два вопроса.

Почему zippedбыстрее чем zip?

Есть ли еще более быстрый способ поэтапного выполнения операций над коллекцией в Scala?

user12140540
источник
2
Похожий вопрос: stackoverflow.com/questions/59125910/…
Марио Галич
8
Поскольку JIT решил оптимизировать более агрессивно во второй раз, он увидел, что вызывается «веселье». Или потому что GC решил что-то почистить, пока ES работал. Или потому, что ваша операционная система решила, что у нее есть дела поважнее во время выполнения теста ES. Может быть что угодно, этот микробенчмарк просто не окончательный.
Андрей Тюкин
1
Каковы результаты на вашей машине? Насколько быстрее?
Peeyush Kushwaha
Для того же размера и конфигурации популяции Zipped
отнимает
3
Ваши результаты не имеют смысла. Используйте JMH, если вы должны сделать микро-тесты.
OrangeDog

Ответы:

17

Чтобы ответить на ваш второй вопрос:

Есть ли более быстрый способ поэлементного выполнения операций в коллекции в Scala?

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

Если производительность критична, хотя отнюдь не универсальна, в таких случаях, как ваш, вы можете развернуть операции Scala обратно в императивные эквиваленты, чтобы восстановить более прямой контроль над использованием памяти и исключить вызовы функций.

В вашем конкретном примере zippedсуммы могут быть выполнены обязательно, предварительно выделив фиксированный, изменяемый массив правильного размера (поскольку zip останавливается, когда в одной из коллекций заканчивается элемент), а затем добавляя элементы с соответствующим индексом вместе (так как доступ элементы массива по порядковому индексу - очень быстрая операция).

Добавление третьей функции ES3в ваш набор тестов:

def ES3(arr :Array[Double], arr1 :Array[Double]) :Array[Double] = {
   val minSize = math.min(arr.length, arr1.length)
   val array = Array.ofDim[Double](minSize)
   for (i <- 0 to minSize - 1) {
     array(i) = arr(i) + arr1(i)
   }
  array
}

На моем i7 я получаю следующее время отклика:

OP ES Total Time Consumed:23.3747857Seconds
OP ES1 Total Time Consumed:11.7506995Seconds
--
ES3 Total Time Consumed:1.0255231Seconds

Еще более отвратительным было бы сделать прямую мутацию на месте более короткого из двух массивов, которая, очевидно, повредила бы содержимое одного из массивов, и это было бы сделано, только если бы исходный массив снова не понадобился:

def ES4(arr :Array[Double], arr1 :Array[Double]) :Array[Double] = {
   val minSize = math.min(arr.length, arr1.length)
   val array = if (arr.length < arr1.length) arr else arr1
   for (i <- 0 to minSize - 1) {
      array(i) = arr(i) + arr1(i)
   }
  array
}

Total Time Consumed:0.3542098Seconds

Но очевидно, что прямая мутация элементов массива не в духе Scala.

StuartLC
источник
2
В моем коде нет ничего распараллеленного Хотя эта конкретная проблема распараллеливается (поскольку несколько потоков могут работать в разных разделах массивов), такая простая операция не будет иметь большого смысла только для 10-килобайтных элементов - затраты на создание и синхронизацию новых потоков, вероятно, перевесят любую выгоду. , Честно говоря, если вам требуется такой уровень оптимизации производительности, вам, вероятно, лучше писать такие алгоритмы на Rust, Go или C.
StuartLC
3
Он будет более похож на Array.tabulate(minSize)(i => arr(i) + arr1(i))
скалу
1
@SarveshKumarSingh это намного медленнее. Занимает почти 9 секунд
user12140540
1
Array.tabulateдолжно быть гораздо быстрее, чем здесь zipили zippedздесь (и это в моих тестах).
Трэвис Браун
1
@StuartLC «Производительность будет эквивалентна, только если функция более высокого порядка будет развернута и встроена». Это не совсем точно. Даже ваша forфункция обращена к вызову функции более высокого порядка ( foreach). В обоих случаях лямбда будет создаваться только один раз.
Трэвис Браун
50

Ни в одном из других ответов не упоминается основная причина разницы в скорости, заключающаяся в том, что zippedверсия избегает 10000 распределений кортежей. Как пара других ответов сделать примечание, то zipверсия включает в себя промежуточный массив, в то время как zippedверсия не делает, но выделять массив 10000 элементов не то , что делает zipверсию намного хуже, это 10.000 короткоживущих кортежей , которые помещаются в этот массив. Они представлены объектами в JVM, поэтому вы делаете кучу распределений объектов для вещей, которые вы немедленно собираетесь выбросить.

Оставшаяся часть этого ответа просто подробнее расскажет о том, как вы можете это подтвердить.

Лучший бенчмаркинг

Вы действительно хотите использовать инфраструктуру, такую ​​как jmh, для ответственного проведения любых сравнительных тестов в JVM, и даже тогда ответственно сложно, хотя настройка самого jmh не так уж и плоха. Если у вас есть project/plugins.sbtтакие:

addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.3.7")

И build.sbtвот так (я использую 2.11.8, так как вы упоминаете, что вы используете):

scalaVersion := "2.11.8"

enablePlugins(JmhPlugin)

Затем вы можете написать свой тест следующим образом:

package zipped_bench

import org.openjdk.jmh.annotations._

@State(Scope.Benchmark)
@BenchmarkMode(Array(Mode.Throughput))
class ZippedBench {
  val arr1 = Array.fill(10000)(math.random)
  val arr2 = Array.fill(10000)(math.random)

  def ES(arr: Array[Double], arr1: Array[Double]): Array[Double] =
    arr.zip(arr1).map(x => x._1 + x._2)

  def ES1(arr: Array[Double], arr1: Array[Double]): Array[Double] =
    (arr, arr1).zipped.map((x, y) => x + y)

  @Benchmark def withZip: Array[Double] = ES(arr1, arr2)
  @Benchmark def withZipped: Array[Double] = ES1(arr1, arr2)
}

И запустить его с sbt "jmh:run -i 10 -wi 10 -f 2 -t 1 zipped_bench.ZippedBench":

Benchmark                Mode  Cnt     Score    Error  Units
ZippedBench.withZip     thrpt   20  4902.519 ± 41.733  ops/s
ZippedBench.withZipped  thrpt   20  8736.251 ± 36.730  ops/s

Что показывает, что zippedверсия получает примерно на 80% больше пропускной способности, что, вероятно, более или менее соответствует вашим измерениям.

Измерительные распределения

Вы также можете попросить JMH измерить распределение с -prof gc :

Benchmark                                                 Mode  Cnt        Score       Error   Units
ZippedBench.withZip                                      thrpt    5     4894.197 ±   119.519   ops/s
ZippedBench.withZip:·gc.alloc.rate                       thrpt    5     4801.158 ±   117.157  MB/sec
ZippedBench.withZip:·gc.alloc.rate.norm                  thrpt    5  1080120.009 ±     0.001    B/op
ZippedBench.withZip:·gc.churn.PS_Eden_Space              thrpt    5     4808.028 ±    87.804  MB/sec
ZippedBench.withZip:·gc.churn.PS_Eden_Space.norm         thrpt    5  1081677.156 ± 12639.416    B/op
ZippedBench.withZip:·gc.churn.PS_Survivor_Space          thrpt    5        2.129 ±     0.794  MB/sec
ZippedBench.withZip:·gc.churn.PS_Survivor_Space.norm     thrpt    5      479.009 ±   179.575    B/op
ZippedBench.withZip:·gc.count                            thrpt    5      714.000              counts
ZippedBench.withZip:·gc.time                             thrpt    5      476.000                  ms
ZippedBench.withZipped                                   thrpt    5    11248.964 ±    43.728   ops/s
ZippedBench.withZipped:·gc.alloc.rate                    thrpt    5     3270.856 ±    12.729  MB/sec
ZippedBench.withZipped:·gc.alloc.rate.norm               thrpt    5   320152.004 ±     0.001    B/op
ZippedBench.withZipped:·gc.churn.PS_Eden_Space           thrpt    5     3277.158 ±    32.327  MB/sec
ZippedBench.withZipped:·gc.churn.PS_Eden_Space.norm      thrpt    5   320769.044 ±  3216.092    B/op
ZippedBench.withZipped:·gc.churn.PS_Survivor_Space       thrpt    5        0.360 ±     0.166  MB/sec
ZippedBench.withZipped:·gc.churn.PS_Survivor_Space.norm  thrpt    5       35.245 ±    16.365    B/op
ZippedBench.withZipped:·gc.count                         thrpt    5      863.000              counts
ZippedBench.withZipped:·gc.time                          thrpt    5      447.000                  ms

…где gc.alloc.rate.norm , пожалуй, самая интересная часть, показывающая, что zipверсия выделяется в три раза больше, чем zipped.

Императивные реализации

Если бы я знал, что этот метод будет вызываться в чрезвычайно чувствительных к производительности контекстах, я бы, вероятно, реализовал его следующим образом:

  def ES3(arr: Array[Double], arr1: Array[Double]): Array[Double] = {
    val minSize = math.min(arr.length, arr1.length)
    val newArr = new Array[Double](minSize)
    var i = 0
    while (i < minSize) {
      newArr(i) = arr(i) + arr1(i)
      i += 1
    }
    newArr
  }

Обратите внимание, что в отличие от оптимизированной версии в одном из других ответов, она использует whileвместо a, forпоскольку forвсе равно будет десагар в операциях с коллекциями Scala. Мы можем сравнить эту реализацию ( withWhile), оптимизированную (но не на месте) реализацию другого ответа ( withFor) и две исходные реализации:

Benchmark                Mode  Cnt       Score      Error  Units
ZippedBench.withFor     thrpt   20  118426.044 ± 2173.310  ops/s
ZippedBench.withWhile   thrpt   20  119834.409 ±  527.589  ops/s
ZippedBench.withZip     thrpt   20    4886.624 ±   75.567  ops/s
ZippedBench.withZipped  thrpt   20    9961.668 ± 1104.937  ops/s

Это действительно огромная разница между императивной и функциональной версиями, и все эти сигнатуры методов абсолютно идентичны, а реализации имеют одинаковую семантику. Это не так, как императивные реализации используют глобальное состояние и т. Д. В то время как zipиzipped версия более читаемая, лично я не думаю , что есть какой -то смысл , в котором императивные версии против «духа Scala», и я бы не колеблясь , использовать их сам.

С таблицей

Обновление: я добавил tabulateреализацию в тест на основе комментария в другом ответе:

def ES4(arr: Array[Double], arr1: Array[Double]): Array[Double] = {
  val minSize = math.min(arr.length, arr1.length)
  Array.tabulate(minSize)(i => arr(i) + arr1(i))
}

Это намного быстрее, чем zipверсии, хотя все еще намного медленнее, чем императивные:

Benchmark                  Mode  Cnt      Score     Error  Units
ZippedBench.withTabulate  thrpt   20  32326.051 ± 535.677  ops/s
ZippedBench.withZip       thrpt   20   4902.027 ±  47.931  ops/s

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

Трэвис Браун
источник
8

Рассматривать lazyZip

(as lazyZip bs) map { case (a, b) => a + b }

вместо zip

(as zip bs) map { case (a, b) => a + b }

Scala 2.13 добавлен lazyZip в пользу.zipped

Вместе с .zipпредставлениями это заменяет .zipped(теперь не рекомендуется). ( scala / collection-strawman # 223 )

zipped(и, следовательно, lazyZip) быстрее, чем zipпотому, что, как объяснили Тим и Майк Аллен , zipследование за ним mapприведет к двум отдельным преобразованиям из-за строгости, а zippedзатемmap приведет к одному преобразованию, выполненному за один раз из-за лени.

zippedдает Tuple2Zippedи анализирует Tuple2Zipped.map,

class Tuple2Zipped[...](val colls: (It1, It2)) extends ... {
  private def coll1 = colls._1
  private def coll2 = colls._2

  def map[...](f: (El1, El2) => B)(...) = {
    val b = bf.newBuilder(coll1)
    ...
    val elems1 = coll1.iterator
    val elems2 = coll2.iterator

    while (elems1.hasNext && elems2.hasNext) {
      b += f(elems1.next(), elems2.next())
    }

    b.result()
  }

мы видим две коллекции coll1и coll2повторяем и на каждой итерации функция, fпереданная в mapнее, применяется по пути

b += f(elems1.next(), elems2.next())

без необходимости выделять и преобразовывать посреднические структуры.


Применяя метод бенчмаркинга Трэвиса, приведем сравнение между новым lazyZipи устаревшим, zippedгде

@State(Scope.Benchmark)
@BenchmarkMode(Array(Mode.Throughput))
class ZippedBench {
  import scala.collection.mutable._
  val as = ArraySeq.fill(10000)(math.random)
  val bs = ArraySeq.fill(10000)(math.random)

  def lazyZip(as: ArraySeq[Double], bs: ArraySeq[Double]): ArraySeq[Double] =
    as.lazyZip(bs).map{ case (a, b) => a + b }

  def zipped(as: ArraySeq[Double], bs: ArraySeq[Double]): ArraySeq[Double] =
    (as, bs).zipped.map { case (a, b) => a + b }

  def lazyZipJavaArray(as: Array[Double], bs: Array[Double]): Array[Double] =
    as.lazyZip(bs).map{ case (a, b) => a + b }

  @Benchmark def withZipped: ArraySeq[Double] = zipped(as, bs)
  @Benchmark def withLazyZip: ArraySeq[Double] = lazyZip(as, bs)
  @Benchmark def withLazyZipJavaArray: ArraySeq[Double] = lazyZipJavaArray(as.toArray, bs.toArray)
}

дает

[info] Benchmark                          Mode  Cnt      Score      Error  Units
[info] ZippedBench.withZipped            thrpt   20  20197.344 ± 1282.414  ops/s
[info] ZippedBench.withLazyZip           thrpt   20  25468.458 ± 2720.860  ops/s
[info] ZippedBench.withLazyZipJavaArray  thrpt   20   5215.621 ±  233.270  ops/s

lazyZipкажется, работает немного лучше, чем zippedна ArraySeq. Интересно, что заметить значительно пониженную производительность при использовании lazyZipна Array.

Марио Галич
источник
lazyZip доступен в Scala 2.13.1. В настоящее время я использую Scala 2.11.8
user12140540
5

Вы всегда должны быть осторожны с измерением производительности из-за JIT-компиляции, но вероятная причина в том, что zippedон ленив и извлекает элементы из исходных Arrayпар во время mapвызова, тогда как zipсоздает новый Arrayобъект, а затем вызывает mapновый объект.

Тим
источник