Концепція умовних конструкцій знайомить із jcc, родиною інструкцій для розгалужень за умовою.
jcc передає керування в іншу точку програми лише тоді, коли виконується її умова.
Однак сучасні процесори швидкі й здатні виконувати багато інструкцій паралельно. Було б надто дорого зупиняти всю роботу, поки процесор чекає на перевірку умови під час виконання.
Саме тому всередині процесор не чекає. Він використовує передбачувач розгалужень, щоб угадати результат умови, і починає спекулятивно виконувати передбачений шлях.
Якщо передбачення правильне, виконання триває так, ніби розгалуження не було. Якщо ж передбачення хибне, процесор мусить відкинути виконану роботу на передбаченому шляху й почати заново на правильному.
Це називають помилкою передбачення розгалуження, і вона дорого коштує, спричиняючи затримку.
Коли розгалуження важко передбачити, ці помилки накопичуються.
У таких випадках іноді краще взагалі обійтися без розгалуження, щоб ті самі інструкції виконувалися незалежно від умови.
Код, який вибирає між значеннями без jcc, називають кодом без розгалужень.
Є кілька способів побудувати код так, щоб він розгалужувався рідше, або щоб наявні розгалуження були передбачуванішими. Зокрема, x86-64 надає дві родини інструкцій, які часто використовують для написання коду без розгалужень.
Родина інструкцій cmovcc копіює операнд-джерело в операнд призначення лише тоді, коли виконується певна умова. Інакше операнд призначення залишається без змін.
Суфікс cc має ті самі назви, що й у jcc, з тим самим значенням:
| інструкція | переміщення, якщо |
|---|---|
cmove |
A == B після cmp A, B
|
cmovne |
A != B після cmp A, B
|
cmovl |
A < B (зі знаком) після cmp A, B
|
cmovb |
A < B (без знаку) після cmp A, B
|
cmovg |
A > B (зі знаком) після cmp A, B
|
cmova |
A > B (без знаку) після cmp A, B
|
Правила подібні до jcc:
l і g використовують для порівнянь зі знаком, а b і a - для порівнянь без знаку.e до суфікса включає рівність.n заперечує умову.cmovz і cmovc.Розглянемо абсолютне значення цілого числа зі знаком у rdi, яке повертається в rax.
Записана через jcc, функція вибирає один із двох шляхів:
abs_branch:
mov rax, rdi
cmp rax, 0
jge .done
neg rax
.done:
ret
Записана через cmovcc, вона обчислює обидва можливі значення безумовно, а умовне переміщення вибирає одне з них:
abs_branchless:
mov rax, rdi
neg rax ; rax = -rdi
cmp rdi, 0
cmovge rax, rdi ; if rdi >= 0, rax = rdi
; otherwise rax stays = -rdi
; now rax = abs(rdi)
ret
Версія без розгалужень завжди виконує ті самі інструкції.
Тут немає jcc, яке мав би вгадувати передбачувач.
Операнд призначення cmovcc мусить бути регістром.
Операнд-джерело може бути регістром або коміркою памʼяті, але не безпосереднім значенням.
У коді вище інструкція neg встановлює кілька прапорців відповідно до результату, зокрема прапорець знаку (SF).
Це означає, що cmp rdi, 0 можна взагалі прибрати:
abs_branchless:
mov rax, rdi
neg rax ; rax = -rdi. `neg` sets SF = 1 if the result is negative
cmovs rax, rdi ; if SF = 1 (i.e., rax < 0), replace rax with rdi
; otherwise rax stays = -rdi, which is non-negative
; now rax = abs(rdi)
ret
Для додатного rdi інструкція neg дає відʼємний результат і SF = 1, тож cmovs відновлює rdi.
Для нуля або відʼємного rdi інструкція neg дає невідʼємний результат і SF = 0, тож rax зберігає заперечене значення (яке й є правильним абсолютним значенням).
Хоча cmp є основним способом порівнювати значення в ассемблерному коді, багато інструкцій також встановлюють прапорці відповідно до свого результату.
Повний перелік прапорців, на які впливає інструкція, зазвичай наведено в її довідці.
Для neg це, зокрема, SF, ZF, CF, OF і PF.
Родина інструкцій setcc встановлює операнд призначення в 1, якщо виконується певна умова, і в 0 в іншому разі.
Призначення завжди є 8-бітним операндом.
Суфікс cc має ті самі назви, що й у jcc та cmovcc.
Наприклад, setz встановлює призначення в 1, якщо ZF == 1, і в 0 в іншому разі.
Оскільки призначення має 8 бітів, після setcc часто ставлять movzx, коли потрібне ширше значення:
cmp rdi, rsi
setg al ; al = 1 if rdi > rsi (signed), 0 otherwise
movzx eax, al ; eax (and rax) = 1 or 0, with the upper bits cleared
Це поширений прийом, щоб перетворити результат порівняння на ціле число 0 або 1.
Код без розгалужень не завжди швидший за код із розгалуженнями.
Коли розгалуження добре передбачуване, передбачувач майже завжди має рацію, і jcc фактично нічого не коштує.
Код без розгалужень, який замінює таке розгалуження, повільніший, бо він завжди виконує обидва можливі обчислення.
Канонічний приклад - лічильник циклу.
Розглянемо функцію, яка додає rsi 64-бітних цілих чисел, починаючи з rdi:
sum_array:
xor rax, rax
.loop:
add rax, [rdi]
add rdi, 8
dec rsi
jnz .loop ; taken on every iteration except the last
ret
Це саме той різновид закономірності, з яким передбачувачі розгалужень добре дають раду, тож розгалуження фактично безкоштовне, незалежно від того, скільки елементів опрацьовує sum_array.
Код без розгалужень себе виправдовує, коли розгалуження важко передбачити або коли потрібна стабільна тривалість виконання незалежно від вхідних даних.
Ґрунтовну довідку про передбачення розгалужень для процесорів x86-64 можна знайти в третьому розділі цієї довідкової книги.