Дорогий стрибок з GCC 5.4.0


171

У мене була функція, яка виглядала приблизно так (показувала лише важливу частину):

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) && (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

Написаний так, ця функція займала ~ 34 мс на моїй машині. Після зміни умови множення bool (зробивши код таким чином):

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) * (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

час виконання скоротився до ~ 19 мс.

Використовуваний компілятор був GCC 5.4.0 з -O3 і після перевірки згенерованого коду ASM за допомогою godbolt.org я з’ясував, що перший приклад генерує стрибок, а другий - ні. Я вирішив спробувати GCC 6.2.0, який також генерує інструкцію про стрибок при використанні першого прикладу, але GCC 7, схоже, вже не створює.

Знайти цей спосіб пришвидшити код був досить жахливим і зайняв досить багато часу. Чому компілятор поводиться так? Це призначено і чи варто це слідкувати програмістам? Чи є ще подібні речі?

EDIT: посилання на godbolt https://godbolt.org/g/5lKPF3


17
Чому компілятор поводиться так? Компілятор може робити, як хоче, доти, доки створений код буде правильним. Деякі компілятори просто кращі в оптимізації, ніж інші.
Jabberwocky

26
Я здогадуюсь, що це &&викликає коротке замикання .
Єнс

9
Зауважте, що саме тому ми і маємо &.
rubenvb

7
@Jakub сортування це, швидше за все, збільшить швидкість виконання, див. Це питання .
rubenvb

8
@rubenvb "не слід оцінювати" насправді нічого не означає для вираження, яке не має побічних ефектів. Я підозрюю, що вектор перевіряє межі і GCC не може довести, що він не вийде за межі. EDIT: На самом деле, я не думаю , що будуть робити що - небудь , щоб зупинити I + перехід від буття поза межами.
Випадково832

Відповіді:


263

Логічний AND оператор ( &&) використовує оцінку короткого замикання, що означає, що друге випробування робиться лише в тому випадку, якщо перше порівняння оцінюється як істинне. Це часто саме та семантика, яка вам потрібна. Наприклад, врахуйте наступний код:

if ((p != nullptr) && (p->first > 0))

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

Можливо також, що оцінка короткого замикання призводить до підвищення продуктивності у випадках, коли оцінка умов є дорогим процесом. Наприклад:

if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))

Якщо DoLengthyCheck1не вдається, дзвонити немає сенсу DoLengthyCheck2.

Однак у отриманому двійковому випадку операція короткого замикання часто призводить до двох гілок, оскільки це найпростіший спосіб збереження цих семантик. (Ось чому, з іншого боку монети, оцінка короткого замикання іноді може гальмувати потенціал оптимізації.) Ви можете переконатися в цьому, переглянувши відповідну частину об'єктного коду, сформованого для вашої ifзаяви GCC 5.4:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13w, 478         ; (curr[i] < 479)
    ja      .L5

    cmp     ax, 478           ; (l[i + shift] < 479)
    ja      .L5

    add     r8d, 1            ; nontopOverlap++

Ви бачите тут два порівняння ( cmpінструкції), за кожним слідує окремий умовний стрибок / гілка ( jaабо стрибок, якщо вище).

Загальним правилом є те, що гілки повільні і тому їх слід уникати в тісних петлях. Це стосується практично всіх процесорів x86, починаючи від скромного 8088 (чий повільний час отримання та надзвичайно мала черга попереднього вибору [порівнянна з кешем інструкцій]) у поєднанні з цілком відсутнім передбаченням гілок означав, що взяті гілки вимагають скидання кешу ) до сучасних реалізацій (чиї довгі трубопроводи роблять непередбачувані гілки аналогічно дорогими). Зверніть увагу на маленький застереження, яке я просунув туди. Сучасні процесори, починаючи з Pentium Pro, мають вдосконалені двигуни прогнозування галузей, розроблені для мінімізації витрат на гілки. Якщо напрямок відділення можна правильно передбачити, вартість мінімальна. Здебільшого це працює добре, але якщо ви потрапляєте у патологічні випадки, коли передбачувач гілок не на вашому боці,ваш код може виходити надзвичайно повільно . Це імовірно, де ви знаходитесь тут, оскільки ви кажете, що ваш масив несортований.

