usar definicion codigo java performance benchmarking bytecode jit

definicion - ¿Por qué es 2*(i*i) más rápido que 2*i*i en Java?



jlabel html (10)

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:

public static void main(String[] args) { int repeat = 10; long A = 0; long B = 0; for (int i = 0; i < repeat; i++) { A += test(); B += testB(); } System.out.println(A / repeat + " ms"); System.out.println(B / repeat + " ms"); } private static long test() { int n = 0; for (int i = 0; i < 1000; i++) { n += multi(i); } long startTime = System.currentTimeMillis(); for (int i = 0; i < 1000000000; i++) { n += multi(i); } long ms = (System.currentTimeMillis() - startTime); System.out.println(ms + " ms A " + n); return ms; } private static long testB() { int n = 0; for (int i = 0; i < 1000; i++) { n += multiB(i); } long startTime = System.currentTimeMillis(); for (int i = 0; i < 1000000000; i++) { n += multiB(i); } long ms = (System.currentTimeMillis() - startTime); System.out.println(ms + " ms B " + n); return ms; } private static int multiB(int i) { return 2 * (i * i); } private static int multi(int i) { return 2 * i * i; }

Salida:

... 405 ms A 785527736 327 ms B 785527736 404 ms A 785527736 329 ms B 785527736 404 ms A 785527736 328 ms B 785527736 404 ms A 785527736 328 ms B 785527736 410 ms 333 ms

¿Entonces por qué? El código de bytes es este:

