Чому 2 * (i * i) швидше, ніж 2 * i * i на Java?


855

Наступна програма Java займає в середньому між 0,50 сек і 0,55 сек:

public static void main(String[] args) {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * (i * i);
    }
    System.out.println((double) (System.nanoTime() - startTime) / 1000000000 + " s");
    System.out.println("n = " + n);
}

Якщо я заміню 2 * (i * i)з 2 * i * i, це займає від 0,60 до 0,65 секунд для запуску. Як це?

Я запускав кожну версію програми 15 разів, чергуючи дві. Ось результати:

 2*(i*i)  |  2*i*i
----------+----------
0.5183738 | 0.6246434
0.5298337 | 0.6049722
0.5308647 | 0.6603363
0.5133458 | 0.6243328
0.5003011 | 0.6541802
0.5366181 | 0.6312638
0.515149  | 0.6241105
0.5237389 | 0.627815
0.5249942 | 0.6114252
0.5641624 | 0.6781033
0.538412  | 0.6393969
0.5466744 | 0.6608845
0.531159  | 0.6201077
0.5048032 | 0.6511559
0.5232789 | 0.6544526

Найшвидший біг 2 * i * iзайняв більше часу, ніж найповільніший 2 * (i * i). Якби вони мали однакову ефективність, ймовірність цього стане меншою, ніж 1/2^15 * 100% = 0.00305%.


5
Я отримую подібні результати (трохи інші цифри, але, безумовно, помітний і стійкий розрив, безумовно, більше, ніж помилка вибірки)
Кріаз

29
Також дивіться: stackoverflow.com/questions/504103 / ...
lexicore

3
@Krease Добре, що ти зловив мою помилку. Відповідно до нового тесту, який я бігав 2 * i * iповільніше. Я також спробую бігти з Грааль.
Jorn Vernee

5
@nullpointer Щоб дійсно з'ясувати, чому один швидше, ніж інший, нам доведеться отримати розбирання або Ідеальні графіки для цих методів. Асемблер дуже дратує намагатися розібратися, тому я намагаюся отримати збірку налагодження OpenJDK, яка може виводити приємні графіки.
Джорн Верні

4
Ви можете перейменувати своє запитання на " Чому i * i * 2швидше, ніж 2 * i * i? ", Щоб зрозуміти, що питання впорядковано.
Cœur

Відповіді:


1202

Існує незначна різниця в упорядкуванні байт-коду.

2 * (i * i):

     iconst_2
     iload0
     iload0
     imul
     imul
     iadd

vs 2 * i * i:

     iconst_2
     iload0
     imul
     iload0
     imul
     iadd

На перший погляд це не повинно змінити значення; якщо що-небудь, друга версія є більш оптимальною, оскільки вона використовує один слот менше.

Тому нам потрібно заглибитися в нижчий рівень (JIT) 1 .

Пам’ятайте, що JIT прагне розкручувати невеликі петлі дуже агресивно. Дійсно, ми спостерігаємо 16-кратну розгортання 2 * (i * i)справи:

030   B2: # B2 B3 <- B1 B2  Loop: B2-B2 inner main of N18 Freq: 1e+006
030     addl    R11, RBP    # int
033     movl    RBP, R13    # spill
036     addl    RBP, #14    # int
039     imull   RBP, RBP    # int
03c     movl    R9, R13 # spill
03f     addl    R9, #13 # int
043     imull   R9, R9  # int
047     sall    RBP, #1
049     sall    R9, #1
04c     movl    R8, R13 # spill
04f     addl    R8, #15 # int
053     movl    R10, R8 # spill
056     movdl   XMM1, R8    # spill
05b     imull   R10, R8 # int
05f     movl    R8, R13 # spill
062     addl    R8, #12 # int
066     imull   R8, R8  # int
06a     sall    R10, #1
06d     movl    [rsp + #32], R10    # spill
072     sall    R8, #1
075     movl    RBX, R13    # spill
078     addl    RBX, #11    # int
07b     imull   RBX, RBX    # int
07e     movl    RCX, R13    # spill
081     addl    RCX, #10    # int
084     imull   RCX, RCX    # int
087     sall    RBX, #1
089     sall    RCX, #1
08b     movl    RDX, R13    # spill
08e     addl    RDX, #8 # int
091     imull   RDX, RDX    # int
094     movl    RDI, R13    # spill
097     addl    RDI, #7 # int
09a     imull   RDI, RDI    # int
09d     sall    RDX, #1
09f     sall    RDI, #1
0a1     movl    RAX, R13    # spill
0a4     addl    RAX, #6 # int
0a7     imull   RAX, RAX    # int
0aa     movl    RSI, R13    # spill
0ad     addl    RSI, #4 # int
0b0     imull   RSI, RSI    # int
0b3     sall    RAX, #1
0b5     sall    RSI, #1
0b7     movl    R10, R13    # spill
0ba     addl    R10, #2 # int
0be     imull   R10, R10    # int
0c2     movl    R14, R13    # spill
0c5     incl    R14 # int
0c8     imull   R14, R14    # int
0cc     sall    R10, #1
0cf     sall    R14, #1
0d2     addl    R14, R11    # int
0d5     addl    R14, R10    # int
0d8     movl    R10, R13    # spill
0db     addl    R10, #3 # int
0df     imull   R10, R10    # int
0e3     movl    R11, R13    # spill
0e6     addl    R11, #5 # int
0ea     imull   R11, R11    # int
0ee     sall    R10, #1
0f1     addl    R10, R14    # int
0f4     addl    R10, RSI    # int
0f7     sall    R11, #1
0fa     addl    R11, R10    # int
0fd     addl    R11, RAX    # int
100     addl    R11, RDI    # int
103     addl    R11, RDX    # int
106     movl    R10, R13    # spill
109     addl    R10, #9 # int
10d     imull   R10, R10    # int
111     sall    R10, #1
114     addl    R10, R11    # int
117     addl    R10, RCX    # int
11a     addl    R10, RBX    # int
11d     addl    R10, R8 # int
120     addl    R9, R10 # int
123     addl    RBP, R9 # int
126     addl    RBP, [RSP + #32 (32-bit)]   # int
12a     addl    R13, #16    # int
12e     movl    R11, R13    # spill
131     imull   R11, R13    # int
135     sall    R11, #1
138     cmpl    R13, #999999985
13f     jl     B2   # loop end  P=1.000000 C=6554623.000000

Ми бачимо, що є 1 регістр, який "пролився" на стек.

А для 2 * i * iверсії:

05a   B3: # B2 B4 <- B1 B2  Loop: B3-B2 inner main of N18 Freq: 1e+006
05a     addl    RBX, R11    # int
05d     movl    [rsp + #32], RBX    # spill
061     movl    R11, R8 # spill
064     addl    R11, #15    # int
068     movl    [rsp + #36], R11    # spill
06d     movl    R11, R8 # spill
070     addl    R11, #14    # int
074     movl    R10, R9 # spill
077     addl    R10, #16    # int
07b     movdl   XMM2, R10   # spill
080     movl    RCX, R9 # spill
083     addl    RCX, #14    # int
086     movdl   XMM1, RCX   # spill
08a     movl    R10, R9 # spill
08d     addl    R10, #12    # int
091     movdl   XMM4, R10   # spill
096     movl    RCX, R9 # spill
099     addl    RCX, #10    # int
09c     movdl   XMM6, RCX   # spill
0a0     movl    RBX, R9 # spill
0a3     addl    RBX, #8 # int
0a6     movl    RCX, R9 # spill
0a9     addl    RCX, #6 # int
0ac     movl    RDX, R9 # spill
0af     addl    RDX, #4 # int
0b2     addl    R9, #2  # int
0b6     movl    R10, R14    # spill
0b9     addl    R10, #22    # int
0bd     movdl   XMM3, R10   # spill
0c2     movl    RDI, R14    # spill
0c5     addl    RDI, #20    # int
0c8     movl    RAX, R14    # spill
0cb     addl    RAX, #32    # int
0ce     movl    RSI, R14    # spill
0d1     addl    RSI, #18    # int
0d4     movl    R13, R14    # spill
0d7     addl    R13, #24    # int
0db     movl    R10, R14    # spill
0de     addl    R10, #26    # int
0e2     movl    [rsp + #40], R10    # spill
0e7     movl    RBP, R14    # spill
0ea     addl    RBP, #28    # int
0ed     imull   RBP, R11    # int
0f1     addl    R14, #30    # int
0f5     imull   R14, [RSP + #36 (32-bit)]   # int
0fb     movl    R10, R8 # spill
0fe     addl    R10, #11    # int
102     movdl   R11, XMM3   # spill
107     imull   R11, R10    # int
10b     movl    [rsp + #44], R11    # spill
110     movl    R10, R8 # spill
113     addl    R10, #10    # int
117     imull   RDI, R10    # int
11b     movl    R11, R8 # spill
11e     addl    R11, #8 # int
122     movdl   R10, XMM2   # spill
127     imull   R10, R11    # int
12b     movl    [rsp + #48], R10    # spill
130     movl    R10, R8 # spill
133     addl    R10, #7 # int
137     movdl   R11, XMM1   # spill
13c     imull   R11, R10    # int
140     movl    [rsp + #52], R11    # spill
145     movl    R11, R8 # spill
148     addl    R11, #6 # int
14c     movdl   R10, XMM4   # spill
151     imull   R10, R11    # int
155     movl    [rsp + #56], R10    # spill
15a     movl    R10, R8 # spill
15d     addl    R10, #5 # int
161     movdl   R11, XMM6   # spill
166     imull   R11, R10    # int
16a     movl    [rsp + #60], R11    # spill
16f     movl    R11, R8 # spill
172     addl    R11, #4 # int
176     imull   RBX, R11    # int
17a     movl    R11, R8 # spill
17d     addl    R11, #3 # int
181     imull   RCX, R11    # int
185     movl    R10, R8 # spill
188     addl    R10, #2 # int
18c     imull   RDX, R10    # int
190     movl    R11, R8 # spill
193     incl    R11 # int
196     imull   R9, R11 # int
19a     addl    R9, [RSP + #32 (32-bit)]    # int
19f     addl    R9, RDX # int
1a2     addl    R9, RCX # int
1a5     addl    R9, RBX # int
1a8     addl    R9, [RSP + #60 (32-bit)]    # int
1ad     addl    R9, [RSP + #56 (32-bit)]    # int
1b2     addl    R9, [RSP + #52 (32-bit)]    # int
1b7     addl    R9, [RSP + #48 (32-bit)]    # int
1bc     movl    R10, R8 # spill
1bf     addl    R10, #9 # int
1c3     imull   R10, RSI    # int
1c7     addl    R10, R9 # int
1ca     addl    R10, RDI    # int
1cd     addl    R10, [RSP + #44 (32-bit)]   # int
1d2     movl    R11, R8 # spill
1d5     addl    R11, #12    # int
1d9     imull   R13, R11    # int
1dd     addl    R13, R10    # int
1e0     movl    R10, R8 # spill
1e3     addl    R10, #13    # int
1e7     imull   R10, [RSP + #40 (32-bit)]   # int
1ed     addl    R10, R13    # int
1f0     addl    RBP, R10    # int
1f3     addl    R14, RBP    # int
1f6     movl    R10, R8 # spill
1f9     addl    R10, #16    # int
1fd     cmpl    R10, #999999985
204     jl     B2   # loop end  P=1.000000 C=7419903.000000

Тут ми спостерігаємо набагато більше "розливу" та більше доступу до стеку [RSP + ...], завдяки більш проміжним результатам, які потрібно зберегти.

Таким чином, відповідь на питання проста: 2 * (i * i)швидше, ніж 2 * i * iчерез те, що JIT генерує більш оптимальний код складання для першого випадку.


Але звичайно очевидно, що ні перша, ні друга версія не є корисною; цикл може дійсно отримати вигоду від векторизації, оскільки будь-який процесор x86-64 має щонайменше підтримку SSE2.

Отже, це питання оптимізатора; як це часто буває, він надто агресивно розгортається і стріляє в ногу, весь час пропускаючи різні можливості.

Насправді сучасні процесори x86-64 розбивають інструкції далі на micro-ops (µops) і з такими функціями, як перейменування реєстру, µop кеші та буфери циклу, оптимізація циклу вимагає набагато більше вишуканості, ніж проста розгортання для досягнення оптимальної продуктивності. Відповідно до посібника з оптимізації Agner Fog :

Коефіцієнт підсилення внаслідок кешу µop може бути досить значним, якщо середня довжина інструкції перевищує 4 байти. Можна розглядати наступні методи оптимізації використання кешу µop:

  • Переконайтеся, що критичні петлі достатньо малі, щоб вміститись у кеш-пам'ять µop.
  • Вирівняйте найбільш критичні записи та функції функцій за 32.
  • Уникайте зайвої розмотки циклу.
  • Уникайте інструкцій, які мають додатковий час завантаження
    . . .

Що стосується часу завантаження - навіть найшвидший хіт L1D коштує 4 цикли , додатковий реєстр та µop, так що так, навіть кілька доступів до пам'яті зашкодить продуктивності в тісних петлях.

Але повернемось до можливості векторизації - щоб побачити, наскільки швидко вона може бути, ми можемо скласти аналогічну програму C із GCC , яка відверто векторизує її (показано AVX2, схожий SSE2) 2 :

  vmovdqa ymm0, YMMWORD PTR .LC0[rip]
  vmovdqa ymm3, YMMWORD PTR .LC1[rip]
  xor eax, eax
  vpxor xmm2, xmm2, xmm2
.L2:
  vpmulld ymm1, ymm0, ymm0
  inc eax
  vpaddd ymm0, ymm0, ymm3
  vpslld ymm1, ymm1, 1
  vpaddd ymm2, ymm2, ymm1
  cmp eax, 125000000      ; 8 calculations per iteration
  jne .L2
  vmovdqa xmm0, xmm2
  vextracti128 xmm2, ymm2, 1
  vpaddd xmm2, xmm0, xmm2
  vpsrldq xmm0, xmm2, 8
  vpaddd xmm0, xmm2, xmm0
  vpsrldq xmm1, xmm0, 4
  vpaddd xmm0, xmm0, xmm1
  vmovd eax, xmm0
  vzeroupper

З часом виконання:

  • SSE: 0,24 с, або в 2 рази швидше.
  • AVX: 0,15 с, або в 3 рази швидше.
  • AVX2: 0,08 с, або в 5 разів швидше.

1 Для отримання результату збірки, створеного JIT, отримайте налагодження JVM та запустіть з-XX:+PrintOptoAssembly

2 Версія C складена з -fwrapvпрапором, що дозволяє GCC трактувати підписане ціле число як переповнення двох доповнень.


11
Найбільшою проблемою, з якою стикається оптимізатор на прикладі C, є невизначена поведінка, на яку посилається переповнення підписаного цілого числа. Що в іншому випадку, ймовірно, призведе до простого завантаження константи, оскільки весь цикл можна обчислити під час компіляції.
Деймон

44
@Damon Чому невизначена поведінка буде проблемою для оптимізатора? Якщо оптимізатор бачить, що він переповнює під час спроби обчислити результат, це просто означає, що він може оптимізувати його, як хоче, оскільки поведінка не визначена.

13
@Runemoro: якщо оптимізатор доведе, що виклик функції неминуче призведе до невизначеної поведінки, він може вирішити припустити, що функція ніколи не буде викликатися, і не випромінює для неї жодного тіла. Або випромінюйте лише retінструкцію, або випромінюйте мітку, а не інструкцію ret, тому виконання просто пропадає. GCC насправді так поводиться, що це було іноді, коли він стикається з UB. Наприклад: навіщо ret зникає з оптимізацією? . Ви обов'язково хочете скласти добре сформований код, щоб переконатися, що asm є здоровим.
Пітер Кордес

8
Це, мабуть, лише вузьке місце з пропускною спроможністю загального типу через неефективного коду. Це навіть не використання LEA як маточника для mov/ add-immediate. наприклад movl RBX, R9/ addl RBX, #8повинен бути leal ebx, [r9 + 8], 1 взагалі для копіювання та додавання. Або leal ebx, [r9 + r9 + 16]робити ebx = 2*(r9+8). Тож так, розгортання до місця розливу є німим, і так є наївний кодеген мозкового коду, який не використовує цілісні тотожності та асоціативну цілочислову математику.
Пітер Кордес

7
Векторизація для послідовного скорочення була відключена в C2 ( bugs.openjdk.java.net/browse/JDK-8078563 ), але зараз розглядається для повторного включення ( bugs.openjdk.java.net/browse/JDK-8188313 ).
pron

131

Коли множення є 2 * (i * i), JVM може вивести множення за 2допомогою циклу, в результаті чого цей еквівалентний, але більш ефективний код:

int n = 0;
for (int i = 0; i < 1000000000; i++) {
    n += i * i;
}
n *= 2;

але коли множення є (2 * i) * i, JVM не оптимізує його, оскільки множення на константу вже не є правильним перед додаванням.

Ось кілька причин, чому я думаю, що це так:

  • Додавання if (n == 0) n = 1заяви на початку циклу призводить до того, що обидві версії є настільки ж ефективними, оскільки розподілення множення більше не гарантує, що результат буде однаковим
  • Оптимізована версія (шляхом розмноження на множення на 2) рівно стільки ж, скільки і 2 * (i * i)версія

Ось тестовий код, за допомогою якого я робив ці висновки:

public static void main(String[] args) {
    long fastVersion = 0;
    long slowVersion = 0;
    long optimizedVersion = 0;
    long modifiedFastVersion = 0;
    long modifiedSlowVersion = 0;

    for (int i = 0; i < 10; i++) {
        fastVersion += fastVersion();
        slowVersion += slowVersion();
        optimizedVersion += optimizedVersion();
        modifiedFastVersion += modifiedFastVersion();
        modifiedSlowVersion += modifiedSlowVersion();
    }

    System.out.println("Fast version: " + (double) fastVersion / 1000000000 + " s");
    System.out.println("Slow version: " + (double) slowVersion / 1000000000 + " s");
    System.out.println("Optimized version: " + (double) optimizedVersion / 1000000000 + " s");
    System.out.println("Modified fast version: " + (double) modifiedFastVersion / 1000000000 + " s");
    System.out.println("Modified slow version: " + (double) modifiedSlowVersion / 1000000000 + " s");
}

private static long fastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long slowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

private static long optimizedVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += i * i;
    }
    n *= 2;
    return System.nanoTime() - startTime;
}

private static long modifiedFastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long modifiedSlowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

І ось результати:

Fast version: 5.7274411 s
Slow version: 7.6190804 s
Optimized version: 5.1348007 s
Modified fast version: 7.1492705 s
Modified slow version: 7.2952668 s

3
Думаю, на оптимізованому n *= 2000000000;
версії

4
@StefansArya - Ні. Розглянемо випадок, коли межа становить 4, і ми намагаємося обчислити 2*1*1 + 2*2*2 + 2*3*3. Очевидно, що обчислення 1*1 + 2*2 + 3*3та множення на 2 є правильним, тоді як множення на 8 не було б.
Мартін Боннер підтримує Моніку

5
Математичне рівняння було саме таким 2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²). Це було дуже просто, і я просто забув це, оскільки приріст петлі.
StefansArya

5
Якщо ви роздрукуєте збірку за допомогою налагодження jvm, це здається невірним. Ви побачите купу шал ..., №1, помножених на 2, у циклі. Цікаво, що повільніша версія, схоже, не має множин у циклі.
Даніель Берлін

2
Чому JVM може вивести коефіцієнт 2 з, 2 * (i * i)але не з (2 * i) * i? Я думаю, що вони рівноцінні (це може бути моїм поганим припущенням). Якщо так, не став би JVM канонізувати вираз перед оптимізацією?
RedSpikeyThing

41

Коди байтів: https://cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html Переглядач байтових кодів: https://github.com/Konloch/bytecode-viewer

У моєму JDK (Windows 10 64 біт, 1.8.0_65-b17) я можу відтворити та пояснити:

public static void main(String[] args) {
    int repeat = 10;
    long A = 0;
    long B = 0;
    for (int i = 0; i < repeat; i++) {
        A += test();
        B += testB();
    }

    System.out.println(A / repeat + " ms");
    System.out.println(B / repeat + " ms");
}


private static long test() {
    int n = 0;
    for (int i = 0; i < 1000; i++) {
        n += multi(i);
    }
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 1000000000; i++) {
        n += multi(i);
    }
    long ms = (System.currentTimeMillis() - startTime);
    System.out.println(ms + " ms A " + n);
    return ms;
}


private static long testB() {
    int n = 0;
    for (int i = 0; i < 1000; i++) {
        n += multiB(i);
    }
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 1000000000; i++) {
        n += multiB(i);
    }
    long ms = (System.currentTimeMillis() - startTime);
    System.out.println(ms + " ms B " + n);
    return ms;
}

private static int multiB(int i) {
    return 2 * (i * i);
}

private static int multi(int i) {
    return 2 * i * i;
}

Вихід:

...
405 ms A 785527736
327 ms B 785527736
404 ms A 785527736
329 ms B 785527736
404 ms A 785527736
328 ms B 785527736
404 ms A 785527736
328 ms B 785527736
410 ms
333 ms

Так чому? Байтовий код такий:

 private static multiB(int arg0) { // 2 * (i * i)
     <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>

     L1 {
         iconst_2
         iload0
         iload0
         imul
         imul
         ireturn
     }
     L2 {
     }
 }

 private static multi(int arg0) { // 2 * i * i
     <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>

     L1 {
         iconst_2
         iload0
         imul
         iload0
         imul
         ireturn
     }
     L2 {
     }
 }

Різниця полягає в: З дужками ( 2 * (i * i)):

  • push const стек
  • натисніть локальний на стек
  • натисніть локальний на стек
  • помножити верхню частину стека
  • помножити верхню частину стека

Без дужок ( 2 * i * i):

  • push const стек
  • натисніть локальний на стек
  • помножити верхню частину стека
  • натисніть локальний на стек
  • помножити верхню частину стека

Завантаження всіх на стек, а потім повернення вниз - швидше, ніж перемикання між встановленням стека та роботою над ним.


Але чому "push-push-множити-множувати" швидше, ніж push-множити-push-множити?
m0skit0

35

Касперд запитав у коментарі прийняту відповідь:

У прикладах Java та C використовуються досить різні імена регістрів. Чи обидва приклади використовують AMD64 ISA?

xor edx, edx
xor eax, eax
.L2:
mov ecx, edx
imul ecx, edx
add edx, 1
lea eax, [rax+rcx*2]
cmp edx, 1000000000
jne .L2

У мене немає достатньої репутації, щоб відповісти на це в коментарях, але це та сама ISA. Варто зазначити, що версія GCC використовує 32-бітну цілочисельну логіку, а компільована версія JVM використовує 64-бітну цілочисельну логіку всередині.

R8 до R15 - це лише нові регістри X86_64 . EAX до EDX - це нижні частини регістрів загального призначення RAX до RDX. Важливою частиною відповіді є те, що версія GCC не розгортається. Він просто виконує один раунд циклу на фактичний цикл машинного коду. Хоча версія JVM має 16 раундів циклу в одному фізичному циклі (на основі відповіді на рустик, я не повторно тлумачив збірку). Це одна з причин, чому застосовується більше регістрів, оскільки тіло петлі насправді в 16 разів довше.


2
Занадто поганий gcc не помічає, що він може *2витікати з циклу. Хоча в цьому випадку це навіть не виграш, тому що це робиться безкоштовно з LEA. На процесорах Intel lea eax, [rax+rcx*2]є така ж затримка 1с, що і add eax,ecx. Однак на процесорах AMD будь-який масштабований індекс збільшує затримку LEA до 2 циклів. Таким чином, ланцюг залежної від циклу подовжується до 2 циклів, стаючи вузьким місцем на Ryzen. ( imul ecx,edxпропускна здатність становить 1 на добу для Ryzen та Intel).
Пітер Кордес

31

Хоча це не було пов'язано безпосередньо з оточенням питання, лише для цікавості, я те саме перевірив у режимі випуску .NET Core 2.1, x64.

Ось цікавий результат, що підтверджує подібні явища (навпаки), що відбуваються над темною стороною сили. Код:

static void Main(string[] args)
{
    Stopwatch watch = new Stopwatch();

    Console.WriteLine("2 * (i * i)");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * (i * i);
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds} ms");
    }

    Console.WriteLine();
    Console.WriteLine("2 * i * i");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * i * i;
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds}ms");
    }
}

Результат:

2 * (i * i)

  • результат: 119860736, 438 мс
  • результат: 119860736, 433 мс
  • результат: 119860736, 437 мс
  • результат: 119860736, 435 мс
  • результат: 119860736, 436 мс
  • результат: 119860736, 435 мс
  • результат: 119860736, 435 мс
  • результат: 119860736, 439 мс
  • результат: 119860736, 436 мс
  • результат: 119860736, 437 мс

2 * i * i

  • результат: 119860736, 417 мс
  • результат: 119860736, 417 мс
  • результат: 119860736, 417 мс
  • результат: 119860736, 418 мс
  • результат: 119860736, 418 мс
  • результат: 119860736, 417 мс
  • результат: 119860736, 418 мс
  • результат: 119860736, 416 мс
  • результат: 119860736, 417 мс
  • результат: 119860736, 418 мс

1
Хоча це і не є відповіддю на питання, але це додає значення. Якщо говорити, якщо щось важливе для вашої публікації, будь ласка , введіть її в посаду, а не посилаючись на ресурс поза сайтом. Посилання відмирають.
Джаред Сміт

1
@JaredSmith Дякую за відгук. З огляду на посилання, яке ви згадуєте, є посиланням "результат", це зображення не є джерелом за межами сайту. Я завантажив його у stackoverflow через власну панель.
Ünsal Ersöz

1
Це посилання на imgur, так що так, це не важливо, як ви додали посилання. Я не бачу, що так складно з копіюванням вставлення деякого консольного виходу.
Джаред Сміт

5
За винятком цього навпаки
леппі

2
@SamB все ще знаходиться в домені imgur.com, а це означає, що він виживе лише до тих пір, як imgur.
p91paul

21

Я отримав подібні результати:

2 * (i * i): 0.458765943 s, n=119860736
2 * i * i: 0.580255126 s, n=119860736

Я отримав результати SAME, якщо обидва циклу були в одній програмі або кожен був в окремому файлі .java / .class, виконаному в окремому виконанні.

Нарешті, ось javap -c -v <.java>декомпіляція кожного:

     3: ldc           #3                  // String 2 * (i * i):
     5: invokevirtual #4                  // Method java/io/PrintStream.print:(Ljava/lang/String;)V
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
    11: lstore_1
    12: iconst_0
    13: istore_3
    14: iconst_0
    15: istore        4
    17: iload         4
    19: ldc           #6                  // int 1000000000
    21: if_icmpge     40
    24: iload_3
    25: iconst_2
    26: iload         4
    28: iload         4
    30: imul
    31: imul
    32: iadd
    33: istore_3
    34: iinc          4, 1
    37: goto          17

vs.

     3: ldc           #3                  // String 2 * i * i:
     5: invokevirtual #4                  // Method java/io/PrintStream.print:(Ljava/lang/String;)V
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
    11: lstore_1
    12: iconst_0
    13: istore_3
    14: iconst_0
    15: istore        4
    17: iload         4
    19: ldc           #6                  // int 1000000000
    21: if_icmpge     40
    24: iload_3
    25: iconst_2
    26: iload         4
    28: imul
    29: iload         4
    31: imul
    32: iadd
    33: istore_3
    34: iinc          4, 1
    37: goto          17

FYI -

java -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)

1
Найкращою відповіддю і , можливо , ви можете проголосувати неудаліть - stackoverflow.com/a/53452836/1746118 ... примітка боку - я не downvoter в будь-якому випадку.
Наман

@nullpointer - я згоден. Я б неодмінно проголосував за те, щоб я не мав змоги. Я також хотів би "подвоїти заявку" stefan за визначення кількісного визначення "значущого"
paulsm4

Це було видалено самостійно, оскільки воно вимірювало неправильну річ - дивіться коментар автора до цього питання
Krease

2
Отримайте налагодження jre та запустіть з -XX:+PrintOptoAssembly. Або просто використовувати vtune або подібне.
іржа

1
@ rustyx - Якщо проблема полягає у впровадженні JIT ... тоді "отримання налагодженої версії" ПОБІЛЬНО РІЗНОГО JRE не обов'язково допоможе. Тим не менше: це звучить як те, що ви знайшли вище при розбиранні JIT на вашому JRE, також пояснює поведінку на JRE і моїх ОП. А також пояснює, чому інші JRE поводяться "інакше". +1: дякую за чудову детективну роботу!
paulsm4

18

Цікаве спостереження за допомогою Java 11 та вимкнення циклу розкручування за допомогою наступного варіанту VM:

-XX:LoopUnrollLimit=0

Цикл із 2 * (i * i)виразом призводить до отримання більш компактного нативного коду 1 :

L0001: add    eax,r11d
       inc    r8d
       mov    r11d,r8d
       imul   r11d,r8d
       shl    r11d,1h
       cmp    r8d,r10d
       jl     L0001

порівняно з 2 * i * iверсією:

L0001: add    eax,r11d
       mov    r11d,r8d
       shl    r11d,1h
       add    r11d,2h
       inc    r8d
       imul   r11d,r8d
       cmp    r8d,r10d
       jl     L0001

Версія Java:

java version "11" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11+28)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)

Результати порівняння:

Benchmark          (size)  Mode  Cnt    Score     Error  Units
LoopTest.fast  1000000000  avgt    5  694,868 ±  36,470  ms/op
LoopTest.slow  1000000000  avgt    5  769,840 ± 135,006  ms/op

Вихідний код орієнтиру:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@State(Scope.Thread)
@Fork(1)
public class LoopTest {

    @Param("1000000000") private int size;

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
            .include(LoopTest.class.getSimpleName())
            .jvmArgs("-XX:LoopUnrollLimit=0")
            .build();
        new Runner(opt).run();
    }

    @Benchmark
    public int slow() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * i * i;
        return n;
    }

    @Benchmark
    public int fast() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * (i * i);
        return n;
    }
}

1 - використовувані варіанти VM: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0


2
Ого, це якась думка про голову. Замість того, щоб нарощувати, i перш ніж копіювати його для обчислення 2*i, він робить це після, тому йому потрібна додаткова add r11d,2інструкція. (Плюс це не вистачає вікна, add same,sameа не shl1 (додавання запуску на більшій кількості портів). Також він пропускає візок LEA для x*2 + 2( lea r11d, [r8*2 + 2]), якщо він дійсно хоче робити в цьому порядку з якоїсь божевільної причини планування інструкцій. Ми вже могли бачити з розгорнута версія, яка відсутня на LEA, коштувала її багато упс, як і обидві петлі тут.
Пітер Кордес

2
lea eax, [rax + r11 * 2]замінив би дві інструкції (в обох циклах), якщо компілятор JIT встиг шукати оптимізацію в тривалих циклах. Будь-який гідний достроковий компілятор знайде це. (Якщо, можливо, налаштування тільки для AMD, де масштабований індекс LEA має 2 циклу затримки, тому, можливо, не варто.)
Пітер Кордес,

15

Я спробував JMH за допомогою архетипу за замовчуванням: я також додав оптимізовану версію на основі пояснення Runemoro .

@State(Scope.Benchmark)
@Warmup(iterations = 2)
@Fork(1)
@Measurement(iterations = 10)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
//@BenchmarkMode({ Mode.All })
@BenchmarkMode(Mode.AverageTime)
public class MyBenchmark {
  @Param({ "100", "1000", "1000000000" })
  private int size;

  @Benchmark
  public int two_square_i() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * (i * i);
    }
    return n;
  }

  @Benchmark
  public int square_i_two() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += i * i;
    }
    return 2*n;
  }

  @Benchmark
  public int two_i_() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * i * i;
    }
    return n;
  }
}