Ви говорите , що тести підтвердили , що заміна &&з *робить код значно швидше. Причина цього очевидна, коли ми порівнюємо відповідну частину об'єктного коду:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    xor     r15d, r15d        ; (curr[i] < 479)
    cmp     r13w, 478
    setbe   r15b

    xor     r14d, r14d        ; (l[i + shift] < 479)
    cmp     ax, 478
    setbe   r14b

    imul    r14d, r15d        ; meld results of the two comparisons

    cmp     r14d, 1           ; nontopOverlap++
    sbb     r8d, -1

Трохи контрінтуїтивним є те, що це може бути швидше, оскільки тут є більше інструкцій, але саме так іноді працює оптимізація. Ви бачите ті самі порівняння ( cmp), які робляться тут, але тепер перед кожним передує знак xora, а за ним - a setbe. XOR - це лише стандартний трюк для очищення реєстру. Це setbeінструкція x86, яка встановлює біт на основі значення прапора і часто використовується для реалізації коду без гілок. Тут setbeвідбувається зворотне значення ja. Він встановлює свій регістр призначення на 1, якщо порівняння було нижче або рівне (оскільки регістр був попередньо нульовим, він буде 0 в іншому випадку), тоді як jaрозгалуженим, якщо порівняння було вище. Після отримання цих двох значень в r15bіr14bрегістри, вони множать разом, використовуючи imul. Мультиплікація традиційно була відносно повільною роботою, але це швидко просувається на сучасних процесорах, і це буде особливо швидко, оскільки це лише множення двох значень розміру байтів.

Ви можете так само легко замінити множення на бітовий оператор AND ( &), який не робить короткого замикання. Це робить код набагато зрозумілішим і є моделлю, який компілятори зазвичай розпізнають. Але коли ви робите це зі своїм кодом і компілюєте його з GCC 5.4, він продовжує випромінювати першу гілку:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13w, 478         ; (curr[i] < 479)
    ja      .L4

    cmp     ax, 478           ; (l[i + shift] < 479)
    setbe   r14b

    cmp     r14d, 1           ; nontopOverlap++
    sbb     r8d, -1

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

Нові покоління компілятора (та інші компілятори, як, наприклад, Кланг) знають це правило, і іноді використовуватимуть його для створення того ж коду, який ви б шукали шляхом ручної оптимізації. Я регулярно бачу, як Кланг перекладає &&вирази в той самий код, який був би виданий, якби я використовував &. Далі представлений відповідний вихід з GCC 6.2 з вашим кодом за допомогою звичайного &&оператора:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L7

    xor     r14d, r14d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r14b

    add     esi, r14d         ; nontopOverlap++

Зверніть увагу, наскільки це розумно ! Він використовує підписані умови ( jgі setle) на відміну від непідписаних умов ( jaі setbe), але це не важливо. Ви можете бачити, що він як і раніше робить порівняння та розгалуження для першої умови, як і попередня версія, і використовує ту саму setCCінструкцію для генерування коду без розгалужень для другої умови, але він зробив набагато ефективнішим у тому, як це зростає . Замість того, щоб робити друге, зайве порівняння для встановлення прапорів для sbbоперації, він використовує знання, які r14dбудуть або 1, або 0, щоб просто беззастережно додати це значення nontopOverlap. Якщо r14dдорівнює 0, то додавання є неоперативним; в іншому випадку він додає 1, точно так, як це слід зробити.

GCC 6.2 фактично створює більш ефективний код при використанні &&оператора короткого замикання, ніж бітовий &оператор:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L6

    cmp     eax, 478          ; (l[i + shift] < 479)
    setle   r14b

    cmp     r14b, 1           ; nontopOverlap++
    sbb     esi, -1

