Чому обробка відсортованого масиву швидша, ніж обробка несортованого масиву?


24443

Ось фрагмент коду С ++, який показує дуже своєрідну поведінку. З якоїсь дивної причини сортування даних дивом робить код майже в шість разів швидшим:

#include <algorithm>
#include <ctime>
#include <iostream>

int main()
{
    // Generate data
    const unsigned arraySize = 32768;
    int data[arraySize];

    for (unsigned c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;

    // !!! With this, the next loop runs faster.
    std::sort(data, data + arraySize);

    // Test
    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i)
    {
        // Primary loop
        for (unsigned c = 0; c < arraySize; ++c)
        {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;

    std::cout << elapsedTime << std::endl;
    std::cout << "sum = " << sum << std::endl;
}
  • Без std::sort(data, data + arraySize);цього код працює за 11,54 секунди.
  • З відсортованими даними код працює за 1,93 секунди.

Спочатку я думав, що це може бути просто аномалія мови чи компілятора, тому я спробував Java:

import java.util.Arrays;
import java.util.Random;

public class Main
{
    public static void main(String[] args)
    {
        // Generate data
        int arraySize = 32768;
        int data[] = new int[arraySize];

        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c)
            data[c] = rnd.nextInt() % 256;

        // !!! With this, the next loop runs faster
        Arrays.sort(data);

        // Test
        long start = System.nanoTime();
        long sum = 0;

        for (int i = 0; i < 100000; ++i)
        {
            // Primary loop
            for (int c = 0; c < arraySize; ++c)
            {
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}

З аналогічним, але менш екстремальним результатом.


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

  • Що відбувається?
  • Чому обробка відсортованого масиву швидша, ніж обробка несортованого масиву?

Код підсумовує деякі незалежні терміни, тому порядок не має значення.



16
@SachinVerma Вгорі голови: 1) JVM, нарешті, може бути досить розумним, щоб використовувати умовні рухи. 2) Код пов'язаний з пам'яттю. 200M занадто великий, щоб вміститись у кеш процесора. Таким чином, продуктивність буде обмежена пропускною здатністю пам’яті, а не розгалуженням.
Містичний

12
@ Містичне, про 2). Я подумав, що таблиця прогнозування відстежує шаблони (незалежно від фактичних змінних, які перевірялися для цього шаблону) та змінює вихід прогнозування на основі історії. Скажіть, будь ласка, причину, чому надзвичайно великий масив не отримає користі від гілок прогнозування?
Сачін Верма

15
@SachinVerma Це так, але коли масив настільки великий, в гру вступає ще більший фактор - пропускна здатність пам'яті. Пам'ять не рівна . Доступ до пам'яті відбувається дуже повільно, і пропускна здатність обмежена. Щоб надто спростити речі, є лише стільки байтів, які можна перенести між процесором та пам'яттю за певний час. Простий код, подібний до цього в цьому питанні, ймовірно, досяг цієї межі, навіть якщо його сповільнить помилкові прогнози. Це не відбувається з масивом 32768 (128 КБ), оскільки він вписується в кеш-пам'ять L2 ЦП.
Містичний

12
Існує новий недолік безпеки називається BranchScope: cs.ucr.edu/~nael/pubs/asplos18.pdf
Veve

Відповіді:


31787

Ви стаєте жертвою невдач прогнозування галузей .


Що таке галузеве передбачення?

Розглянемо залізничний вузол:

Зображення, що показує залізничний вузол Зображення Меканісмо, через Wikimedia Commons. Використовується за ліцензією CC-By-SA 3.0 .

Тепер заради аргументації, припустимо, це ще в 1800-х роках - до міжміських або радіозв'язку.

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

Потяги важкі і мають велику інерційність. Тому вони вічно запускаються і сповільнюються.

Чи є кращий спосіб? Ви здогадуєтесь, в якому напрямку поїзд поїде!

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

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


Розглянемо твердження if: На рівні процесора це гілка інструкція:

Знімок екрана складеного коду, що містить оператор if

Ви процесор, і ви бачите гілку. Ви не маєте уявлення, яким шляхом він піде. Що ти робиш? Ви зупиняєте виконання та чекаєте, поки попередні інструкції не будуть виконані. Потім ви продовжуєте вниз правильним шляхом.

Сучасні процесори складні і мають довгі трубопроводи. Тож вони вічно беруться «зігріватися» і «гальмувати».

Чи є кращий спосіб? Ви здогадуєтесь, в якому напрямку піде гілка!

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

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


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

Тож як би ви стратегічно здогадалися мінімізувати кількість разів, коли поїзд повинен повертатися назад та йти другим шляхом? Ви дивитесь на минулу історію! Якщо поїзд їде залишив 99% часу, то ви здогадаєтесь ліворуч. Якщо воно чергується, то ви чергуєте свої здогадки. Якщо вона піде в один бік кожні три рази, ви здогадаєтесь те саме ...

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

Більшість додатків мають добре відомі гілки. Тож сучасні галузеві передбачувачі зазвичай досягають> 90% частоти показів. Але стикаючись з непередбачуваними гілками без впізнавальних зразків, передбачувачі галузей практично марні.

Подальше читання: стаття "Провізор гілки" у Вікіпедії .


Як натякано зверху, винуватцем цього є if-заява:

if (data[c] >= 128)
    sum += data[c];

Зауважте, що дані розподіляються рівномірно між 0 і 255. Коли дані сортуються, приблизно перша половина ітерацій не буде входити в оператор if. Після цього всі вони введуть if-заяву.

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

Швидка візуалізація:

T = branch taken
N = branch not taken

data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, 130, ... 250, 251, 252, ...
branch = N  N  N  N  N  ...   N    N    T    T    T  ...   T    T    T  ...

       = NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT  (easy to predict)

Однак, коли дані є абсолютно випадковими, передбачувач гілки стає марним, оскільки він не може передбачити випадкових даних. Таким чином, мабуть, буде близько 50% непередбачуваності (не краще, ніж випадкові здогадки).

data[] = 226, 185, 125, 158, 198, 144, 217, 79, 202, 118,  14, 150, 177, 182, 133, ...
branch =   T,   T,   N,   T,   T,   T,   T,  N,   T,   N,   N,   T,   T,   T,   N  ...

       = TTNTTTTNTNNTTTN ...   (completely random - hard to predict)

Отже, що можна зробити?

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

Замінити:

if (data[c] >= 128)
    sum += data[c];

з:

int t = (data[c] - 128) >> 31;
sum += ~t & data[c];

Це виключає гілку і замінює її деякими побітовими операціями.

(Зверніть увагу, що цей хак не є строго еквівалентним вихідному if-оператору. Але в цьому випадку він дійсний для всіх вхідних значень data[].)

Орієнтири: Core i7 920 при 3,5 ГГц

C ++ - Visual Studio 2010 - x64 Випуск

//  Branch - Random
seconds = 11.777

//  Branch - Sorted
seconds = 2.352

//  Branchless - Random
seconds = 2.564

//  Branchless - Sorted
seconds = 2.587

Java - NetBeans 7.1.1 JDK 7 - x64

//  Branch - Random
seconds = 10.93293813

//  Branch - Sorted
seconds = 5.643797077

//  Branchless - Random
seconds = 3.113581453

//  Branchless - Sorted
seconds = 3.186068823

Спостереження:

  • За допомогою Відділення: Існує величезна різниця між відсортованими та несортованими даними.
  • З Hack: Немає різниці між відсортованими та несортованими даними.
  • У випадку C ++ хак насправді набирає повільніше, ніж у гілці, коли дані сортуються.

Загальне правило - уникати залежних від даних розгалужень у критичних петлях (як, наприклад, у цьому прикладі).


Оновлення:

  • GCC 4.6.1 з -O3або -ftree-vectorizeна x64 здатний генерувати умовний хід. Тож різниці між відсортованими та несортованими даними немає - обидва швидкі.

    (Або дещо швидко: для вже відсортованого випадку cmovможе бути повільніше, особливо якщо GCC ставить його на критичний шлях замість просто add, особливо в Intel перед Бродвеллом, де cmovзатримка 2 циклу: прапор оптимізації gcc -O3 робить код повільніше, ніж -O2 )

  • VC ++ 2010 не в змозі генерувати умовні рухи для цієї гілки навіть під /Ox.

  • Компілятор Intel C ++ (ICC) 11 робить щось чудотворне. Він перетинає дві петлі , тим самим підключаючи непередбачувану гілку до зовнішньої петлі. Тож не тільки він несприйнятливий до помилок, він також удвічі швидший, ніж будь-який VC ++ та GCC може генерувати! Іншими словами, ICC скористався тест-циклом, щоб перемогти тест ...

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

Це свідчить про те, що навіть зрілі сучасні компілятори можуть сильно відрізнятися у своїй здатності оптимізувати код ...


256
Погляньте на наступне запитання: stackoverflow.com/questions/11276291/… Intel Compiler підійшов досить близько, щоб повністю позбутися зовнішньої петлі.
Містичне

24
@Mysticial Як поїзд / компілятор знає, що він вступив неправильно?
onmyway133

26
@obe: Враховуючи ієрархічні структури пам’яті, неможливо сказати, якою буде сума пропуску кешу. Він може пропустити L1 і бути вирішеним у повільному L2, або пропустити L3 та вирішити у системній пам'яті. Однак, якщо з якихось химерних причин цей пропуск кешу не спричинить завантаження пам’яті на нерезидентну сторінку з диска, у вас є хороша точка ... пам'ять не мала часу доступу в межах мілісекунд протягом приблизно 25-30 років ;)
Андон М. Коулман

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

