¿Por qué un bucle Java de 4 mil millones de iteraciones toma solo 2 ms?

113

Estoy ejecutando el siguiente código Java en una computadora portátil con Intel Core i7 a 2.7 GHz. Tenía la intención de dejar que mida cuánto tiempo lleva terminar un ciclo con 2 ^ 32 iteraciones, que esperaba que fueran aproximadamente 1,48 segundos (4 / 2,7 = 1,48).

Pero en realidad solo toma 2 milisegundos, en lugar de 1,48 s. Me pregunto si esto es el resultado de alguna optimización de JVM debajo.

public static void main(String[] args)
{
    long start = System.nanoTime();

    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++){
    }
    long finish = System.nanoTime();
    long d = (finish - start) / 1000000;

    System.out.println("Used " + d);
}
twimo
fuente
69
Bueno, sí. Debido a que el cuerpo del bucle no tiene efectos secundarios, el compilador lo elimina felizmente. Examine el código de bytes con javap -vpara ver.
Elliott Frisch
36
No volverá a ver eso en el código de bytes. javacrealiza muy poca optimización real y deja la mayor parte al compilador JIT.
Jorn Vernee
4
"Me pregunto si esto es el resultado de alguna optimización JVM subyacente". - ¿Qué piensas? ¿Qué más podría ser si no una optimización de JVM?
apangin
7
La respuesta a esta pregunta se encuentra básicamente en stackoverflow.com/a/25323548/3182664 . También contiene el ensamblado resultante (código de máquina) que el JIT genera para tales casos, lo que muestra que el bucle está completamente optimizado por el JIT . (La pregunta en stackoverflow.com/q/25326377/3182664 muestra que podría llevar un poco más de tiempo si el bucle no realiza 4 mil millones de operaciones, sino 4 mil millones menos uno ;-)). Casi consideraría esta pregunta como un duplicado de la otra, ¿alguna objeción?
Marco13
7
Asume que el procesador realizará una iteración por Hz. Esa es una suposición de gran alcance. Los procesadores de hoy realizan todo tipo de optimizaciones, como mencionó @Rahul, y a menos que sepa mucho más sobre cómo funciona el Core i7, no puede asumir eso.
Tsahi Asher

Respuestas:

106

Hay una de dos posibilidades aquí:

  1. El compilador se dio cuenta de que el bucle es redundante y no hace nada, por lo que lo optimizó.

  2. El JIT (compilador just-in-time) se dio cuenta de que el bucle es redundante y no hace nada, por lo que lo optimizó.

Los compiladores modernos son muy inteligentes; pueden ver cuando el código es inútil. Intente poner un bucle vacío en GodBolt y observe la salida, luego active las -O2optimizaciones, verá que la salida es algo parecido a

main():
    xor eax, eax
    ret

Me gustaría aclarar algo, en Java la mayoría de las optimizaciones las realiza el JIT. En algunos otros lenguajes (como C / C ++), la mayoría de las optimizaciones las realiza el primer compilador.

van dench
fuente
¿Está autorizado el compilador a realizar tales optimizaciones? No lo sé con certeza para Java, pero los compiladores de .NET generalmente deberían evitar esto para permitir que JIT haga las mejores optimizaciones para la plataforma.
IllidanS4 quiere a Monica de vuelta el
1
@ IllidanS4 En general, esto depende del estándar del idioma. Si el compilador puede realizar optimizaciones que significan que el código, interpretado por el estándar, tiene el mismo efecto, entonces sí. Sin embargo, hay muchas sutilezas que se deben considerar, por ejemplo, hay algunas transformaciones para cálculos de punto flotante que pueden resultar en la posibilidad de que se introduzca un desbordamiento / subdesbordamiento, por lo que cualquier optimización debe realizarse con cuidado.
user1997744
9
@ IllidanS4 ¿cómo debería el entorno de ejecución poder realizar una mejor optimización? Como mínimo, debe analizar el código que no puede ser más rápido que eliminar el código durante la compilación.
Gerhardh
2
@Gerhardh No estaba hablando de este caso preciso, cuando el tiempo de ejecución no puede hacer un mejor trabajo para eliminar partes redundantes del código, pero, por supuesto, puede haber algunos casos en los que esta razón sea correcta. Y debido a que puede haber otros compiladores para JRE de otros lenguajes, el tiempo de ejecución también debería hacer estas optimizaciones, por lo que potencialmente no hay razón para que lo hagan tanto el tiempo de ejecución como el compilador.
IllidanS4 quiere que Monica vuelva el
6
@ IllidanS4 cualquier optimización de tiempo de ejecución no puede tardar menos de cero. Evitar que el compilador elimine el código no tendría ningún sentido.
Gerhardh
55

