Чому введення непотрібних інструкцій MOV прискорить щільний цикл у зборі x86_64?


222

Фон:

Оптимізуючи деякий код Pascal із вбудованою мовою збірки, я помітив непотрібну MOVінструкцію та видалив її.

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

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

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

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

Дані:

Версія мого коду умовно компілює три непотрібні операції в середині циклу, який працює в 2**20==1048576рази. (Навколишня програма просто обчислює хеші SHA-256 ).

Результати на моїй досить старій машині (Intel (R) Core (TM) 2 CPU 6400 при 2,13 ГГц):

avg time (ms) with -dJUNKOPS: 1822.84 ms
avg time (ms) without:        1836.44 ms

Програми виконувались 25 разів у циклі, при цьому порядок виконання змінювався випадковим чином кожного разу.

Витяг:

{$asmmode intel}
procedure example_junkop_in_sha256;
  var s1, t2 : uint32;
  begin
    // Here are parts of the SHA-256 algorithm, in Pascal:
    // s0 {r10d} := ror(a, 2) xor ror(a, 13) xor ror(a, 22)
    // s1 {r11d} := ror(e, 6) xor ror(e, 11) xor ror(e, 25)
    // Here is how I translated them (side by side to show symmetry):
  asm
    MOV r8d, a                 ; MOV r9d, e
    ROR r8d, 2                 ; ROR r9d, 6
    MOV r10d, r8d              ; MOV r11d, r9d
    ROR r8d, 11    {13 total}  ; ROR r9d, 5     {11 total}
    XOR r10d, r8d              ; XOR r11d, r9d
    ROR r8d, 9     {22 total}  ; ROR r9d, 14    {25 total}
    XOR r10d, r8d              ; XOR r11d, r9d

    // Here is the extraneous operation that I removed, causing a speedup
    // s1 is the uint32 variable declared at the start of the Pascal code.
    //
    // I had cleaned up the code, so I no longer needed this variable, and 
    // could just leave the value sitting in the r11d register until I needed
    // it again later.
    //
    // Since copying to RAM seemed like a waste, I removed the instruction, 
    // only to discover that the code ran slower without it.
    {$IFDEF JUNKOPS}
    MOV s1,  r11d
    {$ENDIF}

    // The next part of the code just moves on to another part of SHA-256,
    // maj { r12d } := (a and b) xor (a and c) xor (b and c)
    mov r8d,  a
    mov r9d,  b
    mov r13d, r9d // Set aside a copy of b
    and r9d,  r8d

    mov r12d, c
    and r8d, r12d  { a and c }
    xor r9d, r8d

    and r12d, r13d { c and b }
    xor r12d, r9d

    // Copying the calculated value to the same s1 variable is another speedup.
    // As far as I can tell, it doesn't actually matter what register is copied,
    // but moving this line up or down makes a huge difference.
    {$IFDEF JUNKOPS}
    MOV s1,  r9d // after mov r12d, c
    {$ENDIF}

    // And here is where the two calculated values above are actually used:
    // T2 {r12d} := S0 {r10d} + Maj {r12d};
    ADD r12d, r10d
    MOV T2, r12d

  end
end;

Спробуйте самі:

Код в Інтернеті на GitHub, якщо ви хочете спробувати його самостійно.

Мої запитання:

  • Чому б марно копіювати вміст реєстру в оперативну пам’ять коли-небудь підвищувати продуктивність?
  • Чому та сама непотрібна інструкція забезпечить прискорення руху на одних лініях, а сповільнення - на інших?
  • Чи є така поведінка чимось, що може передбачувано використовувати компілятор?

7
Існують всілякі "непотрібні" інструкції, які фактично можуть слугувати для розриву ланцюгів залежностей, маркування фізичних регістрів як пенсіонерів тощо. Експлуатація цих операцій вимагає певних знань мікроархітектури . Ваше запитання має містити коротку послідовність інструкцій як мінімальний приклад, а не направляти людей до github.
Бретт Хейл

1
@BrettHale хороший момент, дякую. Я додав уривок коду з деяким коментарем. Чи може скопіювати значення регістра на ram, позначити реєстр як вибутий, навіть якщо значення в ньому буде використано пізніше?
дотична буря