Результат тут:

Benchmark                           (size)  Mode  Samples          Score   Score error  Units
o.s.MyBenchmark.square_i_two           100  avgt       10         58,062         1,410  ns/op
o.s.MyBenchmark.square_i_two          1000  avgt       10        547,393        12,851  ns/op
o.s.MyBenchmark.square_i_two    1000000000  avgt       10  540343681,267  16795210,324  ns/op
o.s.MyBenchmark.two_i_                 100  avgt       10         87,491         2,004  ns/op
o.s.MyBenchmark.two_i_                1000  avgt       10       1015,388        30,313  ns/op
o.s.MyBenchmark.two_i_          1000000000  avgt       10  967100076,600  24929570,556  ns/op
o.s.MyBenchmark.two_square_i           100  avgt       10         70,715         2,107  ns/op
o.s.MyBenchmark.two_square_i          1000  avgt       10        686,977        24,613  ns/op
o.s.MyBenchmark.two_square_i    1000000000  avgt       10  652736811,450  27015580,488  ns/op

На моєму ПК ( Core i7 860 - він нічого не робить, крім читання на своєму смартфоні):

  • n += i*iто n*2спочатку
  • 2 * (i * i) є другим.

JVM явно не оптимізує так само, як це робить людина (на основі відповіді Рунеморо).

