Подтвердить что ты не робот

Почему моя хвостовая рекурсия Scala быстрее, чем цикл while?

Вот два решения для упражнений 4.9 в Cay Horstmann Scala для Impatient: "Напишите функцию lteqgt (значения: Array [Int], v: Int), которая возвращает тройку, содержащую подсчеты значений меньше v, равный v и больше v." Один использует хвостовую рекурсию, другой - цикл while. Я думал, что оба будут скомпилированы для аналогичного байт-кода, но цикл 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 часто выполняет только 1 или 2 теста из-за конструкции else. (NB, изменяя порядок, который я выполняю, оба метода не влияют на результат).

4b9b3361

Ответ 1

Результаты тестов (после уменьшения размера массива до 20000000)

В Java 1.6.22 я получаю 151 and 122 ms для хвостовой рекурсии и while-loop соответственно.

В Java 1.7.0 я получаю 55 and 101 ms

Итак, в Java 6 ваш while-loop на самом деле быстрее; оба улучшены в производительности в 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-loop превосходит другой вопрос о внутренних компонентах JVM. К счастью для программиста Scala, версия, выпущенная из идиоматической хвостовой рекурсии, является более быстрой в последней версии JVM.

Разница также может возникать на уровне процессора. См. этот вопрос, в котором объясняется, как код замедляется, если он содержит непредсказуемое ветвление.

Ответ 2

Две конструкции не идентичны. В частности, в первом случае вам не нужны какие-либо переходы (на x86 вы можете использовать cmp, setle и add, вместо того, чтобы использовать cmp и jb и (если вы не прыгаете) добавить. Не прыгать быстрее чем прыгать практически во все современные архитектуры.

Итак, если у вас есть код, который выглядит как

if (a < b) x += 1

где вы можете добавить, или вы можете прыгать вместо этого, vs.

x += (a < b)

(что имеет смысл только в C/С++, где 1 = true и 0 = false), последний имеет тенденцию быть быстрее, поскольку его можно превратить в более компактный ассемблерный код. В Scala/Java вы не можете этого сделать, но вы можете сделать

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

который должен распознать интеллектуальный JVM, такой же, как x + = (a < b), который имеет переход от машинного кода без скачка, который обычно быстрее, чем прыжок. Еще более разумная JVM распознает, что

if (a < b) x += 1

то же самое снова (потому что добавление нуля ничего не делает).

Компиляторы C/С++ обычно выполняют такие оптимизации. Невозможность применить любую из этих оптимизаций не была отмечена в пользу компилятора JIT; по-видимому, он может быть равен 1,7, но только частично (то есть он не признает, что добавление нуля такое же, как условное добавление одного, но оно, по крайней мере, преобразует x += if (a<b) 1 else 0 в быстрый машинный код).

Теперь ничто из этого не имеет ничего общего с хвостовой рекурсией или циклами как таковыми. С рекурсией хвоста более естественно писать форму if (a < b) 1 else 0, но вы можете это сделать; и с помощью циклов while вы также можете сделать это. Так получилось, что вы выбрали одну форму для хвостовой рекурсии, а другую для цикла while, что делает ее похожей на рекурсию и на цикл. Это изменение вместо двух разных способов сделать условные обозначения.