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

Странная JIT-пессимизация идиомы цикла

Анализируя результаты недавнего вопроса здесь, я столкнулся с довольно своеобразным явлением: очевидно, дополнительный слой HotSpot JIT-оптимизации фактически замедляет выполнение на моей машине.

Вот код, который я использовал для измерения:

@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@OperationsPerInvocation(Measure.ARRAY_SIZE)
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 5, time = 1)
@State(Scope.Thread)
@Threads(1)
@Fork(2)
public class Measure
{
  public static final int ARRAY_SIZE = 1024;
  private final int[] array = new int[ARRAY_SIZE];

  @Setup public void setup() {
    final Random random = new Random();
    for (int i = 0; i < ARRAY_SIZE; ++i) {
      final int x = random.nextInt();
      array[i] = x == 0? 1 : x;
    }
  }

  @GenerateMicroBenchmark public int normalIndex() {
    final int[] array = this.array;
    int result = 0;
    for (int i = 0; i < array.length; i++) {
      final int j = i & array.length-1;
      final int entry = array[i];
      result ^= entry + j;
    }
    return result;
  }

  @GenerateMicroBenchmark public int maskedIndex() {
    final int[] array = this.array;
    int result = 0;
    for (int i = 0; i < array.length; i++) {
      final int j = i & array.length-1;
      final int entry = array[j];
      result ^= entry + i;
    }
    return result;
  }

  @GenerateMicroBenchmark public int normalWithExitPoint() {
    final int[] array = this.array;
    int result = 0;
    for (int i = 0; i < array.length; i++) {
      final int j = i & array.length-1;
      final int entry = array[i];
      result ^= entry + j;
      if (entry == 0) break;
    }
    return result;
  }

  @GenerateMicroBenchmark public int maskedWithExitPoint() {
    final int[] array = this.array;
    int result = 0;
    for (int i = 0; i < array.length; i++) {
      final int j = i & array.length-1;
      final int entry = array[j];
      result ^= entry + i;
      if (entry == 0) break;
    }
    return result;
  }


}

Код довольно тонкий, поэтому позвольте мне указать важные бит:

  • Варианты "нормального индекса" используют прямую переменную i для индекса массива. HotSpot может легко определить диапазон i по всему циклу и устранить проверку границ массива;
  • индекс вариантов "маскированный индекс" на j, который фактически равен i, но этот факт "скрыт" от HotSpot через операцию маскировки AND;
  • Варианты "с точкой выхода" вводят точку выхода эксклюзионного контура. Важность этого будет объяснена ниже.

Развертка и переупорядочение цикла

Обратите внимание, что оценки проверяют цифры двумя важными способами:

  • с ним связаны связанные с ним служебные данные времени выполнения (сравнение выполняется с условной ветвью);
  • он представляет собой точку выхода цикла, которая может прерывать цикл на любом шаге. Это, как оказалось, имеет важные последствия для применимых оптимизаций JIT.

Проверяя машинный код, испущенный для вышеуказанных четырех методов, я заметил следующее:

  • во всех случаях цикл развернут;
  • в случае normalIndex, который отличается как единственный, не имеющий точек преждевременного выхода цикла, операции всех развернутых шагов изменяются так, что сначала выполняются все выборки массива, за которыми следует XOR-ing all значения в аккумуляторе.

Ожидаемые и фактические результаты измерений

Теперь мы можем классифицировать четыре метода в соответствии с обсуждаемыми функциями:

  • normalIndex не имеет проверок границ и точек выхода петли;
  • normalWithExitPoint не имеет проверок границ и 1 точки выхода;
  • maskedIndex имеет 1 контрольную границу и 1 точку выхода;
  • maskedWithExitPoint имеет 1 контрольную границу и 2 точки выхода.

Очевидное ожидание состоит в том, что приведенный выше список должен представлять методы в порядке убывания производительности; однако, это мои фактические результаты:

