【问题标题】:Why is my Scala tail-recursion faster than the while loop?为什么我的 Scala 尾递归比 while 循环快?
【发布时间】:2012-02-06 22:58:33
【问题描述】:

下面是 Cay Horstmann 的 Scala 中针对不耐烦的练习 4.9 的两个解决方案:“编写一个函数 lteqgt(values: Array[Int], v: Int),它返回一个包含小于 v 的值的计数的三元组,等于v,并且大于 v。”一个使用尾递归,另一个使用 while 循环。我认为两者都可以编译成相似的字节码,但是 while 循环比尾递归慢了将近 2 倍。这表明我的 while 方法写得不好。

import scala.annotation.tailrec
import scala.util.Random
object PerformanceTest {

  def main(args: Array[String]): Unit = {
    val bigArray:Array[Int] = fillArray(new Array[Int](100000000))
    println(time(lteqgt(bigArray, 25)))
    println(time(lteqgt2(bigArray, 25)))
  }

  def time[T](block : => T):T = {
    val start = System.nanoTime : Double
    val result = block
    val end = System.nanoTime : Double
    println("Time = " + (end - start) / 1000000.0 + " millis")
    result
  }

  @tailrec def fillArray(a:Array[Int], pos:Int=0):Array[Int] = {
    if (pos == a.length)
      a
    else {
      a(pos) = Random.nextInt(50)
      fillArray(a, pos+1)
    }
  }

  @tailrec def lteqgt(values: Array[Int], v:Int, lt:Int=0, eq:Int=0, gt:Int=0, pos:Int=0):(Int, Int, Int) = {
    if (pos == values.length)
      (lt, eq, gt)
    else
      lteqgt(values, v, lt + (if (values(pos) < v) 1 else 0), eq + (if (values(pos) == v) 1 else 0), gt + (if (values(pos) > v) 1 else 0), pos+1) 
  }

  def lteqgt2(values:Array[Int], v:Int):(Int, Int, Int) = {
    var lt = 0
    var eq = 0
    var gt = 0
    var pos = 0
    val limit = values.length
    while (pos < limit) {
      if (values(pos) > v)
        gt += 1
      else if (values(pos) < v)
        lt += 1
      else
        eq += 1
      pos += 1
    }
    (lt, eq, gt)
  }
}

根据你的堆大小调整 bigArray 的大小。这是一些示例输出:

Time = 245.110899 millis
(50004367,2003090,47992543)
Time = 465.836894 millis
(50004367,2003090,47992543)

为什么while方法比tailrec慢很多?天真地,tailrec 版本似乎处于轻微的劣势,因为它必须始终为每次迭代执行 3 次“if”检查,而 while 版本由于 else 构造通常只会执行 1 或 2 次测试。 (注意颠倒我执行这两种方法的顺序不会影响结果)。

【问题讨论】:

  • 我自己也经常想知道这一点。答案肯定在于 JIT。在完全禁用 JIT 的同时重复基准测试会很有趣。
  • stackoverflow.com/a/48143130/1172685 中查看结果,其中 while 循环比尾递归快得多(scala 2.12.x 带有试图管理 JVM 不一致的 scalameter 基准)。

标签: performance scala loops tail-recursion


【解决方案1】:

测试结果(将数组大小减小到 20000000 后)

在 Java 1.6.22 下,我得到 151 and 122 ms 分别用于尾递归和 while 循环。

在 Java 下 1.7.0 我得到 55 and 101 ms

所以在 Java 6 下,您的 while 循环实际上更快;在 Java 7 下两者的性能都有所提高,但尾递归版本已经超越了循环。

说明

性能差异是由于在您的循环中,您有条件地将 1 添加到总数中,而对于递归,您总是添加 1 或 0。因此它们不等价。与您的递归方法等效的 while 循环是:

  def lteqgt2(values:Array[Int], v:Int):(Int, Int, Int) = {
    var lt = 0
    var eq = 0
    var gt = 0
    var pos = 0
    val limit = values.length
    while (pos < limit) {
      gt += (if (values(pos) > v) 1 else 0)
      lt += (if (values(pos) < v) 1 else 0)
      eq += (if (values(pos) == v) 1 else 0)
      pos += 1
    }
    (lt, eq, gt)
  }

这给出了与递归方法完全相同的执行时间(无论 Java 版本如何)。

讨论

对于为什么 Java 7 VM (HotSpot) 可以比您的第一个版本更好地优化这一点,我不是专家,但我猜这是因为它每次都在代码中采用相同的路径(而不是沿着if / else if 路径),因此可以更有效地内联字节码。

但请记住,Java 6 中并非如此。为什么一个 while 循环优于另一个是 JVM 内部问题。让 Scala 程序员高兴的是,从惯用的尾递归生成的版本是 JVM 最新版本中速度更快的版本。

差异也可能发生在处理器级别。请参阅this question,它解释了如果代码包含不可预测的分支,代码会如何变慢。

【讨论】:

  • 好地方-谢谢,我也得到了与那个版本相同的性能结果。因此,只要我在每个中正确编写了等效的主体,尾递归和 while-loop 结构很可能正在编译为几乎相同的字节码。不过,关于 if/else 语句的一个有趣的效果。
【解决方案2】:

这两个结构不相同。特别是,在第一种情况下,您不需要任何跳转(在 x86 上,您可以使用 cmp 和 setle 并添加,而不必使用 cmp 和 jb 和(如果您不跳转)添加。不跳转更快而不是跳上几乎所有现代建筑。

所以,如果你的代码看起来像

if (a < b) x += 1

可能添加或您可能跳转的地方,与

x += (a < b)

(仅在 C/C++ 中有意义,其中 1 = true 和 0 = false),后者往往更快,因为它可以转换为更紧凑的汇编代码。在 Scala/Java 中,你不能这样做,但你可以这样做

x += if (a < b) 1 else 0

智能JVM应该识别的和x += (a

if (a < b) x += 1

还是一样(因为加零没有任何作用)。

C/C++ 编译器通常会执行这样的优化。无法应用这些优化中的任何一个都不是 JIT 编译器的标志。显然它可以从 1.7 开始,但只是部分地(即它不承认加零与条件加一相同,但它至少将 x += if (a&lt;b) 1 else 0 转换为快速机器代码)。

现在,这些都与尾递归或 while 循环本身没有任何关系。使用尾递归,写if (a &lt; b) 1 else 0 形式更自然,但你可以这样做;并且使用 while 循环,您也可以这样做。碰巧您选择了一种形式用于尾递归,另一种形式用于 while 循环,看起来递归与循环是一种变化,而不是两种不同的执行条件的方式。

【讨论】:

  • 恐怕你的答案的细节超出了我的理解范围,但听起来结果是尾递归应该优先于 while 循环作为编程风格(编译器支持) ,并且在 Scala 中,尾递归可能(如果不是现在,在未来)运行速度明显快于 while 循环。这是正确的吗?
  • @waifnstray - 不,这不是重点。让我为清楚起见进行编辑。
  • 知道了,谢谢。我误解了你指的是哪两个构造。
  • 无跳转编码尤其在游戏开发中更为常见。此外,要验证此答案,请在增量中添加 10 而不是 1 - 如果您想知道的话。
猜你喜欢
  • 2011-08-20
  • 2019-07-17
  • 1970-01-01
  • 2022-01-04
  • 2023-04-09
  • 2011-02-08
  • 2013-09-21
相关资源
最近更新 更多