Скрытая стоимость исполнения в Scala?

Я наткнулся на этот старый вопрос и провел следующий эксперимент со Scala 2.10.3.

Я переписал версию Scala, чтобы использовать явную хвостовую рекурсию:

import scala.annotation.tailrec

object ScalaMain {
  private val t = 20

  private def run() {
    var i = 10
    while(!isEvenlyDivisible(2, i, t))
      i += 2
    println(i)
  }

  @tailrec private def isEvenlyDivisible(i: Int, a: Int, b: Int): Boolean = {
    if (i > b) true
    else (a % i == 0) && isEvenlyDivisible(i+1, a, b)
  }

  def main(args: Array[String]) {
    val t1 = System.currentTimeMillis()
    var i = 0
    while (i < 20) {
      run()
      i += 1
    }
    val t2 = System.currentTimeMillis()
    println("time: " + (t2 - t1))
  }
}

и сравнил его со следующей версией Java. Я сознательно сделал функции нестатичными для справедливого сравнения со Scala:

public class JavaMain {
    private final int t = 20;

    private void run() {
        int i = 10;
        while (!isEvenlyDivisible(2, i, t))
            i += 2;
        System.out.println(i);
    }

    private boolean isEvenlyDivisible(int i, int a, int b) {
        if (i > b) return true;
        else return (a % i == 0) && isEvenlyDivisible(i+1, a, b);
    }

    public static void main(String[] args) {
        JavaMain o = new JavaMain();
        long t1 = System.currentTimeMillis();
        for (int i = 0; i < 20; ++i)
          o.run();
        long t2 = System.currentTimeMillis();
        System.out.println("time: " + (t2 - t1));
    }
}

Вот результаты на моем компьютере:

> java JavaMain
....
time: 9651
> scala ScalaMain
....
time: 20592

Это Scala 2.10.3 на (Java HotSpot(TM) 64-разрядный сервер VM, Java 1.7.0_51).

Мой вопрос: какова скрытая стоимость с версией Scala?

Большое спасибо.

4 ответа

Решение

Что ж, бенчмаркинг OP не идеален. К счастью, JMH уже позаботился о многих вещах и имеет привязки как для Java, так и для Scala, необходимо смягчить тонны эффектов, включая разогрев, удаление мертвого кода, разветвление и т. Д. Пожалуйста, следуйте процедурам на странице JMH, чтобы получить тестовый проект, затем вы можете перенести тестовые тесты ниже.

Это пример теста Java:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Fork(3)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
public class JavaBench {

    @Param({"1", "5", "10", "15", "20"})
    int t;

    private int run() {
        int i = 10;
        while(!isEvenlyDivisible(2, i, t))
            i += 2;
        return i;
    }

    private boolean isEvenlyDivisible(int i, int a, int b) {
        if (i > b)
            return true;
        else
            return (a % i == 0) && isEvenlyDivisible(i + 1, a, b);
    }

    @GenerateMicroBenchmark
    public int test() {
        return run();
    }

}

... и это пример теста Scala:

@BenchmarkMode(Array(Mode.AverageTime))
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Fork(3)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
class ScalaBench {

  @Param(Array("1", "5", "10", "15", "20"))
  var t: Int = _

  private def run(): Int = {
    var i = 10
    while(!isEvenlyDivisible(2, i, t))
      i += 2
    i
  }

  @tailrec private def isEvenlyDivisible(i: Int, a: Int, b: Int): Boolean = {
    if (i > b) true
    else (a % i == 0) && isEvenlyDivisible(i + 1, a, b)
  }

  @GenerateMicroBenchmark
  def test(): Int = {
    run()
  }

}

Если вы запустите их на JDK 8 GA, Linux x86_64, вы получите:

Benchmark             (t)   Mode   Samples         Mean   Mean error    Units
o.s.ScalaBench.test     1   avgt        15        0.005        0.000    us/op
o.s.ScalaBench.test     5   avgt        15        0.489        0.001    us/op
o.s.ScalaBench.test    10   avgt        15       23.672        0.087    us/op
o.s.ScalaBench.test    15   avgt        15     3406.492        9.239    us/op
o.s.ScalaBench.test    20   avgt        15  2483221.694     5973.236    us/op

Benchmark            (t)   Mode   Samples         Mean   Mean error    Units
o.s.JavaBench.test     1   avgt        15        0.002        0.000    us/op
o.s.JavaBench.test     5   avgt        15        0.254        0.007    us/op
o.s.JavaBench.test    10   avgt        15       12.578        0.098    us/op
o.s.JavaBench.test    15   avgt        15     1628.694       11.282    us/op
o.s.JavaBench.test    20   avgt        15  1066113.157    11274.385    us/op

Обратите внимание, мы жонглируем t чтобы увидеть, является ли эффект локальным для конкретного значения t, Это не так, эффект является систематическим, и Java-версия в два раза быстрее.

PrintAssembly позволит пролить свет на это. Это самый горячий блок в тесте Scala:

0x00007fe759199d42: test   %r8d,%r8d
0x00007fe759199d45: je     0x00007fe759199d76  ;*irem
                                               ; - org.sample.ScalaBench::isEvenlyDivisible@11 (line 52)
                                               ; - org.sample.ScalaBench::run@10 (line 45)
