Поділ з плаваючою точкою проти множення з плаваючою точкою


78

Чи є якісь (не мікрооптимізаційні) підвищення продуктивності за допомогою кодування

float f1 = 200f / 2

у порівнянні з

float f2 = 200f * 0.5

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

Чи відповідає це твердження сучасній архітектурі ПК?

Оновлення1

Стосовно коментаря, будь ласка, також розгляньте цей випадок:

float f1;
float f2 = 2
float f3 = 3;
for( i =0 ; i < 1e8; i++)
{
  f1 = (i * f2 + i / f3) * 0.5; //or divide by 2.0f, respectively
}

Оновлення 2 Цитування з коментарів:

[Я хочу] знати, які алгоритмічні / архітектурні вимоги обумовлюють> розділення набагато складніше в апаратному забезпеченні, ніж множення


3
Реальний спосіб знайти відповідь - спробувати обидва і виміряти час.
гострий зуб

15
Більшість компіляторів оптимізують такий буквальний константний вираз, як цей, тому це не має значення.
Paul R

2
@sharptooth: Так, випробування мене вирішить проблему на моїй розробницькій машині, але я думав, якщо хтось із натовпу SO вже має відповідь на загальний випадок, він хотів би поділитися;)
sum1stolemyname

7
@Gabe, я думаю, що Пол мав на увазі те, що це перетвориться 200f / 2на 100f.
mikerobi

10
@ Пол: Така оптимізація можлива для степенів 2, але не загалом. Окрім степенів двох, жодне число з плаваючою точкою не має зворотного значення, яке ви можете помножити на місце ділення.
R .. GitHub СТОП ДОПОМОГАЙ ЛЕДІ

Відповіді:


86

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

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

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


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

2
Як я пам’ятаю, Cray-1 не турбувався про інструкцію ділення, він мав взаємну інструкцію і очікував, що після цього ви будете множитися. Саме з цієї причини.
Марк Ренсом,

1
Позначка: Дійсно, алгоритм ділення на 4 кроки описаний на сторінці 3-28 Посилання на апаратне забезпечення CRAY-1: взаємне наближення, взаємне повторення, чисельник * наближення, коефіцієнт напівточності * коефіцієнт корекції.
Гейб,

2
@aaronman: Якби номери FP зберігалися як x ^ y, то множення на x ^ -yбуло б однаковим, як ділення. Однак номери FP зберігаються як x * 2^y. Множення на x * 2^-y- це просто множення.
Гейб

4
Що таке "початкова школа"?
Pharap

34

Будьте дуже обережні з розподілом і уникайте його, коли це можливо. Наприклад, підняти float inverse = 1.0f / divisor;з петлі і помножити на inverseвнутрішню петлю. (Якщо помилка округлення в inverseдопустима)

Зазвичай 1.0/xне може бути точно представленим як floatабо double. Це точно буде , коли xце сила 2. Це дозволяє компілятори Оптимізувати x / 2.0fдля x * 0.5fбез будь - яких змін в результаті.

Щоб дозволити компілятору виконати цю оптимізацію за вас, навіть коли результат не буде точним (або з дільником змінної виконання), вам потрібні такі опції gcc -O3 -ffast-math. В Зокрема, -freciprocal-math(включається -funsafe-math-optimizationsвключається -ffast-math) дозволяє компілятору замінити x / yз , x * (1/y)коли це корисно. Інші компілятори мають подібні параметри, і ICC може ввімкнути деяку "небезпечну" оптимізацію за замовчуванням (я думаю, що це робить, але я забуваю).

-ffast-mathчасто важливо дозволити автоматичну векторизацію циклів FP, особливо скорочень (наприклад, підсумовування масиву в один скалярний підсумок), оскільки математика FP не асоціативна. Чому GCC не оптимізує a * a * a * a * a * a до (a * a * a) * (a * a * a)?

Також зверніть увагу , що C ++ компілятор можна скласти +і *в FMA в деяких випадках (при компіляції для мети , яка підтримує його, як -march=haswell), але вони не можуть зробити це з /.


