Чому (a * b! = 0) швидше, ніж (a! = 0 && b! = 0) на Java?


412

Я пишу якийсь код на Java, де в якийсь момент потік програми визначається тим, чи є дві змінні int, "a" і "b" не нульовими (зауважте: a і b ніколи не є негативними, і ніколи не в цілому діапазоні переповнення).

Я можу це оцінити за допомогою

if (a != 0 && b != 0) { /* Some code */ }

Або в якості альтернативи

if (a*b != 0) { /* Some code */ }

Оскільки я очікую, що цей фрагмент коду буде виконуватися мільйони разів за пробіг, мені було цікаво, який з них буде швидшим. Я робив експеримент, порівнюючи їх на величезному безладно генерованому масиві, і мені також було цікаво побачити, як обмеженість масиву (частка даних = 0) вплине на результати:

long time;
final int len = 50000000;
int arbitrary = 0;
int[][] nums = new int[2][len];

for (double fraction = 0 ; fraction <= 0.9 ; fraction += 0.0078125) {
    for(int i = 0 ; i < 2 ; i++) {
        for(int j = 0 ; j < len ; j++) {
            double random = Math.random();

            if(random < fraction) nums[i][j] = 0;
            else nums[i][j] = (int) (random*15 + 1);
        }
    }

    time = System.currentTimeMillis();

    for(int i = 0 ; i < len ; i++) {
        if( /*insert nums[0][i]*nums[1][i]!=0 or nums[0][i]!=0 && nums[1][i]!=0*/ ) arbitrary++;
    }
    System.out.println(System.currentTimeMillis() - time);
}

І результати показують, що якщо ви очікуєте, що "a" або "b" буде дорівнювати 0 більше ~ 3% часу, a*b != 0це швидше, ніж a!=0 && b!=0:

Графічний графік результатів A і b не нульовий

Мені цікаво знати, чому. Хтось міг пролити світло? Це компілятор чи це на апаратному рівні?

Редагувати: З цікавості ... тепер, коли я дізнався про передбачення галузей, мені було цікаво, що показуватиме аналогове порівняння для АБО b - не нульове:

Графік a або b ненульовий

Ми бачимо такий же ефект прогнозування гілок, як і очікувалося, що цікаво, графік дещо розгорнутий уздовж осі X.

Оновлення

1- Я додав !(a==0 || b==0) до аналізу, щоб побачити, що відбувається.

2 я включив a != 0 || b != 0, (a+b) != 0і (a|b) != 0з цікавості, після вивчення передбачення розгалужень. Але вони логічно не рівноцінні іншим виразам, тому що лише АБО b має бути ненульовим, щоб повернути істину, тому їх не порівнюють за ефективністю обробки.

3- Я також додав фактичний орієнтир, який я використав для аналізу, який є просто ітерацією довільної змінної int.

4- Деякі люди пропонували включити їх a != 0 & b != 0, на відміну від цього a != 0 && b != 0, з передбаченням, що це буде вести себе більш уважноa*b != 0 оскільки ми видалимо ефект прогнозування гілок. Я не знав, що &можна використовувати з булевими змінними, я вважав, що він використовується лише для двійкових операцій з цілими числами.

Примітка. У контексті, що я розглядав усе це, int overflow не є проблемою, але це, безумовно, важливий розгляд у загальних контекстах.

Процесор: Intel Core i7-3610QM при 2.3 ГГц

Версія Java: 1.8.0_45
Java (TM) SE Runtime Environment (збірка 1.8.0_45-b14)
Java HotSpot (TM) 64-бітний сервер VM (збірка 25.45-b02, змішаний режим)


11
Про що if (!(a == 0 || b == 0))? Мікро-орієнтири, як відомо, ненадійні, це навряд чи можна виміряти (~ 3% для мене звучить як помилка).
Елліот Фріш

9
Або a != 0 & b != 0.
Луї Вассерман

16
Відгалуження відбувається повільно, якщо передбачувана гілка неправильна. a*b!=0має ще одну гілку
Ервін Болвідт

19
(1<<16) * (1<<16) == 0але обидва відрізняються від нуля.
CodesInChaos

13
@Gene: Ваша запропонована оптимізація недійсна. Навіть ігноруючи переповнення, a*bдорівнює нулю, якщо один із aі bдорівнює нулю; a|bдорівнює нулю, лише якщо обидва є.
hmakholm залишився над Монікою

Відповіді:


240

Я ігнорую питання про те, що ваше тестування може бути помилковим, і я отримую результат за номінал.

