Scala 中不可变集实现的性能

Posted

技术标签:

【中文标题】Scala 中不可变集实现的性能【英文标题】:Performance of immutable set implementations in Scala 【发布时间】:2011-08-04 19:59:53 【问题描述】:

我最近一直在研究 Scala,并且(也许可以预见)花了很多时间研究 Scala 标准库中的不可变集合 API。

我正在编写一个应用程序,它必须在大型集合上执行许多 +/- 操作。出于这个原因,我想确保我选择的实现是所谓的“持久”数据结构,这样我就可以避免写时复制。我看到了 Martin Odersky 的 this answer,但它并没有真正解决我的问题。

我写了下面的测试代码来比较 ListSet 和 HashSet 对 add 操作的性能:

import scala.collection.immutable._

object TestListSet extends App 
  var set = new ListSet[Int]
  for(i <- 0 to 100000) 
    set += i
  


object TestHashSet extends App 
  var set = new HashSet[Int]
  for(i <- 0 to 100000) 
    set += i
  

这是 HashSet 的粗略运行时测量:

$ time scala TestHashSet

real    0m0.955s
user    0m1.192s
sys     0m0.147s

和列表集:

$ time scala TestListSet

real    0m30.516s
user    0m30.612s
sys     0m0.168s

单链表的缺点是一个常数时间的操作,但这种性能看起来是线性的或更差。这种性能损失是否与需要检查集合的每个元素的对象相等性以符合 Set 的无重复不变量的需要有关?如果是这种情况,我意识到这与“持久性”无关。

至于官方文档,我只能找到以下页面,但似乎不完整:Scala 2.8 Collections API -- Performance Characteristics。由于 ListSet 最初似乎是其内存占用的好选择,因此 API 文档中可能应该有一些关于其性能的信息。

【问题讨论】:

【参考方案1】:

一个老问题,但也是在错误的基础上得出结论的一个很好的例子。

Connor,基本上你是在尝试做一个微基准测试。这是一般不推荐并且很难正确地做到这一点。

为什么?因为除了执行示例中的代码之外,JVM 还在做许多其他事情。它正在加载类、进行垃圾回收、将字节码编译为本机代码等。所有这些都是动态的,并且基于运行时采样的不同指标。

所以你不能用上面的测试代码得出关于这两个集合的性能的任何结论。例如,您实际测量的可能是HashSet+= 方法的编译时间和ListSet 的垃圾收集时间。所以这是苹果和梨的比较。

要正确进行微基准测试,您应该:

    预热 JVM:加载所有类,确保运行基准测试中的所有代码路径并编译代码中的热点(例如 += 方法)。 运行基准测试并确保在测试期间既不运行 GC 也不运行编译器(使用 JVM 标志 -XX:-PrintCompilation-XX:-PrintGC)。如果在测试期间有任何一个运行,则丢弃结果。 重复第 2 步并采样 10-15 个良好的测量值。计算方差和标准差。 评估:如果每个基准 +/- 3 标准差的平均值不重叠,那么您可以得出哪个更快的结论。否则,结果会很模糊(取决于重叠量)。

我可以推荐阅读 Oracle's recommendations for doing micro benchmarks 和 Brian Goetz 撰写的关于 benchmark pitfalls 的精彩文章。

另外,如果您想使用一个可以为您完成上述所有工作的好工具,请尝试 Google 提供的Caliper。

【讨论】:

【参考方案2】:

来自ListSet 源的关键行是(在子类Node 内):

override def +(e: A): ListSet[A] = if (contains(e)) this else new Node(e)

您可以在其中看到仅在尚未包含项目时才添加项目。所以添加到集合中的是O(n)。您通常可以假设 XMap 具有与 XSet 相似的性能特征,并且ListMap 一直被列为线性时间。这就是为什么,这也是集合应该表现的方式。

附:在 TestHashSet 案例中,您正在测量启动时间。速度快了 30 倍以上。

【讨论】:

这是有道理的,这就是我将额外的0(n) 归因于的原因。网上有很多文章吹捧“持久性”是灵丹妙药,但我们不能忘记基本的复杂性。谢谢雷克斯! scala.collection.immutable.HashSet 是您创建不可变集时的默认实现,在这里您可以了解原因。我想知道在哪种情况下会明确使用 ListSet? 我认为它在内存非常稀缺的环境中会很有用。然而,在这种情况下,可能不会以 JVM 为目标。 顺便说一下,R​​ex,你对速度差异的看法是对的。我在循环周围扔了一些更精确的计时代码,看起来HashSet 在我机器上的 100k 次迭代中比 ListSet 快了大约 130 倍。【参考方案3】:

由于集合必须没有重复项,因此在添加元素之前,集合必须检查它是否已经包含该元素。在无法保证元素位置的列表中进行此搜索将是 O(N) 线性时间。相同的一般思想适用于它的删除操作。

使用 HashSet,该类定义了一个函数,该函数为 O(1) 中的任何元素选择位置,这使得 contains(element) 方法更快,但代价是占用更多空间以减少元素出现的机会位置冲突。

【讨论】:

感谢 Dylan,考虑实施,这对我来说很有意义。我认为我的困惑与文档有关。我只是没有完全考虑过“这个集合由一个列表支持,集合不能包含重复项,所以添加会很慢”。相反,我想:“缺点很快,列表将比哈希表使用更少的内存”。我的错误,但可以通过在 ListSet 类的 scaladoc 中注明这种效果来避免。

以上是关于Scala 中不可变集实现的性能的主要内容,如果未能解决你的问题,请参考以下文章

2021年大数据常用语言Scala(十七):基础语法学习 Set

scala数据结构

Scala集合

4.Scala-数据结构

保留插入顺序的不可变 Scala Map 实现

scala 数据结构:映射 Map