Тепер, читаючи байт-код: javap -c -v ./target/classes/org/sample/MyBenchmark.class

Я не знаю байт-коду, але ми iload_2раніше, ніж ми imul: ось, мабуть, тут і різниця: я можу припустити, що JVM оптимізує читання iвдвічі ( iвже тут, і не потрібно завантажувати його знову), поки в 2*i*iньому можна » т.


4
Байткод AFAICT є неактуальним для продуктивності, і я б не намагався оцінити, що на ньому швидше. Це лише вихідний код для компілятора JIT ... впевнений, що може зберегти змістовий спосіб упорядкування рядків вихідного коду змінити отриманий код та його ефективність, але це все досить непередбачувано.
maaartinus

13

Більше додатку. Я повторно експериментував із використанням останнього Java 8 JVM від IBM:

java version "1.8.0_191"
Java(TM) 2 Runtime Environment, Standard Edition (IBM build 1.8.0_191-b12 26_Oct_2018_18_45 Mac OS X x64(SR5 FP25))
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

І це показує дуже схожі результати:

0.374653912 s
n = 119860736
0.447778698 s
n = 119860736

(другі результати за допомогою 2 * i * i).

Цікаво, що під час роботи на одній машині, але за допомогою Oracle Java:

Java version "1.8.0_181"
Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)

