Чому GCC використовує множення на дивне число при здійсненні цілого поділу?


228

Я читав про divі mulскладальних операціях, і я вирішив , щоб побачити їх у дії, написавши просту програму в C:

Розділення файлів.c

#include <stdlib.h>
#include <stdio.h>

int main()
{
    size_t i = 9;
    size_t j = i / 5;
    printf("%zu\n",j);
    return 0;
}

А потім генерувати код мови збірки за допомогою:

gcc -S division.c -O0 -masm=intel

Але дивлячись на згенерований division.sфайл, він не містить жодних діючих операцій! Натомість це робить якусь чорну магію зі зміщенням бітів та магічними числами. Ось фрагмент коду, який обчислює i/5:

mov     rax, QWORD PTR [rbp-16]   ; Move i (=9) to RAX
movabs  rdx, -3689348814741910323 ; Move some magic number to RDX (?)
mul     rdx                       ; Multiply 9 by magic number
mov     rax, rdx                  ; Take only the upper 64 bits of the result
shr     rax, 2                    ; Shift these bits 2 places to the right (?)
mov     QWORD PTR [rbp-8], rax    ; Magically, RAX contains 9/5=1 now, 
                                  ; so we can assign it to j

Що тут відбувається? Чому GCC взагалі не використовує div? Як він генерує це магічне число і чому все працює?


29
gcc оптимізує поділи за константами, спробуйте поділи на 2,3,4,5,6,7,8, і ви, швидше за все, побачите дуже різний код для кожного випадку.
Jabberwocky

28
Примітка: Магічне число -3689348814741910323перетворюється на CCCCCCCCCCCCCCCDа ( uint64_tабо приблизно) (2 ^ 64) * 4/5.
chux

32
@qiubit: Компілятор не буде збочно створювати неефективний код лише тому, що оптимізація вимкнена. Тривіальна "оптимізація", яка не передбачає переупорядкування коду чи усунення змінних, буде здійснена незалежно, наприклад. По суті, одне твердження джерела переводить на найбільш ефективний код для цієї операції ізольовано. Оптимізація компілятора враховує навколишній код, а не лише одне твердження.
Кліффорд

20
Прочитайте цю дивовижну статтю: Труд дивізії
Шут

9
Деякі компілятори насправді будуть збочно генерувати неефективний код, оскільки оптимізація відключена. Зокрема, вони зроблять це налагодженням налагодження, як-от можливість встановити точки перерви в окремих рядках коду. Насправді, GCC є досить незвичним тим, що він не має справжнього режиму "без оптимізації", оскільки багато його оптимізації конституційно включені. Це приклад, коли ви можете бачити це за допомогою GCC. Clang, з іншого боку, і MSVC, буде видавати divінструкції в -O0. (cc @ clifford)
Коді Грей

Відповіді:


169

Поділ цілих чисел - одна з найповільніших арифметичних операцій, яку можна виконувати на сучасному процесорі, із затримкою до десятків циклів і поганою пропускною здатністю. (Для x86 див. Таблиці інструкцій Agner Fog та посібник з мікроарха ).

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

Реалізація /оператора С таким способом замість послідовності з декількома інструкціями, що стосується, divє лише способом GCC, що робить поділ на константи. Це не вимагає оптимізації для всіх операцій і нічого не змінює навіть для налагодження. ( Однак, якщо -Osвикористовувати невеликий розмір коду, GCC може використовуватись div.) Використання мультиплікативного зворотного замість ділення - це як використання leaзамість mulіadd

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

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


5
Я не впевнений, що справедливо об'єднувати FP і цілі операції в порівнянні швидкостей, @fuz. Можливо, Sneftel повинен сказати, що поділ - це найповільніша ціла операція, яку ви можете виконати на сучасному процесорі? Також у коментарях були наведені деякі посилання на подальші пояснення цієї "магії". Як ви вважаєте, їх було б доцільно зібрати у вашій відповіді для наочності? 1 , 2 , 3
Коді Грей

