Чому цикл Java на 4 мільярди ітерації займає лише 2 мс?


113

У мене наступний код Java на ноутбуці з 2,7 ГГц Intel Core i7. Я мав намір дозволити йому виміряти, скільки часу потрібно, щоб закінчити цикл з 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 (щойно вчасно встановлений компілятор) зрозумів, що цикл є зайвим і нічого не робить, тому оптимізував його.

Сучасні компілятори дуже розумні; вони можуть бачити, коли код марний. Спробуйте ввести порожній цикл у 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% випадків наш порожній цикл нічого не змінює і є марним. У цій ситуації С2 виходить на сцену. Коли код викликається дуже часто, його можна оптимізувати та компілювати за допомогою C2 за допомогою зібраної статистики.

Як приклад, я протестую наступний фрагмент коду (мій JDK встановлений для 9-внутрішнього уповільнення помилок ):

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. Для мене кінцевий варіант (С2) виглядає приблизно так:

...

; 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 оптимізація застосовується до загальної кількості примітивних циклів, якщо тип intпримітки 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.