Asi*_*sif 39 performance scala scala-collections jmh elementwise-operations
我编写了一些 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)
Run Code Online (Sandbox Code Playgroud)
为了在速度方面比较这两种方法,我编写了以下代码:
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")
}
Run Code Online (Sandbox Code Playgroud)
我调用该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)
Run Code Online (Sandbox Code Playgroud)
结果表明,命名方法ES1,使用zipped速度更快的方法比ES使用zip。基于这些观察,我有两个问题。
为什么zipped比 快zip?
有没有更快的方法对 Scala 中的集合进行元素操作?
Tra*_*own 53
其他答案都没有提到速度差异的主要原因,即该zipped版本避免了 10,000 元组分配。正如其他几个答案所指出的那样,该zip版本涉及一个中间数组,而该zipped版本不涉及,但是为 10,000 个元素分配一个数组并不是使zip版本变得如此糟糕的原因——它是 10,000 个短命元组正在被放入该数组中。这些由 JVM 上的对象表示,因此您正在为您将立即扔掉的东西进行大量对象分配。
这个答案的其余部分只是详细介绍了如何确认这一点。
您真的希望使用像jmh这样的框架在 JVM 上负责任地进行任何类型的基准测试,即使如此,负责任的部分也很难,尽管设置 jmh 本身并不算太糟糕。如果你有project/plugins.sbt这样的:
addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.3.7")
Run Code Online (Sandbox Code Playgroud)
和build.sbt这样的(我使用的是 2.11.8,因为你提到那是你正在使用的):
scalaVersion := "2.11.8"
enablePlugins(JmhPlugin)
Run Code Online (Sandbox Code Playgroud)
然后你可以像这样写你的基准:
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)
}
Run Code Online (Sandbox Code Playgroud)
并运行它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
Run Code Online (Sandbox Code Playgroud)
这表明该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
Run Code Online (Sandbox Code Playgroud)
...哪里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
}
Run Code Online (Sandbox Code Playgroud)
请注意,与其他答案之一中的优化版本不同,它使用while而不是 a ,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
Run Code Online (Sandbox Code Playgroud)
命令式和函数式版本之间确实存在巨大差异,所有这些方法签名都完全相同,并且实现具有相同的语义。它不像命令式实现使用全局状态等。虽然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))
}
Run Code Online (Sandbox Code Playgroud)
它比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
Run Code Online (Sandbox Code Playgroud)
这是我所期望的,因为调用函数本身并不昂贵,而且通过索引访问数组元素非常便宜。
Stu*_*tLC 19
回答你的第二个问题:
有没有更快的方法来对 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
}
Run Code Online (Sandbox Code Playgroud)
在我的 i7 上,我得到以下响应时间:
OP ES Total Time Consumed:23.3747857Seconds
OP ES1 Total Time Consumed:11.7506995Seconds
--
ES3 Total Time Consumed:1.0255231Seconds
Run Code Online (Sandbox Code Playgroud)
更令人发指的是对两个数组中较短的数组进行直接就地突变,这显然会破坏这个较短数组的内容,因此只有在不需要原始数组进行进一步工作时才应该实施呼叫者:
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
Run Code Online (Sandbox Code Playgroud)
但显然,直接改变作为参数传递的数组元素并不符合 Scala 的精神——这段代码有副作用。
老实说,如果您需要在紧密循环中进行这种级别的性能优化,那么最好用 Rust、Go 或 C 编写这些类型的算法。
考虑 lazyZip
(as lazyZip bs) map { case (a, b) => a + b }
Run Code Online (Sandbox Code Playgroud)
代替 zip
(as zip bs) map { case (a, b) => a + b }
Run Code Online (Sandbox Code Playgroud)
添加 lazyZip了Scala 2.13以支持.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()
}
Run Code Online (Sandbox Code Playgroud)
我们看到两个集合coll1,coll2并在每次迭代中进行迭代,沿途应用f传递给的函数map
b += f(elems1.next(), elems2.next())
Run Code Online (Sandbox Code Playgroud)
无需分配和转换中间结构。
应用特拉维斯标杆法,这里是新的比较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)
}
Run Code Online (Sandbox Code Playgroud)
给
[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
Run Code Online (Sandbox Code Playgroud)
lazyZip似乎表现比zippedon好一点ArraySeq。有趣的是,当使用lazyZipon时,注意到性能显着下降Array。
由于 JIT 编译,您应该始终对性能测量保持谨慎,但一个可能的原因是它zipped很懒惰并且Array在map调用期间从原始值中提取元素,而zip创建一个新Array对象然后调用map新对象。
| 归档时间: |
|
| 查看次数: |
4876 次 |
| 最近记录: |