private static multiB(int arg0) { // 2 * (i * i) <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2> L1 { iconst_2 iload0 iload0 imul imul ireturn } L2 { } } private static multi(int arg0) { // 2 * i * i <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2> L1 { iconst_2 iload0 imul iload0 imul ireturn } L2 { } }

La diferencia es: con corchetes ( 2 * (i * i) ):

  • push const stack
  • empujar local en la pila
  • empujar local en la pila
  • multiplicar la parte superior de la pila
  • multiplicar la parte superior de la pila

Sin corchetes ( 2 * i * i ):

  • push const stack
  • empujar local en la pila
  • multiplicar la parte superior de la pila
  • empujar local en la pila
  • multiplicar la parte superior de la pila

Cargar todo en la pila y luego volver a bajar es más rápido que cambiar entre colocar la pila y operar en ella.

El siguiente programa Java toma en promedio entre 0.50 segundos y 0.55 segundos para 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 , se tarda entre 0.60 y 0.65 segundos en ejecutarse. ¿Cómo?

Ejecuté cada versión del programa 15 veces, alternando entre las dos. Aquí están 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 tan eficientes, la probabilidad de que esto sucediera sería menor que 1/2 ^ 15 * 100% = 0.00305%.


Cuando la multiplicación es 2 * (i * i) , la JVM puede factorizar la multiplicación por 2 del bucle, lo que da como resultado este código equivalente pero más eficiente:

int n = 0; for (int i = 0; i < 1000000000; i++) { n += i * i; } n *= 2;

pero cuando la multiplicación es (2 * i) * i , la JVM no la optimiza, ya que la multiplicación por una constante ya no es correcta antes de la suma.

Aquí hay algunas razones por las que creo que este es el caso:

  • La adición de una sentencia if (n == 0) n = 1 al inicio del bucle hace que ambas versiones sean igual de eficientes, ya que la factorización de la multiplicación ya no garantiza que el resultado sea el mismo
  • La versión optimizada (al factorizar la multiplicación por 2) es exactamente tan rápida como la versión 2 * (i * i)

Aquí está el código de prueba que utilicé para sacar estas conclusiones:

public static void main(String[] args) { long fastVersion = 0; long slowVersion = 0; long optimizedVersion = 0; long modifiedFastVersion = 0; long modifiedSlowVersion = 0; for (int i = 0; i < 10; i++) { fastVersion += fastVersion(); slowVersion += slowVersion(); optimizedVersion += optimizedVersion(); modifiedFastVersion += modifiedFastVersion(); modifiedSlowVersion += modifiedSlowVersion(); } System.out.println("Fast version: " + (double) fastVersion / 1000000000 + " s"); System.out.println("Slow version: " + (double) slowVersion / 1000000000 + " s"); System.out.println("Optimized version: " + (double) optimizedVersion / 1000000000 + " s"); System.out.println("Modified fast version: " + (double) modifiedFastVersion / 1000000000 + " s"); System.out.println("Modified slow version: " + (double) modifiedSlowVersion / 1000000000 + " s"); } private static long fastVersion() { long startTime = System.nanoTime(); int n = 0; for (int i = 0; i < 1000000000; i++) { n += 2 * (i * i); } return System.nanoTime() - startTime; } private static long slowVersion() { long startTime = System.nanoTime(); int n = 0; for (int i = 0; i < 1000000000; i++) { n += 2 * i * i; } return System.nanoTime() - startTime; } private static long optimizedVersion() { long startTime = System.nanoTime(); int n = 0; for (int i = 0; i < 1000000000; i++) { n += i * i; } n *= 2; return System.nanoTime() - startTime; } private static long modifiedFastVersion() { long startTime = System.nanoTime(); int n = 0; for (int i = 0; i < 1000000000; i++) { if (n == 0) n = 1; n += 2 * (i * i); } return System.nanoTime() - startTime; } private static long modifiedSlowVersion() { long startTime = System.nanoTime(); int n = 0; for (int i = 0; i < 1000000000; i++) { if (n == 0) n = 1; n += 2 * i * i; } return System.nanoTime() - startTime; }

Y aquí están los resultados:

Fast version: 5.7274411 s Slow version: 7.6190804 s Optimized version: 5.1348007 s Modified fast version: 7.1492705 s Modified slow version: 7.2952668 s


Hay una ligera diferencia en el orden del código de bytes.

2 * (i * i) :

iconst_2 iload0 iload0 imul imul iadd

vs 2 * i * i :

iconst_2 iload0 imul iload0 imul iadd

A primera vista esto no debería hacer una diferencia; En todo caso, la segunda versión es más óptima, ya que utiliza una ranura menos.

Así que necesitamos profundizar en el nivel inferior (JIT) 1 .

Recuerde que el JIT tiende a desenrollar los bucles pequeños de manera muy agresiva. De hecho, observamos un desenrollado 16x para el caso 2 * (i * i) :

030 B2: # B2 B3 <- B1 B2 Loop: B2-B2 inner main of N18 Freq: 1e+006 030 addl R11, RBP # int 033 movl RBP, R13 # spill 036 addl RBP, #14 # int 039 imull RBP, RBP # int 03c movl R9, R13 # spill 03f addl R9, #13 # int 043 imull R9, R9 # int 047 sall RBP, #1 049 sall R9, #1 04c movl R8, R13 # spill 04f addl R8, #15 # int 053 movl R10, R8 # spill 056 movdl XMM1, R8 # spill 05b imull R10, R8 # int 05f movl R8, R13 # spill 062 addl R8, #12 # int 066 imull R8, R8 # int 06a sall R10, #1 06d movl [rsp + #32], R10 # spill 072 sall R8, #1 075 movl RBX, R13 # spill 078 addl RBX, #11 # int 07b imull RBX, RBX # int 07e movl RCX, R13 # spill 081 addl RCX, #10 # int 084 imull RCX, RCX # int 087 sall RBX, #1 089 sall RCX, #1 08b movl RDX, R13 # spill 08e addl RDX, #8 # int 091 imull RDX, RDX # int 094 movl RDI, R13 # spill 097 addl RDI, #7 # int 09a imull RDI, RDI # int 09d sall RDX, #1 09f sall RDI, #1 0a1 movl RAX, R13 # spill 0a4 addl RAX, #6 # int 0a7 imull RAX, RAX # int 0aa movl RSI, R13 # spill 0ad addl RSI, #4 # int 0b0 imull RSI, RSI # int 0b3 sall RAX, #1 0b5 sall RSI, #1 0b7 movl R10, R13 # spill 0ba addl R10, #2 # int 0be imull R10, R10 # int 0c2 movl R14, R13 # spill 0c5 incl R14 # int 0c8 imull R14, R14 # int 0cc sall R10, #1 0cf sall R14, #1 0d2 addl R14, R11 # int 0d5 addl R14, R10 # int 0d8 movl R10, R13 # spill 0db addl R10, #3 # int 0df imull R10, R10 # int 0e3 movl R11, R13 # spill 0e6 addl R11, #5 # int 0ea imull R11, R11 # int 0ee sall R10, #1 0f1 addl R10, R14 # int 0f4 addl R10, RSI # int 0f7 sall R11, #1 0fa addl R11, R10 # int 0fd addl R11, RAX # int 100 addl R11, RDI # int 103 addl R11, RDX # int 106 movl R10, R13 # spill 109 addl R10, #9 # int 10d imull R10, R10 # int 111 sall R10, #1 114 addl R10, R11 # int 117 addl R10, RCX # int 11a addl R10, RBX # int 11d addl R10, R8 # int 120 addl R9, R10 # int 123 addl RBP, R9 # int 126 addl RBP, [RSP + #32 (32-bit)] # int 12a addl R13, #16 # int 12e movl R11, R13 # spill 131 imull R11, R13 # int 135 sall R11, #1 138 cmpl R13, #999999985 13f jl B2 # loop end P=1.000000 C=6554623.000000

Vemos que hay 1 registro que se "derrama" en la pila.

Y para la versión 2 * i * i :

05a B3: # B2 B4 <- B1 B2 Loop: B3-B2 inner main of N18 Freq: 1e+006 05a addl RBX, R11 # int 05d movl [rsp + #32], RBX # spill 061 movl R11, R8 # spill 064 addl R11, #15 # int 068 movl [rsp + #36], R11 # spill 06d movl R11, R8 # spill 070 addl R11, #14 # int 074 movl R10, R9 # spill 077 addl R10, #16 # int 07b movdl XMM2, R10 # spill 080 movl RCX, R9 # spill 083 addl RCX, #14 # int 086 movdl XMM1, RCX # spill 08a movl R10, R9 # spill 08d addl R10, #12 # int 091 movdl XMM4, R10 # spill 096 movl RCX, R9 # spill 099 addl RCX, #10 # int 09c movdl XMM6, RCX # spill 0a0 movl RBX, R9 # spill 0a3 addl RBX, #8 # int 0a6 movl RCX, R9 # spill 0a9 addl RCX, #6 # int 0ac movl RDX, R9 # spill 0af addl RDX, #4 # int 0b2 addl R9, #2 # int 0b6 movl R10, R14 # spill 0b9 addl R10, #22 # int 0bd movdl XMM3, R10 # spill 0c2 movl RDI, R14 # spill 0c5 addl RDI, #20 # int 0c8 movl RAX, R14 # spill 0cb addl RAX, #32 # int 0ce movl RSI, R14 # spill 0d1 addl RSI, #18 # int 0d4 movl R13, R14 # spill 0d7 addl R13, #24 # int 0db movl R10, R14 # spill 0de addl R10, #26 # int 0e2 movl [rsp + #40], R10 # spill 0e7 movl RBP, R14 # spill 0ea addl RBP, #28 # int 0ed imull RBP, R11 # int 0f1 addl R14, #30 # int 0f5 imull R14, [RSP + #36 (32-bit)] # int 0fb movl R10, R8 # spill 0fe addl R10, #11 # int 102 movdl R11, XMM3 # spill 107 imull R11, R10 # int 10b movl [rsp + #44], R11 # spill 110 movl R10, R8 # spill 113 addl R10, #10 # int 117 imull RDI, R10 # int 11b movl R11, R8 # spill 11e addl R11, #8 # int 122 movdl R10, XMM2 # spill 127 imull R10, R11 # int 12b movl [rsp + #48], R10 # spill 130 movl R10, R8 # spill 133 addl R10, #7 # int 137 movdl R11, XMM1 # spill 13c imull R11, R10 # int 140 movl [rsp + #52], R11 # spill 145 movl R11, R8 # spill 148 addl R11, #6 # int 14c movdl R10, XMM4 # spill 151 imull R10, R11 # int 155 movl [rsp + #56], R10 # spill 15a movl R10, R8 # spill 15d addl R10, #5 # int 161 movdl R11, XMM6 # spill 166 imull R11, R10 # int 16a movl [rsp + #60], R11 # spill 16f movl R11, R8 # spill 172 addl R11, #4 # int 176 imull RBX, R11 # int 17a movl R11, R8 # spill 17d addl R11, #3 # int 181 imull RCX, R11 # int 185 movl R10, R8 # spill 188 addl R10, #2 # int 18c imull RDX, R10 # int 190 movl R11, R8 # spill 193 incl R11 # int 196 imull R9, R11 # int 19a addl R9, [RSP + #32 (32-bit)] # int 19f addl R9, RDX # int 1a2 addl R9, RCX # int 1a5 addl R9, RBX # int 1a8 addl R9, [RSP + #60 (32-bit)] # int 1ad addl R9, [RSP + #56 (32-bit)] # int 1b2 addl R9, [RSP + #52 (32-bit)] # int 1b7 addl R9, [RSP + #48 (32-bit)] # int 1bc movl R10, R8 # spill 1bf addl R10, #9 # int 1c3 imull R10, RSI # int 1c7 addl R10, R9 # int 1ca addl R10, RDI # int 1cd addl R10, [RSP + #44 (32-bit)] # int 1d2 movl R11, R8 # spill 1d5 addl R11, #12 # int 1d9 imull R13, R11 # int 1dd addl R13, R10 # int 1e0 movl R10, R8 # spill 1e3 addl R10, #13 # int 1e7 imull R10, [RSP + #40 (32-bit)] # int 1ed addl R10, R13 # int 1f0 addl RBP, R10 # int 1f3 addl R14, RBP # int 1f6 movl R10, R8 # spill 1f9 addl R10, #16 # int 1fd cmpl R10, #999999985 204 jl B2 # loop end P=1.000000 C=7419903.000000

Aquí observamos muchos más "derrames" y más accesos a la pila [RSP + ...] , debido a los resultados más intermedios que deben conservarse.

Por lo tanto, la respuesta a la pregunta es simple: 2 * (i * i) es más rápido que 2 * i * i porque el JIT genera un código de ensamblaje más óptimo para el primer caso.

Pero, por supuesto, es obvio que ni la primera ni la segunda versión son buenas; el bucle podría beneficiarse realmente de la vectorización, ya que cualquier CPU x86-64 tiene al menos soporte SSE2.

Así que es un problema del optimizador; como suele ser el caso, se desenrolla de forma demasiado agresiva y se dispara en el pie, perdiendo al mismo tiempo otras oportunidades.

De hecho, las modernas CPU x86-64 desglosan las instrucciones en microoperaciones (µops) y con funciones como cambio de nombre de registro, cachés µop y buffers de bucle, la optimización de bucles requiere mucha más precisión que un simple desenrollado para un rendimiento óptimo. Según la guía de optimización de Agner Fog :

La ganancia en el rendimiento debido al caché µop puede ser bastante considerable si la longitud promedio de la instrucción es más de 4 bytes. Se pueden considerar los siguientes métodos para optimizar el uso de la memoria caché µop:

  • Asegúrese de que los bucles críticos sean lo suficientemente pequeños como para caber en el caché µop.
  • Alinee las entradas de bucle más críticas y las entradas de función en 32
  • Evitar el desenrollado innecesario del bucle.
  • Evite instrucciones que tengan tiempo de carga extra
    . . .

Con respecto a esos tiempos de carga, incluso el golpe L1D más rápido cuesta 4 ciclos , un registro adicional y µop, así que sí, incluso algunos accesos a la memoria afectarán el rendimiento en bucles ajustados.

Pero volviendo a la oportunidad de vectorización, para ver qué tan rápido puede ser, podemos compilar una aplicación C similar con GCC , que lo vectoriza (AVX2, SSE2 es similar) 2 :

vmovdqa ymm0, YMMWORD PTR .LC0[rip] vmovdqa ymm3, YMMWORD PTR .LC1[rip] xor eax, eax vpxor xmm2, xmm2, xmm2 .L2: vpmulld ymm1, ymm0, ymm0 inc eax vpaddd ymm0, ymm0, ymm3 vpslld ymm1, ymm1, 1 vpaddd ymm2, ymm2, ymm1 cmp eax, 125000000 ; 8 calculations per iteration jne .L2 vmovdqa xmm0, xmm2 vextracti128 xmm2, ymm2, 1 vpaddd xmm2, xmm0, xmm2 vpsrldq xmm0, xmm2, 8 vpaddd xmm0, xmm2, xmm0 vpsrldq xmm1, xmm0, 4 vpaddd xmm0, xmm0, xmm1 vmovd eax, xmm0 vzeroupper

Con tiempos de ejecución:

  • SSE: 0.24 s, o 2 veces más rápido.
  • AVX: 0.15 s, o 3 veces más rápido.
  • AVX2: 0.08 s, o 5 veces más rápido.

1 Para obtener una salida de ensamblaje generada por JIT, obtenga una JVM de depuración y ejecute con -XX:+PrintOptoAssembly

2 La versión C se compila con el indicador -fwrapv , que permite a GCC tratar el desbordamiento de enteros con signo como un resumen de dos complementos.


Los dos métodos de adición generan un código de byte ligeramente diferente:

17: iconst_2 18: iload 4 20: iload 4 22: imul 23: imul 24: iadd

Para 2 * (i * i) vs:

17: iconst_2 18: iload 4 20: imul 21: iload 4 23: imul 24: iadd

Para 2 * i * i .

Y cuando se utiliza un punto de referencia JMH como este:

@Warmup(iterations = 5, batchSize = 1) @Measurement(iterations = 5, batchSize = 1) @Fork(1) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MILLISECONDS) @State(Scope.Benchmark) public class MyBenchmark { @Benchmark public int noBrackets() { int n = 0; for (int i = 0; i < 1000000000; i++) { n += 2 * i * i; } return n; } @Benchmark public int brackets() { int n = 0; for (int i = 0; i < 1000000000; i++) { n += 2 * (i * i); } return n; } }

