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


855

El siguiente programa Java tarda 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 lo reemplazo 2 * (i * i)con 2 * i * i, 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 * itomó más tiempo que la ejecución más lenta de 2 * (i * i). Si tuvieran la misma eficiencia, la probabilidad de que esto ocurra sería menor que 1/2^15 * 100% = 0.00305%.


55
Obtengo resultados similares (números ligeramente diferentes, pero definitivamente una brecha notable y consistente, definitivamente más que un error de muestreo)
Krease el


3
@Krease Good que capturaste mi error. Según el nuevo punto de referencia que ejecuté 2 * i * ies más lento. Intentaré correr con Graal también.
Jorn Vernee el

55
@nullpointer Para descubrir de verdad por qué uno es más rápido que el otro, tendríamos que obtener el desmontaje o los gráficos ideales para esos métodos. El ensamblador es muy molesto para tratar de averiguarlo, así que estoy tratando de obtener una compilación de depuración de OpenJDK que pueda generar buenos gráficos.
Jorn Vernee el

44
Puede cambiar el nombre de su pregunta a " ¿Por qué es i * i * 2más rápido que 2 * i * i? " Para mejorar la claridad de que el problema está en el orden de las operaciones.
Cœur

Respuestas:


1202

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 la diferencia; en todo caso, la segunda versión es más óptima ya que usa una ranura menos.

Por lo tanto, debemos profundizar en el nivel inferior (JIT) 1 .

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

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 2 * i * iversión:

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 mucho más "derrame" y más accesos a la pila [RSP + ...], debido a resultados más intermedios que deben preservarse.

Por lo tanto, la respuesta a la pregunta es simple: 2 * (i * i)es más rápido que 2 * i * iporque 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 ciclo realmente podría beneficiarse de la vectorización, ya que cualquier CPU x86-64 tiene al menos soporte SSE2.

Entonces es un problema del optimizador; Como suele ser el caso, se desenrolla con demasiada agresividad y se dispara en el pie, sin dejar pasar otras oportunidades.

De hecho, las CPU modernas x86-64 desglosan las instrucciones en microoperaciones (µops) y con características como cambio de nombre de registros, cachés µop y buffers de bucle, la optimización de bucle requiere mucha más delicadeza 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 a la caché µop puede ser bastante considerable si la longitud promedio de la instrucción es superior a 4 bytes. Se pueden considerar los siguientes métodos para optimizar el uso de la 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 por 32.
  • Evite desenrollar innecesariamente el bucle.
  • Evite las instrucciones que tienen tiempo de carga adicional
    . . .

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

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 la vectoriza directamente (se muestra 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 ejecútela con-XX:+PrintOptoAssembly

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


11
El mayor problema que encuentra el optimizador en el ejemplo de C es el comportamiento indefinido invocado por el desbordamiento de enteros con signo. Lo que, de lo contrario, probablemente resultaría en simplemente cargar una constante ya que todo el ciclo se puede calcular en tiempo de compilación.
Damon

44
@Damon ¿Por qué el comportamiento indefinido sería un problema para el optimizador? Si el optimizador ve que se desborda cuando intenta calcular el resultado, eso solo significa que puede optimizarlo como quiera, porque el comportamiento no está definido.

13
@Runemoro: si el optimizador demuestra que llamar a la función inevitablemente dará como resultado un comportamiento indefinido, podría optar por suponer que la función nunca se llamará y no emitirá ningún cuerpo para ello. O emitir solo una retinstrucción, o emitir una etiqueta y ninguna instrucción ret, por lo que la ejecución simplemente falla. Sin embargo, GCC se comporta de hecho, esto fue a veces cuando se encuentra con UB. Por ejemplo: ¿por qué ret desaparecer con la optimización? . Definitivamente desea compilar código bien formado para asegurarse de que el asm esté cuerdo.
Peter Cordes

8
Probablemente sea solo un cuello de botella de rendimiento de UOP front-end debido a la ineficiente generación de código. Ni siquiera está usando LEA como mirilla para mov/ add-immediate. por ejemplo, movl RBX, R9/ addl RBX, #8debería ser leal ebx, [r9 + 8], 1 uop para copiar y agregar. O leal ebx, [r9 + r9 + 16]para hacer ebx = 2*(r9+8). Entonces, sí, desenrollar hasta el punto de derramar es tonto, y también lo es el ingenuo codegendedead-brain que no aprovecha las identidades de enteros y las matemáticas de enteros asociativos.
Peter Cordes

77
La vectorización para la reducción secuencial se deshabilitó en C2 ( bugs.openjdk.java.net/browse/JDK-8078563 ), pero ahora se está considerando para volver a habilitarla ( bugs.openjdk.java.net/browse/JDK-8188313 ).
pron

131

Cuando la multiplicación es 2 * (i * i), la JVM puede factorizar la multiplicación por 2del bucle, lo que resulta en 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 justo antes de la suma.

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

  • Agregar una if (n == 0) n = 1declaración al comienzo del ciclo da como resultado que ambas versiones sean tan eficientes, ya que factorizar la multiplicación ya no garantiza que el resultado sea el mismo.
  • La versión optimizada (factorizando la multiplicación por 2) es exactamente tan rápida como la 2 * (i * i)versión

Aquí está el código de prueba que usé 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

3
Creo que en la n *= 2000000000;
versión

44
@StefansArya - No. Considere el caso donde el límite es 4, y estamos tratando de calcular 2*1*1 + 2*2*2 + 2*3*3. Es obvio que calcular 1*1 + 2*2 + 3*3y multiplicar por 2 es correcto, mientras que multiplicar por 8 no lo sería.
Martin Bonner apoya a Mónica el

55
La ecuación matemática era así 2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²). Eso fue muy simple y lo olvidé porque el incremento del bucle.
StefansArya