22
@Sandeep Так. Процесори все ще мають галузеве передбачення. Якщо щось змінилося, це компілятори. Сьогодні, я думаю, що вони мають більше шансів зробити те, що тут зробили ICC та GCC (під -O3) - тобто видалити гілку. З огляду на настільки високе значення цього питання, цілком можливо, що компілятори були оновлені, щоб спеціально обробити цей випадок. Однозначно зверніть увагу на SO. І це сталося з цього питання, коли GCC було оновлено протягом 3 тижнів. Я не бачу, чому це не сталося б і тут.
Містичний

4086

Галузеве передбачення

При відсортованому масиві умова data[c] >= 128спочатку falseдля смуги значень, потім стає trueдля всіх пізніших значень. Це легко передбачити. Маючи несортований масив, ви оплачуєте вартість розгалуження.


105
Чи краще прогнозування гілок працює на відсортованих масивах проти масивів з різними моделями? Наприклад, для масиву -> {10, 5, 20, 10, 40, 20, ...} наступним елементом у масиві з шаблону є 80. Чи буде цей вид масиву прискореним передбаченням гілок у який наступний елемент - тут 80, якщо дотримуватися шаблон? Або зазвичай це допомагає лише для відсортованих масивів?
Адам Фріман

132
Так що в основному все, що я умовно дізнався про big-O, знаходиться поза вікном? Краще понести вартість сортування, ніж вартість розгалуження?
Агрім Патхак

133
@AgrimPathak Це залежить. Для не надто великого введення алгоритм з більшою складністю швидший, ніж алгоритм з меншою складністю, коли константи менші для алгоритму з більшою складністю. Де знаходиться точка беззбитковості, важко передбачити. Також порівняйте це , важливим є місцевість. Big-O важливий, але це не єдиний критерій продуктивності.
Даніель Фішер

65
Коли відбувається прогнозування галузей? Коли мова дізнається, що масив відсортований? Я думаю про ситуацію масиву, яка виглядає так: [1,2,3,4,5, ... 998,999,1000, 3, 10001, 10002]? чи збільшить цей незрозумілий 3 час роботи? Чи буде це довгий несортований масив?
Філіп Бартузі

63
@FilipBartuzi Branch передбачення відбувається в процесорі нижче рівня мови (але мова може запропонувати способи сказати компілятору, що можливо, тому компілятор може випускати відповідний для цього код). У вашому прикладі, поза порядком 3 призведе до неправильного прогнозування (для відповідних умов, коли 3 дає інший результат, ніж 1000), і таким чином обробка цього масиву, ймовірно, займе на пару десятків чи сотень наносекунд довше, ніж відсортований масив навряд чи буде помітний. Що коштує часу - це висока швидкість непередбачень, одна помилка на 1000 не дуже.
Даніель Фішер

3310

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

Тепер, якщо ми подивимось на код

if (data[c] >= 128)
    sum += data[c];

ми можемо виявити, що сенс цієї конкретної if... else...галузі полягає в тому, щоб додати щось, коли умова задоволена. Цей тип гілки можна легко перетворити на умовний оператор переміщення , який буде складено в умовну інструкцію переміщення:, cmovlуx86 системі. Відгалуження та, таким чином, можливе покарання передбачення гілки знімаються

Таким Cчином C++, оператор, який би склав безпосередньо (без оптимізації) в інструкцію умовного переміщення x86, - це потрійний оператор ... ? ... : .... Тож переписуємо вищевикладене твердження в рівнозначне:

sum += data[c] >=128 ? data[c] : 0;

Зберігаючи читабельність, ми можемо перевірити коефіцієнт швидкості.

У режимі випуску Intel Core i7 -2600K при 3,4 ГГц та режимі випуску Visual Studio 2010 орієнтиром є (формат скопійований з Mysticial):

x86

//  Branch - Random
seconds = 8.885

//  Branch - Sorted
seconds = 1.528

//  Branchless - Random
seconds = 3.716

//  Branchless - Sorted
seconds = 3.71

x64

//  Branch - Random
seconds = 11.302

//  Branch - Sorted
 seconds = 1.830

//  Branchless - Random
seconds = 2.736

//  Branchless - Sorted
seconds = 2.737

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

Тепер давайте докладніше вивчимо x86збірку, яку вони генерують. Для простоти використовуємо дві функції max1іmax2 .

max1використовує умовну гілку if... else ...:

int max1(int a, int b) {
    if (a > b)
        return a;
    else
        return b;
}

max2використовує потрійний оператор ... ? ... : ...:

int max2(int a, int b) {
    return a > b ? a : b;
}

На машині x86-64 GCC -Sгенерується збірка нижче.

:max1
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    -8(%rbp), %eax
    jle     .L2
    movl    -4(%rbp), %eax
    movl    %eax, -12(%rbp)
    jmp     .L4
.L2:
    movl    -8(%rbp), %eax
    movl    %eax, -12(%rbp)
.L4:
    movl    -12(%rbp), %eax
    leave
    ret

:max2
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    %eax, -8(%rbp)
    cmovge  -8(%rbp), %eax
    leave
    ret

max2використовує набагато менше коду через використання інструкції cmovge. Але реальна вигода полягає в тому, що max2це не передбачає стрибків гілок jmp, що призведе до значного штрафу за продуктивність, якщо прогнозований результат не буде правильним.

Так чому ж умовний хід працює краще?

У типовому x86процесорі виконання інструкції поділяється на кілька етапів. Приблизно у нас є різне обладнання для вирішення різних етапів. Тож нам не потрібно чекати, коли закінчиться одна інструкція, щоб розпочати нову. Це називається трубопровідним .

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

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

Друге видання пояснює цю книгу " Комп'ютерні системи: перспектива програміста" . Ви можете перевірити розділ 3.6.6 щодо інструкцій з умовного переміщення , весь розділ 4 архітектури процесорів та розділ 5.11.2 щодо спеціального режиму передбачення галузевих та непередбачуваних покарань .

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


7
@ BlueRaja-DannyPflughoeft Це неоптимізована версія. Компілятор НЕ оптимізував тернар-оператора, він просто ПЕРЕВІРИТИ його. GCC може оптимізувати if-тоді, якщо йому достатній рівень оптимізації, тим не менш, цей показує силу умовного переміщення, а ручна оптимізація має значення.
WiSaGaN

100
@WiSaGaN Код нічого не демонструє, оскільки ваші два фрагменти коду складаються в один і той же машинний код. Критично важливо, щоб люди не здогадувались про те, що якось твердження if у вашому прикладі відрізняється від теренари у вашому прикладі. Це правда, що вам належить подібність у вашому останньому абзаці, але це не стирає факту, що решта прикладу шкідлива.
Джастін Л.

55
@WiSaGaN Моя послуга, безумовно, перетвориться на репутацію, якщо ви змінили свою відповідь, щоб видалити оманливий -O0приклад та показати різницю в оптимізованому асмі на ваших двох тестових скриньках.
Джастін Л.

56
@UpAndAdam На момент випробування VS2010 не може оптимізувати початкову гілку в умовний хід навіть при визначенні високого рівня оптимізації, тоді як gcc може.
WiSaGaN

9
Цей потрійний операторський трюк прекрасно працює для Java. Прочитавши відповідь Mystical, мені було цікаво, що можна зробити для Java, щоб уникнути помилкового прогнозування гілок, оскільки у Java немає нічого еквівалентного -O3. потрійний оператор: 2.1943s та оригінал: 6.0303s.
Кін Ченг

2271

Якщо вам цікаво ще більше оптимізацій, які можна зробити з цим кодом, врахуйте це:

Починаючи з оригінальної петлі:

for (unsigned i = 0; i < 100000; ++i)
{
    for (unsigned j = 0; j < arraySize; ++j)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

За допомогою обміну циклами ми можемо безпечно змінити цю петлю на:

for (unsigned j = 0; j < arraySize; ++j)
{
    for (unsigned i = 0; i < 100000; ++i)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

Тоді ви можете бачити, що ifумовне є постійним протягом усього виконання iциклу, тому ви можете підняти ifвихід:

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        for (unsigned i = 0; i < 100000; ++i)
        {
            sum += data[j];
        }
    }
}

Потім ви бачите, що внутрішній цикл можна згортати в один єдиний вираз, якщо припустити, що модель з плаваючою точкою дозволяє це (наприклад /fp:fast, кидається)

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        sum += data[j] * 100000;
    }
}

Це в 100 000 разів швидше, ніж раніше.


276
Якщо ви хочете обдурити, ви також можете взяти множення поза циклом і зробити суму * = 100000 після циклу.
Jyaif

78
@Michael - Я вважаю, що цей приклад насправді є прикладом оптимізації циклу підйому (LIH), а НЕ петлі . У цьому випадку вся внутрішня петля не залежить від зовнішньої петлі і тому може бути піднята поза зовнішньої петлі, після чого результат просто множиться на суму, що перевищує iодну одиницю = 1e5. Кінцевий результат не має різниці, але я просто хотів встановити рекорд, оскільки це така часто відвідувана сторінка.
Яїр Альтман

