Perché la zip è più veloce della zip in Scala?


38

Ho scritto del codice Scala per eseguire un'operazione saggia su una collezione. Qui ho definito due metodi che svolgono la stessa attività. Un metodo utilizza zipe l'altro utilizza 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)

Per confrontare questi due metodi in termini di velocità, ho scritto il seguente codice:

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")
}

Chiamo il funmetodo e passo ESe ES1come di seguito:

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)

I risultati mostrano che il metodo denominato ES1che utilizza zippedè più veloce del metodo ESche utilizza zip. Sulla base di queste osservazioni, ho due domande.

Perché è zippedpiù veloce di zip?

Esiste un modo ancora più veloce per eseguire operazioni basate sull'elemento su una collezione in Scala?



8
Perché JIT ha deciso di ottimizzare in modo più aggressivo la seconda volta che ha visto il "divertimento" essere invocato. O perché GC ha deciso di ripulire qualcosa mentre ES era in esecuzione. O perché il tuo sistema operativo ha deciso che aveva cose migliori da fare mentre il tuo test ES era in esecuzione. Potrebbe essere qualsiasi cosa, questo microbenchmark non è proprio conclusivo.
Andrey Tyukin,

1
Quali sono i risultati sulla tua macchina? Quanto più veloce?
Peeyush Kushwaha,

Per le stesse dimensioni e configurazioni della popolazione, Zipping impiega 32 secondi mentre Zip impiega 44 secondi
user12140540

3
I tuoi risultati sono insignificanti. Utilizzare JMH se è necessario eseguire micro-benchmark.
OrangeDog

Risposte:


17

Per rispondere alla tua seconda domanda:

Esiste un modo più veloce per eseguire operazioni sagge sugli elementi in una collezione in Scala?

La triste verità è che, nonostante la concisione, la produttività migliorata e la resilienza ai bug, i linguaggi funzionali non sono necessariamente i più performanti - l'uso di funzioni di ordine superiore per definire una proiezione da eseguire su raccolte non libere, e il tuo stretto ciclo lo evidenzia. Come altri hanno sottolineato, anche l'allocazione di memoria aggiuntiva per i risultati intermedi e finali avrà un sovraccarico.

Se le prestazioni sono critiche, anche se non universali, in casi come i tuoi è possibile ricondurre le operazioni di Scala in equivalenti imperativi per riguadagnare un controllo più diretto sull'utilizzo della memoria ed eliminare le chiamate di funzione.

Nel tuo esempio specifico, le zippedsomme possono essere eseguite in modo imperativo pre-allocando un array fisso, mutabile di dimensioni corrette (poiché zip si ferma quando una delle raccolte esaurisce gli elementi), quindi aggiungendo insieme gli elementi all'indice appropriato (dal momento dell'accesso gli elementi dell'array per indice ordinale sono un'operazione molto veloce).

Aggiunta di una terza funzione ES3alla suite di test:

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
}

Sul mio i7 ottengo i seguenti tempi di risposta:

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

Ancora più odioso sarebbe fare la mutazione diretta sul posto del più corto dei due array, che ovviamente corromperà il contenuto di uno degli array e sarebbe possibile solo se l'array originale non fosse di nuovo necessario:

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

Ma ovviamente, la mutazione diretta degli elementi dell'array non è nello spirito di Scala.


2
Non c'è nulla di parallelizzato nel mio codice sopra. Sebbene questo problema specifico sia parallelizzabile (poiché più thread potrebbero funzionare su diverse sezioni delle matrici), non ci sarebbe molto senso in un'operazione così semplice su soli 10k elementi: il sovraccarico della creazione e della sincronizzazione di nuovi thread avrebbe probabilmente più peso di qualsiasi vantaggio . Ad essere onesti, se hai bisogno di questo livello di ottimizzazione delle prestazioni, probabilmente stai meglio scrivendo questo tipo di algoritmi in Rust, Go o C.
StuartLC

3
Sarà più simile allo scala e più veloce da usare Array.tabulate(minSize)(i => arr(i) + arr1(i))per creare il tuo array
Sarvesh Kumar Singh

1
@SarveshKumarSingh è molto più lento.
Dura

1
Array.tabulatedovrebbe essere molto più veloce di entrambi zipo zippedqui (ed è nei miei parametri di riferimento).
Travis Brown,

1
@StuartLC "Le prestazioni sarebbero equivalenti solo se la funzione di ordine superiore fosse in qualche modo scartata e incorporata." Questo non è molto preciso. Perfino il tuo forè destinato a una chiamata di funzione di ordine superiore ( foreach). La lambda verrà istanziata una sola volta in entrambi i casi.
Travis Brown,

50