Гілка та умовний набір все ще є, але тепер вона повертається до менш розумного способу збільшення nontopOverlap. Це важливий урок, чому ви повинні бути обережними, намагаючись випередити свій компілятор!

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

nontopOverlap += ((curr[i] < 479) & (l[i + shift] < 479));

Тут взагалі немає ifзаяв, і переважна більшість компіляторів ніколи не задумається над тим, щоб видати для цього код розгалуження. GCC не є винятком; всі версії генерують щось подібне до наступного:

    movzx   r14d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r14d, 478         ; (curr[i] < 479)
    setle   r15b

    xor     r13d, r13d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r13b

    and     r13d, r15d        ; meld results of the two comparisons
    add     esi, r13d         ; nontopOverlap++

Якщо ви дотримуєтесь попередніх прикладів, це повинно вам виглядати дуже добре. Обидва порівняння проводяться без гілок, проміжні результати andредагуються разом, а потім цей результат (який буде або 0, або 1) addредагується nontopOverlap. Якщо ви хочете безроздільний код, це практично гарантує його отримання.

GCC 7 став ще розумнішим. Тепер він генерує практично ідентичний код (за винятком невеликої перестановки інструкцій) для вищевказаного трюку, як оригінальний код. Отже, відповідь на ваше запитання "Чому компілятор поводиться так?" , мабуть тому, що вони не ідеальні! Вони намагаються використовувати евристику для створення найбільш оптимального коду, але вони не завжди приймають найкращі рішення. Але принаймні вони з часом можуть бути розумнішими!

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

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


3
Ну, універсального "кращого" немає. Все залежить від вашої ситуації, саме тому вам абсолютно доведеться орієнтуватися, коли ви робите подібну оптимізацію продуктивності низького рівня. Як я вже пояснював у відповіді, якщо ви на програв розмірі пророкування розгалужень, помилкові гілки збираються уповільнити ваш код вниз багато . Останній біт коду не використовує жодних гілок (зверніть увагу на відсутність j*інструкцій), тому в цьому випадку це буде швидше. [продовження]
Коді Грей


2
@ 8bit біт прав. Я мав на увазі чергу попереднього вибору. Я, мабуть, не повинен був би називати це кешем, але я не страшенно переживав фразування і не витрачав багато часу, намагаючись пригадати специфіку, оскільки я не вважав, що когось турбує, окрім історичної цікавості. Якщо ви хочете детальніше, мова дзен асемблера Майкла Абраша є неоціненною. Вся книга доступна в різних місцях в Інтернеті; ось відповідна частина розгалуження , але ви також повинні прочитати та зрозуміти частини попереднього вилучення.
Коді Грей

6
@Hurkyl Я відчуваю, що вся відповідь говорить на це питання. Ви маєте рацію, що я насправді не викликав це прямо, але здавалося, що це вже досить довго. :-) Кожен, хто потребує часу, щоб прочитати всю річ, повинен достатньо зрозуміти цей момент. Але якщо ви думаєте, що чогось не вистачає або потребує більшого роз'яснення, будь ласка, не соромтеся редагувати відповідь, щоб включити її. Деяким це не подобається, але я абсолютно не проти. Я додав короткий коментар з цього приводу, разом із зміною моїх формулювань, запропонованих 8bittree.
Коді Грей

2
Ха, дякую за доповнення, @green. У мене немає нічого конкретного, щоб підказати. Як і у всьому, ви стаєте експертом, роблячи, бачачи та переживаючи. Я прочитав усе, до чого можу дістатись, коли йдеться про архітектуру x86, оптимізацію, внутрішній компілятор та інші речі низького рівня, і я все ще знаю лише частину всього, що потрібно знати. Найкращий спосіб навчитися - брудно копати руки. Але перш ніж ви навіть можете сподіватися на початок, вам знадобиться чітке розуміння C (або C ++), покажчиків, мови складання та всіх інших основ низького рівня.
Коді Грей