54
Хоча це не в простому дусі заміни циклів, внутрішнє ifв цій точці може бути перетворене на: sum += (data[j] >= 128) ? data[j] * 100000 : 0;що компілятор може бути в змозі зменшити cmovgeабо еквівалент.
Алекс Норт-Кіс

43
Зовнішня петля - зробити час, необхідний для внутрішньої петлі, достатньо великим для профілю. То чому б ви петлювали своп. Зрештою, ця петля все одно буде видалена.
saurabheights

34
@saurabheights: Неправильне запитання: чому компілятор НЕ циклічно міняється. Microbenchmarks важко;)
Матьє М.

1884

Без сумніву, хтось із нас був би зацікавлений у способах ідентифікації коду, який є проблематичним для передбачувача процесора. Інструмент Valgrind cachegrindмає симулятор передбачення гілки, включений за допомогою --branch-sim=yesпрапора. Запустивши його над прикладами цього питання, кількість зовнішніх циклів зменшена до 10000 і складена з g++, дає такі результати:

Сортування:

==32551== Branches:        656,645,130  (  656,609,208 cond +    35,922 ind)
==32551== Mispredicts:         169,556  (      169,095 cond +       461 ind)
==32551== Mispred rate:            0.0% (          0.0%     +       1.2%   )

Несортовано:

==32555== Branches:        655,996,082  (  655,960,160 cond +  35,922 ind)
==32555== Mispredicts:     164,073,152  (  164,072,692 cond +     460 ind)
==32555== Mispred rate:           25.0% (         25.0%     +     1.2%   )

Свердління до виводу рядка за рядком, який cg_annotateми бачимо, йдеться про відповідну петлю:

Сортування:

          Bc    Bcm Bi Bim
      10,001      4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .      .  .   .      {
           .      .  .   .          // primary loop
 327,690,000 10,016  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .      .  .   .          {
 327,680,000 10,006  0   0              if (data[c] >= 128)
           0      0  0   0                  sum += data[c];
           .      .  .   .          }
           .      .  .   .      }

Несортовано:

          Bc         Bcm Bi Bim
      10,001           4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .           .  .   .      {
           .           .  .   .          // primary loop
 327,690,000      10,038  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .           .  .   .          {
 327,680,000 164,050,007  0   0              if (data[c] >= 128)
           0           0  0   0                  sum += data[c];
           .           .  .   .          }
           .           .  .   .      }

Це дозволяє легко визначити проблематичну лінію - у if (data[c] >= 128)несортізованій версії ця лінія викликає 164,050,007 умовно-прогнозованих умовних гілок ( Bcm) за моделлю передбачувача гілок кешгринда, тоді як вона лише спричиняє 10 006 у сортованій версії.


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

perf stat ./sumtest_sorted

Сортування:

 Performance counter stats for './sumtest_sorted':

  11808.095776 task-clock                #    0.998 CPUs utilized          
         1,062 context-switches          #    0.090 K/sec                  
            14 CPU-migrations            #    0.001 K/sec                  
           337 page-faults               #    0.029 K/sec                  
26,487,882,764 cycles                    #    2.243 GHz                    
41,025,654,322 instructions              #    1.55  insns per cycle        
 6,558,871,379 branches                  #  555.455 M/sec                  
       567,204 branch-misses             #    0.01% of all branches        

  11.827228330 seconds time elapsed

Несортовано:

 Performance counter stats for './sumtest_unsorted':

  28877.954344 task-clock                #    0.998 CPUs utilized          
         2,584 context-switches          #    0.089 K/sec                  
            18 CPU-migrations            #    0.001 K/sec                  
           335 page-faults               #    0.012 K/sec                  
65,076,127,595 cycles                    #    2.253 GHz                    
41,032,528,741 instructions              #    0.63  insns per cycle        
 6,560,579,013 branches                  #  227.183 M/sec                  
 1,646,394,749 branch-misses             #   25.10% of all branches        

  28.935500947 seconds time elapsed

Він також може робити анотацію вихідного коду за допомогою розбирання.

perf record -e branch-misses ./sumtest_unsorted
perf annotate -d sumtest_unsorted
 Percent |      Source code & Disassembly of sumtest_unsorted
------------------------------------------------
...
         :                      sum += data[c];
    0.00 :        400a1a:       mov    -0x14(%rbp),%eax
   39.97 :        400a1d:       mov    %eax,%eax
    5.31 :        400a1f:       mov    -0x20040(%rbp,%rax,4),%eax
    4.60 :        400a26:       cltq   
    0.00 :        400a28:       add    %rax,-0x30(%rbp)
...

Докладнішу інформацію див. У навчальному посібнику щодо продуктивності .


74
Це страшно, у списку, що не перекручується, має бути 50% шансів потрапити на додаток. Так чи інакше галузевий прогноз має лише 25% пропуску, як це зробити краще, ніж 50% промах?
TallBrian

128
@ tall.b.lo: 25% - це всі гілки - в циклі є дві гілки, одна для data[c] >= 128(яка має 50% пропуску, як ви пропонуєте) і одна для умови циклу, c < arraySizeяка має ~ 0% пропуску .
caf

1340

Я просто зачитав це питання та його відповіді, і відчуваю, що відповіді не вистачає.

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

Цей підхід працює в цілому, якщо:

  1. це невелика таблиця і, ймовірно, буде кешована в процесор, і
  2. ви працюєте в досить тісному циклі і / або процесор може попередньо завантажити дані.

Передумови та причини

З точки зору процесора, ваша пам'ять повільна. Щоб компенсувати різницю в швидкості, в ваш процесор вбудовано пару кешів (кеш L1 / L2). Тож уявіть, що ви робите свої приємні розрахунки, і зрозумійте, що вам потрібен шматок пам'яті. Процесор отримає свою "навантаження" операцію і завантажує частину пам'яті в кеш - і потім використовує кеш, щоб виконати решту обчислень. Оскільки пам'ять відносно повільна, це "завантаження" сповільнить вашу програму.

Як і передбачення гілок, це було оптимізовано в процесорах Pentium: процесор передбачає, що йому потрібно завантажити частину даних і намагається завантажити її в кеш, перш ніж операція дійсно потрапить у кеш. Як ми вже бачили, передбачення гілок іноді йде жахливо неправильно - у гіршому випадку потрібно повернутися назад і насправді чекати завантаження пам'яті, яке візьме назавжди ( іншими словами: невдале прогнозування гілки погано, пам'ять навантаження після невдалого прогнозування гілки просто жахливо! ).

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

Перше, що нам потрібно знати, це те, що мало ? Хоча менше, як правило, краще, правило є дотримуватися таблиць пошуку розміром <= 4096 байт. В якості верхньої межі: якщо таблиця пошуку більша за 64 К, ймовірно, варто переглянути.

Побудова таблиці

Тож ми зрозуміли, що можемо створити невелику таблицю. Наступне, що потрібно зробити, це отримати функцію пошуку на місці. Функції пошуку - це зазвичай невеликі функції, які використовують пару основних цілих операцій (і, або, xor, зсув, додавання, видалення та, можливо, множення). Ви хочете, щоб ваш вклад було переведено функцією пошуку на якийсь "унікальний ключ" у вашій таблиці, який потім просто дає відповідь на всю роботу, яку ви хотіли виконати.

У цьому випадку:> = 128 означає, що ми можемо зберегти значення, <128 означає, що ми його позбуємося. Найпростіший спосіб зробити це за допомогою "І": якщо ми збережемо його, ми І з 7FFFFFFF; якщо ми хочемо його позбутися, ми І це з 0. Зауважимо також, що 128 - це сила 2 - тож ми можемо йти вперед і скласти таблицю з цілими числами 32768/128 і заповнити її одним нулем і безліччю 7FFFFFFFF's.

Керовані мови

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

Ну не зовсім ... :-)

Було проведено досить багато роботи з усунення цієї галузі для керованих мов. Наприклад:

for (int i = 0; i < array.Length; ++i)
{
   // Use array[i]
}

У цьому випадку компілятору очевидно, що гранична умова ніколи не буде потраплена. Принаймні компілятор Microsoft JIT (але, я думаю, що Java робить подібні речі) помітить це і видалить чек взагалі. WOW, це означає, що немає гілки. Аналогічним чином він вирішить і інші очевидні випадки.

Якщо у вас виникли проблеми з пошуками на керованих мовах - головне - додати & 0x[something]FFFфункцію пошуку, щоб зробити граничну перевірку передбачуваною - і спостерігати за тим, як вона пройде швидше.

Результат цієї справи

// Generate data
int arraySize = 32768;
int[] data = new int[arraySize];

Random random = new Random(0);
for (int c = 0; c < arraySize; ++c)
{
    data[c] = random.Next(256);
}

/*To keep the spirit of the code intact, I'll make a separate lookup table
(I assume we cannot modify 'data' or the number of loops)*/

int[] lookup = new int[256];

for (int c = 0; c < 256; ++c)
{
    lookup[c] = (c >= 128) ? c : 0;
}

// Test
DateTime startTime = System.DateTime.Now;
long sum = 0;