Benchmark               Mode   Samples         Mean   Mean error    Units
normalIndex             avgt        20        0.946        0.010    ns/op
normalWithExitPoint     avgt        20        0.807        0.010    ns/op
maskedIndex             avgt        20        0.803        0.007    ns/op
maskedWithExitPoint     avgt        20        1.007        0.009    ns/op
  • normalWithExitPoint и maskedIndex идентичны погрешности измерения по модулю, хотя только последняя имеет проверку границ;
  • наибольшая аномалия наблюдается на normalIndex, которая должна была быть самой быстрой, но значительно медленнее, чем normalWithExitPoint, идентичной ей во всех отношениях, кроме наличия еще одной строки кода, той, которая вводит точку выхода.

Так как normalIndex - единственный метод, который имеет дополнительную корректировку "упорядочения", применяемую к нему, вывод заключается в том, что это является причиной замедления.

Я тестирую:

  • Java HotSpot(TM) 64-Bit Server VM (build 24.0-b56, mixed mode) (Обновление для Java 7 40)
  • OS X Version 10.9.1
  • 2.66 ГГц Intel Core i7

Я успешно воспроизвел результаты на Java 8 EA b118.

Мой вопрос:

Является ли это явление воспроизводимым на других аналогичных машинах? Из вопроса, упомянутого в начале, у меня уже есть намек на то, что по крайней мере некоторые машины не воспроизводят его, поэтому другой результат от того же CPU будет очень интересным.

Обновление 1: больше измерений, основанных на выводах maaartinus

Я собрал следующую таблицу, которая сопоставляет время выполнения с аргументом командной строки -XX:LoopUnrollLimit. Здесь я фокусируюсь только на двух вариантах: с линией if (entry == 0) break; и без нее:

LoopUnrollLimit:   14 15 18 19 22 23 60
withExitPoint:     96 95 95 79 80 80 69   1/100 ns
withoutExitPoint:  94 64 64 63 64 77 75   1/100 ns

Наблюдаются следующие внезапные изменения:

  • при переходе от 14 до 15 вариант withoutExitPoint получает выгодное преобразование LCM 1 и значительно ускоряется. Из-за предела разворачивания цикла все загруженные значения вписываются в регистры;

  • на 18- > 19, вариант withExitPoint получает ускорение, меньшее, чем указано выше;

  • на 22- > 23, вариант withoutExitPoint замедляется. На этом этапе я вижу, как разброс в местах стека, как описано в ответе maaartinus, начинает происходить.

По умолчанию loopUnrollLimit для моей установки - 60, поэтому я представляю результаты в последнем столбце.


1 LCM = Движение локального кода. Это преобразование, которое приводит к тому, что весь доступ к массиву происходит сверху, а затем обработка загруженных значений.

Обновление 2: на самом деле это известная проблема с сообщением

https://bugs.openjdk.java.net/browse/JDK-7101232



Приложение: развернутый и переупорядоченный цикл normalIndex в машинный код

0x00000001044a37c0: mov    ecx,eax
0x00000001044a37c2: and    ecx,esi            ;*iand
                                              ; - org.sample.Measure::[email protected] (line 44)
0x00000001044a37c4: mov    rbp,QWORD PTR [rsp+0x28]  ;*iload_3
                                              ; - org.sample.Measure::[email protected] (line 44)