La diferencia es clara:

# JMH version: 1.21 # VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28 # VM options: <none> Benchmark (n) Mode Cnt Score Error Units MyBenchmark.brackets 1000000000 avgt 5 380.889 ± 58.011 ms/op MyBenchmark.noBrackets 1000000000 avgt 5 512.464 ± 11.098 ms/op

Lo que observa es correcto y no solo una anomalía de su estilo de evaluación comparativa (es decir, sin calentamiento, consulte ¿Cómo escribo un micro-benchmark correcto en Java? )

Corriendo de nuevo con Graal:

# JMH version: 1.21 # VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28 # VM options: -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler Benchmark (n) Mode Cnt Score Error Units MyBenchmark.brackets 1000000000 avgt 5 335.100 ± 23.085 ms/op MyBenchmark.noBrackets 1000000000 avgt 5 331.163 ± 50.670 ms/op

Usted ve que los resultados son mucho más cercanos, lo que tiene sentido, ya que Graal es un compilador en general, de mejor rendimiento y más moderno.

Por lo tanto, esto realmente depende de qué tan bien el compilador JIT sea capaz de optimizar un fragmento de código en particular, y no necesariamente tiene una razón lógica para ello.


Más de un addendum. Reproduje el experimento utilizando la última JVM Java 8 de IBM:

java version "1.8.0_191" Java(TM) 2 Runtime Environment, Standard Edition (IBM build 1.8.0_191-b12 26_Oct_2018_18_45 Mac OS X x64(SR5 FP25)) Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

Y esto muestra resultados muy similares:

0.374653912 s n = 119860736 0.447778698 s n = 119860736

(segundos resultados utilizando 2 * i * i).

Curiosamente, cuando se ejecuta en la misma máquina, pero utilizando Oracle Java:

Java version "1.8.0_181" Java(TM) SE Runtime Environment (build 1.8.0_181-b13) Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)

los resultados son en promedio un poco más lentos:

0.414331815 s n = 119860736 0.491430656 s n = 119860736

Larga historia corta: incluso el número de versión menor de HotSpot importa aquí, ya que las diferencias sutiles dentro de la implementación JIT pueden tener efectos notables.


Observación interesante utilizando Java 11 y apagando el ciclo de desenrollado con la siguiente opción de VM:

-XX:LoopUnrollLimit=0

El bucle con la expresión 2 * (i * i) da como resultado un código nativo 1 más compacto:

L0001: add eax,r11d inc r8d mov r11d,r8d imul r11d,r8d shl r11d,1h cmp r8d,r10d jl L0001

en comparación con la versión 2 * i * i :