for (int i = 0; i < 100000; ++i)
{
    // Primary loop
    for (int j = 0; j < arraySize; ++j)
    {
        /* Here you basically want to use simple operations - so no
        random branches, but things like &, |, *, -, +, etc. are fine. */
        sum += lookup[data[j]];
    }
}

DateTime endTime = System.DateTime.Now;
Console.WriteLine(endTime - startTime);
Console.WriteLine("sum = " + sum);
Console.ReadLine();

57
Ви хочете обійти гілку-предиктор, чому? Це оптимізація.
Дастін Опреа

108
Тому що жодна гілка не краща за гілку :-) У багатьох ситуаціях це просто набагато швидше ... якщо ви оптимізуєте, то, безумовно, варто спробувати. Вони також досить часто використовують його у f.ex. graphics.stanford.edu/~seander/bithacks.html
atlaste

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

38
@Zain, якщо ви дійсно хочете знати ... Так: 15 секунд з гілкою і 10 з моєю версією. Незалежно, це корисна техніка знати будь-який спосіб.
атлас

42
Чому не sum += lookup[data[j]]де lookupмасив з 256 записів, перші з них дорівнює нулю , а останні, будучи дорівнює індексу?
Кріс Вандермоттен

1200

Оскільки дані розподіляються між 0 і 255 під час сортування масиву, навколо першої половини ітерацій не буде входити в- ifзаяву ( ifзаяву надано нижче).

if (data[c] >= 128)
    sum += data[c];

Питання полягає в тому, що змушує вищезазначене твердження не виконуватись у певних випадках, як у випадку відсортованих даних? Тут з'являється "гілок прогнозування". Індикатор гілки - це цифровий ланцюг, який намагається здогадатися, куди if-then-elseпіде гілка (наприклад, структура), перш ніж це точно відомо. Метою галузевого прогноктора є поліпшення потоку в інструкційному трубопроводі. Галузеві прогнози грають вирішальну роль у досягненні високої ефективності!

Давайте зробимо кілька розміток на лавці, щоб краще зрозуміти це

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

Давайте виміряємо продуктивність цього циклу за різних умов:

for (int i = 0; i < max; i++)
    if (condition)
        sum++;

Ось моменти циклу з різними істинно-хибними візерунками:

Condition                Pattern             Time (ms)
-------------------------------------------------------
(i & 0×80000000) == 0    T repeated          322

(i & 0xffffffff) == 0    F repeated          276

(i & 1) == 0             TF alternating      760

(i & 3) == 0             TFFFTFFF           513

(i & 2) == 0             TTFFTTFF           1675

(i & 4) == 0             TTTTFFFFTTTTFFFF   1275

(i & 8) == 0             8T 8F 8T 8F        752

(i & 16) == 0            16T 16F 16T 16F    490

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

Тож сумнівів у впливі галузевого прогнозування на результативність немає!


23
@MooingDuck 'Тому що це не змінить - це значення може бути будь-яким, але воно все ще буде в межах цих порогів. То навіщо показувати випадкове значення, коли ви вже знаєте межі? Хоча я згоден, що ти можеш показати його заради повноти та "просто для чортви".
cst1992

24
@ cst1992: Наразі його найбільш повільний термін - це TTFFTTFFTTFF, який, на моє людське око, здається цілком передбачуваним. Випадкове за своєю суттю непередбачуване, тому цілком можливо, воно все-таки повільніше, і, таким чином, поза межами, показаними тут. ОТО, можливо, TTFFTTFF чудово вражає патологічний випадок. Не можу сказати, оскільки він не показав терміни випадково.
Mooing Duck

21
@MooingDuck Для людського ока "TTFFTTFFTTFF" - це передбачувана послідовність, але те, про що ми тут говоримо, - це поведінка гілки прогнозування, вбудованої в процесор. Індикатор гілки не є розпізнаванням шаблонів на рівні AI; це дуже просто. Коли ви просто чергуєте гілки, це не добре прогнозує. У більшості кодів гілки йдуть однаково майже весь час; розглянемо цикл, який виконується тисячу разів. Гілка в кінці циклу повертається до початку циклу 999 разів, а потім у тисячний раз робиться щось інше. Зазвичай дуже простий гілок прогнозування працює добре.
steveha

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

5
@steveha: Дворівневий адаптивний прогноктор може зафіксувати схему TTFFTTFF без жодних проблем. "Варіанти цього методу прогнозування використовуються в більшості сучасних мікропроцесорів". Місцеве галузеве прогнозування та глобальне прогнозування галузей засноване на дворівневому адаптивному прогнокторі, вони також можуть. "Глобальне прогнозування гілок використовується в процесорах AMD, а також в процесорах Intel Pentium M, Core, Core 2 та Atom на базі Сільвермонта". До цього списку додайте також Agree preictor, Hybrid predictor, прогнозування непрямих стрибків. Індикатор циклу не замикається, але становить 75%. Це залишає лише 2, які не можуть зафіксуватися
Mooing Duck

1126

Один із способів уникнути помилок прогнозування гілок - це побудувати таблицю пошуку та індексувати її за допомогою даних. Штефан де Бруйн обговорював це у своїй відповіді.

Але в цьому випадку ми знаємо, що значення знаходяться в діапазоні [0, 255], і ми дбаємо лише про значення> = 128. Це означає, що ми можемо легко витягти один біт, який підкаже нам, чи хочемо ми значення чи ні: шляхом зміщення дані праворуч 7 біт, нам залишається 0 біт або 1 біт, і ми хочемо додати значення лише тоді, коли у нас є 1 біт. Назвемо цей біт «бітом рішення».

Використовуючи значення 0/1 біта рішення в якості індексу в масив, ми можемо зробити код, який буде однаково швидким, сортувавши дані чи не відсортувавшись. Наш код завжди додасть значення, але коли біт рішення дорівнює 0, ми додамо значення десь нам не цікаво. Ось код:

// Test
clock_t start = clock();
long long a[] = {0, 0};
long long sum;

for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        int j = (data[c] >> 7);
        a[j] += data[c];
    }
}

double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];

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

Але в моєму тестуванні явна таблиця пошуку була трохи швидшою, ніж ця, ймовірно, тому, що індексація в таблиці пошуку була трохи швидшою, ніж бітова зміна. Це показує, як мій код налаштовує та використовує таблицю пошуку (незвично називається)lut до "LookUp Table" у коді). Ось код C ++:

// Declare and then fill in the lookup table
int lut[256];
for (unsigned c = 0; c < 256; ++c)
    lut[c] = (c >= 128) ? c : 0;

// Use the lookup table after it is built
for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        sum += lut[data[c]];
    }
}

У цьому випадку таблиця пошуку становила лише 256 байт, тому вона добре вміщується в кеш-пам'яті, і все пройшло швидко. Ця методика не буде добре працювати, якби дані були 24-бітовими значеннями, і ми хотіли лише їх половини ... таблиця пошуку була б занадто великою, щоб бути практичною. З іншого боку, ми можемо поєднати дві прийоми, показані вище: спочатку змістіть біти, а потім індексуйте таблицю пошуку. Для 24-бітного значення, яке нам потрібне лише значення верхньої половини, ми можемо потенційно змістити дані на 12 біт і залишити 12-бітове значення для індексу таблиці. 12-бітний індекс таблиці передбачає таблицю з 4096 значень, що може бути практичним.

Техніка індексації в масиві замість використання ifоператора може використовуватися для вирішення, який вказівник використовувати. Я побачив бібліотеку, яка реалізовувала двійкові дерева, і замість того, щоб мати два названі вказівники ( pLeftіpRight чи що завгодно) мали масив покажчиків довжиною-2 і застосував техніку "біт рішення", щоб визначити, який саме слід слідкувати. Наприклад, замість:

if (x < node->value)
    node = node->pLeft;
else
    node = node->pRight;

ця бібліотека зробила б щось на кшталт:

i = (x < node->value);
node = node->link[i];

Ось посилання на цей код: Червоні чорні дерева , Вічно заплутані


29
Правильно, ви можете також просто використовувати біт безпосередньо та множити ( data[c]>>7- про що йде мова і десь тут); Я навмисно покинув це рішення, але ви, звичайно, правильні. Лише невелика примітка: правило для таблиць пошуку полягає в тому, що якщо вона вміщується в 4 КБ (через кешування), вона буде працювати - бажано, щоб таблиця була якомога меншою. Для керованих мов я б підштовхнув це до 64 КБ, для мов низького рівня, таких як C ++ та C, я б, напевно, передумав (це лише мій досвід). Оскільки typeof(int) = 4я б спробував дотримуватися максимум 10 біт.
атлас

17
Я думаю, що індексація зі значенням 0/1, ймовірно, буде швидшою, ніж множинне множення, але я думаю, якщо продуктивність дійсно важлива, вам слід її проаналізувати. Я погоджуюся, що невеликі таблиці пошуку є важливими для уникнення тиску кешу, але очевидно, якщо у вас є більший кеш, ви можете піти з більшою таблицею пошуку, тому 4 КБ - це більше велике правило, ніж жорстке правило. Я думаю, ти мав на увазі sizeof(int) == 4? Це було б правдою для 32-розрядних. Мій дворічний мобільний телефон має кеш-пам'ять L1 32 Кб, тому навіть таблиця пошуку 4К може працювати, особливо якщо значення пошуку були байтом замість int.
steveha