1
Оскільки послідовність операцій функціонально однакова ... це завжди є вимогою, навіть при -O3. Компілятор повинен зробити код, який дає правильні результати для всіх можливих вхідних значень. Це змінюється лише з плаваючою точкою -ffast-math, і в AFAIK немає "небезпечних" цілих оптимізацій. (Якщо ввімкнено оптимізацію, компілятор, можливо, зможе довести щось про можливий діапазон значень, що дозволяє йому використовувати щось, що працює лише для невід'ємних цілих чисел, наприклад, підписаних.)
Пітер Кордес

6
Справжня відповідь полягає в тому, що gcc -O0 все ще перетворює код за допомогою внутрішніх представлень як частина перетворення C в машинний код . Просто буває, що модульні мультиплікативні обертання включені за замовчуванням навіть у -O0(але не з -Os). Інші компілятори (як clang) використовуватимуть DIV для констант, що не мають потужності 2 -O0. пов’язано: Я думаю, що я включив абзац про це у мою відповідь на рукописну гадку про Колац
Пітер Кордес

6
@PeterCordes І так, я думаю, що GCC (і багато інших компіляторів) забули придумати гарне обгрунтування того, "які види оптимізацій застосовуються, коли оптимізація відключена". Провівши більшу частину дня, відстежуючи неясну помилку кодегену, я трохи не дратуюся з цього приводу саме зараз.
Sneftel

9
@Sneftel: Це, мабуть, лише тому, що кількість розробників додатків, які активно скаржаться розробникам компілятора на те, що їх код працює швидше, ніж очікувалося, порівняно невеликий.
dan04

121

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

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

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

Розглянемо хвилинку, чому 0.CCCCCCCC...(шістнадцятковий) або 0.110011001100...двійковий дорівнює 4/5. Розділіть двійкове представлення на 4 (змініть праворуч на 2 місця), і ми отримаємо, 0.001100110011...яким за допомогою тривіального огляду можна додати оригінал, щоб отримати 0.111111111111..., що, очевидно, дорівнює 1, той самий спосіб 0.9999999...у десятковій мірі дорівнює одиниці. Таким чином, ми знаємо , що x + x/4 = 1, таким чином 5x/4 = 1, x=4/5. Потім він представляється як CCCCCCCCCCCCDу шістнадцятковій частині для округлення (так як двійкова цифра поза останньою поданою була б а 1).


2
@ user2357112 сміливо публікуйте власну відповідь, але я не згоден. Ви можете помножити на 64,0 біт на 0,64 розмноження, даючи 128-бітну фіксовану точку відповіді, з яких відкидаються найнижчі 64 біти, потім ділення на 4 (як я вказую в першому пункті). Можливо, ви зможете придумати альтернативну модульну арифметичну відповідь, яка однаково добре пояснює рухи бітів, але я впевнений, що це працює як пояснення.
abligh

6
Значення насправді "CCCCCCCCCCCCCCCD" Останнє значення D є важливим, воно гарантує, що коли результат обрізаний, точні поділи виходять з правильною відповіддю.
підключення

4
Не звертай уваги. Я не бачив, що вони беруть верхні 64 біти результату 128-бітного множення; це не те, що ти можеш робити на більшості мов, тому я спочатку не розумів, що це відбувається. Ця відповідь була б значно покращена шляхом явної згадки про те, як отримання верхніх 64 біт 128-розрядного результату еквівалентно множенню на число з фіксованою точкою та округленні вниз. (Крім того, було б добре пояснити, чому це має бути 4/5 замість 1/5, і чому ми повинні
округлювати

2
Афактично вам доведеться розібратися, наскільки велика помилка потрібна, щоб кинути поділ на 5 вгору через межу округлення, а потім порівняйте її з найгіршою помилкою у вашому обчисленні. Імовірно, розробники gcc зробили це і зробили висновок, що це завжди дасть правильні результати.
plugwash

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

60

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

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

-3689348814741910323 - 0xCCCCCCCCCCCCCCCD, це значення трохи більше 4/5, виражене в 0,64 фіксованої точки.

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

Це однозначно дає нам принаймні наближення ділення на 5, але чи дає нам точну відповідь, правильно округлену до нуля?

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

Точна відповідь на поділ на 5 завжди матиме дробову частину 0, 1/5, 2/5, 3/5 або 4/5. Тому позитивна помилка менше 1/5 у множинні та зміщеному результаті ніколи не висуне результат через межу округлення.

Похибка в нашій постійній дорівнює (1/5) * 2 -64 . Значення i менше 2 64, тому похибка після множення менше 1/5. Після ділення на 4 похибка менше (1/5) * 2 −2 .

(1/5) * 2 −2 <1/5, тому відповідь завжди дорівнює точному поділу та округленню до нуля.


На жаль, це працює не для всіх дільників.

Якщо ми спробуємо представити 4/7 як 0,64 фіксовану точку з округленням від нуля, то в кінцевому підсумку з помилкою (6/7) * 2 -64 . Помноживши на значення i трохи менше 2 64, ми закінчуємо помилкою трохи менше 6/7, а після ділення на чотири, ми виявляємо помилку трохи менше 1,5 / 7, що більше 1/7.

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


8
Ця відповідь перетворила модульні мультиплікативні звороти з "математики, яка виглядає складніше, ніж я хочу витратити час" на щось, що має сенс. +1 для зручної для розуміння версії. Мені ніколи не потрібно було робити нічого, крім просто використовувати константи, створені компілятором, тому я лише знімав інші статті, що пояснюють математику.
Пітер Кордес

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

3
Це модуль 2 ^ n, як і вся математика в цілому регістрі. en.wikipedia.org/wiki/…
Пітер Кордес

4
Для точного поділу використовуються модульні мультиплікативні звороти @PeterCordes, afaik вони не корисні для загального поділу
harold

4
@PeterCordes множення на зворотні з фіксованою точкою? Я не знаю, як це називають усі, але, мабуть, я б це назвав, це досить описово
harold

12

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

http://gmplib.org/~tege/divcnst-pldi94.pdf

У статті uword має N біт, udword має 2N біт, n = чисельник = дивіденд, d = знаменник = дільник, initially спочатку встановлюється на ceil (log2 (d)), shpre є попередньою зміною (використовується перед множенням ) = e = кількість кінцевих нульових бітів у d, шпост - післязсувний (використовується після множення), прецизія - точність = N - е = N - шпре. Мета - оптимізувати обчислення n / d за допомогою попереднього зсуву, множення та післязсуву.

Прокрутіть униз до рисунка 6.2, де визначено, як створюється множник udword (максимальний розмір N + 1 біт), але не чітко пояснює процес. Я поясню це нижче.

На рис. 4.2 та на рисунку 6.2 показано, як множник може бути зведений до N біт або менше множника для більшості дільників. Рівняння 4.5 пояснює, як отримана формула, яка використовується для обробки N + 1 бітних множників на рисунках 4.1 та 4.2.

У випадку із сучасними процесорами X86 та іншими процесорами час множення фіксовано, тому попереднє зсув не допомагає цим процесорам, але все одно допомагає зменшити множник з N + 1 біт на N біт. Я не знаю, чи GCC або Visual Studio усунули попередню зміну для цілей X86.

Повернення до малюнка 6.2. Чисельник (дивіденд) для mlow і mhigh може бути більшим, ніж удворд, лише коли знаменник (дільник)> 2 ^ (N-1) (коли ℓ == N => mlow = 2 ^ (2N)), в цьому випадку оптимізована заміна n / d - порівняння (якщо n> = d, q = 1, інакше q = 0), тому множник не генерується. Початкові значення mlow і mhigh становитимуть N + 1 біт, і два поділи udword / uword можна використовувати для отримання кожного значення N + 1 біт (mlow або mhigh). Використання X86 в 64-бітовому режимі як приклад:

; upper 8 bytes of dividend = 2^(ℓ) = (upper part of 2^(N+ℓ))
; lower 8 bytes of dividend for mlow  = 0
; lower 8 bytes of dividend for mhigh = 2^(N+ℓ-prec) = 2^(ℓ+shpre) = 2^(ℓ+e)
dividend  dq    2 dup(?)        ;16 byte dividend
divisor   dq    1 dup(?)        ; 8 byte divisor

; ...
        mov     rcx,divisor
        mov     rdx,0
        mov     rax,dividend+8     ;upper 8 bytes of dividend
        div     rcx                ;after div, rax == 1
        mov     rax,dividend       ;lower 8 bytes of dividend
        div     rcx
        mov     rdx,1              ;rdx:rax = N+1 bit value = 65 bit value

Ви можете перевірити це за допомогою GCC. Ви вже бачили, як j = i / 5 обробляється. Погляньте, як обробляється j = i / 7 (що має бути випадком множника N + 1 біт).

У більшості поточних процесорів множина має фіксований термін, тому попереднє зміна не потрібне. Для X86 кінцевий результат - це дві послідовності інструкцій для більшості дільників і п’ять послідовностей інструкцій для дільників типу 7 (для емуляції N + 1 бітного множника, як показано в рівнянні 4.5 та рисунку 4.2 файлу pdf). Приклад коду X86-64:

;       rax = dividend, rbx = 64 bit (or less) multiplier, rcx = post shift count
;       two instruction sequence for most divisors:

        mul     rbx                     ;rdx = upper 64 bits of product
        shr     rdx,cl                  ;rdx = quotient
;
;       five instruction sequence for divisors like 7
;       to emulate 65 bit multiplier (rbx = lower 64 bits of multiplier)

        mul     rbx                     ;rdx = upper 64 bits of product
        sub     rbx,rdx                 ;rbx -= rdx
        shr     rbx,1                   ;rbx >>= 1
        add     rdx,rbx                 ;rdx = upper 64 bits of corrected product
        shr     rdx,cl                  ;rdx = quotient
;       ...

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

У цьому документі від 1994 року описано його застосування в gcc, тому час для gcc оновив свій алгоритм. На всякий випадок, якщо у інших немає часу перевірити, що означає 94 у цій URL-адресі.
Ед Грімм

0

Я відповім з дещо іншого кута: Тому що це дозволено робити.

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

  • Компілятору дозволено вносити будь-які зміни до тих пір, поки це не змінить спостережувану поведінку, як зазначено в абстрактній машині. Немає жодних обґрунтованих очікувань, що компілятор перетворить ваш код найбільш простим можливим способом (навіть коли багато програмістів на C припускають це). Зазвичай це робить це тому, що компілятор хоче оптимізувати продуктивність порівняно з прямолінійним підходом (як детально обговорюється в інших відповідях).
  • Якщо за будь-яких обставин компілятор "оптимізує" правильну програму до чогось, що має іншу поведінку, що спостерігається, це помилка компілятора.
  • Будь-яка не визначена поведінка в нашому коді (підписане ціле число переповнення є класичним прикладом) і цей договір недійсний.
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.