L0001: add eax,r11d mov r11d,r8d shl r11d,1h add r11d,2h inc r8d imul r11d,r8d cmp r8d,r10d jl L0001

Versión de Java:

java version "11" 2018-09-25 Java(TM) SE Runtime Environment 18.9 (build 11+28) Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)

Resultados de referencia:

Benchmark (size) Mode Cnt Score Error Units LoopTest.fast 1000000000 avgt 5 694,868 ± 36,470 ms/op LoopTest.slow 1000000000 avgt 5 769,840 ± 135,006 ms/op

Código fuente de referencia:

@BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MILLISECONDS) @Warmup(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS) @Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS) @State(Scope.Thread) @Fork(1) public class LoopTest { @Param("1000000000") private int size; public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder().include(LoopTest.class.getSimpleName()) .jvmArgs("-XX:LoopUnrollLimit=0") .build(); new Runner(opt).run(); } @Benchmark public int slow() { int n = 0; for (int i = 0; i < size; i++) { n += 2 * i * i; } return n; } @Benchmark public int fast() { int n = 0; for (int i = 0; i < size; i++) { n += 2 * (i * i); } return n; } }

1 - Opciones de VM utilizadas: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0


Obtuve resultados similares:

2 * (i * i): 0.458765943 s, n=119860736 2 * i * i: 0.580255126 s, n=119860736