0x00000001044a37c9: add    ecx,DWORD PTR [rbp+rsi*4+0x10]
0x00000001044a37cd: xor    ecx,r8d
0x00000001044a37d0: mov    DWORD PTR [rsp],ecx
0x00000001044a37d3: mov    r10d,esi
0x00000001044a37d6: add    r10d,0xf
0x00000001044a37da: and    r10d,eax
0x00000001044a37dd: mov    r8d,esi
0x00000001044a37e0: add    r8d,0x7
0x00000001044a37e4: and    r8d,eax
0x00000001044a37e7: mov    DWORD PTR [rsp+0x4],r8d
0x00000001044a37ec: mov    r11d,esi
0x00000001044a37ef: add    r11d,0x6
0x00000001044a37f3: and    r11d,eax
0x00000001044a37f6: mov    DWORD PTR [rsp+0x8],r11d
0x00000001044a37fb: mov    r8d,esi
0x00000001044a37fe: add    r8d,0x5
0x00000001044a3802: and    r8d,eax
0x00000001044a3805: mov    DWORD PTR [rsp+0xc],r8d
0x00000001044a380a: mov    r11d,esi
0x00000001044a380d: inc    r11d
0x00000001044a3810: and    r11d,eax
0x00000001044a3813: mov    DWORD PTR [rsp+0x10],r11d
0x00000001044a3818: mov    r8d,esi
0x00000001044a381b: add    r8d,0x2
0x00000001044a381f: and    r8d,eax
0x00000001044a3822: mov    DWORD PTR [rsp+0x14],r8d
0x00000001044a3827: mov    r11d,esi
0x00000001044a382a: add    r11d,0x3
0x00000001044a382e: and    r11d,eax
0x00000001044a3831: mov    r9d,esi
0x00000001044a3834: add    r9d,0x4
0x00000001044a3838: and    r9d,eax
0x00000001044a383b: mov    r8d,esi
0x00000001044a383e: add    r8d,0x8
0x00000001044a3842: and    r8d,eax
0x00000001044a3845: mov    DWORD PTR [rsp+0x18],r8d
0x00000001044a384a: mov    r8d,esi
0x00000001044a384d: add    r8d,0x9
0x00000001044a3851: and    r8d,eax
0x00000001044a3854: mov    ebx,esi
0x00000001044a3856: add    ebx,0xa
0x00000001044a3859: and    ebx,eax
0x00000001044a385b: mov    ecx,esi
0x00000001044a385d: add    ecx,0xb
0x00000001044a3860: and    ecx,eax
0x00000001044a3862: mov    edx,esi
0x00000001044a3864: add    edx,0xc
0x00000001044a3867: and    edx,eax
0x00000001044a3869: mov    edi,esi
0x00000001044a386b: add    edi,0xd
0x00000001044a386e: and    edi,eax
0x00000001044a3870: mov    r13d,esi
0x00000001044a3873: add    r13d,0xe
0x00000001044a3877: and    r13d,eax
0x00000001044a387a: movsxd r14,esi
0x00000001044a387d: add    r10d,DWORD PTR [rbp+r14*4+0x4c]
0x00000001044a3882: mov    DWORD PTR [rsp+0x24],r10d
0x00000001044a3887: mov    QWORD PTR [rsp+0x28],rbp
0x00000001044a388c: mov    ebp,DWORD PTR [rsp+0x4]
0x00000001044a3890: mov    r10,QWORD PTR [rsp+0x28]
0x00000001044a3895: add    ebp,DWORD PTR [r10+r14*4+0x2c]
0x00000001044a389a: mov    DWORD PTR [rsp+0x4],ebp
0x00000001044a389e: mov    r10d,DWORD PTR [rsp+0x8]
0x00000001044a38a3: mov    rbp,QWORD PTR [rsp+0x28]
0x00000001044a38a8: add    r10d,DWORD PTR [rbp+r14*4+0x28]
0x00000001044a38ad: mov    DWORD PTR [rsp+0x8],r10d
0x00000001044a38b2: mov    r10d,DWORD PTR [rsp+0xc]
0x00000001044a38b7: add    r10d,DWORD PTR [rbp+r14*4+0x24]
0x00000001044a38bc: mov    DWORD PTR [rsp+0xc],r10d
0x00000001044a38c1: mov    r10d,DWORD PTR [rsp+0x10]
0x00000001044a38c6: add    r10d,DWORD PTR [rbp+r14*4+0x14]
0x00000001044a38cb: mov    DWORD PTR [rsp+0x10],r10d
0x00000001044a38d0: mov    r10d,DWORD PTR [rsp+0x14]
0x00000001044a38d5: add    r10d,DWORD PTR [rbp+r14*4+0x18]
0x00000001044a38da: mov    DWORD PTR [rsp+0x14],r10d
0x00000001044a38df: add    r13d,DWORD PTR [rbp+r14*4+0x48]
0x00000001044a38e4: add    r11d,DWORD PTR [rbp+r14*4+0x1c]
0x00000001044a38e9: add    r9d,DWORD PTR [rbp+r14*4+0x20]
0x00000001044a38ee: mov    r10d,DWORD PTR [rsp+0x18]
0x00000001044a38f3: add    r10d,DWORD PTR [rbp+r14*4+0x30]
0x00000001044a38f8: mov    DWORD PTR [rsp+0x18],r10d
0x00000001044a38fd: add    r8d,DWORD PTR [rbp+r14*4+0x34]
0x00000001044a3902: add    ebx,DWORD PTR [rbp+r14*4+0x38]
0x00000001044a3907: add    ecx,DWORD PTR [rbp+r14*4+0x3c]
0x00000001044a390c: add    edx,DWORD PTR [rbp+r14*4+0x40]
0x00000001044a3911: add    edi,DWORD PTR [rbp+r14*4+0x44]
0x00000001044a3916: mov    r10d,DWORD PTR [rsp+0x10]
0x00000001044a391b: xor    r10d,DWORD PTR [rsp]
0x00000001044a391f: mov    ebp,DWORD PTR [rsp+0x14]
0x00000001044a3923: xor    ebp,r10d
0x00000001044a3926: xor    r11d,ebp
0x00000001044a3929: xor    r9d,r11d
0x00000001044a392c: xor    r9d,DWORD PTR [rsp+0xc]
0x00000001044a3931: xor    r9d,DWORD PTR [rsp+0x8]
0x00000001044a3936: xor    r9d,DWORD PTR [rsp+0x4]
0x00000001044a393b: mov    r10d,DWORD PTR [rsp+0x18]
0x00000001044a3940: xor    r10d,r9d
0x00000001044a3943: xor    r8d,r10d
0x00000001044a3946: xor    ebx,r8d
0x00000001044a3949: xor    ecx,ebx
0x00000001044a394b: xor    edx,ecx
0x00000001044a394d: xor    edi,edx
0x00000001044a394f: xor    r13d,edi
0x00000001044a3952: mov    r8d,DWORD PTR [rsp+0x24]
0x00000001044a3957: xor    r8d,r13d           ;*ixor
                                              ; - org.sample.Measure::[email protected] (line 46)