Nessuna delle altre risposte menziona il motivo principale della differenza di velocità, ovvero che la zippedversione evita 10.000 allocazioni di tuple. Come una coppia delle altre risposte fanno notare, la zipversione comporta una serie intermedia, mentre la zippedversione non, ma allocare una matrice per 10.000 elementi che non è ciò che rende la zipversione in modo molto peggio: è 10.000 tuple di breve durata che vengono inseriti in quell'array. Questi sono rappresentati da oggetti sulla JVM, quindi stai facendo un sacco di allocazioni di oggetti per cose che immediatamente butterai via.

Il resto di questa risposta fornisce solo qualche dettaglio in più su come confermarlo.

Migliore benchmarking

Volete davvero usare un framework come jmh per fare qualsiasi tipo di benchmarking responsabilmente sulla JVM, e anche in questo caso la parte responsabile è difficile, anche se configurare jmh in sé non è poi così male. Se hai un project/plugins.sbtsimile:

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

E un build.sbtsimile (sto usando 2.11.8 poiché dici che è quello che stai usando):

scalaVersion := "2.11.8"

enablePlugins(JmhPlugin)

Quindi puoi scrivere il tuo benchmark in questo modo:

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)
}

Ed eseguilo con 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

Ciò dimostra che la zippedversione ottiene circa l'80% in più di throughput, che è probabilmente più o meno la stessa delle tue misurazioni.

Misurazione delle allocazioni

Puoi anche chiedere a jmh di misurare le allocazioni con -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

... dove gc.alloc.rate.normè probabilmente la parte più interessante, a dimostrazione del fatto che la zipversione è allocata oltre tre volte tanto zipped.

Implementazioni imperative

Se sapessi che questo metodo sarebbe stato chiamato in contesti estremamente sensibili alle prestazioni, probabilmente lo implementerei in questo modo:

  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
  }

Si noti che a differenza della versione ottimizzata in una delle altre risposte, questo utilizza whileinvece di un forpoiché la forvolontà sarà ancora desugar nelle operazioni delle collezioni Scala. Possiamo confrontare questa implementazione ( withWhile), l' implementazione ottimizzata (ma non sul posto) dell'altra risposta ( withFor) e le due implementazioni originali:

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

Questa è una differenza davvero enorme tra le versioni imperative e funzionali, e tutte queste firme dei metodi sono esattamente identiche e le implementazioni hanno la stessa semantica. Non è che le implementazioni imperative stiano usando lo stato globale, ecc. Mentre le versioni zipe zippedsono più leggibili, personalmente non penso che ci sia alcun senso in cui le versioni imperative siano contro lo "spirito di Scala", e non esiterei per usarli da solo.

Con tabulato

Aggiornamento: ho aggiunto tabulateun'implementazione al benchmark sulla base di un commento in un'altra risposta:

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))
}

È molto più veloce delle zipversioni, anche se ancora molto più lento di quelle imperative:

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

Questo è quello che mi aspetterei, dal momento che non c'è nulla di intrinsecamente costoso nel chiamare una funzione e perché l'accesso agli elementi dell'array per indice è molto economico.


8

Prendere in considerazione lazyZip

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

invece di zip

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

Scala 2.13 aggiunta lazyZip a favore di.zipped

Insieme alle .zipvisualizzazioni, questo sostituisce .zipped(ora obsoleto). ( scala / collection-strawman # 223 )

zipped(e quindi lazyZip) è più veloce di zipperché, come spiegato da Tim e Mike Allen , zipseguito da mapcomporterà due trasformazioni separate per rigore, mentre zippedseguito da mapcomporterà una singola trasformazione eseguita in una volta a causa di pigrizia.

zippedTuple2Zippede analizza 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()
  }

vediamo le due raccolte coll1e coll2vengono ripetute e su ciascuna iterazione la funzione fpassata mapviene applicata lungo la strada

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

senza dover allocare e trasformare strutture intermedie.


Applicando il metodo di benchmarking di Travis, ecco un confronto tra nuovo lazyZipe deprecato zippeddove

@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

lazyZipsembra funzionare un po 'meglio che zippedsu ArraySeq. È interessante notare che durante l'utilizzo di lazyZipOn si notano prestazioni notevolmente degradate Array.


lazyZip è disponibile in Scala 2.13.1. Attualmente sto usando Scala 2.11.8
user12140540

5

Dovresti sempre essere cauto con la misurazione delle prestazioni a causa della compilazione JIT, ma un motivo probabile è che zippedè pigro ed estrae elementi dai Arrayvaule originali durante la mapchiamata, mentre zipcrea un nuovo Arrayoggetto e quindi chiama mapil nuovo oggetto.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.