Obtuve los MISMOS resultados si ambos bucles estaban en el mismo programa, o si cada uno estaba en un archivo .java / clase separado, ejecutado en una ejecución separada.

Finalmente, aquí hay un javap -c -v <.java> descompilación de cada uno:

3: ldc #3 // String 2 * (i * i): 5: invokevirtual #4 // Method java/io/PrintStream.print:(Ljava/lang/String;)V 8: invokestatic #5 // Method java/lang/System.nanoTime:()J 8: invokestatic #5 // Method java/lang/System.nanoTime:()J 11: lstore_1 12: iconst_0 13: istore_3 14: iconst_0 15: istore 4 17: iload 4 19: ldc #6 // int 1000000000 21: if_icmpge 40 24: iload_3 25: iconst_2 26: iload 4 28: iload 4 30: imul 31: imul 32: iadd 33: istore_3 34: iinc 4, 1 37: goto 17

contra

3: ldc #3 // String 2 * i * i: 5: invokevirtual #4 // Method java/io/PrintStream.print:(Ljava/lang/String;)V 8: invokestatic #5 // Method java/lang/System.nanoTime:()J 11: lstore_1 12: iconst_0 13: istore_3 14: iconst_0 15: istore 4 17: iload 4 19: ldc #6 // int 1000000000 21: if_icmpge 40 24: iload_3 25: iconst_2 26: iload 4 28: imul 29: iload 4 31: imul 32: iadd 33: istore_3 34: iinc 4, 1 37: goto 17

Para tu información

java -version java version "1.8.0_121" Java(TM) SE Runtime Environment (build 1.8.0_121-b13) Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)


Probé un JMH usando el arquetipo predeterminado: también agregué una versión optimizada basada en la explicación de Runemoro .

@State(Scope.Benchmark) @Warmup(iterations = 2) @Fork(1) @Measurement(iterations = 10) @OutputTimeUnit(TimeUnit.NANOSECONDS) //@BenchmarkMode({ Mode.All }) @BenchmarkMode(Mode.AverageTime) public class MyBenchmark { @Param({ "100", "1000", "1000000000" }) private int size; @Benchmark public int two_square_i() { int n = 0; for (int i = 0; i < size; i++) { n += 2 * (i * i); } return n; } @Benchmark public int square_i_two() { int n = 0; for (int i = 0; i < size; i++) { n += i * i; } return 2*n; } @Benchmark public int two_i_() { int n = 0; for (int i = 0; i < size; i++) { n += 2 * i * i; } return n; } }

El resultado está aquí:

Benchmark (size) Mode Samples Score Score error Units o.s.MyBenchmark.square_i_two 100 avgt 10 58,062 1,410 ns/op o.s.MyBenchmark.square_i_two 1000 avgt 10 547,393 12,851 ns/op o.s.MyBenchmark.square_i_two 1000000000 avgt 10 540343681,267 16795210,324 ns/op o.s.MyBenchmark.two_i_ 100 avgt 10 87,491 2,004 ns/op o.s.MyBenchmark.two_i_ 1000 avgt 10 1015,388 30,313 ns/op o.s.MyBenchmark.two_i_ 1000000000 avgt 10 967100076,600 24929570,556 ns/op o.s.MyBenchmark.two_square_i 100 avgt 10 70,715 2,107 ns/op o.s.MyBenchmark.two_square_i 1000 avgt 10 686,977 24,613 ns/op o.s.MyBenchmark.two_square_i 1000000000 avgt 10 652736811,450 27015580,488 ns/op

