为什么在 Scala 中压缩比 zip 快?

Why is zipped faster than zip in Scala?

我编写了一些 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 方法并传递 ESES1,如下所示:

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)

结果表明使用 zipped 的名为 ES1 的方法比使用 zip 的方法 ES 更快。 基于这些观察,我有两个问题。

为什么 zippedzip 快?

在 Scala 中对集合进行逐元素操作是否有更快的方法?

由于 JIT 编译,您应该始终对性能测量保持谨慎,但一个可能的原因是 zipped 懒惰并在 map 期间从原始 Array vaules 中提取元素调用,而 zip 创建一个新的 Array 对象,然后在新对象上调用 map

回答你的第二个问题:

Is there any more faster way to do element wise operation on a collection in Scala?

可悲的事实是,尽管函数式语言简洁明了、提高了生产力并且对错误具有恢复能力,但它不一定是性能最高的。在 OP 的示例中,使用高阶函数定义要针对集合执行的投影会产生开销,而紧密循环会放大这一点。正如其他人指出的那样,为中间结果和最终结果分配额外的存储空间也会产生开销。

如果性能很关键,虽然绝不是普遍的,但在像您这样的情况下,您可以将简洁的函数代码展开回命令式等价物,以便重新获得对内存使用的更直接控制并消除函数调用开销。

在您的具体示例中,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 的精神 - 这段代码有副作用。

老实说,如果您需要在紧密循环中进行这种级别的性能优化,您最好用 Rust、Go 或 C 编写这些算法。

考虑lazyZip

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

而不是zip

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

Scala 2.13 added lazyZip 支持 .zipped

Together with .zip on views, this replaces .zipped (now deprecated). (scala/collection-strawman#223)

zipped(因此 lazyZip)比 zip 快,因为正如 and Mike Allen 所解释的那样,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()
  }

我们看到两个集合 coll1coll2 被迭代,并且在每次迭代中传递给 map 的函数 f 被沿途应用

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

lazyZipArraySeq 上的表现似乎比 zipped 好一点。有趣的是,注意在 Array 上使用 lazyZip 时性能显着下降。

None 的其他答案提到了速度差异的主要原因,即 zipped 版本避免了 10,000 个元组分配。作为其他几个答案 do 注意,zip 版本涉及一个中间数组,而 zipped 版本没有,但是为 10,000 分配一个数组elements 并不是让 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

命令式版本和函数式版本之间确实存在巨大差异,所有这些方法签名都完全相同,并且实现具有相同的语义。它不像命令式实现使用全局状态等。虽然 zipzipped 版本更具可读性,但我个人认为命令式版本反对 [=] 没有任何意义83=],我会毫不犹豫地自己使用它们。

和列表

更新:我根据另一个答案中的评论向基准添加了 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

这正是我所期望的,因为调用函数本身并不昂贵,而且通过索引访问数组元素非常便宜。