Warum wird in Scala schneller gezippt als zip?

38

Ich habe Scala-Code geschrieben, um eine elementweise Operation für eine Sammlung auszuführen. Hier habe ich zwei Methoden definiert, die dieselbe Aufgabe ausführen. Eine Methode verwendet zipund die andere verwendet 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)

Um diese beiden Methoden hinsichtlich der Geschwindigkeit zu vergleichen, habe ich den folgenden Code geschrieben:

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

Ich rufe die funMethode auf und übergebe ESund ES1wie folgt:

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)

Die Ergebnisse zeigen , dass die Methode mit dem Namen , ES1dass Anwendungen zippedschneller als Verfahren , ESdass Verwendungen zip. Aufgrund dieser Beobachtungen habe ich zwei Fragen.

Warum ist zippedschneller als zip?

Gibt es eine noch schnellere Möglichkeit, elementweise Operationen an einer Sammlung in Scala durchzuführen?

user12140540
quelle
2
Verwandte Frage: stackoverflow.com/questions/59125910/…
Mario Galic
8
Weil JIT beschlossen hat, beim zweiten Aufruf aggressiver zu optimieren, wurde "Spaß" aufgerufen. Oder weil GC beschlossen hat, etwas aufzuräumen, während ES lief. Oder weil Ihr Betriebssystem entschieden hat, dass es bessere Dinge zu tun hat, während Ihr ES-Test ausgeführt wurde. Könnte alles sein, diese Mikrobank ist einfach nicht schlüssig.
Andrey Tyukin
1
Was sind die Ergebnisse auf Ihrer Maschine? Wie viel schneller?
Peeyush Kushwaha
Bei gleicher Populationsgröße und Konfiguration dauert
Zipped
3
Ihre Ergebnisse sind bedeutungslos. Verwenden Sie JMH, wenn Sie Mikro-Benchmarks durchführen müssen.
OrangeDog

Antworten:

17

So beantworten Sie Ihre zweite Frage:

Gibt es eine schnellere Möglichkeit, eine Sammlung in Scala elementweise zu bearbeiten?

Die traurige Wahrheit ist, dass funktionale Sprachen trotz ihrer Prägnanz, verbesserten Produktivität und Widerstandsfähigkeit gegenüber Fehlern nicht unbedingt die leistungsstärksten sind. Verwenden Sie Funktionen höherer Ordnung, um eine Projektion zu definieren, die für nicht freie Sammlungen ausgeführt werden soll, und Ihre enge Schleife hebt dies hervor. Wie andere bereits betont haben, wird die zusätzliche Speicherzuweisung für Zwischen- und Endergebnisse ebenfalls einen Overhead verursachen.

Wenn die Leistung kritisch ist, obwohl dies keineswegs universell ist, können Sie in Fällen wie Ihrem die Vorgänge von Scala wieder in zwingende Äquivalente zurückführen, um die Kontrolle über die Speichernutzung wieder direkter zu erlangen und Funktionsaufrufe zu vermeiden.

In Ihrem speziellen Beispiel können die zippedSummen unbedingt ausgeführt werden, indem ein festes, veränderbares Array mit der richtigen Größe vorab zugewiesen wird (da die Zip-Funktion stoppt, wenn in einer der Sammlungen keine Elemente mehr vorhanden sind) und anschließend Elemente am entsprechenden Index hinzugefügt werden (seit dem Zugriff) Array-Elemente nach Ordnungsindex sind eine sehr schnelle Operation.

Hinzufügen einer dritten Funktion ES3zu Ihrer Testsuite:

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
}

Auf meinem i7 erhalte ich folgende Antwortzeiten:

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

Noch hektischer wäre es, eine direkte Mutation des kürzeren der beiden Arrays an Ort und Stelle durchzuführen, was offensichtlich den Inhalt eines der Arrays verfälschen würde und nur dann erfolgen würde, wenn das ursprüngliche Array erneut nicht benötigt würde:

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

Aber offensichtlich ist die direkte Mutation von Array-Elementen nicht im Sinne von Scala.

StuartLC
quelle
2
In meinem obigen Code ist nichts parallelisiert. Obwohl dieses spezielle Problem parallelisierbar ist (da mehrere Threads in verschiedenen Abschnitten der Arrays funktionieren könnten), wäre eine so einfache Operation mit nur 10.000 Elementen nicht sinnvoll - der Aufwand für das Erstellen und Synchronisieren neuer Threads würde wahrscheinlich den Nutzen überwiegen . Um ehrlich zu sein, wenn Sie diese Stufe der Leistungsoptimierung benötigen, ist es wahrscheinlich besser, diese Art von Algorithmen in Rust, Go oder C zu
schreiben
3
Es wird Array.tabulate(minSize)(i => arr(i) + arr1(i))
skalaähnlicher
1
@SarveshKumarSingh das ist viel langsamer.
Dauert
1
Array.tabulatesollte viel schneller sein als entweder zipoder zippedhier (und ist in meinen Benchmarks).
Travis Brown
1
@StuartLC "Die Leistung wäre nur dann gleichwertig, wenn die Funktion höherer Ordnung irgendwie entpackt und inline ist." Das ist nicht wirklich genau. Sogar Ihr forwird zu einem Funktionsaufruf höherer Ordnung ( foreach) entschärft . Das Lambda wird in beiden Fällen nur einmal instanziiert.
Travis Brown
50