En mi PC ( Core i7 860 - no hace nada más que leer en mi teléfono inteligente):

  • n += i*i entonces n*2 es primero
  • 2 * (i * i) es el segundo.

La JVM claramente no está optimizando de la misma manera que lo hace un humano (basado en la respuesta de Runemoro).

Ahora, leyendo el javap -c -v ./target/classes/org/sample/MyBenchmark.class : javap -c -v ./target/classes/org/sample/MyBenchmark.class

No soy experto en bytecode, pero iload_2 antes de imul : probablemente es donde se obtiene la diferencia: puedo suponer que la JVM optimiza la lectura i dos veces (ya estoy aquí y no hay necesidad de volver a cargarla) mientras esté en el 2*i*i no puedo.


Si bien no estaba directamente relacionado con el entorno de la pregunta, solo por curiosidad, hice la misma prueba en .NET Core 2.1, x64, modo de lanzamiento.

Aquí está el resultado interesante, que confirma que la phonomena similar (al revés) ocurre sobre el lado oscuro de la fuerza. Código:

static void Main(string[] args) { Stopwatch watch = new Stopwatch(); Console.WriteLine("2 * (i * i)"); for (int a = 0; a < 10; a++) { int n = 0; watch.Restart(); for (int i = 0; i < 1000000000; i++) { n += 2 * (i * i); } watch.Stop(); Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds} ms"); } Console.WriteLine(); Console.WriteLine("2 * i * i"); for (int a = 0; a < 10; a++) { int n = 0; watch.Restart(); for (int i = 0; i < 1000000000; i++) { n += 2 * i * i; } watch.Stop(); Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds}ms"); } }

Resultado:

2 * (i * i)

  • resultado: 119860736, 438 ms
  • resultado: 119860736, 433 ms
  • resultado: 119860736, 437 ms
  • resultado: 119860736, 435 ms
  • resultado: 119860736, 436 ms
  • resultado: 119860736, 435 ms
  • resultado: 119860736, 435 ms
  • resultado: 119860736, 439 ms
  • resultado: 119860736, 436 ms
  • resultado: 119860736, 437 ms

2 * i * i

  • resultado: 119860736, 417 ms
  • resultado: 119860736, 417 ms
  • resultado: 119860736, 417 ms
  • resultado: 119860736, 418 ms
  • resultado: 119860736, 418 ms
  • resultado: 119860736, 417 ms
  • resultado: 119860736, 418 ms
  • resultado: 119860736, 416 ms
  • resultado: 119860736, 417 ms
  • resultado: 119860736, 418 ms

Kasperd preguntó en un comentario de la respuesta aceptada:

Los ejemplos de Java y C utilizan nombres de registro bastante diferentes. ¿Ambos ejemplos utilizan la AMD64 ISA?

xor edx, edx xor eax, eax .L2: mov ecx, edx imul ecx, edx add edx, 1 lea eax, [rax+rcx*2] cmp edx, 1000000000 jne .L2

No tengo suficiente reputación para responder esto en los comentarios, pero estos son los mismos ISA. Vale la pena señalar que la versión GCC utiliza la lógica de enteros de 32 bits y la versión compilada de JVM utiliza la lógica de enteros de 64 bits internamente.

R8 a R15 son solo nuevos registers X86_64. EAX a EDX son las partes inferiores de los registros de propósito general RAX a RDX. La parte importante de la respuesta es que la versión de GCC no se desenrolla. Simplemente ejecuta una ronda del bucle por cada bucle de código de máquina real. Si bien la versión JVM tiene 16 rondas del bucle en un bucle físico (basado en la respuesta de rustyx, no reinterpreté el ensamblaje). Esta es una de las razones por las que se utilizan más registros, ya que el cuerpo del bucle es 16 veces más largo.