Объяснение производительности: код работает медленнее после прогрева

Приведенный ниже код выполняет один и тот же расчет 3 раза (он мало что делает: в основном складываются все числа от 1 до 100 м). Первые 2 блока работают примерно в 10 раз быстрее, чем третий. Я запустил эту тестовую программу более 10 раз, и результаты показывают очень небольшую разницу.

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

35974537
36368455
296471550

Может кто-нибудь объяснить, что происходит? (Просто чтобы быть ясно, я не пытаюсь что-то исправить здесь, просто пытаюсь лучше понять, что происходит)

Замечания:

  • GC не запускается во время программы (контролируется с помощью -XX:+PrintGC)
  • протестировано с Oracle JDK версий 1.6.0_30, 1.7.0_02 и 1.7.0_05
  • также проверено со следующими параметрами: -XX:+PrintGC -Xms1000m -Xmx1000m -XX:NewSize=900m => тот же результат
  • если блок помещен в цикл, все прогоны выполняются быстро
  • если блок извлекается в метод, все прогоны выполняются быстро (независимо от того, вызван ли метод 3 раза или в цикле), нет разницы)
public static void main(String... args) {
    //three identical blocks
    {
        long start = System.nanoTime();
        CountByOne c = new CountByOne();
        int sum = 0;
        for (int i = 0; i < 100000000; i++) {
            sum += c.getNext();
        }
        if (sum != c.getSum()) throw new IllegalStateException(); //use sum
        long end = System.nanoTime();
        System.out.println((end - start));
    }
    {
        long start = System.nanoTime();
        CountByOne c = new CountByOne();
        int sum = 0;
        for (int i = 0; i < 100000000; i++) {
            sum += c.getNext();
        }
        if (sum != c.getSum()) throw new IllegalStateException(); //use sum
        long end = System.nanoTime();
        System.out.println((end - start));
    }
    {
        long start = System.nanoTime();
        CountByOne c = new CountByOne();
        int sum = 0;
        for (int i = 0; i < 100000000; i++) {
            sum += c.getNext();
        }
        if (sum != c.getSum()) throw new IllegalStateException(); //use sum
        long end = System.nanoTime();
        System.out.println((end - start));
    }
}

public static class CountByOne {

    private int i = 0;
    private int sum = 0;

    public int getSum() {
        return sum;
    }

    public int getNext() {
        i += 1;
        sum += i;
        return i;
    }
}

2 ответа

Решение

Коротко: Компилятор Just In Time тупой.

Прежде всего вы можете использовать опцию -XX:+PrintCompilation чтобы увидеть, когда JIT что-то делает. Тогда вы увидите что-то вроде этого:

$ java -XX:+PrintCompilation weird
    168    1             weird$CountByOne::getNext (28 bytes)
    174    1 %           weird::main @ 18 (220 bytes)
    279    1 %           weird::main @ -2 (220 bytes)   made not entrant
113727636
    280    2 %           weird::main @ 91 (220 bytes)
106265475
427228826

Итак, вы видите, что метод main компилируется иногда во время первого и второго блока.

Добавление опций -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOption даст вам больше информации о том, что делает JIT. Обратите внимание, это требует hsdis-amd64.so который, кажется, не очень хорошо доступен в распространенных дистрибутивах Linux. Вы можете скомпилировать его самостоятельно из OpenJDK.

То, что вы получаете, это огромный кусок ассемблерного кода для getNext и main.

Для меня в первой компиляции кажется, что на самом деле компилируется только первый блок в main, это можно узнать по номерам строк. Он содержит такие забавные вещи, как это:

  0x00007fa35505fc5b: add    $0x1,%r8           ;*ladd
                                                ; - weird$CountByOne::getNext@6 (line 12)
                                                ; - weird::main@28 (line 31)
  0x00007fa35505fc5f: mov    %r8,0x10(%rbx)     ;*putfield i
                                                ; - weird$CountByOne::getNext@7 (line 12)
                                                ; - weird::main@28 (line 31)
  0x00007fa35505fc63: add    $0x1,%r14          ;*ladd
                                                ; - weird::main@31 (line 31)

(Действительно, это очень долго, из-за раскручивания и наматывания петли)

По-видимому, во время перекомпиляции main, второй И третий блок компилируется. Второй блок там очень похож на первый вариант. (Опять просто выдержка)

 0x00007fa35505f05d: add    $0x1,%r8           ;*ladd
                                                ; - weird$CountByOne::getNext@6 (line 12)
                                                ; - weird::main@101 (line 42)
  0x00007fa35505f061: mov    %r8,0x10(%rbx)     ;*putfield i
                                                ; - weird$CountByOne::getNext@7 (line 12)
                                                ; - weird::main@101 (line 42)
  0x00007fa35505f065: add    $0x1,%r13          ;*ladd

ОДНАКО третий блок компилируется по-разному. Без врезки и раскатывания

На этот раз весь цикл выглядит так:

  0x00007fa35505f20c: xor    %r10d,%r10d
  0x00007fa35505f20f: xor    %r8d,%r8d          ;*lload
                                                ; - weird::main@171 (line 53)
  0x00007fa35505f212: mov    %r8d,0x10(%rsp)
  0x00007fa35505f217: mov    %r10,0x8(%rsp)
  0x00007fa35505f21c: mov    %rbp,%rsi
  0x00007fa35505f21f: callq  0x00007fa355037c60  ; OopMap{rbp=Oop off=580}
                                                ;*invokevirtual getNext
                                                ; - weird::main@174 (line 53)
                                                ;   {optimized virtual_call}
  0x00007fa35505f224: mov    0x8(%rsp),%r10
  0x00007fa35505f229: add    %rax,%r10          ;*ladd
                                                ; - weird::main@177 (line 53)
  0x00007fa35505f22c: mov    0x10(%rsp),%r8d
  0x00007fa35505f231: inc    %r8d               ;*iinc
                                                ; - weird::main@180 (line 52)
  0x00007fa35505f234: cmp    $0x5f5e100,%r8d
  0x00007fa35505f23b: jl     0x00007fa35505f212  ;*if_icmpge
                                                ; - weird::main@168 (line 52)

Я предполагаю, что JIT определил, что эта часть кода используется нечасто, так как он использовал информацию профилирования при выполнении второго блока, и поэтому не сильно оптимизировал ее. Также JIT кажется ленивым в том смысле, что не перекомпилирует один метод после того, как все соответствующие части были скомпилированы. Помните, что первый результат компиляции не содержал исходного кода для второго / третьего блока AT all, поэтому JIT пришлось его перекомпилировать.

Вы должны поместить каждый цикл в другой метод. Причина, по которой вам нужно это сделать, заключается в том, что JIT собирает статистику о том, как выполнялся код, и это используется для оптимизации кода. Однако метод оптимизируется после того, как он вызывается 10000 раз или цикл запускается 10000 раз.

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

Другие вопросы по тегам