Чому в 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?


2
Питання, пов’язані з цим: stackoverflow.com/questions/59125910/…
Маріо Галич

8
Тому що JIT вирішив оптимізувати більш агресивно вдруге, коли побачив, що "забава" викликається. Або тому, що GC вирішила щось очистити під час роботи ES. Або тому, що ваша операційна система вирішила, що це краще робити, поки працює ваш тест на ES. Це може бути що-небудь, цей мікро-орієнтир просто не є переконливим.
Андрій Тюкін

1
Які результати на вашій машині? На скільки швидше?
Peeyush Kushwaha

Для однакового розміру та конфігурації населення Zipped займає 32 секунди, а Zip - 44 секунди
user12140540

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.


2
У моєму коді вище нічого не паралельне. Хоча ця специфічна проблема є паралельною (оскільки декілька потоків можуть працювати на різних ділянках масивів), не було б багато сенсу в такій простій операції лише на 10 к елементів - накладні витрати на створення та синхронізацію нових потоків, швидше за все, переважують будь-яку користь . Якщо чесно, якщо вам потрібен такий рівень оптимізації продуктивності, вам, ймовірно, краще писати такі алгоритми в Rust, Go або C.
StuartLC

3
Це буде більш схожим на Array.tabulate(minSize)(i => arr(i) + arr1(i))
масштаби

1
@SarveshKumarSingh це набагато повільніше. Займає майже 9 секунд
користувач12140540

1
Array.tabulateмає бути набагато швидше, ніж будь-коли zipабо zippedтут (і є в моїх орієнтирах).
Тревіс Браун

1
@StuartLC "Продуктивність була б еквівалентною лише в тому випадку, якщо функція вищого порядку якось розкручується і накреслюється." Це не дуже точно. Навіть ваш forпритулок перебуває у виклику функції вищого порядку ( foreach). Лямбда буде ініційована лише один раз в обох випадках.
Тревіс Браун

50

Жодна з інших відповідей не згадує первинну причину різниці у швидкості, яка полягає в тому, що zippedверсія уникає 10 000 кортежів. Як пара інших відповідей зробити примітку, то 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замість того, 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 }

Скала 2.13 додана lazyZip на користь.zipped

Разом із .zipпоглядами це замінює .zipped(тепер застаріле). ( scala / колекція-солом’яник # 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новий об'єкт.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.