¿Por qué 2 * (i * i) es más rápido que 2 * i * i en Java?
El siguiente programa Java tarda una media de entre 0,50 y 0,55 segundos en ejecutarse:
public static void main(String[] args) {
long startTime = System.nanoTime();
int n = 0;
for (int i = 0; i < 1000000000; i++) {
n += 2 * (i * i);
}
System.out.println((double) (System.nanoTime() - startTime) / 1000000000 + " s");
System.out.println("n = " + n);
}
Si sustituyo 2 * (i * i)
por 2 * i * i
, tarda entre 0,60 y 0,65 s en ejecutarse. ¿Por qué?
He ejecutado cada versión del programa 15 veces, alternando entre las dos. Estos son los resultados:
2*(i*i) | 2*i*i
----------+----------
0.5183738 | 0.6246434
0.5298337 | 0.6049722
0.5308647 | 0.6603363
0.5133458 | 0.6243328
0.5003011 | 0.6541802
0.5366181 | 0.6312638
0.515149 | 0.6241105
0.5237389 | 0.627815
0.5249942 | 0.6114252
0.5641624 | 0.6781033
0.538412 | 0.6393969
0.5466744 | 0.6608845
0.531159 | 0.6201077
0.5048032 | 0.6511559
0.5232789 | 0.6544526
La ejecución más rápida de 2 * i * i
tomó más tiempo que la ejecución más lenta de 2 * (i * i)
. Si ambos fueran igual de eficientes, la probabilidad de que esto ocurra sería inferior a 1/2^15 * 100% = 0,00305%.
833
3
Códigos de bytes: https://cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html Visor de códigos de bytes: https://github.com/Konloch/bytecode-viewer
En mi JDK (Windows 10 64 bit, 1.8.0_65-b17) puedo reproducir y explicar:
Salida:
¿Y por qué? El código de bytes es este:
La diferencia es: Con paréntesis (
2 * (i * i)
):Sin paréntesis (
2 * i * i
):Cargar todo en la pila y luego trabajar hacia abajo es más rápido que cambiar entre poner en la pila y operar en ella.
Obtuve resultados similares:
Obtuve los resultados SAME si ambos bucles estaban en el mismo programa, o si cada uno estaba en un archivo .java/.class separado, ejecutado en una ejecución separada.
Finalmente, aquí está un
javap -c -v
descompilar de cada uno:vs.
PARA QUE SEPAS...
Los dos métodos de adición generan un código de bytes ligeramente diferente:
Para
2 * (i * i)
vs:Para
2 * i * i
.Y cuando se utiliza un punto de referencia JMH como este:
La diferencia es clara:
Lo que observas es correcto, y no sólo una anomalía de tu estilo de benchmarking (es decir, sin calentamiento, ver ¿Cómo escribo un micro-benchmark correcto en Java?)
Corriendo de nuevo con Graal:
Verás que los resultados son mucho más parecidos, lo que tiene sentido, ya que Graal es un compilador más moderno y de mejor rendimiento en general.
Así que esto es realmente sólo depende de lo bien que el compilador JIT es capaz de optimizar un pedazo de código en particular, y no tiene necesariamente una razón lógica a la misma.