java performance scala jvm microbenchmark

java - ¿Costo de rendimiento oculto en Scala?



performance jvm (4)

Me encontré con esta vieja pregunta e hice el siguiente experimento con scala 2.10.3.

Reescribí la versión de Scala para usar la recursión de cola explícita:

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)) } }

y lo comparó con la siguiente versión de Java. Inconscientemente hice las funciones no estáticas para una comparación justa con 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)); } }

Aquí están los resultados en mi computadora:

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

Esto es scala 2.10.3 en (Java HotSpot (TM) 64-Bit Server VM, Java 1.7.0_51).

Mi pregunta es ¿cuál es el costo oculto con la versión scala?

Muchas gracias.


Bueno, la evaluación comparativa de OP no es la ideal. Se deben mitigar toneladas de efectos, incluidos el calentamiento, la eliminación del código muerto, el bifurcación, etc. Afortunadamente, JMH ya se ocupa de muchas cosas y tiene enlaces tanto para Java como para Scala. Siga los procedimientos en la página de JMH para obtener el proyecto de referencia, luego puede trasplantar los puntos de referencia que figuran a continuación.

Este es el ejemplo de referencia de 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(); } }

... y este es el ejemplo de referencia de 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() } }

Si ejecuta estos en JDK 8 GA, Linux x86_64, obtendrá:

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

Observe que hacemos malabares con t para ver si el efecto es local para el valor particular de t . No lo es, el efecto es sistemático y la versión de Java es el doble de rápida.

PrintAssembly arrojará algo de luz sobre esto. Este es el bloque más popular en referencia de 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

... y este es un bloque similar en 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)

Observe cómo en la versión de Java el compilador empleó el truco para traducir el cálculo del residuo entero en la multiplicación y el desplazamiento a la derecha (ver Hacker''s Delight, Ch. 10, Sec. 19). Esto es posible cuando el compilador detecta que calculamos el resto contra la constante, lo que sugiere que la versión de Java alcanzó esa optimización dulce, pero la versión de Scala no. Puede profundizar en el desmontaje del bytecode para descubrir qué peculiaridad en scalac ha intervenido, pero el objetivo de este ejercicio es que las diminutas diferencias sorprendentes en la generación de código se magnifican mucho por los benchmarks.

PD: mucho para @tailrec ...

ACTUALIZACIÓN: Una explicación más detallada del efecto: http://shipilev.net/blog/2014/java-scala-divided-we-fail/


Cambié el val

private val t = 20

a una definición constante

private final val t = 20

y obtuve un aumento significativo en el rendimiento, ahora parece que ambas versiones tienen un rendimiento casi igual [en mi sistema, ver actualización y comentarios].

No he investigado en el bytecode, pero si usa val t = 20 , puede ver usando javap que hay un método (y esa versión es tan lenta como la del private val ).

Así que supongo que incluso un valor private val implica llamar a un método, y eso no es directamente comparable con una final en Java.

Actualizar

En mi sistema obtuve estos resultados

Versión de Java: tiempo: 14725

Versión de Scala: tiempo: 13228

Usando OpenJDK 1.7 en un Linux de 32 bits.

En mi experiencia, el JDK de Oracle en un sistema de 64 bits realmente funciona mejor, por lo que esto probablemente explica que otras mediciones produzcan incluso mejores resultados a favor de la versión de Scala.

En cuanto a la versión de Scala que funciona mejor, asumo que la optimización de recursión de cola tiene un efecto aquí (ver la respuesta de Phil, si la versión de Java se reescribe para usar un bucle en lugar de la recursión, se repite igualmente).


Miré esta pregunta y edité la versión de Scala para tener t dentro de 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)) } }

La nueva versión de Scala ahora corre dos veces más rápido que la original de Java:

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

Me di cuenta de que es porque Java no tiene llamadas de cola. El Java optimizado con bucle en lugar de recursión se ejecuta igual de rápido:

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)); } }

Ahora mi confusión está completamente resuelta:

> java JavaMain .... time: 4795

En conclusión, la versión original de Scala fue lenta porque no declaraba que era final (directa o indirectamente, como señala la answer ). Y la versión original de Java fue lenta debido a la falta de llamadas finales.


Para hacer que la versión de Java sea completamente equivalente a su código Scala, debe cambiarla de esta manera.

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); }

Es más lento porque la JVM no puede optimizar las llamadas a métodos.