55
Si imprime el ensamblaje utilizando un jvm de depuración, esto no parece ser correcto. Verá un montón de sall ..., # 1, que se multiplica por 2, en el bucle. Curiosamente, la versión más lenta no parece tener multiplicaciones en el ciclo.
Daniel Berlin

2
¿Por qué la JVM puede factorizar el 2 desde 2 * (i * i)pero no desde (2 * i) * i? Creo que son equivalentes (esa puede ser mi mala suposición). Si es así, ¿la JVM no canonizaría la expresión antes de optimizarla?
RedSpikeyThing

41

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

  • empujar 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):

  • empujar 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 operarla.


Pero, ¿por qué es push-push-multiply-multiply más rápido que push-multiply-push-multiply?
m0skit0

35

Kasperd preguntó en un comentario de la respuesta aceptada:

Los ejemplos de Java y C usan nombres de registro bastante diferentes. ¿Ambos ejemplos usan el 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 usa lógica de enteros de 32 bits y la versión compilada de JVM usa lógica de enteros de 64 bits internamente.

R8 a R15 son solo nuevos X86_64 registros . EAX a EDX son las partes inferiores de los registros de propósito general de RAX a RDX. La parte importante en la respuesta es que la versión GCC no se desenrolla. Simplemente ejecuta una ronda del bucle por bucle de código de máquina real. Si bien la versión JVM tiene 16 rondas del bucle en un bucle físico (según 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 en realidad 16 veces más largo.


2
Lástima que gcc no se dé cuenta de que puede hundir *2el ciclo. Aunque en este caso, ni siquiera es una victoria hacer eso, porque lo está haciendo gratis con LEA. En las CPU Intel, lea eax, [rax+rcx*2]tiene la misma latencia 1c que add eax,ecx. Sin embargo, en las CPU AMD, cualquier índice escalado aumenta la latencia LEA a 2 ciclos. Por lo tanto, la cadena de dependencia transportada en bucle se alarga a 2 ciclos, convirtiéndose en el cuello de botella en Ryzen. (el imul ecx,edxrendimiento es de 1 por reloj en Ryzen y en Intel).
Peter Cordes el

31

Si bien no está 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, confirmando que fenómenos similares (al revés) suceden 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

1
Si bien esta no es una respuesta a la pregunta, agrega valor. Dicho esto, si algo es vital para su publicación, instálelo en la publicación en lugar de vincularlo a un recurso externo. Los enlaces se apagan.
Jared Smith

1
@JaredSmith Gracias por los comentarios. Teniendo en cuenta que el enlace que menciona es el enlace "resultado", esa imagen no es una fuente externa. Lo cargué al stackoverflow a través de su propio panel.
Ünsal Ersöz

1
Es un enlace a imgur, así que sí, no importa cómo agregaste el enlace. No veo qué es tan difícil de copiar y pegar algunos resultados de la consola.
Jared Smith el

55
Excepto que esto es al revés
leppie el

2
@SamB todavía está en el dominio imgur.com, lo que significa que sobrevivirá solo mientras imgur.
p91paul

21

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 / clase .java 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

vs.

     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

FYI -

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)