0x00000001044a395a: add    esi,0x10           ;*iinc
                                              ; - org.sample.Measure::[email protected] (line 43)
0x00000001044a395d: cmp    esi,DWORD PTR [rsp+0x20]
0x00000001044a3961: jl     0x00000001044a37c0  ;*if_icmpge
                                              ; - org.sample.Measure::[email protected] (line 43)
4b9b3361

Ответ 1

Причина, по которой JITC пытается сгруппировать все вместе, для меня довольно неясна. AFAIK существуют (были?) Архитектуры, для которых группировка двух нагрузок ведет к лучшей производительности (думаю, некоторые ранние Pentium).

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

Код вычисляет 16 временных значений, по одному на итерацию

int j = i & array.length-1;
int entry = array[i];
int tmp = entry + j;
result ^= tmp;

Каждое вычисление довольно тривиально, один И, один LOAD и один ADD. Значения должны отображаться в регистры, но их недостаточно. Значения должны быть сохранены и загружены позже.

Это происходит для 7 из 16 регистров и значительно увеличивает затраты.

Update

Я не очень уверен в проверке этого, используя -XX:LoopUnrollLimit:

LoopUnrollLimit Benchmark   Mean   Mean error    Units

 8 ..normalIndex           0.902        0.004    ns/op
 8 ..normalWithExitPoint   0.913        0.005    ns/op
 8 ..maskedIndex           0.918        0.006    ns/op
 8 ..maskedWithExitPoint   0.996        0.008    ns/op

16 ..normalIndex           0.769        0.003    ns/op
16 ..normalWithExitPoint   0.930        0.004    ns/op
16 ..maskedIndex           0.937        0.004    ns/op
16 ..maskedWithExitPoint   1.012        0.003    ns/op

32 ..normalIndex           0.814        0.003    ns/op
32 ..normalWithExitPoint   0.816        0.005    ns/op
32 ..maskedIndex           0.838        0.003    ns/op
32 ..maskedWithExitPoint   0.978        0.002    ns/op

 - ..normalIndex           0.830        0.002    ns/op
 - ..normalWithExitPoint   0.683        0.002    ns/op
 - ..maskedIndex           0.791        0.005    ns/op
 - ..maskedWithExitPoint   0.908        0.003    ns/op

Предел 16 делает normalIndex самым быстрым вариантом, что указывает на то, что я был прав с "штрафным штрафом". Bur согласно Марко, сгенерированная сборка изменяется с пределом разворота и в других аспектах, поэтому все сложнее.