Почему цикл Java с 4 миллиардами итераций занимает всего 2 мс?


113

Я запускаю следующий код Java на ноутбуке с процессором Intel Core i7 с тактовой частотой 2,7 ГГц. Я намеревался позволить ему измерить, сколько времени требуется для завершения цикла с 2 ^ 32 итерациями, что, как я ожидал, составит примерно 1,48 секунды (4 / 2,7 = 1,48).

Но на самом деле это занимает всего 2 миллисекунды вместо 1,48 с. Мне интересно, является ли это результатом какой-либо оптимизации JVM внизу?

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);
}

69
Ну да. Поскольку тело цикла не имеет побочных эффектов, компилятор с радостью их устраняет. Просмотрите байт-код с помощью, javap -vчтобы увидеть.
Эллиотт Фриш

36
Вы не увидите этого в байт-коде. javacвыполняет очень небольшую фактическую оптимизацию и оставляет большую часть ее JIT-компилятору.
Йорн Верни

4
«Мне интересно, является ли это результатом какой-либо оптимизации JVM внизу?» - Что вы думаете? Что еще могло быть, если не оптимизация JVM?
apangin

7
Ответ на этот вопрос в основном содержится в stackoverflow.com/a/25323548/3182664 . Он также содержит результирующую сборку (машинный код), которую JIT генерирует для таких случаев, показывая, что цикл полностью оптимизирован JIT . (Вопрос на stackoverflow.com/q/25326377/3182664 показывает, что это может занять немного больше времени, если цикл выполняет не 4 миллиарда операций, а 4 миллиарда минус один ;-)). Я бы почти рассматривал этот вопрос как дубликат другого - есть возражения?
Marco13,

7
Вы предполагаете, что процессор будет выполнять одну итерацию за каждую Гц. Это далеко идущее предположение. Как сказал @Rahul, сегодня процессоры выполняют всевозможные оптимизации, и если вы не знаете больше о том, как работает Core i7, вы не можете этого предполагать.
Цахи Ашер

Ответы:


106

Здесь возможны два варианта:

  1. Компилятор понял, что цикл избыточен и ничего не делает, поэтому оптимизировал его.

  2. JIT (JIT-компилятор) понял, что цикл избыточен и ничего не делает, поэтому оптимизировал его.

Современные компиляторы очень умны; они могут видеть, когда код бесполезен. Попробуйте поместить пустой цикл в GodBolt и посмотрите на результат, затем включите -O2оптимизацию, вы увидите, что результат похож на

main():
    xor eax, eax
    ret

Я хотел бы кое-что прояснить, в Java большая часть оптимизаций выполняется JIT. В некоторых других языках (например, C / C ++) большая часть оптимизации выполняется первым компилятором.


Разрешено ли компилятору делать такую ​​оптимизацию? Я не знаю точно, что касается Java, но компиляторы .NET обычно должны избегать этого, чтобы позволить JIT оптимизировать платформу наилучшим образом.
IllidanS4 хочет вернуть Монику

1
@ IllidanS4 В общем, это зависит от языкового стандарта. Если компилятор может выполнять оптимизацию, которая означает, что код, интерпретируемый стандартом, имеет такой же эффект, тогда да. Однако есть много тонкостей, которые необходимо учитывать, например, есть некоторые преобразования для вычислений с плавающей запятой, которые могут привести к появлению возможности переполнения / потери значимости, поэтому любую оптимизацию следует проводить осторожно.
user1997744

9
@ IllidanS4, как среда выполнения должна лучше оптимизировать? По крайней мере, он должен проанализировать код, что не может быть быстрее, чем удаление кода во время компиляции.
Герхард

2
@Gerhardh Я не говорил об этом конкретном случае, когда среда выполнения не может лучше справиться с удалением избыточных частей кода, но, конечно, могут быть некоторые случаи, когда эта причина верна. И поскольку могут быть другие компиляторы для JRE с других языков, среда выполнения также должна выполнять эту оптимизацию, поэтому потенциально нет причин для их выполнения как средой выполнения, так и компилятором.
IllidanS4 хочет вернуть Монику