12
Можливо, мені щось не вистачає, але у вашому jметоді дорівнює 0 або 1, чому ви просто не помножите свою величину, jперш ніж додавати її, а не використовувати індексацію масиву (можливо, слід помножити на, 1-jа не j)
Річард Тінгл

6
@steveha Множення повинно бути швидшим, я намагався шукати його в книгах Intel, але не міг його знайти ... в будь-якому випадку, порівняльний аналіз також дає мені цей результат тут.
атлас

10
@steveha PS: ще одна можлива відповідь, int c = data[j]; sum += c & -(c >> 7);яка не потребує множення взагалі.
атлас

1021

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

Дійсно, масив розділений на суміжній зоні з, data < 128а інший з data >= 128. Тож вам слід знайти точку розбиття за допомогою дихотомічного пошуку (використовуючи Lg(arraySize) = 15порівняння), а потім зробіть пряме накопичення з цієї точки.

Щось на кшталт (не перевірено)

int i= 0, j, k= arraySize;
while (i < k)
{
  j= (i + k) >> 1;
  if (data[j] >= 128)
    k= j;
  else
    i= j;
}
sum= 0;
for (; i < arraySize; i++)
  sum+= data[i];

або, трохи більш затуманений

int i, k, j= (i + k) >> 1;
for (i= 0, k= arraySize; i < k; (data[j] >= 128 ? k : i)= j)
  j= (i + k) >> 1;
for (sum= 0; i < arraySize; i++)
  sum+= data[i];

Ще швидше підхід, який дає наближене рішення для обох сортовані або несортовані є: sum= 3137536;(припускаючи , що дійсно рівномірний розподіл, 16384 зразків з очікуваним значенням 191.5) :-)


23
sum= 3137536- розумний. Це, очевидно, не в цьому питання. Питання чітко стосується пояснення дивних характеристик продуктивності. Я схильний сказати, що додавання робити std::partitionзамість std::sortє цінним. Хоча власне питання поширюється на більше, ніж просто заданий синтетичний показник.
sehe

12
@DeadMG: це дійсно не стандартний дихотомічний пошук заданого ключа, а пошук індексу розділення; воно вимагає єдиного порівняння за ітерацією. Але не покладайтеся на цей код, я його не перевіряв. Якщо ви зацікавлені в гарантованому правильному виконанні, дайте мені знати.
Ів Дауст

831

Вищевказана поведінка відбувається через передбачення відділення.

Для розуміння галузевого прогнозування спочатку слід зрозуміти конвеєр інструкцій :

Будь-яка інструкція розбита на послідовність кроків, так що різні кроки можуть виконуватися паралельно. Ця методика відома як інструментальний конвеєр, і вона використовується для збільшення пропускної здатності в сучасних процесорах. Щоб краще зрозуміти це, дивіться цей приклад у Вікіпедії .

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

  1. ЯКЩО - Отримайте інструкцію з пам'яті
  2. ID - Розшифрувати інструкцію
  3. EX - Виконай інструкцію
  4. WB - Назад до реєстру процесора

4-ступінчастий трубопровід взагалі за 2 інструкції. 4-ступінчастий трубопровід загалом

Повернувшись до вищезазначеного питання, давайте розглянемо наступні вказівки:

                        A) if (data[c] >= 128)
                                /\
                               /  \
                              /    \
                        true /      \ false
                            /        \
                           /          \
                          /            \
                         /              \
              B) sum += data[c];          C) for loop or print().

Без галузевого прогнозування відбулося б таке:

Щоб виконати інструкцію B або інструкцію C, процесору доведеться чекати, поки інструкція A не досягне стадії EX в трубопроводі, оскільки рішення про перехід до інструкції B або інструкції C залежить від результату інструкції A. Отже, конвеєр буде виглядати так.

коли якщо умова повертає справжнє: введіть тут опис зображення

Коли якщо стан повертається неправдиво: введіть тут опис зображення

В результаті очікування результату інструкції А загальний цикл процесора, витрачений у вищенаведеному випадку (без прогнозування галузей; для істинного і хибного), становить 7.

То що таке галузеве передбачення?

Передбачувач гілок спробує відгадати, куди піде гілка (структура тоді), перш ніж це буде відомо точно. Він не чекатиме, коли інструкція A досягне EX-етапу трубопроводу, але відгадає рішення і перейде до цієї інструкції (B або C у випадку нашого прикладу).

У разі правильної здогадки трубопровід виглядає приблизно так: введіть тут опис зображення

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

У коді ОП, коли в умовному випадку передбачувач гілки не має жодної інформації для підґрунтя передбачення, тому перший раз він випадковим чином вибере наступну інструкцію. Пізніше, у циклі for, він може грунтувати прогноз на історії. Для масиву, відсортованого у порядку зростання, є три можливості:

  1. Всього елементів менше 128
  2. Всіх елементів більше 128
  3. Деякі вихідні нові елементи менше 128, а пізніше вони стають більше 128

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

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

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

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


1
як дві інструкції виконуються разом? це робиться з окремими процесорними ядрами або інструкція конвеєра інтегрована в одне ядро ​​процесора?
M.kazem Akhgary

1
@ M.kazemAkhgary Все це всередині одного логічного ядра. Якщо вас цікавить, це чудово описано, наприклад, у Посібнику для розробників програмного забезпечення Intel
Sergey.quixoticaxis.Ivanov

727

Офіційна відповідь буде від

  1. Intel - уникнення витрат непередбачуваних галузей
  2. Реорганізація філій та циклів для запобігання непередбачуваним прогнозам
  3. Наукові праці - комп'ютерна архітектура прогнозування
  4. Книги: Дж. Л. Хеннесі, Д. А. Паттерсон: Комп'ютерна архітектура: кількісний підхід
  5. Статті в наукових публікаціях: TY Yeh, YN Patt зробили багато цього на галузевих прогнозах.

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

2-бітова діаграма стану

Кожен елемент у вихідному коді є випадковим значенням

data[c] = std::rand() % 256;

тому передбачувач змінить сторони, як std::rand()удар.

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



696

У цьому ж рядку (я думаю, що це не було виділено жодною відповіддю) добре згадати, що іноді (особливо в програмному забезпеченні, де важлива продуктивність - як у ядрі Linux), ви можете знайти деякі, якщо такі заяви, як:

if (likely( everything_is_ok ))
{
    /* Do something */
}

або аналогічно:

if (unlikely(very_improbable_condition))
{
    /* Do something */    
}

Обидва , likely()і unlikely()насправді є макросами, які визначені за допомогою що - щось на зразок GCC - х , __builtin_expectщоб допомогти компілятору вставити код передбачення на користь умови , беручи до уваги інформацію , надану користувачем. GCC підтримує інші вбудовані модулі, які можуть змінити поведінку запущеної програми або видавати інструкції низького рівня, такі як очищення кеша тощо. Дивіться цю документацію, яка проходить через наявні вбудовані програми GCC.

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


678

Часто використовувані булеві операції в C ++ дають багато гілок складеної програми. Якщо ці гілки знаходяться всередині циклів і важко передбачити, вони можуть значно уповільнити виконання. Булеві змінні зберігаються як 8-бітні цілі числа зі значенням 0для falseі 1для true.

Булеві змінні переопределено в тому сенсі, що всі оператори, які мають булеві змінні як вхідні, перевіряють, чи є вхідні дані інші, ніж 0або 1, але оператори, які мають булі, як вихід, не можуть створювати іншого значення, ніж 0або 1. Це робить операції з булевими змінними як введення менш ефективними, ніж потрібно. Розглянемо приклад:

bool a, b, c, d;
c = a && b;
d = a || b;

Зазвичай компілятор реалізується таким чином:

bool a, b, c, d;
if (a != 0) {
    if (b != 0) {
        c = 1;
    }
    else {
        goto CFALSE;
    }
}
else {
    CFALSE:
    c = 0;
}
if (a == 0) {
    if (b == 0) {
        d = 0;
    }
    else {
        goto DTRUE;
    }
}
else {
    DTRUE:
    d = 1;
}

Цей код далеко не оптимальний. Гілки можуть зайняти тривалий час у разі помилок. Булеві операції можна зробити набагато ефективнішими, якщо з впевненістю відомо, що операнди не мають інших значень, ніж 0і 1. Причина, по якій компілятор не робить такого припущення, полягає в тому, що змінні можуть мати інші значення, якщо вони не ініціалізовані або походять з невідомих джерел. Наведений вище код можна оптимізувати , якщо aі bбув инициализируется допустимих значення або якщо вони приходять від операторів , які виробляють логічний вихід. Оптимізований код виглядає приблизно так:

char a = 0, b = 1, c, d;
c = a & b;
d = a | b;

charвикористовується замість boolтого, щоб зробити можливим використання побітових операторів ( &і |) замість булевих операторів ( &&і ||). Побітні оператори - це поодинокі інструкції, що займають лише один тактовий цикл. Оператор АБО ( |) працює, навіть якщо aі bмає інші значення, ніж 0або 1. Оператор AND ( &) та оператор ЕКСКЛЮЗИВНИЙ АБО ( ^) можуть дати непослідовні результати, якщо операнди мають інші значення, ніж 0та 1.