23

Важливо відзначити одне важливе

(curr[i] < 479) && (l[i + shift] < 479)

і

(curr[i] < 479) * (l[i + shift] < 479)

не є семантично рівнозначними! Зокрема, якщо у вас виникне ситуація, коли:

  • 0 <= iі i < curr.size()обидва вірні
  • curr[i] < 479 неправдиво
  • i + shift < 0або i + shift >= l.size()це правда

тоді вираз (curr[i] < 479) && (l[i + shift] < 479)гарантовано буде чітко визначеним булевим значенням. Наприклад, це не викликає дефекти сегментації.

Однак за цих обставин вираз (curr[i] < 479) * (l[i + shift] < 479)є невизначеною поведінкою ; це буде дозволено викликати помилку сегментації.

Це означає, що, наприклад, для початкового фрагмента коду компілятор не може просто записати цикл, який виконує обидва порівняння та виконує andоперацію, якщо компілятор також не зможе довести, що l[i + shift]ніколи не спричинить сегментацію в ситуації, якої не потрібно.

Коротше кажучи, оригінальний фрагмент коду пропонує менше можливостей для оптимізації, ніж останній. (звичайно, чи визнає компілятор можливість чи ні - це зовсім інше питання)

Ви можете виправити оригінальну версію, замість цього зробивши

bool t1 = (curr[i] < 479);
bool t2 = (l[i + shift] < 479);
if (t1 && t2) {
    // ...

Це! Залежно від значення shiftmax) тут є UB ...
Матьє М.

18

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

Ви можете створити невеликий приклад, щоб показати це:

#include <iostream>

bool f(int);
bool g(int);

void test(int x, int y)
{
  if ( f(x) && g(x)  )
  {
    std::cout << "ok";
  }
}

Вихід асемблера можна знайти тут .

Ви можете побачити згенерований код спочатку дзвінків f(x), потім перевіряє вихід і переходить до оцінки, g(x)коли це було true. Інакше він залишає функцію.

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

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


1
Чому ви заявляєте, що множення змушує кожного разу оцінювати обидва операнди? 0 * x = x * 0 = 0 незалежно від значення x. У якості оптимізації компілятор також може «замикати» на множення. Наприклад, див. Stackoverflow.com/questions/8145894/… Більше того, на відміну від &&оператора, множення може бути ліниво оцінене або з першим, або з другим аргументом, що дає більше свободи для оптимізації.
SomeWittyUsername

@Jens - "Зазвичай прогнозування галузей допомагає, але якщо ваші дані є випадковими, не так багато, що можна передбачити". - дає добру відповідь.
SChepurin

1
@SomeWittyUsername Добре, компілятор, звичайно, вільний робити будь-яку оптимізацію, яка зберігає поведінку, що спостерігається. Це може або не може перетворити його і не виключати обчислень. якщо ви здійснюєте обчислення 0 * f()та fповедінку, що спостерігається, компілятор повинен це викликати. Різниця полягає в тому, що оцінка короткого замикання є обов'язковою, &&але дозволена, якщо може виявити, що вона еквівалентна *.
Єнс

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

3
@Jens: Оцінка короткого замикання не є обов'язковою. Код потрібен лише для того, щоб він поводився так, ніби він короткий замикання; компілятору дозволено використовувати будь-які засоби, які йому подобаються для досягнення результату.

-2

Це може бути тому, що при використанні логічного оператора &&компілятор повинен перевірити дві умови, щоб оператор if вдався. Однак у другому випадку, оскільки ви неявно перетворюєте значення int у bool, компілятор робить деякі припущення на основі типів та значень, що передаються, разом із (можливо) умовою одиночного стрибка. Можливо також, що компілятор повністю оптимізує відхилення стрибків бітовими зрушеннями.


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