Це компілятор чи це на апаратному рівні?

Останнє, я думаю:

  if (a != 0 && b != 0)

складе до 2 завантажень пам'яті та двох умовних гілок

  if (a * b != 0)

складе до 2 завантажень пам'яті, множення і однієї умовної гілки.

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

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

(Примітка: вищезазначене пояснення надто спрощене. Для більш точного пояснення вам потрібно ознайомитись з літературою, наданою виробником процесора для кодерів мови збірки та авторів компілятора. Сторінка Вікіпедії на галузевих прогнозах є хорошим фоном.)


Однак є одна річ, з якою потрібно бути обережним при цій оптимізації. Чи є значення, де a * b != 0дадуть неправильну відповідь? Розглянемо випадки, коли обчислення продукту призводить до переповнення цілого числа.


ОНОВЛЕННЯ

Ваші графіки, як правило, підтверджують те, що я сказав.

  • У a * b != 0випадку умовної гілки також існує ефект "передбачення гілки" , і це з’являється у графіках.

  • Якщо проеціювати криві понад 0,9 на вісь X, це виглядає так: 1) вони зустрінуться приблизно в 1,0 та 2) точка зустрічі буде приблизно такою ж, як і для Y = 0,0.


ОНОВЛЕННЯ 2

Я не розумію, чому криві різні для випадків a + b != 0та a | b != 0випадків. У логіці галузевих провісників може бути щось розумне. Або це може означати щось інше.

(Зауважте, що подібні речі можуть бути специфічними для конкретного номера моделі чипа чи навіть версії. Результати ваших орієнтирів можуть бути різними в інших системах.)

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


1
@DebosmitRay - 1) ПД не повинно бути. Проміжні результати зберігатимуться в реєстрі. 2) У другому випадку є дві доступні гілки: одна для виконання "деякого коду", а інша для переходу до наступного оператора після if.
Стівен C

1
@StephenC ти прав слід плутати про + Ь і | Ь, оскільки криві мають ті ж самі, я думаю , що це колір будучи дуже близько. Вибачте за кольорових сліпих людей!
Maljam

3
@ njzk2 з точки зору ймовірності ці випадки повинні бути симетричними відповідно до осі на 50% (ймовірність нуля a&bі a|b). Вони є, але не ідеально, це і є загадка.
Антонін Лейсек

3
@StephenC Причина, по якій a*b != 0і a+b != 0тестування по-різному, полягає в тому, що a+b != 0це зовсім не еквівалентно і ніколи не повинно було оцінюватися. Наприклад, при a = 1, b = 0, перший вираз оцінюється як хибний, а другий - істинний. Множення діє на зразок оператора та оператора, тоді як додавання діє на зразок оператора чи оператора.
JS1

2
@ AntonínLejsek Я думаю, ймовірності будуть різними. Якщо у вас nнулі, то ймовірність обох aі bбути нулем збільшується з n. При ANDоперації з більшою nймовірністю того, що один з них буде ненульовим, збільшується і умова виконується. Це ORоперація протилежна (ймовірність того, що будь-який з них дорівнює нулю, зростає з n). На цьому базується математична перспектива. Я не впевнений, чи так працює апаратне забезпечення.
WYSIWYG

70

Я думаю, що ваш орієнтир має деякі недоліки і може бути не корисним для висновку про реальні програми. Ось мої думки:

  • (a|b)!=0і (a+b)!=0перевірити, якщо будь-яке значення не дорівнює нулю, тоді як a != 0 && b != 0і (a*b)!=0тестуйте, якщо обидва є не нульовими. Отже, ви не порівнюєте терміни просто арифметики: якщо умова частіше відповідає дійсності, це спричиняє більше страт ifорганізму, що також займає більше часу.

  • (a+b)!=0 зробить неправильну дію для позитивних і негативних значень, які дорівнюють нулю, тому ви не можете використовувати його в загальному випадку, навіть якщо він працює тут.

  • Так само (a*b)!=0зробить неправильне значення для значень, які переповнюються. (Випадковий приклад: 196608 * 327680 дорівнює 0, тому що справжній результат ділиться на 2 32 , тому його низькі 32 біти дорівнюють 0, і ці біти - це все, що ви отримуєте, якщо це intоперація.)

  • VM оптимізує вираз під час перших запусків зовнішньої ( fraction) циклу, коли fractionдорівнює 0, коли гілки майже ніколи не беруться. Оптимізатор може робити різні речі, якщо ви починаєте fractionз 0,5.

  • Якщо VM не зможе усунути деякі перевірки меж масиву, тут є чотири інші гілки у виразі саме завдяки перевіркам меж, і це складний фактор, коли намагаються з’ясувати, що відбувається на низькому рівні. Ви можете отримати різні результати, якщо розділити двовимірний масив на два плоских масиви, змінюючи nums[0][i]і nums[1][i]на, nums0[i]і на nums1[i].

  • Провідники гілок процесора виявляють короткі закономірності в даних, або запуски всіх гілок, взятих чи не взятих. Ваші випадково генеровані базові дані - це найгірший сценарій для прогнозувача галузей . Якщо дані в реальному світі мають передбачуваний малюнок, або у нього тривалий цикл значень "нуль" і "не-нуль", гілки можуть коштувати набагато дешевше.

  • Конкретний код, який виконується після виконання умови, може впливати на ефективність оцінки самої умови, оскільки він впливає на те, чи можна цикл розкручувати, які регістри процесора доступні, і якщо будь-яке із отриманих numsзначень потрібно повторно використовувати після оцінки стану. Просто збільшення лічильника в еталоні не є ідеальним заповнювачем того, що реально робити.

  • System.currentTimeMillis()є в більшості систем не більш точним, ніж +/- 10 мс. System.nanoTime()зазвичай більш точний.