Keine der anderen Antworten erwähnt den Hauptgrund für den Geschwindigkeitsunterschied, nämlich dass die zippedVersion 10.000 Tupelzuweisungen vermeidet. Als ein paar der anderen Antworten tun Note, die zipbeinhaltet Version eine Zwischen Array, während die zippedVersion nicht der Fall ist, sondern auch für 10.000 Elemente eines Arrays Zuteilung ist nicht das, was die macht zipVersion so viel schlechter es die 10.000 kurzlebig Tupel ist das werden in dieses Array eingefügt. Diese werden durch Objekte in der JVM dargestellt, sodass Sie eine Reihe von Objektzuordnungen für Dinge vornehmen, die Sie sofort wegwerfen werden.

Der Rest dieser Antwort geht nur etwas detaillierter darauf ein, wie Sie dies bestätigen können.

Besseres Benchmarking

Sie möchten wirklich ein Framework wie jmh verwenden , um verantwortungsbewusstes Benchmarking für die JVM durchzuführen , und selbst dann ist der verantwortungsvolle Teil schwierig, obwohl das Einrichten von jmh selbst nicht schlecht ist. Wenn Sie eine project/plugins.sbtsolche haben:

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

Und build.sbtso etwas (ich verwende 2.11.8, da Sie erwähnen, dass Sie das verwenden):

scalaVersion := "2.11.8"

enablePlugins(JmhPlugin)

Dann können Sie Ihren Benchmark folgendermaßen schreiben:

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

Und führen Sie es aus mit 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

Dies zeigt, dass die zippedVersion etwa 80% mehr Durchsatz erzielt, was wahrscheinlich mehr oder weniger Ihren Messungen entspricht.

Zuordnungen messen

Sie können jmh auch bitten, Zuordnungen zu messen mit -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

… Wo gc.alloc.rate.normist wahrscheinlich der interessanteste Teil, der zeigt, dass die zipVersion mehr als dreimal so viel zuweist wie zipped.

Imperative Implementierungen

Wenn ich wüsste, dass diese Methode in extrem leistungsabhängigen Kontexten aufgerufen werden würde, würde ich sie wahrscheinlich folgendermaßen implementieren:

  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
  }

Beachten Sie, dass im Gegensatz zur optimierten Version in einer der anderen Antworten whileanstelle von a verwendet wird, forda der forWille weiterhin in Scala-Sammlungsvorgängen enthalten ist. Wir können diese Implementierung ( withWhile), die optimierte (aber nicht vorhandene) Implementierung ( withFor) der anderen Antwort ( ) und die beiden ursprünglichen Implementierungen vergleichen:

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

Das ist ein wirklich großer Unterschied zwischen der imperativen und der funktionalen Version, und alle diese Methodensignaturen sind genau identisch und die Implementierungen haben dieselbe Semantik. Es ist nicht so, dass die imperativen Implementierungen den globalen Status usw. verwenden. Obwohl die zipund zipped-Versionen besser lesbar sind, glaube ich persönlich nicht, dass die imperativen Versionen in irgendeiner Weise gegen den "Geist von Scala" sind, und ich würde nicht zögern sie selbst zu benutzen.

Mit tabellarisch

Update: Ich tabulatehabe dem Benchmark eine Implementierung hinzugefügt , die auf einem Kommentar in einer anderen Antwort basiert:

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

Es ist viel schneller als die zipVersionen, obwohl immer noch viel langsamer als die zwingenden:

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

Dies ist, was ich erwarten würde, da das Aufrufen einer Funktion nicht von Natur aus teuer ist und der Zugriff auf Array-Elemente über den Index sehr billig ist.

Travis Brown
quelle
8

Erwägen lazyZip

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

anstatt zip

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

Scala 2.13 zugunsten von hinzugefügt lazyZip.zipped

Zusammen mit .zipOn Views ersetzt dies .zipped(jetzt veraltet). ( Scala / Collection-Strawman # 223 )

zipped(und daher lazyZip) ist schneller als zipweil, wie von Tim und Mike Allen erklärt , zipgefolgt von mapzwei getrennten Transformationen aufgrund von Strenge führt, während zippedgefolgt von mapeiner einzelnen Transformation aufgrund von Faulheit auf einmal ausgeführt wird.

zippedgibt Tuple2Zippedund analysiert 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()
  }

wir sehen die beiden Sammlungen coll1und coll2sind iteriert und bei jeder Iteration der Funktion fübergeben mapwird , auf dem Weg angewandt

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

ohne zwischengeschaltete Strukturen zuordnen und transformieren zu müssen.


Anwenden von Travis' Benchmark - Methode, hier ist ein Vergleich zwischen neuen lazyZipund veraltete , zippedwo

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

gibt

[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

lazyZipscheint etwas besser zu funktionieren als zippedauf ArraySeq. Interessanterweise sollten Sie bei Verwendung lazyZipvon "Die Leistung erheblich beeinträchtigen" Array.

Mario Galic
quelle
lazyZip ist in Scala 2.13.1 verfügbar. Derzeit verwende ich Scala 2.11.8
user12140540
5

Aufgrund der JIT-Kompilierung sollten Sie bei der Leistungsmessung immer vorsichtig sein. Ein wahrscheinlicher Grund ist jedoch, dass Sie zippedfaul sind und Arraywährend des mapAufrufs Elemente aus den ursprünglichen Vaules extrahieren , während Sie zipein neues ArrayObjekt erstellen und dann mapdas neue Objekt aufrufen .

Tim
quelle