Parece que fue optimizado por el compilador JIT. Cuando lo apago ( -Djava.compiler=NONE), el código se ejecuta mucho más lento:

$ javac MyClass.java
$ java MyClass
Used 4
$ java -Djava.compiler=NONE MyClass
Used 40409

Puse el código de OP dentro de class MyClass.

Akavall
fuente
2
Extraño. Cuando ejecuto el código en ambos sentidos, es más rápido sin la bandera, pero solo por un factor de 10, y agregar o eliminar ceros al número de iteraciones en el ciclo también afecta el tiempo de ejecución por factores de diez, con y sin el bandera. Entonces (para mí) el bucle parece no estar optimizado por completo, solo se hizo 10 veces más rápido, de alguna manera. (Oracle Java 8-151)
tobias_k
@tobias_k, depende de la etapa del JIT por la que esté pasando el bucle, supongo que stackoverflow.com/a/47972226/1059372
Eugene
21

Solo diré lo obvio: que esta es una optimización de JVM que ocurre, el bucle simplemente se eliminará. Aquí hay una pequeña prueba que muestra la gran diferencia que JITtiene cuando está habilitado / habilitado solo para C1 Compilery deshabilitado en absoluto.

Descargo de responsabilidad: no escriba pruebas como esta, esto es solo para demostrar que la "eliminación" del bucle real ocurre en C2 Compiler:

@Benchmark
@Fork(1)
public void full() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
        ++result;
    }
}

@Benchmark
@Fork(1)
public void minusOne() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++) {
        ++result;
    }
}

@Benchmark
@Fork(value = 1, jvmArgsAppend = { "-XX:TieredStopAtLevel=1" })
public void withoutC2() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++) {
        ++result;
    }
}

@Benchmark
@Fork(value = 1, jvmArgsAppend = { "-Xint" })
public void withoutAll() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++) {
        ++result;
    }
}

Los resultados muestran que, dependiendo de qué parte del JITmétodo esté habilitado, el método se vuelve más rápido (tanto más rápido que parece que no hace "nada" - eliminación de bucle, que parece estar sucediendo en el C2 Compiler- que es el nivel máximo):

 Benchmark                Mode  Cnt      Score   Error  Units
 Loop.full        avgt    2      10⁻⁷          ms/op
 Loop.minusOne    avgt    2      10⁻⁶          ms/op
 Loop.withoutAll  avgt    2  51782.751          ms/op
 Loop.withoutC2   avgt    2   1699.137          ms/op 
Eugenio
fuente
13

Como ya se señaló, el compilador JIT (just-in-time) puede optimizar un ciclo vacío para eliminar iteraciones innecesarias. ¿Pero cómo?

En realidad, hay dos compiladores JIT: C1 y C2 . Primero, el código se compila con el C1. C1 recopila las estadísticas y ayuda a la JVM a descubrir que en el 100% de los casos, nuestro bucle vacío no cambia nada y es inútil. En esta situación C2 entra en escena. Cuando se llama al código con mucha frecuencia, se puede optimizar y compilar con el C2 utilizando estadísticas recopiladas.

Como ejemplo, probaré el siguiente fragmento de código (mi JDK está configurado para slowdebug build 9-internal ):

public class Demo {
    private static void run() {
        for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
        }
        System.out.println("Done!");
    }
}

Con las siguientes opciones de línea de comando:

-XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,*Demo.run