результати в середньому трохи повільніші:

0.414331815 s
n = 119860736
0.491430656 s
n = 119860736

Короткий короткий опис: навіть незначна кількість версій HotSpot має значення, оскільки тонкі відмінності в реалізації JIT можуть мати помітні наслідки.


5

Два способи додавання дій генерують дещо різний байт-код:

  17: iconst_2
  18: iload         4
  20: iload         4
  22: imul
  23: imul
  24: iadd

Для 2 * (i * i)vs:

  17: iconst_2
  18: iload         4
  20: imul
  21: iload         4
  23: imul
  24: iadd

Для 2 * i * i.

І при використанні такого еталону JMH :

@Warmup(iterations = 5, batchSize = 1)
@Measurement(iterations = 5, batchSize = 1)
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class MyBenchmark {

    @Benchmark
    public int noBrackets() {
        int n = 0;
        for (int i = 0; i < 1000000000; i++) {
            n += 2 * i * i;
        }
        return n;
    }

    @Benchmark
    public int brackets() {
        int n = 0;
        for (int i = 0; i < 1000000000; i++) {
            n += 2 * (i * i);
        }
        return n;
    }

}

Різниця очевидна:

# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: <none>

Benchmark                      (n)  Mode  Cnt    Score    Error  Units
MyBenchmark.brackets    1000000000  avgt    5  380.889 ± 58.011  ms/op
MyBenchmark.noBrackets  1000000000  avgt    5  512.464 ± 11.098  ms/op

Те, що ви спостерігаєте, є правильним, а не лише аномалією вашого стилю бенчмаркінгу (тобто немає розминки, див. Як написати правильний мікро-орієнтир на Java? )

Знову запуск з Graal:

# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler

Benchmark                      (n)  Mode  Cnt    Score    Error  Units
MyBenchmark.brackets    1000000000  avgt    5  335.100 ± 23.085  ms/op
MyBenchmark.noBrackets  1000000000  avgt    5  331.163 ± 50.670  ms/op

Ви бачите, що результати набагато ближчі, що має сенс, адже Graal - це загальний, більш ефективний, більш сучасний, компілятор.

Отже, це насправді залежить від того, наскільки добре компілятор JIT здатний оптимізувати певний фрагмент коду, і це не обов'язково має для цього логічну причину.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.