6
@ IllidanS4 любая оптимизация времени выполнения не может занять меньше нуля времени. Запрещать компилятору удалять код не имеет смысла.
Герхард

55

Похоже, он был оптимизирован JIT-компилятором. Когда я выключаю его ( -Djava.compiler=NONE), код работает намного медленнее:

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

Я поместил код OP внутрь class MyClass.


2
Странно. Когда я запускаю код в обе стороны, он работает быстрее без флага, но только в 10 раз, и добавление или удаление нулей к количеству итераций в цикле также влияет на время выполнения в десять раз, с учетом и без флаг. Итак (для меня) цикл, кажется, не полностью оптимизирован, просто каким-то образом он стал в 10 раз быстрее. (Oracle Java 8-151)
tobias_k

@tobias_k, это зависит от того, на каком этапе JIT проходит цикл, я думаю, stackoverflow.com/a/47972226/1059372
Евгений

21

Я просто констатирую очевидное - что это оптимизация JVM, цикл просто будет удален. Вот небольшой тест, который показывает огромную разницу JITпри включении / включении только C1 Compilerи отключении вообще.

Отказ от ответственности: не пишите подобные тесты - это просто для того, чтобы доказать, что фактическое «удаление» цикла происходит в 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;
    }
}

Результаты показывают, что в зависимости от того, какая часть JITвключена, метод становится быстрее (настолько быстрее, что кажется, что он «ничего не делает» - удаление цикла, которое, похоже, происходит C2 Compilerна максимальном уровне):

 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 

13

Как уже отмечалось, JIT -компилятор (точно в срок) может оптимизировать пустой цикл, чтобы удалить ненужные итерации. Но как?

На самом деле существует два JIT-компилятора: C1 и C2 . Сначала код компилируется с помощью C1. C1 собирает статистику и помогает JVM обнаружить, что в 100% случаев наш пустой цикл ничего не меняет и бесполезен. В этой ситуации на сцену выходит C2. Когда код вызывается очень часто, его можно оптимизировать и скомпилировать с C2, используя собранную статистику.

В качестве примера я протестирую следующий фрагмент кода (мой JDK настроен на 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!");
    }
}

Со следующими параметрами командной строки:

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

И есть разные версии моего метода запуска , скомпилированные соответственно с C1 и C2. Для меня окончательный вариант (C2) выглядит примерно так:

...

; 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

...

Это немного запутано, но если вы присмотритесь, вы можете заметить, что здесь нет длительного цикла. Есть 3 блока: B1, B2 и B3, и шаги выполнения могут быть B1 -> B2 -> B3или B1 -> B3. Где Freq: 1- нормированная расчетная частота выполнения блока.


8

Вы измеряете время, необходимое для обнаружения, что цикл ничего не делает, компилируете код в фоновом потоке и удаляете код.

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);
}

Если вы запустите это с, -XX:+PrintCompilationвы увидите, что код был скомпилирован в фоновом режиме до уровня 3 или компилятора C1 и после нескольких циклов до уровня 4 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

Если вы измените цикл на использование, longон не будет оптимизирован.

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

вместо этого вы получаете

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

Странно ... почему longсчетчик предотвращает такую ​​же оптимизацию?
Райан Амос

@RyanAmos оптимизация применяется только к общему количеству примитивных циклов, если тип intnote char и short фактически одинаковы на уровне байтового кода.
Питер Лоури

-1

Вы считаете время начала и окончания в наносекундах и делите на 10 ^ 6 для вычисления задержки.

long d = (finish - start) / 1000000

это должно быть 10^9потому, что 1секунда = 10^9наносекунда.


То, что вы предложили, не имеет отношения к моей точке зрения. Меня интересовало, сколько времени это заняло, и не имеет значения, будет ли эта продолжительность напечатана / представлена ​​в миллисекундах или секундах.
twimo
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.