~не можна використовувати для NOT. Натомість ви можете створити булеву НЕ на змінній, яка, як відомо, 0або 1XOR'ing 1:

bool a, b;
b = !a;

можна оптимізувати до:

char a = 0, b;
b = a ^ 1;

a && bне може бути замінений на a & bif b- це вираз, який не слід оцінювати, якщо aє false( &&не буде оцінювати b, &буде). Так само a || bне можна замінити, a | bякщо bє виразом, який не слід оцінювати, якщо aє true.

Використання побітових операторів вигідніше, якщо операнди є змінними, ніж якщо операнди є порівняннями:

bool a; double x, y, z;
a = x > y && z < 5.0;

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


341

Це точно!...

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

Якщо масив відсортований, ваш стан є помилковим на першому кроці:, data[c] >= 128то стає справжнім значенням для всього шляху до кінця вулиці. Ось так ви швидше дістаєтесь до логіки. З іншого боку, використовуючи несортований масив, вам потрібно багато поворотів і обробки, які змушують ваш код точно повільніше працювати ...

Подивіться на зображення, яке я створив для вас нижче. Яку вулицю швидше закінчити?

Галузеве передбачення

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

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

1. Статичний

2. Динамічний

Галузеве передбачення

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

Для того, щоб ефективно написати свій код, щоб скористатися цими правилами, під час написання if-else чи перемикання виписок спочатку перевіряйте найпоширеніші випадки та працюйте поступово до найменшого поширеного. Цикли не обов'язково вимагають спеціального впорядкування коду для прогнозу статичної гілки, оскільки зазвичай використовується лише умова ітератора циклу.


304

На це питання вже багато разів відповідали. Я все ж хотів би звернути увагу групи на ще один цікавий аналіз.

Нещодавно цей приклад (дуже незначно модифікований) також використовувався як спосіб продемонструвати, як фрагмент коду може бути профільований в рамках самої програми в Windows. Одночасно автор також показує, як використовувати результати, щоб визначити, де код витрачає більшу частину свого часу як у відсортованому, так і в несортованому випадку. Нарешті, у статті також показано, як використовувати маловідому функцію HAL (Hardware Abstraction Layer), щоб визначити, скільки непередбачуваних галузевих дій відбувається в несортованому випадку.

Посилання тут: http://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm


3
Це дуже цікава стаття (насправді я тільки що її прочитала), але як вона відповідає на питання?
Пітер Мортенсен

2
@PeterMortensen Я трохи розгублений вашим запитанням. Наприклад, ось один відповідний рядок із цього твору: When the input is unsorted, all the rest of the loop takes substantial time. But with sorted input, the processor is somehow able to spend not just less time in the body of the loop, meaning the buckets at offsets 0x18 and 0x1C, but vanishingly little time on the mechanism of looping. Автор намагається обговорити профілювання в контексті коду, розміщеного тут, і в процесі намагається пояснити, чому сортований випадок набагато швидший.
ForeverLearning

260

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

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

У комп'ютерній архітектурі передбачувач гілки - це цифровий ланцюг, який намагається здогадатися, куди піде гілка (наприклад, структура if-then-else), перш ніж це буде відомо точно. Метою галузевого прогноктора є поліпшення потоку в інструкційному трубопроводі. Провідники галузей відіграють вирішальну роль у досягненні високої ефективності в багатьох сучасних конвеєрних мікропроцесорних архітектурах, таких як x86.

Двостороння розгалуження зазвичай реалізується за допомогою умовної інструкції про стрибок. Умовний стрибок можна або "не взяти", і продовжити виконання з першою гілкою коду, яка слідує відразу після умовного стрибка, або її можна "взяти" і перейти на інше місце в пам'яті програми, де друга гілка коду зберігається. Достеменно невідомо, чи буде прийнятий умовний стрибок чи не буде здійснено, поки умова не буде розрахована і умовний стрибок не пройде етап виконання в інструкційному конвеєрі (див. Рис. 1).

Фігура 1

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

  1. Без галузевого передбачувача.

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

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

без галузевого прогноктора

Для виконання 3 інструкцій знадобиться 9 циклів годин.

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

введіть тут опис зображення

Для виконання 3 інструкцій знадобиться 7 тактових годин.

  1. Використовуйте гілку "Прогноз" і виконайте умовний стрибок. Припустимо, що прогноз не сприймає умовного стрибка.

введіть тут опис зображення

Для виконання 3 інструкцій знадобиться 9 циклів годин.

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

Як бачимо, схоже, у нас немає причин не використовувати передбачувану гілку.

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


1
Майже настільки ж хороші, як і маркетингові анімації Intel, і вони були одержимі не просто передбаченням галузей, а поза виконанням порядку, обидві стратегії були "умоглядними". Попереднє читання в пам'яті та зберіганні (послідовне попереднє отримання до буфера) також є спекулятивним. Це все додає.
mckenzm

@mckenzm: спекулятивний exec поза замовленням робить передбачення галузей ще більш цінним; а також приховування бульбашок вилучення / декодування, прогнозування гілок + спекулятивний exec знімає контрольні залежності від критичної затримки шляху. Код всередині або після if()блоку може виконуватися до того, як стане відомою умова гілки. Або для циклу пошуку, як-от strlenабо memchr, інтеграції можуть перетинатися. Якщо вам довелося чекати, коли результат матчу чи / не буде відомий перед запуском будь-якої наступної ітерації, ви будете вузьким місцем для завантаження кешу + затримка ALU замість пропускної здатності.
Пітер Кордес

209

Приріст галузевого прогнозування!

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

if (expression)
{
    // Run 1
} else {
    // Run 2
}

Щоразу, коли є if-else\ switchоператор, вираз слід оцінювати, щоб визначити, який блок слід виконати. У код складання, сформований компілятором, вставляються умовні інструкції гілки .

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

Якщо говорити, компілятор намагається передбачити результат до того, як він буде фактично оцінений. Він отримає інструкції з ifблоку, і якщо вираз виявиться правдивим, то чудовий! Ми отримали час, необхідний для його оцінювання, і прогресували в кодексі; якщо ні, то ми виконуємо неправильний код, конвеєр промивається, і правильний блок запускається.

Візуалізація:

Скажімо, вам потрібно вибрати маршрут 1 або маршрут 2. Чекаючи, коли ваш партнер перевірить карту, ви зупинилися на ## і чекали, або ви можете просто вибрати маршрут1 і якщо вам пощастило (маршрут 1 - правильний маршрут), тоді чудово вам не довелося чекати, коли ваш партнер перевірить карту (ви заощадили час, який би знадобився йому для перевірки карти), інакше ви просто повернете назад.

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

 O      Route 1  /-------------------------------
/|\             /
 |  ---------##/
/ \            \
                \
        Route 2  \--------------------------------

Хоча промивання трубопроводів відбувається дуже швидко Не дуже. Це швидко в порівнянні з кешем, пропущеним до DRAM, але на сучасному високопродуктивному x86 (наприклад, сімейство Intel Sandybridge) це близько десятка циклів. Хоча швидке відновлення дає змогу уникнути очікування, коли всі старі незалежні інструкції вийдуть на пенсію до початку відновлення, ви все одно втрачаєте безліч передових циклів за неправильним прогнозом. Що саме відбувається, коли неймовірний процесор неправильно прогнозує галузь? . (І на кожен цикл може бути близько 4 інструкцій роботи.) Погано для високопропускного коду.
Пітер Кордес

153

Для ARM немає необхідної гілки, оскільки кожна інструкція має 4-бітове поле умови, яке тестує (за нульової вартості) будь-які 16 різних умов, які можуть виникнути в Реєстрі статусу процесора, і якщо умова в інструкції є false, інструкція пропускається. Це виключає потребу в коротких гілках, і для цього алгоритму прогнозування гілок не буде. Тому відсортована версія цього алгоритму працюватиме повільніше, ніж несортована версія в ARM, через додаткові накладні сортування.

В мові складання ARM внутрішній цикл цього алгоритму виглядатиме приблизно так:

MOV R0, #0     // R0 = sum = 0
MOV R1, #0     // R1 = c = 0
ADR R2, data   // R2 = addr of data array (put this instruction outside outer loop)
.inner_loop    // Inner loop branch label
    LDRB R3, [R2, R1]     // R3 = data[c]
    CMP R3, #128          // compare R3 to 128
    ADDGE R0, R0, R3      // if R3 >= 128, then sum += data[c] -- no branch needed!
    ADD R1, R1, #1        // c++
    CMP R1, #arraySize    // compare c to arraySize
    BLT inner_loop        // Branch to inner_loop if c < arraySize

Але це насправді частина більшої картини:

CMPопкоди завжди оновлюють біти статусу в Реєстрі статусу процесора (PSR), оскільки це їхня мета, але більшість інших інструкцій не стосуються PSR, якщо ви не додаєте Sдо інструкції додатковий суфікс із зазначенням, що PSR слід оновлювати на основі результат інструкції. Як і 4-розрядний суфікс умови, можливість виконувати інструкції, не впливаючи на PSR, - це механізм, який зменшує потребу в гілках на ARM, а також полегшує відправлення поза замовленням на апаратному рівні , оскільки після виконання деякої операції X, яка оновлює біти стану, згодом (або паралельно) ви можете виконати купу інших робіт, які явно не повинні впливати на біти статусу, тоді ви можете перевірити стан бітів статусу, встановлених раніше X.