1
Una mejor respuesta y tal vez puedas votar para recuperarlo - stackoverflow.com/a/53452836/1746118 ... Nota al margen - De todos modos, no soy el votante negativo.
Naman

@nullpointer - Estoy de acuerdo. Definitivamente votaría para recuperar, si pudiera. También me gustaría "duplicar el voto" a Stefan por dar una definición cuantitativa de "significativo"
paulsm4

Ese se eliminó automáticamente, ya que midió algo incorrecto: vea el comentario de ese autor sobre la pregunta anterior
Krease el

2
Obtenga una depuración jre y corra con -XX:+PrintOptoAssembly. O simplemente use vtune o similar.
rustyx

1
@ rustyx: si el problema es la implementación de JIT ... "obtener una versión de depuración" de un JRE COMPLETAMENTE diferente no necesariamente va a ayudar. Sin embargo: parece que lo que encontró arriba con el desmontaje de su JIT en su JRE también explica el comportamiento en el JRE del OP y en el mío. Y también explica por qué otros JRE se comportan "de manera diferente". +1: ¡gracias por el excelente trabajo de detective!
paulsm4

18

Observación interesante utilizando Java 11 y desactivando el desenrollado del bucle con la siguiente opción de VM:

-XX:LoopUnrollLimit=0

El bucle con la 2 * (i * i)expresión 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 2 * i * iversión:

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


2
Wow, eso es algo de cerebro muerto. En lugar de aumentar i antes de copiarlo para calcular 2*i, lo hace después, por lo que necesita una add r11d,2instrucción adicional . (Además, se pierde la add same,samemirilla en lugar de shl1 (agregue carreras en más puertos). También se pierde una mirilla LEA para x*2 + 2( lea r11d, [r8*2 + 2]) si realmente quiere hacer las cosas en ese orden por alguna loca razón de programación de instrucciones. la versión desenrollada que se perdió en LEA le estaba costando muchos uops, igual que ambos bucles aquí.
Peter Cordes

2
lea eax, [rax + r11 * 2]reemplazaría 2 instrucciones (en ambos bucles) si el compilador JIT tuviera tiempo de buscar esa optimización en bucles de larga ejecución. Cualquier compilador decente de antemano lo encontraría. (A no ser que tal vez sólo para sintonizar AMD, donde índice escalado LEA tiene 2 ciclo de latencia así que quizás no vale la pena.)
Peter Cordes

15

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 mucho más que leer en mi teléfono inteligente):

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

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

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

No soy un experto en bytecode, pero iload_2antes que nosotros imul: probablemente sea allí donde se obtiene la diferencia: puedo suponer que JVM optimiza la lectura idos veces ( iya está aquí, y no hay necesidad de volver a cargarla) mientras está en la 2*i*ilata ' t.


44
El código de bytes AFAICT es bastante irrelevante para el rendimiento, y no trataría de estimar qué es más rápido basado en él. Es solo el código fuente para el compilador JIT ... seguro que las líneas de código fuente de reordenamiento que conservan el significado pueden cambiar el código resultante y su eficiencia, pero todo eso es bastante impredecible.
maaartinus

13

Más de un apéndice. Reprendí el experimento usando la última Java 8 JVM 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 usando 2 * i * i).

Curiosamente, cuando se ejecuta en la misma máquina, pero usando 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 de JIT pueden tener efectos notables.


5

Los dos métodos de agregar generan código de bytes 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 microevaluación 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

Verá que los resultados están mucho más cerca, lo cual tiene sentido, ya que Graal es un compilador general de mejor desempeño y más moderno.

Entonces, esto depende realmente de cuán bien el compilador JIT pueda optimizar un fragmento de código en particular, y no necesariamente tiene una razón lógica.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.