Подтвердить что ты не робот

Почему этот внутренний цикл на 4 раза быстрее первой итерации через внешний цикл?

Я пытался воспроизвести некоторые из эффектов кэша процессора, описанные в здесь. Я понимаю, что Java - это управляемая среда, и эти примеры не будут точно переводить, но я столкнулся с странным случаем, который я попытался перевести на простой пример, иллюстрирующий эффект:

public static void main(String[] args) {
    final int runs = 10;
    final int steps = 1024 * 1024 * 1024;

    for (int run = 0; run < runs; run++) {
        final int[] a = new int[1];
        long start = System.nanoTime();
        for (int i = 0; i < steps; i++) {
            a[0]++;
        }
        long stop = System.nanoTime();
        long time = TimeUnit.MILLISECONDS.convert(stop - start, TimeUnit.NANOSECONDS);
        System.out.printf("Time for loop# %2d: %5d ms\n", run, time);
    }
}

Вывод:

 Time for loop#  0:    24 ms
 Time for loop#  1:   106 ms
 Time for loop#  2:   104 ms
 Time for loop#  3:   103 ms
 Time for loop#  4:   102 ms
 Time for loop#  5:   103 ms
 Time for loop#  6:   104 ms
 Time for loop#  7:   102 ms
 Time for loop#  8:   105 ms
 Time for loop#  9:   102 ms

Первая итерация внутреннего цикла примерно в 4 раза быстрее, чем последующие итерации. Это противоположность тому, что я обычно ожидал бы, так как обычно performace поднимается по мере того, как JIT пинает.

Конечно, можно было бы сделать несколько циклов прогрева в любом серьезном микро-контроле, но мне любопытно, что может вызвать такое поведение, тем более, что, если мы знаем, что цикл может быть выполнен за 24 мс, это не очень удовлетворяя, что установившееся время составляет более 100 мсек.

Для справки JDK я использую (в linux):

 openjdk version "1.8.0_40"
 OpenJDK Runtime Environment (build 1.8.0_40-b20)
 OpenJDK 64-Bit Server VM (build 25.40-b23, mixed mode)

UPDATE:

Вот некоторые сведения об обновлении, основанные на некоторых комментариях, и некоторые эксперименты:

1) перемещение входа/выхода System.out из цикла (путем хранения синхронизации в массиве "прогонов" размера) не приводит к существенным различиям во времени.

2) вывод, отображаемый выше, - это когда я запускаю из Eclipse. Когда я компилирую и запускаю из командной строки (с тем же JDK/JVM), я получаю более скромные, но все же значительные результаты (2x вместо 4x быстрее). Это кажется интересным, так как usaully работает в затмении замедлит все, что угодно.

3) перемещение a вверх, из цикла, так что он будет повторно использован, каждая итерация не имеет эффекта.

4), если int[] a изменено на long[] a, первая итерация выполняется еще быстрее (около 20%), в то время как другие итерации по-прежнему остаются той же (медленной) скоростью.

ОБНОВЛЕНИЕ 2:

Я думаю, что ответ Апангин объясняет это. Я попробовал это с JVM Sun 1.9, и это происходит от:

openjdk version "1.8.0_40"
OpenJDK Runtime Environment (build 1.8.0_40-b20)
OpenJDK 64-Bit Server VM (build 25.40-b23, mixed mode)

Time for loop#  0:    48 ms
Time for loop#  1:   116 ms
Time for loop#  2:   112 ms
Time for loop#  3:   113 ms
Time for loop#  4:   112 ms
Time for loop#  5:   112 ms
Time for loop#  6:   111 ms
Time for loop#  7:   111 ms
Time for loop#  8:   113 ms
Time for loop#  9:   113 ms

в

java version "1.9.0-ea"
Java(TM) SE Runtime Environment (build 1.9.0-ea-b73)
Java HotSpot(TM) 64-Bit Server VM (build 1.9.0-ea-b73, mixed mode)

Time for loop#  0:    48 ms
Time for loop#  1:    26 ms
Time for loop#  2:    22 ms
Time for loop#  3:    22 ms
Time for loop#  4:    22 ms
Time for loop#  5:    22 ms
Time for loop#  6:    22 ms
Time for loop#  7:    22 ms
Time for loop#  8:    22 ms
Time for loop#  9:    23 ms

Это вполне улучшилось!

4b9b3361

Ответ 1

Это субоптимальная перекомпиляция метода.

JIT-компилятор использует статистику времени выполнения, собранную во время интерпретации. Когда метод main скомпилирован в первый раз, внешний цикл еще не завершил свою первую итерацию = > статистика времени выполнения сообщает, что код после внутреннего цикла никогда не выполняется, поэтому JIT никогда не документирует его компиляцию. Он скорее генерирует необычную ловушку.

Когда внутренний цикл заканчивается в первый раз, появляется необычная ловушка, вызывающая деоптимизацию метода.

На второй итерации внешнего цикла метод main перекомпилируется с новым знанием. Теперь JIT имеет больше статистики и больше контекста для компиляции. По какой-то причине теперь он не кэширует значение a[0] в регистре (возможно, потому, что JIT обманут более широким контекстом). Поэтому он генерирует команду addl для обновления массива в памяти, что фактически представляет собой комбинацию загрузки и хранения памяти.

Напротив, во время первой компиляции JIT кэширует значение a[0] в регистре, существует только команда mov для хранения значения в памяти (без нагрузки).

Быстрый цикл (первая итерация):

0x00000000029fc562: mov    %ecx,0x10(%r14)   <<< array store
0x00000000029fc566: mov    %r11d,%edi
0x00000000029fc569: mov    %r9d,%ecx
0x00000000029fc56c: add    %edi,%ecx
0x00000000029fc56e: mov    %ecx,%r11d
0x00000000029fc571: add    $0x10,%r11d       <<< increment in register
0x00000000029fc575: mov    %r11d,0x10(%r14)  <<< array store
0x00000000029fc579: add    $0x11,%ecx
0x00000000029fc57c: mov    %edi,%r11d
0x00000000029fc57f: add    $0x10,%r11d
0x00000000029fc583: cmp    $0x3ffffff2,%r11d
0x00000000029fc58a: jl     0x00000000029fc562

Медленная петля (после перекомпиляции):

0x00000000029fa1b0: addl   $0x10,0x10(%r14)  <<< increment in memory
0x00000000029fa1b5: add    $0x10,%r13d
0x00000000029fa1b9: cmp    $0x3ffffff1,%r13d
0x00000000029fa1c0: jl     0x00000000029fa1b0

Однако эта проблема, похоже, исправлена ​​в JDK 9. Я проверил этот тест с недавней версией JDK 9 Early Access и подтвердил, что он работает как ожидалось:

Time for loop#  0:   104 ms
Time for loop#  1:   101 ms
Time for loop#  2:    91 ms
Time for loop#  3:    63 ms
Time for loop#  4:    60 ms
Time for loop#  5:    60 ms
Time for loop#  6:    59 ms
Time for loop#  7:    55 ms
Time for loop#  8:    57 ms
Time for loop#  9:    59 ms