Існує багато невизначеностей, і завжди важко сказати що-небудь певне з подібними мікрооптимізаціями, тому що прийом, який швидше в одному VM чи процесорі, може бути повільнішим для іншого. Якщо ви працюєте з 32-розрядним HotSpot JVM, а не з 64-розрядною версією, пам’ятайте, що він постачається у двох варіантах: при використанні VM «Клієнт» є різні (слабкіші) оптимізації порівняно з VM «Server».

Якщо ви можете розібрати машинний код, генерований VM , робіть це, а не намагайтеся вгадати, що це робить!


24

Відповіді тут хороші, хоча у мене була ідея, яка може покращити щось.

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

bool aNotZero = (nums[0][i] != 0);
bool bNotZero = (nums[1][i] != 0);
if (aNotZero && bNotZero) { /* Some code */ }

Це також може зробити

int a = nums[0][i];
int b = nums[1][i];
if (a != 0 && b != 0) { /* Some code */ }

Причина полягає в правилах короткого замикання, якщо перший булевий помилковий, другий не повинен оцінюватися. Він повинен виконати додаткову гілку, щоб не оцінювати, nums[1][i]чи nums[0][i]був помилковим. Тепер ви можете не турбуватися про те, що nums[1][i]його оцінюють, але компілятор не може бути впевнений, що він не викине з діапазону або нульовий перелік, коли ви це зробите. Зменшуючи блок if на прості булі, компілятор може бути досить розумним, щоб усвідомити, що оцінювання другого булевого сигналу зайве не матиме негативних побічних ефектів.


3
Хоча я маю відчуття, це не зовсім відповідає на питання.
П’єр Арло

3
Це спосіб ввести гілку, не змінюючи логіку від нерозгалуження (якби спосіб, який ви отримали aта bмав побічні ефекти, ви б їх зберегли). У вас все ще є, &&тому у вас ще є філія.
Джон Ханна

11

Коли ми беремо множення, навіть якщо одне число дорівнює 0, то добуток дорівнює 0. Під час написання

    (a*b != 0)

Він оцінює результат продукту, тим самим усуваючи перші кілька випадків ітерації, починаючи з 0. В результаті порівняння менше, ніж у випадку

   (a != 0 && b != 0)

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


4
У другому виразі, якщо aдорівнює нулю, його bне слід оцінювати, оскільки ціле вираз уже помилкове. Тож кожен елемент порівнюється - це неправда.
Kuba Wyrostek

9

Ви використовуєте рандомізовані вхідні дані, що робить гілки непередбачуваними. На практиці гілки часто (~ 90%) передбачувані, тому в реальному коді розгалужувальний код, ймовірно, буде швидшим.

Це сказало. Я не бачу, як a*b != 0можна швидше, ніж (a|b) != 0. Як правило, множення на цілі числа дорожче, ніж порозрядне АБО. Але такі речі час від часу стають дивними. Дивіться, наприклад, приклад "Приклад 7: Складність обладнання" з Галереї ефектів кешового процесора .


2
&не є "побітним АБО", але (в даному випадку) "логічним І", тому що обидва операнди булеві, і це не |;-)
siegi

1
@siegi TIL Java '&' - це фактично логічне І без короткого замикання.
StackedCrooked
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.