Y hay diferentes versiones de mi método de ejecución , compiladas adecuadamente con C1 y C2. Para mí, la variante final (C2) se parece a esto:

...

; B1: # B3 B2 <- BLOCK HEAD IS JUNK  Freq: 1
0x00000000125461b0: mov   dword ptr [rsp+0ffffffffffff7000h], eax
0x00000000125461b7: push  rbp
0x00000000125461b8: sub   rsp, 40h
0x00000000125461bc: mov   ebp, dword ptr [rdx]
0x00000000125461be: mov   rcx, rdx
0x00000000125461c1: mov   r10, 57fbc220h
0x00000000125461cb: call  indirect r10    ; *iload_1

0x00000000125461ce: cmp   ebp, 7fffffffh  ; 7fffffff => 2147483647
0x00000000125461d4: jnl   125461dbh       ; jump if not less

; B2: # B3 <- B1  Freq: 0.999999
0x00000000125461d6: mov   ebp, 7fffffffh  ; *if_icmpge

; B3: # N44 <- B1 B2  Freq: 1       
0x00000000125461db: mov   edx, 0ffffff5dh
0x0000000012837d60: nop
0x0000000012837d61: nop
0x0000000012837d62: nop
0x0000000012837d63: call  0ae86fa0h

...

Es un poco complicado, pero si miras de cerca, es posible que notes que no hay un bucle largo aquí. Hay 3 bloques: B1, B2 y B3 y los pasos de ejecución pueden ser B1 -> B2 -> B3o B1 -> B3. Donde Freq: 1: frecuencia estimada normalizada de la ejecución de un bloque.

Oleksandr Pyrohov
fuente
8

Está midiendo el tiempo que lleva detectar que el bucle no hace nada, compila el código en un hilo de fondo y elimina el código.

for (int t = 0; t < 5; t++) {
    long start = System.nanoTime();
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
    }
    long time = System.nanoTime() - start;

    String s = String.format("%d: Took %.6f ms", t, time / 1e6);
    Thread.sleep(50);
    System.out.println(s);
    Thread.sleep(50);
}

Si ejecuta esto con -XX:+PrintCompilation, puede ver que el código se ha compilado en segundo plano al compilador de nivel 3 o C1 y, después de algunos bucles, al nivel 4 de C4.

    129   34 %     3       A::main @ 15 (93 bytes)
    130   35       3       A::main (93 bytes)
    130   36 %     4       A::main @ 15 (93 bytes)
    131   34 %     3       A::main @ -2 (93 bytes)   made not entrant
    131   36 %     4       A::main @ -2 (93 bytes)   made not entrant
0: Took 2.510408 ms
    268   75 %     3       A::main @ 15 (93 bytes)
    271   76 %     4       A::main @ 15 (93 bytes)
    274   75 %     3       A::main @ -2 (93 bytes)   made not entrant
1: Took 5.629456 ms
2: Took 0.000000 ms
3: Took 0.000364 ms
4: Took 0.000365 ms

Si cambia el bucle para usar un long, no se optimiza tanto.

    for (long i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
    }

en su lugar obtienes

0: Took 1579.267321 ms
1: Took 1674.148662 ms
2: Took 1885.692166 ms
3: Took 1709.870567 ms
4: Took 1754.005112 ms
Peter Lawrey
fuente
Eso es extraño ... ¿por qué un longcontador evitaría que ocurra la misma optimización?
Ryan Amos
@RyanAmos, la optimización solo se aplica al recuento de bucles primitivos comunes si el tipo de intnota char y short son efectivamente lo mismo en el nivel de código de bytes.
Peter Lawrey
-1

Considera el tiempo de inicio y finalización en nanosegundos y lo divide por 10 ^ 6 para calcular la latencia

long d = (finish - start) / 1000000

debería ser 10^9porque 1segundo = 10^9nanosegundo.

DHARMENDRA SINGH
fuente
Lo que sugirió es irrelevante para mi punto. Lo que me preguntaba es cuánto tiempo tomó, y no importa si esta duración se imprime / representa en términos de milisegundos o segundos.
twimo