Поле тестування стану та необов'язкове поле "встановити біт статусу" можна комбінувати, наприклад:

  • ADD R1, R2, R3виконує R1 = R2 + R3без оновлення будь-яких бітів статусу.
  • ADDGE R1, R2, R3 виконує ту саму операцію лише в тому випадку, якщо попередня інструкція, яка вплинула на біти статусу, призвела до стану "Більше чи рівного".
  • ADDS R1, R2, R3виконує додавання і потім оновлює N, Z, Cі Vпрапори в Processor регістра стану на основі чи був результат негативний, нульовий, що носяться (для знака складання) або переповненого (для знакового додатково).
  • ADDSGE R1, R2, R3виконує додавання лише в тому випадку, якщо GEтест є істинним, а згодом оновлює біти стану на основі результату додавання.

Більшість архітектур процесорів не мають можливості визначати, чи слід оновлювати біти стану для даної операції, що може вимагати написання додаткового коду для збереження та пізніше відновлення бітів статусу, або може вимагати додаткових гілок, або може обмежити вихід процесора Ефективність виконання замовлення: один з побічних ефектів більшості архітектурних наборів процесорів, примусово оновлюючи біти статусу після більшості інструкцій, полягає в тому, що набагато складніше розривати окремі інструкції, які можна виконувати паралельно, не заважаючи один одному. Оновлення бітів статусу має побічні ефекти, тому має лінеаризуючий вплив на код.Здатність ARM змішувати та узгоджувати тестування умов без віток будь-якої інструкції з можливістю оновлення або не оновлення бітів статусу після будь-якої інструкції є надзвичайно потужною, як для програмістів на мові збірки, так і для компіляторів, і створює дуже ефективний код.

Якщо ви коли-небудь замислювались, чому ARM був настільки феноменально успішним, блискуча ефективність та взаємодія цих двох механізмів є важливою частиною історії, оскільки вони є одним з найбільших джерел ефективності архітектури ARM. Блиск оригінальних дизайнерів ARM ISA ще в 1983 році Стіва Фербера та Роджера (тепер Софі) Вілсона не можна переоцінити.


1
Іншим нововведенням в ARM є додавання суфікса інструкцій S, також необов'язкового для (майже) всіх інструкцій, які, якщо вони відсутні, не дозволяють інструкціям змінювати біти статусу (за винятком інструкції CMP, завданням якої є встановлення бітів статусу, тому він не потребує суфікса S). Це дозволяє уникнути інструкцій CMP у багатьох випадках, якщо порівняння дорівнює нулю або подібному (наприклад, SUBS R0, R0, # 1 встановить біт Z (нуль), коли R0 досягне нуля). Умови та суфікс S несуть нульові накладні витрати. Це досить красивий ISA.
Люк Хатчісон

2
Якщо не додавати суфікс S, ви можете мати кілька умовних вказівок підряд, не переживаючи, що один з них може змінити біти статусу, що в іншому випадку може мати побічний ефект від пропуску решти умовних інструкцій.
Люк Хатчісон

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

До речі, ви можете зберегти інструкцію в циклі, індексуючи відносно кінця масиву. Перед циклом встановіть R2 = data + arraySize, а потім почніть з R1 = -arraySize. Низ петлі стає adds r1, r1, #1/ bnz inner_loop. Компілятори не використовують цю оптимізацію з якихось причин: / Але в будь-якому випадку, передбачуване виконання надбудови в цьому випадку принципово не відрізняється від того, що ви можете зробити з безрозгалужувальним кодом для інших ISA, наприклад x86 cmov. Хоча це не так приємно: прапор оптимізації gcc -O3 робить код повільніше, ніж -O2
Пітер Кордес,

1
(Виконання ARM прогнозує дійсно NOPs інструкцію, тому ви можете навіть використовувати її на завантаженнях або сховищах, які б cmovвиникла , на відміну від x86 з операндом джерела пам'яті. Більшість ISA, включаючи AArch64, мають лише операції вибору ALU. Таким чином, прогнозування ARM може бути потужним, і використовувати його більш ефективно, ніж безроздільний код на більшості ISA.)
Пітер Кордес,

146

Йдеться про галузеве передбачення. Що це?

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

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

  • На додаток до цього, у складних техніках прогнозування час, необхідний для прогнозування гілок, дуже великий - складає від 2 до 5 циклів - що можна порівняти з часом виконання фактичних гілок.

  • Прогнозування галузей - це, по суті, проблема оптимізації (мінімізації), де акцент робиться на досягненні мінімально можливого пропуску, низькому споживанні енергії та низькій складності з мінімальними ресурсами.

Дійсно є три різні види галузей:

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

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

Безумовні гілки - це включає стрибки, виклики процедури та повернення, які не мають конкретної умови. Наприклад, безумовна інструкція стрибків може бути закодована мовою складання просто "jmp", і потік інструкцій повинен бути негайно спрямований до цільового місця, на яке вказує інструкція стрибку, тоді як умовний стрибок, який може бути кодований як "jmpne" буде перенаправляти потік інструкцій лише в тому випадку, якщо результат порівняння двох значень у попередній інструкції "порівняння" показує, що значення не рівні. (Схема сегментованої адресації, що використовується архітектурою x86, додає додаткової складності, оскільки стрибки можуть бути або "поруч" (у межах сегмента), або "далеко" (поза сегментом). Кожен тип має різний вплив на алгоритми прогнозування гілок.)

Статичне / динамічне передбачення гілки: Мікропроцесор застосовує статичне прогнозування гілок вперше, коли виникає умовна гілка, а динамічне прогнозування гілок використовується для успішного виконання умовного коду гілки.

Список літератури:


145

Крім того, що передбачення гілок може сповільнити вас, відсортований масив має ще одну перевагу:

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

 // sort backwards (higher values first), may be in some other part of the code
 std::sort(data, data + arraySize, std::greater<int>());

 for (unsigned c = 0; c < arraySize; ++c) {
       if (data[c] < 128) {
              break;
       }
       sum += data[c];               
 }

1
Правильно, але вартість налаштування сортування масиву становить O (N log N), тому переривання на ранніх етапах не допоможе вам, якщо єдиною причиною сортування масиву є можливість вийти з ладу рано. Якщо ж у вас є інші причини для сортування масиву, то так, це цінно.
Люк Хатчісон

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

2
Так, це саме те, що я зробив у своєму першому коментарі :-) Ви говорите: "Прогноз у галузі буде пропущений лише один раз". Але ви не враховуєте промахи гілки O (N log N) всередині алгоритму сортування, що насправді більше, ніж помилки прогнозування гілки O (N) у несортованому випадку. Таким чином, вам потрібно буде використати цілу кількість відсортованих даних O (log N), щоб розбити цілком (мабуть, фактично ближче до O (10 log N), залежно від алгоритму сортування, наприклад для швидкості, через кеш-пропуски - mergesort є більш кешованою, тому вам знадобиться ближче до використання O (2 log N), щоб вирівняти.)
Люк Хатчісон,

Однією з важливих оптимізацій, хоча було б зробити лише "півкількості", сортуючи лише елементи, менші за цільове значення опорної позначки 127 (припускаючи, що все менше, ніж рівно , відсортовано після повороту). Як тільки ви досягнете стрижня, підсумовуйте елементи перед стрижнем. Це запускається в час запуску O (N), а не O (N log N), хоча все ще буде багато прогалин гілок, можливо, порядку O (5 N) на основі чисел, які я дав раніше, оскільки це півкільця.
Люк Хатчісон

132

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