9
Чи можете ви поставити стандартне відхилення на ці середні показники? У цій публікації немає фактичних ознак того, що існує реальна різниця.
голодував

2
Чи можете ви спробувати встановити час, використовуючи інструкцію rdtscp, і перевірити цикли годин для обох версій?
jakobbotsch

2
Може це також пов’язано з вирівнюванням пам’яті? Я не займався математикою самостійно (ледачий: P), але додавання інструкцій на манекен може призвести до того, що ваш код буде вирівняний по пам'яті ...
Lorenzo Dematté

Відповіді:


144

Найбільш вірогідною причиною покращення швидкості є те, що:

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

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

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

Якщо ви хочете зрозуміти, як неправильні прогнози галузі впливають на ефективність, погляньте на цю чудову відповідь: https://stackoverflow.com/a/11227902/1001643

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


2
Хм. Це звучить перспективно. Єдиними умовними гілками в цій реалізації sha256 є перевірка кінця циклів FOR. У той час я позначив цю версію як дивацтво в git і продовжував оптимізувати. Одним із моїх наступних кроків було перезапис циклу pascal FOR у монтажі, після чого ці додаткові інструкції вже не мали позитивного ефекту. Можливо, генерований код безкоштовного паскаля процесору було важче передбачити, ніж простий лічильник, на який я його замінив.
дотична буря

1
@tangentstorm Це здається гарним підсумком. Таблиця передбачення гілок не дуже велика, тому одна запис таблиці може стосуватися більше однієї гілки. Це може зробити деякі прогнози марними. Проблема легко вирішується, якщо одна з конфліктуючих гілок переходить до іншої частини таблиці. Майже будь-які невеликі зміни можуть зробити це :-)
Реймонд Хеттінгер

1
Я думаю, що це найрозумніше пояснення конкретної поведінки, яку я спостерігав, тому збираюся позначити це як відповідь. Дякую. :)
tangentstorm

3
Проводиться абсолютно чудове обговорення подібної проблеми, з якою наштовхнувся один із учасників програми Bochs, ви можете додати це до своєї відповіді: emulators.com/docs/nx25_nostradamus.htm
орендар

3
Вирівнювання в інтернеті має значення набагато більше, ніж просто цілі галузі. Вузькі місця декодування є величезною проблемою для Core2 та Nehalem: у нього часто важко тримати зайняті пристрої виконання. Введення Сендібрідж загального кешу збільшило пропускну здатність в інтернеті величезної кількості. Вирівнювання цілей гілки відбувається через цю проблему, але вона впливає на весь код.
Пітер Кордес

80

Ви можете прочитати http://research.google.com/pubs/pub37077.html

TL; DR: випадкове вставлення nop інструкцій у програми може легко підвищити продуктивність на 5% або більше, і ні, компілятори не можуть це легко використати. Зазвичай це комбінація передбачувача гілки та поведінки кешу, але це може також бути, наприклад, зупинкою станції бронювання (навіть у тому випадку, коли ланцюги залежностей не розірвані, або очевидного перенавантаження на ресурси).


1
Цікаво. Але чи достатньо розумний процесор (або FPC), щоб побачити, що писати в оперативної пам’яті - це NOP?
дотична буря

8
Асемблер не оптимізований.
Марко ван де Ворт

5
Компілятори можуть використовувати це, роблячи неймовірно дорогі оптимізації, такі як багаторазове складання та профілювання, а потім змінюючи вихід компілятора за допомогою імітованого відпалу або генетичного алгоритму. Я читав про деякі роботи в цій галузі. Але ми говоримо мінімум 5-10 хвилин 100% процесора для компіляції, і отримані оптимізації, ймовірно, будуть основними моделями процесора і навіть переробкою ядра або мікрокоду.
АдамІєріменко

Я б не називав це випадковим NOP, вони пояснюють, чому NOP можуть позитивно впливати на продуктивність (tl; dr: stackoverflow.com/a/5901856/357198 ), а випадкове введення NOP призвело до зниження продуктивності. Що цікаво в документі, це те, що видалення «стратегічного» НОП GCC не впливало на загальну ефективність роботи!
PuercoPop

15

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

