Scalaでzipよりも速く圧縮されるのはなぜですか?


38

コレクションに対して要素ごとの操作を実行するためのScalaコードをいくつか記述しました。ここでは、同じタスクを実行する2つのメソッドを定義しました。1つの方法はを使用zipし、もう1つの方法はを使用し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)

速度の点でこれら2つの方法を比較するために、次のコードを書きました。

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

I呼び出す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。これらの観察に基づいて、私は2つの質問があります。

なぜzippedより速いのですzipか?

Scalaのコレクションで要素ごとの操作を行うさらに速い方法はありますか?



8
JITは、「fun」が呼び出されるのを2回目に見たときに、より積極的に最適化することを決定したためです。または、GCがESの実行中に何かをクリーンアップすることを決定したためです。または、ESテストの実行中に、オペレーティングシステムが実行すべきことを決定したためです。何であれ、このマイクロベンチマークは決定的なものではありません。
Andrey Tyukin

1
あなたのマシンでの結果は何ですか?どれくらい速く?
Peeyush Kushwaha

同じ母集団サイズと構成の場合、Zipは32秒かかりますが、Zipは44秒かかります
user12140540

3
結果は無意味です。マイクロベンチマークを行う必要がある場合は、JMHを使用します。
OrangeDog

回答:


17

2番目の質問に答えるには:

Scalaのコレクションで要素ごとの操作を行うより速い方法はありますか?

悲しい真実は、簡潔さ、生産性の向上、バグへの耐性があるにもかかわらず、関数型言語が必ずしも最もパフォーマンスが高いわけではないということです。高次関数を使用して、自由ではないコレクションに対して実行されるプロジェクションを定義します。タイトなループがこれを強調しています。他の人が指摘したように、中間結果と最終結果のための追加のストレージ割り当てにもオーバーヘッドがあります。

パフォーマンスが重要である場合、決して普遍的ではありませんが、あなたのような場合には、Scalaの操作を同等の命令に戻し、メモリの使用をより直接制御し、関数呼び出しを排除することができます。

特定の例ではzipped、正しいサイズの固定された可変配列を事前に割り当てて(コレクションの1つが要素を使い果たすとzipが停止するため)、次に適切なインデックスに要素を一緒に追加する(アクセス後に序数インデックスによる配列要素は非常に高速な操作です)。

ES3テストスイートに3番目の関数を追加します。

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

さらに厄介なのは、2つの配列のうち短い方の配列の直接インプレース変異を行うことです。これにより、配列の1つの内容が明らかに破損し、元の配列が再び必要ない場合にのみ実行されます。

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
上記の私のコードには何も並列化されていません。この特定の問題は並列化可能ですが(配列の異なるセクションで複数のスレッドが機能する可能性があるため)、このような単純な操作で10k要素のみを使用してもあまり意味がありません。新しいスレッドの作成と同期のオーバーヘッドは、メリットを上回ります。 。正直に言うと、あなたはパフォーマンスの最適化のこのレベルを必要としている場合、あなたはおそらくより良い錆、移動またはCでアルゴリズムのこれらの種類を書くのだ
StuartLC

3
Array.tabulate(minSize)(i => arr(i) + arr1(i))アレイの作成に使用する方が
スカラー

1
@SarveshKumarSinghこれはずっと遅いです。約9秒かかります
user12140540

1
Array.tabulateどちらかzipまたはzippedここよりもはるかに高速である必要があります(そして私のベンチマークにあります)。
トラビスブラウン

1
@StuartLC「パフォーマンスは、高次関数が何らかの形でアンラップされ、インライン化されている場合にのみ同等になります。」これは正確ではありません。あなたもfor高次の関数呼び出し(foreach)を必要とします。ラムダはどちらの場合でも1回だけインスタンス化されます。
Travis Brown

50

他の回答はどれも速度の違いの主な理由について言及していません。これは、zippedバージョンが10,000のタプル割り当てを回避するということです。他のいくつかの回答指摘しているように、zipバージョンには中間配列が含まれますが、バージョンには含まれzippedませんが、10,000要素に配列を割り当てることでzipバージョンが大幅に悪化するわけではありません。その配列に入れられています。これらは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バージョンが3倍以上割り当てられていることを示してい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のコレクションの操作にdesugarます。この実装(withWhile)、他の回答の最適化(インプレースではない)実装(withFor)、および2つの元の実装を比較できます。

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

.ziponビューとともに、これは置き換えられます.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()
  }

我々は2つのコレクションを参照coll1し、coll2繰り返し処理され、各反復で機能fに渡されたがmap道に沿って適用されます

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

中間構造を割り当てて変換する必要はありません。


Travisのベンチマーク手法を適用して、新しいもの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ことです。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.