Індикатор гілки - це цифровий ланцюг (в комп'ютерній архітектурі), який намагається передбачити, яким шляхом буде йти галузь, покращуючи потік в конвеєрі інструкцій. Схема / комп'ютер прогнозує наступний крок і виконує його.

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

Відповідь на ваше запитання дуже проста.

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

Сортований масив: Пряма дорога ____________________________________________________________________________________ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Неортинований масив: Крива дорога

______   ________
|     |__|

Прогнозування галузі: Вгадайте / прогнозуйте, яка дорога пряма, і слідувати за нею без перевірки

___________________________________________ Straight road
 |_________________________________________|Longer road

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


Також хочу цитувати @Simon_Weaver з коментарів:

Він не робить менше прогнозів - робить менше неправильних прогнозів. Це все одно має передбачати кожен раз через цикл ...


122

Я спробував той самий код з MATLAB 2011b з моїм MacBook Pro (Intel i7, 64 біт, 2,4 ГГц) для наступного коду MATLAB:

% Processing time with Sorted data vs unsorted data
%==========================================================================
% Generate data
arraySize = 32768
sum = 0;
% Generate random integer data from range 0 to 255
data = randi(256, arraySize, 1);


%Sort the data
data1= sort(data); % data1= data  when no sorting done


%Start a stopwatch timer to measure the execution time
tic;

for i=1:100000

    for j=1:arraySize

        if data1(j)>=128
            sum=sum + data1(j);
        end
    end
end

toc;

ExeTimeWithSorting = toc - tic;

Результати для вищевказаного коду MATLAB такі:

  a: Elapsed time (without sorting) = 3479.880861 seconds.
  b: Elapsed time (with sorting ) = 2377.873098 seconds.

Результати коду С як у @GManNickG я отримую:

  a: Elapsed time (without sorting) = 19.8761 sec.
  b: Elapsed time (with sorting ) = 7.37778 sec.

Виходячи з цього, виглядає, що MATLAB майже в 175 разів повільніше, ніж реалізація C без сортування, і в 350 разів повільніше при сортуванні. Іншими словами, ефект (прогнозування галузей) становить 1,46x для реалізації MATLAB та 2,7x для реалізації C.


6
Просто задля повноти, мабуть, це не так, як ви це реалізували б у Matlab. Надіюсь, це буде набагато швидше, якби це зробити після векторизації проблеми.
ysap

1
Matlab робить автоматичну паралелізацію / векторизацію в багатьох ситуаціях, але тут питання полягає в тому, щоб перевірити ефект прогнозування галузей. Матлаб ні в якому разі не застрахований!
Шань

1
Чи використовує matlab рідні номери або специфічну реалізацію мат-лабораторії (нескінченна кількість цифр чи так?)
Thorbjørn Ravn Andersen

54

Припущення інших відповідей про те, що потрібно сортувати дані, не є правильним.

Наступний код не сортує весь масив, а лише 200-елементні його сегменти, і тим самим працює найшвидше.

Сортування лише розділів k-елементів завершує попередню обробку за лінійним часом O(n), а не тим O(n.log(n))часом, необхідним для сортування всього масиву.

#include <algorithm>
#include <ctime>
#include <iostream>

int main() {
    int data[32768]; const int l = sizeof data / sizeof data[0];

    for (unsigned c = 0; c < l; ++c)
        data[c] = std::rand() % 256;

    // sort 200-element segments, not the whole array
    for (unsigned c = 0; c + 200 <= l; c += 200)
        std::sort(&data[c], &data[c + 200]);

    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i) {
        for (unsigned c = 0; c < sizeof data / sizeof(int); ++c) {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    std::cout << static_cast<double>(clock() - start) / CLOCKS_PER_SEC << std::endl;
    std::cout << "sum = " << sum << std::endl;
}

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


4
Я насправді не бачу, як це щось доводить? Єдине, що ви показали, - це те, що "виконання всієї роботи по сортуванню всього масиву займає менше часу, ніж сортування всього масиву". Ваше твердження, що це "також працює найшвидше", дуже залежить від архітектури. Дивіться мою відповідь про те, як це працює на ARM. PS ви можете зробити свій код швидше в архітектурах, що не належать до ARM, помістивши підсумок всередині блоку циклу 200 елементів, сортуючи в зворотному порядку, а потім використовуючи пропозицію Yochai Timmer про розбиття, як тільки ви отримаєте значення поза діапазоном. Таким чином, кожне 200-елементне підсумовування блоку може бути припинено достроково.
Люк Хатчісон,

Якщо ви просто хочете ефективно реалізувати алгоритм над несортованими даними, ви зробите цю операцію без гілок (і з SIMD, наприклад, з x86, pcmpgtbщоб знайти елементи з їх високим набором бітів, а потім на нуль менших елементів). Витрачати будь-який час насправді сортування шматочків буде повільніше. Версія без галузей мала б незалежні дані, що також доводить, що вартість походить від неправильного прогнозування галузей. Або просто скористайтеся лічильниками ефективності, щоб спостерігати це безпосередньо, як Skylake, int_misc.clear_resteer_cyclesабо int_misc.recovery_cyclesрахувати передніми холостими циклами від непередбачуваних дій
Peter Cordes

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

36

Відповідь Б'ярна Струструпа на це питання:

Це звучить як запитання про інтерв'ю. Це правда? Як би ти знав? Відповідати на питання щодо ефективності погано, не попередньо провівши деякі вимірювання, тому важливо знати, як виміряти.

Отже, я спробував з вектором мільйон цілих чисел і отримав:

Already sorted    32995 milliseconds
Shuffled          125944 milliseconds

Already sorted    18610 milliseconds
Shuffled          133304 milliseconds

Already sorted    17942 milliseconds
Shuffled          107858 milliseconds

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

void run(vector<int>& v, const string& label)
{
    auto t0 = system_clock::now();
    sort(v.begin(), v.end());
    auto t1 = system_clock::now();
    cout << label 
         << duration_cast<microseconds>(t1  t0).count() 
         << " milliseconds\n";
}

void tst()
{
    vector<int> v(1'000'000);
    iota(v.begin(), v.end(), 0);
    run(v, "already sorted ");
    std::shuffle(v.begin(), v.end(), std::mt19937{ std::random_device{}() });
    run(v, "shuffled    ");
}

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

Однією з причин є передбачення галузей: ключова операція в алгоритмі сортування є “if(v[i] < pivot]) …”рівнозначною. Для відсортованої послідовності цей тест завжди відповідає дійсності, тоді як для випадкової послідовності обрана гілка змінюється випадковим чином.

Ще одна причина полягає в тому, що коли вектор вже відсортований, нам ніколи не потрібно переміщувати елементи у їх правильне положення. Ефект цих дрібниць - це п’ять-шість факторів, які ми бачили.

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

Якщо ви хочете написати ефективний код, вам потрібно трохи знати про машинну архітектуру.


27

Це запитання пов'язане з моделями прогнозування галузей на процесорах. Я рекомендую прочитати цей документ:

Підвищення швидкості завантаження інструкцій через передбачення декількох гілок та кеш адрес адреси філії

Коли ви відсортуєте елементи, ІК не зможе заважати отримувати всі інструкції процесора, знову і знову, це отримує їх з кешу.


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

15

Один із способів уникнути помилок прогнозування гілок - це побудувати таблицю пошуку та індексувати її за допомогою даних. Штефан де Бруйн обговорював це у своїй відповіді.

Але в цьому випадку ми знаємо, що значення знаходяться в діапазоні [0, 255], і ми дбаємо лише про значення> = 128. Це означає, що ми можемо легко витягти один біт, який підкаже нам, чи хочемо ми значення чи ні: шляхом зміщення дані праворуч 7 біт, нам залишається 0 біт або 1 біт, і ми хочемо додати значення лише тоді, коли у нас є 1 біт. Назвемо цей біт «бітом рішення».

Використовуючи значення 0/1 біта рішення в якості індексу в масив, ми можемо зробити код, який буде однаково швидким, сортувавши дані чи не відсортувавшись. Наш код завжди додасть значення, але коли біт рішення дорівнює 0, ми додамо значення десь нам не цікаво. Ось код:

// Тест

clock_t start = clock();
long long a[] = {0, 0};
long long sum;

for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        int j = (data[c] >> 7);
        a[j] += data[c];
    }
}

double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];

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

Але в моєму тестуванні явна таблиця пошуку була трохи швидшою, ніж ця, ймовірно, тому, що індексація в таблиці пошуку була трохи швидшою, ніж бітова зміна. Це показує, як мій код налаштовує та використовує таблицю пошуку (мальовничо називається lut для "LookUp Table" у коді). Ось код C ++:

// Оголосити та потім заповнити таблицю пошуку

int lut[256];
for (unsigned c = 0; c < 256; ++c)
    lut[c] = (c >= 128) ? c : 0;

// Use the lookup table after it is built
for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        sum += lut[data[c]];
    }
}

У цьому випадку таблиця пошуку становила лише 256 байт, тому вона добре вміщується в кеш-пам'яті, і все пройшло швидко. Ця методика не буде добре працювати, якби дані були 24-бітовими значеннями, і ми хотіли лише їх половини ... таблиця пошуку була б занадто великою, щоб бути практичною. З іншого боку, ми можемо поєднати дві прийоми, показані вище: спочатку змістіть біти, а потім індексуйте таблицю пошуку. Для 24-бітного значення, яке нам потрібне лише значення верхньої половини, ми можемо потенційно змістити дані на 12 біт і залишити 12-бітове значення для індексу таблиці. 12-бітний індекс таблиці передбачає таблицю з 4096 значень, що може бути практичним.

Метод індексації в масиві замість використання оператора if може бути використаний для вирішення, який вказівник використовувати. Я бачив бібліотеку, яка реалізовувала двійкові дерева, і замість того, щоб мати два названі вказівники (pLeft та pRight чи будь-що інше), мав масив покажчиків довжиною-2 і застосував техніку "біт рішення", щоб вирішити, який слід використовувати. Наприклад, замість:

if (x < node->value)
    node = node->pLeft;
else
    node = node->pRight;
this library would do something like:

i = (x < node->value);
node = node->link[i];

це приємне рішення, можливо, воно спрацює


З яким компілятором / обладнанням C ++ ви це тестували та з якими параметрами компілятора? Я здивований, що оригінальна версія не автоматично векторизувалась на хороший SIMD-код без гілок. Ви включили повну оптимізацію?
Пітер Кордес

Таблиця пошуку записів 4096 звучить божевільно. Якщо ви виходите будь біти, вам потрібно не тільки використовувати результат LUT , якщо ви хочете додати оригінальний номер. Це все звучить як нерозумні хитрощі, щоб обійти компілятор не просто, використовуючи методи без гілок. Більш прямим було б mask = tmp < 128 : 0 : -1UL;/total += tmp & mask;
Пітер Кордес
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.