Сучасні процесори - це гібриди RISC / CISC, які переводять інструкції CISC x86 у внутрішні інструкції, які є більш RISC у поведінці. Крім того, є аналізатори виконання поза замовлення, передбачувачі гілок, "мікро-операційний синтез" від Intel, які намагаються згрупувати інструкції в більші партії одночасної роботи (типу VLIW / Itanium titanic). Існують навіть межі кешу, які можуть змусити код працювати швидше, якщо бог знає, чому він більший (можливо, контролер кешу проробляє його більш розумно, або тримає його довше).

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

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


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

2
Можливо, я повинен уточнити. Мене бентежить відсутність визначеності
alcuadrado

3
здогадки, що має сенс і з хорошою аргументацією, цілком справедливі.
jturolla

7
Ніхто не може точно знати, чому ОП спостерігає за такою дивною поведінкою, якщо тільки інженер Intel не мав доступу до спеціального діагностичного обладнання. Тому всі інші можуть зробити здогад. Це не вина @ cowarldlydragon.
Алекс Д

2
Downvote; ніщо з того, що ви говорите, не пояснює поведінку ОП. Ваша відповідь марна.
fuz

0

Підготовка кеша

Операції переміщення в пам'ять можуть підготувати кеш і зробити наступні операції переміщення швидше. Процесор зазвичай має два завантажувальні одиниці та один запас. Блок завантаження може читати з пам'яті в реєстр (одне зчитування за цикл), блок зберігання зберігає з регістра в пам'ять. Є й інші підрозділи, які здійснюють операції між регістрами. Всі підрозділи працюють паралельно. Таким чином, на кожному циклі ми можемо робити кілька операцій одночасно, але не більше двох навантажень, один магазин і кілька операцій реєстрації. Зазвичай це до 4 простих операцій з простими регістрами, до 3 простих операцій з регістрами XMM / YMM і 1-2 складних операцій з будь-якими видами регістрів. Ваш код має безліч операцій з регістрами, тому одна операція зберігання фіктивних пам'яті є вільною (оскільки у будь-якому випадку більше 4 операцій з реєстрацією), але він готує кеш пам'яті для наступної операції зберігання. Щоб дізнатися, як працюють сховища пам'яті, зверніться до сторінкиПосібник з оптимізації архітектури Intel 64 та IA-32 .

Порушення помилкових залежностей

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

Добре відомо, що під x86-64 за допомогою 32-бітних операндів очищаються більш високі біти 64-розрядного регістра. Просимо прочитати відповідний розділ - 3.4.1.1 - Інструкції для розробників програмного забезпечення для архітектури Intel® 64 та IA-32, том 1 :

32-бітні операнди генерують 32-розрядний результат, розширений нулем до 64-розрядного результату в регістрі загального призначення призначення

Отже, мов інструкції, які можуть здатися марними на перший погляд, очищають вищі біти відповідних регістрів. Що це дає нам? Він розбиває ланцюги залежностей і дозволяє виконувати вказівки паралельно, у випадковому порядку, алгоритмом « Вихід із порядку», впровадженому внутрішньо центральними процесорами з Pentium Pro в 1995 році.

Цитата з Посібника з оптимізації архітектури Intel® 64 та IA-32 , Розділ 3.5.1.8:

Послідовності коду, що модифікує частковий реєстр, можуть зазнати певної затримки в ланцюзі його залежностей, але можна уникнути, використовуючи ідіоми, що порушують залежність. У процесорах, заснованих на мікро-архітектурі Intel Core, ряд інструкцій може допомогти зрозуміти залежність виконання, коли програмне забезпечення використовує ці інструкції для очищення контенту регістра до нуля. Розбийте залежності на частини регістрів між інструкціями, працюючи над 32-бітовими регістрами замість часткових регістрів. Для рухів це може бути досягнуто 32-бітовими рухами або за допомогою MOVZX.

Правило 37 кодування складання / компілятора (вплив M, загальність MH) : Розбийте залежності на частини регістрів між інструкціями, працюючи над 32-бітовими регістрами замість часткових регістрів. Для рухів це може бути досягнуто 32-бітовими рухами або за допомогою MOVZX.

MOVZX і MOV з 32-бітовими операндами для x64 рівноцінні - всі вони розривають ланцюги залежностей.

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

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

Я думаю, ви зараз бачите, що це занадто очевидно.


Це все правда, але не має нічого спільного з кодом, представленим у запитанні.
Коді Грей

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

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