0x00007fe759199d47: mov    %ecx,%eax
0x00007fe759199d49: cmp    $0x80000000,%eax
0x00007fe759199d4e: jne    0x00007fe759199d58
0x00007fe759199d50: xor    %edx,%edx
0x00007fe759199d52: cmp    $0xffffffffffffffff,%r8d
0x00007fe759199d56: je     0x00007fe759199d5c
0x00007fe759199d58: cltd   
0x00007fe759199d59: idiv   %r8d

... и это аналогичный блок в Java:

0x00007f4a811848cf: movslq %ebp,%r10
0x00007f4a811848d2: mov    %ebp,%r9d
0x00007f4a811848d5: sar    $0x1f,%r9d
0x00007f4a811848d9: imul   $0x55555556,%r10,%r10
0x00007f4a811848e0: sar    $0x20,%r10
0x00007f4a811848e4: mov    %r10d,%r11d
0x00007f4a811848e7: sub    %r9d,%r11d         ;*irem
                                              ; - org.sample.JavaBench::isEvenlyDivisible@9 (line 63)
                                              ; - org.sample.JavaBench::isEvenlyDivisible@19 (line 63)
                                              ; - org.sample.JavaBench::run@10 (line 54)

Обратите внимание, что в Java-версии компилятор использовал трюк для перевода вычисления целочисленного остатка в умножение и сдвиг вправо (см. Восхищение Хакера, гл. 10, раздел 19). Это возможно, когда компилятор обнаруживает, что мы вычисляем остаток от константы, что предполагает, что версия Java попала в эту приятную оптимизацию, а версия Scala - нет. Вы можете покопаться в разборке байт-кода, чтобы выяснить причину ошибки в скаляре, но смысл этого упражнения в том, что удивительные незначительные различия в генерации кода значительно увеличиваются в результате тестирования.

PS Так много для @tailrec...

ОБНОВЛЕНИЕ: более подробное объяснение эффекта: http://shipilev.net/blog/2014/java-scala-divided-we-fail/

Я изменил val

private val t = 20

до постоянного определения

private final val t = 20

и получил значительное повышение производительности, теперь кажется, что обе версии работают почти одинаково [в моей системе, см. обновление и комментарии].

Я не смотрел в байт-код, но если вы используете val t = 20 вы можете увидеть, используя javap что есть метод (и эта версия так же медленно, как та, с private val).

Поэтому я предполагаю, что даже private val включает в себя вызов метода, и это не сопоставимо напрямую с final на Яве.

Обновить

На моей системе я получил эти результаты

Java версия: время: 14725

Версия Scala: время: 13228

Использование OpenJDK 1.7 в 32-битной Linux.

По моему опыту, Oracle JDK в 64-битной системе действительно работает лучше, так что это, вероятно, объясняет, что другие измерения дают еще лучшие результаты в пользу версии Scala.

Что касается версии Scala, работающей лучше, я предполагаю, что оптимизация хвостовой рекурсии здесь имеет эффект (см. Ответ Фила, если версия Java переписана с использованием цикла вместо рекурсии, она снова работает одинаково).

Я посмотрел на этот вопрос и отредактировал версию Scala, чтобы t внутри run:

object ScalaMain {
  private def run() {
    val t = 20
    var i = 10
    while(!isEvenlyDivisible(2, i, t))
      i += 2
    println(i)
  }

  @tailrec private def isEvenlyDivisible(i: Int, a: Int, b: Int): Boolean = {
    if (i > b) true
    else (a % i == 0) && isEvenlyDivisible(i+1, a, b)
  }

  def main(args: Array[String]) {
    val t1 = System.currentTimeMillis()
    var i = 0
    while (i < 20) {
      run()
      i += 1
    }
    val t2 = System.currentTimeMillis()
    println("time: " + (t2 - t1))
  }
}

Новая версия Scala теперь работает в два раза быстрее, чем оригинальная Java:

> fsc ScalaMain.scala
> scala ScalaMain
....
time: 6373
> fsc -optimize ScalaMain.scala
....
time: 4703

Я понял это потому, что у Java нет хвостовых вызовов. Оптимизированный Java с циклом вместо рекурсии выполняется так же быстро:

public class JavaMain {
    private static final int t = 20;

    private void run() {
        int i = 10;
        while (!isEvenlyDivisible(i, t))
            i += 2;
        System.out.println(i);
    }

    private boolean isEvenlyDivisible(int a, int b) {
        for (int i = 2; i <= b; ++i) {
            if (a % i != 0)
                 return false;
        }
        return true;
    }

    public static void main(String[] args) {
        JavaMain o = new JavaMain();
        long t1 = System.currentTimeMillis();
        for (int i = 0; i < 20; ++i)
            o.run();
        long t2 = System.currentTimeMillis();
        System.out.println("time: " + (t2 - t1));
    }
}

Теперь моя путаница полностью решена:

> java JavaMain
....
time: 4795

В заключение, оригинальная версия Scala была медленной, потому что я не объявлял t быть final (прямо или косвенно, как указывает ответ Бериллиума). И оригинальная версия Java была медленной из-за отсутствия хвостовых вызовов.

Чтобы сделать версию Java полностью эквивалентной вашему коду Scala, вам нужно изменить ее следующим образом.

private int t = 20;


private int t() {
    return this.t;
}

private void run() {
    int i = 10;
    while (!isEvenlyDivisible(2, i, t()))
        i += 2;
    System.out.println(i);
}

Это медленнее, потому что JVM не может оптимизировать вызовы методов.

Другие вопросы по тегам