Логічний 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
), які робляться тут, але тепер перед кожним передує знак xor
a, а за ним - 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 став ще розумнішим. Тепер він генерує практично ідентичний код (за винятком невеликої перестановки інструкцій) для вищевказаного трюку, як оригінальний код. Отже, відповідь на ваше запитання "Чому компілятор поводиться так?" , мабуть тому, що вони не ідеальні! Вони намагаються використовувати евристику для створення найбільш оптимального коду, але вони не завжди приймають найкращі рішення. Але принаймні вони з часом можуть бути розумнішими!
Один із способів розгляду цієї ситуації полягає в тому, що код розгалуження має кращі найкращі показники. Якщо прогнозування гілок буде успішним, пропуск непотрібних операцій призведе до трохи швидшого часу роботи. Однак безроздільний код має кращі показники в гіршому випадку . Якщо прогнозування гілок не вдасться, виконання декількох додаткових інструкцій, як це необхідно, щоб уникнути гілки, безумовно, буде швидше, ніж неправильно передбачена гілка. Навіть найрозумнішим та найрозумнішим укладачам буде важко зробити цей вибір.
А на ваше запитання, чи потрібно це стежити за програмістами, відповіді майже точно немає, за винятком певних гарячих циклів, які ви намагаєтеся прискорити за допомогою мікрооптимізації. Потім ви сідаєте з розбиранням і знаходите способи підкрутити його. І, як я вже говорив раніше, будьте готові переглянути ці рішення під час оновлення до нової версії компілятора, тому що це може зробити щось дурне з вашим хитрим кодом, або, можливо, змінило його евристику оптимізації, щоб ви могли повернутися назад до використання оригінального коду. Коментуйте ретельно!