Час затримки підрозділу гірший, ніж множення або додавання (або FMA ) на коефіцієнт від 2 до 4 на сучасних процесорах x86, і гірша пропускна здатність у 6 до 40 1 (для вузького циклу, що виконує лише ділення, а не лише множення).

Блок поділу / sqrt не повністю конвеєрний, з причин, пояснених у відповіді @ NathanWhitehead . Найгірші коефіцієнти відносяться до векторів 256b, оскільки (на відміну від інших одиниць виконання) одиниця поділу, як правило, не на всю ширину, тому широкі вектори доводиться робити у дві половини. Не повністю конвеєрний блок виконання настільки незвичний, що процесори Intel мають arith.divider_activeапаратний лічильник продуктивності, який допоможе вам знайти код, який вузькими місцями на пропускній здатності дільника, замість звичайних вузьких місць у інтерфейсі або порту виконання. (Або частіше вузькі місця в пам'яті або довгі ланцюжки затримок, що обмежують паралелізм на рівні інструкцій, спричиняючи пропускну здатність інструкцій менше ~ 4 за такт).

Однак розділення FP і sqrt на процесорах Intel і AMD (крім KNL) реалізовані як єдине загальне, тому це не обов'язково має великий вплив на пропускну здатність на оточуючий код . Найкращий випадок для поділу - це коли невпорядковане виконання може приховати затримку, і коли є багато множень та додавання (або інша робота), які можуть відбуватися паралельно з поділом.

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

Так, наприклад, це буде дуже погано:

for ()
    a[i] = b[i] / scale;  // division throughput bottleneck

// Instead, use this:
float inv = 1.0 / scale;
for ()
    a[i] = b[i] * inv;  // multiply (or store) throughput bottleneck

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

Скорочення, як accumulator /= b[i]би, було вузьким місцем щодо поділу або множення затримки, а не пропускної здатності. Але за допомогою кількох акумуляторів, які ви ділите або множите в кінці, ви можете приховати затримку і все одно наситити пропускну здатність. Зверніть увагу, що sum += a[i] / b[i]вузькі місця щодо addзатримки або divпропускної здатності, але не divзатримки, оскільки розподіл не знаходиться на критичному шляху (ланцюжок залежностей, що несеться з циклу).


Але приблизно вlog(x) цьому ( апроксимуючи функцію на зразок відношення двох поліномів ), поділ може бути досить дешевим :

for () {
    // (not shown: extracting the exponent / mantissa)
    float p = polynomial(b[i], 1.23, -4.56, ...);  // FMA chain for a polynomial
    float q = polynomial(b[i], 3.21, -6.54, ...);
    a[i] = p/q;
}

Для log()більшого діапазону мантиси відношення двох поліномів порядку N має набагато меншу похибку, ніж окремий поліном з коефіцієнтами 2N, і паралельне обчислення 2 дає певний паралелізм на рівні інструкцій у межах одного тіла циклу замість одного масивно довгого Dep ланцюга, що робить речі НАБАГАТО простішими для виконання поза порядку.

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

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


Коли все-таки потрібно розділити, зазвичай краще просто розділити замість rcpps

x86 має наближену взаємну інструкцію ( rcpps), яка дає лише 12 біт точності. (AVX512F має 14 бітів, а AVX512ER - 28 бітів.)

Ви можете використовувати це, x / y = x * approx_recip(y)не використовуючи фактичну інструкцію розділення. ( rcppsitsef досить швидкий; зазвичай трохи повільніший, ніж множення. Він використовує пошук таблиці з внутрішньої таблиці до центрального процесора. Обладнання розділювача може використовувати ту саму таблицю як вихідну точку.)

Для більшості цілей x * rcpps(y)занадто неточна, і потрібна ітерація Ньютона-Рафсона для подвоєння точності. Але це коштує вам 2 множень і 2 FMA , і затримка приблизно така ж велика, як фактична інструкція поділу. Якщо все, що ви робите, це поділ, то це може бути перемогою в пропускній здатності. (Але спочатку слід уникати такого циклу, якщо можете, можливо, роблячи поділ як частину іншого циклу, який виконує іншу роботу.)

Але якщо ви використовуєте поділ як частину більш складної функції, rcppsсам + додатковий mul + FMA, як правило, пришвидшує просто поділ за допомогою divpsінструкції, за винятком процесорів з дуже низькою divpsпропускною здатністю.

(Наприклад, Knight's Landing, див. Нижче. KNL підтримує AVX512ER , тому для floatвекторів VRCP28PSрезультат вже достатньо точний, щоб просто множитися без ітерації Ньютона-Рафсона. floatРозмір мантиси становить лише 24 біти.)


Конкретні числа з таблиць Агнера Фога:

На відміну від будь-якої іншої операції ALU, затримка / пропускна здатність поділу залежить від даних деяких процесорів. Знову ж таки, це тому, що він настільки повільний і не повністю завершений. Диспетчеризація, яка не працює в порядку, простіша з фіксованими затримками, оскільки вона дозволяє уникнути конфліктів зворотної записи (коли один і той же порт виконання намагається отримати 2 результати в тому ж циклі, наприклад, запустити 3-циклову інструкцію, а потім дві 1-циклові операції) .

Як правило, найшвидші випадки, коли дільник є "круглим" числом, подібним 2.0чи 0.5(тобто представлення base2 floatмає безліч кінцевих нулів у мантисі).

float затримка (цикли) / пропускна здатність (цикли за інструкцією, виконуючи лише це ззаду до спини з незалежними входами):

                   scalar & 128b vector        256b AVX vector
                   divss      |  mulss
                   divps xmm  |  mulps           vdivps ymm | vmulps ymm

Nehalem          7-14 /  7-14 | 5 / 1           (No AVX)
Sandybridge     10-14 / 10-14 | 5 / 1        21-29 / 20-28 (3 uops) | 5 / 1
Haswell         10-13 / 7     | 5 / 0.5       18-21 /   14 (3 uops) | 5 / 0.5
Skylake            11 / 3     | 4 / 0.5          11 /    5 (1 uop)  | 4 / 0.5

Piledriver       9-24 / 5-10  | 5-6 / 0.5      9-24 / 9-20 (2 uops) | 5-6 / 1 (2 uops)
Ryzen              10 / 3     | 3 / 0.5         10  /    6 (2 uops) | 3 / 1 (2 uops)

 Low-power CPUs:
Jaguar(scalar)     14 / 14    | 2 / 1
Jaguar             19 / 19    | 2 / 1            38 /   38 (2 uops) | 2 / 2 (2 uops)

Silvermont(scalar)    19 / 17    | 4 / 1
Silvermont      39 / 39 (6 uops) | 5 / 2            (No AVX)

KNL(scalar)     27 / 17 (3 uops) | 6 / 0.5
KNL             32 / 20 (18uops) | 6 / 0.5        32 / 32 (18 uops) | 6 / 0.5  (AVX and AVX512)

double затримка (цикли) / пропускна здатність (цикли за інструкцією):

                   scalar & 128b vector        256b AVX vector
                   divsd      |  mulsd
                   divpd xmm  |  mulpd           vdivpd ymm | vmulpd ymm

Nehalem         7-22 /  7-22 | 5 / 1        (No AVX)
Sandybridge    10-22 / 10-22 | 5 / 1        21-45 / 20-44 (3 uops) | 5 / 1
Haswell        10-20 /  8-14 | 5 / 0.5      19-35 / 16-28 (3 uops) | 5 / 0.5
Skylake        13-14 /     4 | 4 / 0.5      13-14 /     8 (1 uop)  | 4 / 0.5

Piledriver      9-27 /  5-10 | 5-6 / 1       9-27 / 9-18 (2 uops)  | 5-6 / 1 (2 uops)
Ryzen           8-13 /  4-5  | 4 / 0.5       8-13 /  8-9 (2 uops)  | 4 / 1 (2 uops)

  Low power CPUs:
Jaguar            19 /   19  | 4 / 2            38 /  38 (2 uops)  | 4 / 2 (2 uops)

Silvermont(scalar) 34 / 32    | 5 / 2
Silvermont         69 / 69 (6 uops) | 5 / 2           (No AVX)

KNL(scalar)      42 / 42 (3 uops) | 6 / 0.5   (Yes, Agner really lists scalar as slower than packed, but fewer uops)
KNL              32 / 20 (18uops) | 6 / 0.5        32 / 32 (18 uops) | 6 / 0.5  (AVX and AVX512)

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

Atom, Silvermont і навіть Knight's Landing (Xeon Phi на базі Silvermont) мають надзвичайно низьку продуктивність поділу , і навіть 128b вектор повільніший за скалярний. Низькопотужний процесор AMD Jaguar (використовується в деяких консолях) подібний. Високопродуктивний дільник займає велику площу штампа. Xeon Phi має низьку потужність на ядро , а упаковка великої кількості ядер на матриці забезпечує жорсткіші обмеження площі матриці, ніж Skylake-AVX512. Здається, AVX512ER rcp28ps/ pd- це те, що ви "повинні" використовувати на KNL.

(Див. Цей результат InstLatx64 для Skylake-AVX512 aka Skylake-X. Номери для vdivps zmm: 18c / 10c, тобто половина пропускної здатності ymm.)


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


Примітка 1: як я склав ці співвідношення продуктивності div та mul:

Коефіцієнт поділу FP та множинної продуктивності навіть гірший, ніж у центральних процесорів з низьким енергоспоживанням, таких як Silvermont та Jaguar, і навіть у Xeon Phi (KNL, де слід використовувати AVX512ER).

Фактичні коефіцієнти ділення / множення пропускної здатності для скалярів (без векторів)double : 8 на Ryzen і Skylake з підсиленими дільниками, але 16-28 на Haswell (залежно від даних і, швидше за все, до кінця 28 циклу, якщо ваші дільники не круглі цифри). Ці сучасні центральні процесори мають дуже потужні роздільники, але їх множинна пропускна здатність 2 на годину це здуває. (Навіть більше, коли ваш код може автоматично векторизуватись за допомогою векторів 256b AVX). Також зауважте, що при правильних параметрах компілятора ця множинна пропускна здатність також застосовується до FMA.

Номери з http://agner.org/optimize/ таблиць інструкцій для Intel Haswell / Skylake та AMD Ryzen, для скаляра SSE (не враховуючи x87 fmul/ fdiv) та для векторів 256b AVX SIMD з floatабо double. Див. Також тег wiki.


20

Ділення за своєю суттю набагато повільніша операція, ніж множення.

І це насправді може бути чимось, чого компілятор не може (а може і не хочеться) оптимізувати в багатьох випадках через неточності з плаваючою комою. Ці два твердження:

double d1 = 7 / 10.;
double d2 = 7 * 0.1;

не є семантично ідентичними - 0.1їх неможливо точно представити як a double, тому в кінцевому підсумку буде використано дещо інше значення - заміна множення на ділення дасть інший результат!


3
У g ++, 200.f / 10 та 200.f * 0.1 видають абсолютно однаковий код.
Йохан Котлінський

10
@kotlinski: це робить g ++ неправильним, а не моїм твердженням. Я припускаю, що можна стверджувати, що якщо різниця має значення, вам не слід використовувати floats, перш за все, але це, безумовно, те, що я б робив лише на вищих рівнях оптимізації, якби був автором компілятора.
Майкл Борґвардт,

3
@ Michael: Неправильно за яким стандартом?
Йохан Котлінський

9
якщо ви спробуєте, справедливо (що не дозволяє компілятору оптимізувати чи замінити), ви виявите, що 7/10 та 7 * 0,1 з використанням подвійної точності не дають однакових результатів. Множення дає неправильну відповідь, воно дає число більше, ніж ділення. з плаваючою комою - це точність, якщо навіть один біт вимкнений, це неправильно. те саме стосується 7/5! = 7 / 0,2, але візьмемо число, яке можна представити 7/4 та 7 * 0,25, що дасть той самий результат. IEEE підтримує безліч режимів округлення, щоб ви могли подолати деякі з цих проблем (якщо ви знаєте відповідь заздалегідь).
old_timer

6
До речі, у цьому випадку множення та ділення здійснюються однаково швидко - вони обчислюються під час компіляції.
Йохан Котлінський

9

Так. Кожен FPU, про який я знаю, виконує множення набагато швидше, ніж ділення.

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

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


4
Зробити код читабельним недостатньо. Іноді існують вимоги щодо оптимізації чогось, і це, як правило, ускладнює розуміння коду. Хороший розробник спочатку пише хороші модульні тести, а потім оптимізує код. Читаність - приємна, але не завжди досяжна мета.
BЈовић

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

14
Компілятори не можуть оптимізувати це для вас. Їм це заборонено, оскільки результати будуть різними та невідповідними (wrt IEEE-754). gcc надає -ffast-mathопцію для цієї мети, але вона порушує багато речей і не може бути використана загалом.
R .. GitHub СТОП ДОПОМОГАЙ ЛЕДІ

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

11
Компіляторам C дозволяється оптимізувати це, оскільки як ділення на 2,0, так і множення на 0,5 є точними при використанні двійкової арифметики, отже результат однаковий. Див. Розділ F.8.2 стандарту ISO C99, який показує саме цей випадок як допустиме перетворення, коли використовуються прив’язки IEEE-754.
njuffa

8

Подумайте, що потрібно для множення двох n бітових чисел. За допомогою найпростішого методу ви берете одне число x і кілька разів зсуваєте і умовно додаєте його в акумулятор (на основі біта в іншому числі y). Після n доповнень ви закінчите. Ваш результат поміщається у 2n біти.

Для ділення ви починаєте з x з 2n бітів та y з n бітів, ви хочете обчислити x / y. Найпростіший метод - це довге ділення, але в двійковому. На кожному етапі ви робите порівняння та віднімання, щоб отримати ще один біт фактора. Це займе у вас n кроків.

Деякі відмінності: кожен крок множення повинен розглядати лише 1 біт; на кожному етапі поділу потрібно поглянути на n бітів під час порівняння. Кожен етап множення не залежить від усіх інших етапів (не має значення порядку додавання часткових добутків); для поділу кожен крок залежить від попереднього кроку. Це велика справа в апаратному забезпеченні. Якщо щось можна зробити самостійно, то це може відбутися одночасно протягом годинного циклу.


Останні процесори Intel (починаючи з Бродвелла) використовують дільник radix-1024, щоб розподіл здійснювався за меншу кількість кроків. На відміну від майже всього іншого, блок поділу не повністю конвеєрний (оскільки, як ви кажете, відсутність незалежності / паралелізму - велика проблема в апаратному забезпеченні). наприклад, подвійна точність поділу Skylake ( vdivpd ymm) має в 16 разів гіршу пропускну здатність, ніж множення ( vmulpd ymm), і це гірше у попередніх процесорах з менш потужним розділювальним обладнанням. agner.org/optimize
Пітер Кордес

2

Ньютон Рапсон вирішує ціле ділення в складності O (M (n)) за допомогою апліксимації лінійної алгебри. Швидше ніж складність O (n * n).

У коді Метод містить 10multi 9adds 2bitvisiseshifts.

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


1

Відповідь залежить від платформи, для якої ви програмуєте.

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


Але й інші відповіді хороші. Ділення, як правило, повільніше або рівне, ніж множення, але це залежить від платформи.
BЈовић


divpsє частиною оригінального SSE1, представленого в PentiumIII. Там немає SIMD цілої інструкції поділу, але SIMD FP поділ дійсно існує. Блок поділу іноді має навіть гіршу пропускну здатність / затримку для широких векторів (особливо 256b AVX), ніж для скалярних або 128b векторів. Навіть Intel Skylake (із значно швидшим підрозділом FP, ніж Haswell / Broadwell) має divps xmm(4 упаковані поплавці): 11c затримки, одна на 3c пропускної здатності. divps ymm(8 упакованих поплавців): затримка 11c, одна на пропускну здатність 5c. (або для упакованих парних номерів: один на 4c або один на 8c) Див. вікі-тег x86 для посилань на perf.
Пітер Кордес,
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.