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 * i
tomó 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%
.
java
performance
benchmarking
bytecode
jit
Stefan
fuente
fuente
2 * i * i
es más lento. Intentaré correr con Graal también.i * i * 2
más rápido que2 * i * i
? " Para mejorar la claridad de que el problema está en el orden de las operaciones.Respuestas:
Hay una ligera diferencia en el orden del código de bytes.
2 * (i * i)
:vs
2 * i * i
: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:Vemos que hay 1 registro que se "derrama" en la pila.
Y para la
2 * i * i
versión: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 que2 * 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 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 :
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 :
Con tiempos de ejecución:
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
-fwrapv
indicador, que permite a GCC tratar el desbordamiento de enteros con signo como un complemento de dos.fuente
ret
instrucció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.mov
/add-immediate
. por ejemplo,movl RBX, R9
/addl RBX, #8
debería serleal ebx, [r9 + 8]
, 1 uop para copiar y agregar. Oleal ebx, [r9 + r9 + 16]
para hacerebx = 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.Cuando la multiplicación es
2 * (i * i)
, la JVM puede factorizar la multiplicación por2
del bucle, lo que resulta en este código equivalente pero más eficiente: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:
if (n == 0) n = 1
declaració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.2 * (i * i)
versiónAquí está el código de prueba que usé para sacar estas conclusiones:
Y aquí están los resultados:
fuente
n *= 2000000000;
2*1*1 + 2*2*2 + 2*3*3
. Es obvio que calcular1*1 + 2*2 + 3*3
y multiplicar por 2 es correcto, mientras que multiplicar por 8 no lo sería.2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²)
. Eso fue muy simple y lo olvidé porque el incremento del bucle.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?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:
¿Entonces por qué? El código de bytes es este:
La diferencia es: con corchetes (
2 * (i * i)
):Sin corchetes (
2 * i * i
):Cargar todo en la pila y luego volver a bajar es más rápido que cambiar entre colocar la pila y operarla.
fuente
Kasperd preguntó en un comentario de la respuesta aceptada:
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.
fuente
*2
el 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 queadd 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. (elimul ecx,edx
rendimiento es de 1 por reloj en Ryzen y en Intel).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:
Resultado:
2 * (i * i)
2 * i * i
fuente
Obtuve resultados similares:
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:vs.
FYI -
fuente
-XX:+PrintOptoAssembly
. O simplemente use vtune o similar.Observación interesante utilizando Java 11 y desactivando el desenrollado del bucle con la siguiente opción de VM:
El bucle con la
2 * (i * i)
expresión da como resultado un código nativo 1 más compacto :en comparación con la
2 * i * i
versión:Versión de Java:
Resultados de referencia:
Código fuente de referencia:
1 - Opciones de VM utilizadas:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0
fuente
i
antes de copiarlo para calcular2*i
, lo hace después, por lo que necesita unaadd r11d,2
instrucción adicional . (Además, se pierde laadd same,same
mirilla en lugar deshl
1 (agregue carreras en más puertos). También se pierde una mirilla LEA parax*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í.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.)Probé un JMH usando el arquetipo predeterminado: también agregué una versión optimizada basada en la explicación de Runemoro .
El resultado está aquí:
En mi PC ( Core i7 860, no hace mucho más que leer en mi teléfono inteligente):
n += i*i
entoncesn*2
es primero2 * (i * i)
es el segundoLa 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_2
antes que nosotrosimul
: probablemente sea allí donde se obtiene la diferencia: puedo suponer que JVM optimiza la lecturai
dos veces (i
ya está aquí, y no hay necesidad de volver a cargarla) mientras está en la2*i*i
lata ' t.fuente
Más de un apéndice. Reprendí el experimento usando la última Java 8 JVM de IBM:
Y esto muestra resultados muy similares:
(segundos resultados usando 2 * i * i).
Curiosamente, cuando se ejecuta en la misma máquina, pero usando Oracle Java:
los resultados son en promedio un poco más lentos:
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.
fuente
Los dos métodos de agregar generan 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 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:
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.
fuente