Dlaczego GCC używa mnożenia przez dziwną liczbę w implementacji dzielenia liczb całkowitych?

227

Czytałem o operacjach montażu divi mulmontażu i postanowiłem zobaczyć je w akcji, pisząc prosty program w C:

Podział pliku. C

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

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

A następnie generowanie kodu języka asemblera za pomocą:

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

Ale patrząc na wygenerowany division.splik, nie zawiera żadnych operacji div! Zamiast tego robi jakąś czarną magię z przesunięciem bitów i magicznymi liczbami. Oto fragment kodu, który oblicza 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

Co tu się dzieje? Dlaczego GCC w ogóle nie używa div? Jak generuje tę magiczną liczbę i dlaczego wszystko działa?

qiubit
źródło
29
gcc optymalizuje podziały według stałych, wypróbuj podziały o 2,3,4,5,6,7,8, a najprawdopodobniej zobaczysz zupełnie inny kod dla każdego przypadku.
Jabberwocky
28
Uwaga: Liczby magiczne są -3689348814741910323konwertowane na CCCCCCCCCCCCCCCDa uint64_tlub prawie (2 ^ 64) * 4/5.
chux - Przywróć Monikę
32
@qiubit: Kompilator nie będzie przewrotnie generować niewydajnego kodu tylko dlatego, że optymalizacja jest wyłączona. Trywialna „optymalizacja”, która nie wymaga zmiany kolejności kodu lub eliminacji zmiennych, zostanie przeprowadzona niezależnie na przykład. Zasadniczo pojedyncza instrukcja źródłowa przetłumaczy na najbardziej wydajny kod dla tej operacji w izolacji. Optymalizacja kompilatora uwzględnia otaczający kod, a nie tylko pojedynczą instrukcję.
Clifford,
20
Przeczytaj ten niesamowity artykuł: Labor of Division
Jester
9
Niektóre kompilatory faktycznie będzie przewrotnie wygenerować kod nieefektywne, ponieważ optymalizacja jest wyłączona. W szczególności zrobią to, aby ułatwić debugowanie, na przykład możliwość ustawiania punktów przerwania w poszczególnych wierszach kodu. GCC jest w rzeczywistości dość niezwykły, ponieważ nie ma prawdziwego trybu „bez optymalizacji”, ponieważ wiele jego optymalizacji jest konstytutywnie włączanych. To jest przykład, gdzie możesz to zobaczyć w GCC. Dzyń, z drugiej strony, i MSVC, będzie emitować divdyspozycję w -O0. (cc @ clifford)
Cody Gray

Odpowiedzi:

169

Podział liczb całkowitych jest jedną z najwolniejszych operacji arytmetycznych, które można wykonać na nowoczesnym procesorze, z opóźnieniem do kilkudziesięciu cykli i złą przepustowością. (Dla x86, zobacz tabele instrukcji Agner Fog i przewodnik mikroarchitektury ).

Jeśli znasz dzielnik z wyprzedzeniem, możesz uniknąć podziału, zastępując go zestawem innych operacji (zwielokrotnienia, uzupełnienia i przesunięcia), które mają równoważny efekt. Nawet jeśli potrzebnych jest kilka operacji, często jest to o wiele szybsze niż samo dzielenie liczb całkowitych.

Zaimplementowanie /operatora C w ten sposób zamiast sekwencji obejmującej wiele instrukcji divjest tylko domyślnym sposobem GCC dzielenia według stałych. Nie wymaga optymalizacji między operacjami i niczego nie zmienia nawet podczas debugowania. (Używanie w -Osprzypadku małych rozmiarów kodu powoduje jednak użycie GCC div.) Używanie odwrotności multiplikatywnej zamiast dzielenia jest jak używanie leazamiast muliadd

W wyniku tego masz tendencję do wyświetlania divlub idivwyświetlania danych wyjściowych tylko wtedy, gdy dzielnik nie jest znany w czasie kompilacji.

Aby uzyskać informacje o tym, jak kompilator generuje te sekwencje, a także kod umożliwiający ich wygenerowanie dla siebie (prawie na pewno niepotrzebne, chyba że pracujesz z kompilatorem braindead), zobacz libdivide .

Sneftel
źródło
5
Nie jestem pewien, czy sprawiedliwe jest łączenie operacji FP i liczb całkowitych w porównaniu prędkości, @fuz. Być może Sneftel powinien powiedzieć, że dzielenie jest najwolniejszą operacją na liczbach całkowitych , jaką można wykonać na nowoczesnym procesorze? Ponadto w komentarzach podano linki do dalszych wyjaśnień tej „magii”. Czy uważasz, że należałoby zebrać w odpowiedzi na swoją widoczność? 1 , 2 , 3
Cody Gray
1
Ponieważ sekwencja operacji jest funkcjonalnie identyczna ... jest to zawsze wymóg, nawet przy -O3. Kompilator musi utworzyć kod, który daje prawidłowe wyniki dla wszystkich możliwych wartości wejściowych. To zmienia się tylko dla liczb zmiennoprzecinkowych -ffast-math, a AFAIK nie ma „niebezpiecznych” optymalizacji liczb całkowitych. (Po włączeniu optymalizacji kompilator może być w stanie udowodnić coś na temat możliwego zakresu wartości, co pozwala mu użyć czegoś, co działa tylko na przykład na liczbach całkowitych ze znakiem nieujemnym.)
Peter Cordes
6
Prawdziwa odpowiedź jest taka, że ​​gcc -O0 nadal przekształca kod poprzez wewnętrzne reprezentacje w ramach przekształcania C w kod maszynowy . Zdarza się, że modułowe odwrotności multiplikatywne są domyślnie włączone nawet w -O0(ale nie z -Os). Inne kompilatory (jak clang) będą używać DIV dla stałych innych niż power-of-2 w -O0. powiązane: Wydaje mi się, że umieściłem akapit na ten temat w mojej odręcznie napisanej odpowiedzi na pytanie Collatza
Peter Cordes,
6
@PeterCordes I tak, myślę, że GCC (i wiele innych kompilatorów) zapomniało wymyślić dobre uzasadnienie „jakie rodzaje optymalizacji mają zastosowanie, gdy optymalizacja jest wyłączona”. Spędziłem większą część dnia, szukając niejasnego błędu codegen, w tej chwili jestem trochę zirytowany.
Sneftel,
9
@Sneftel: Prawdopodobnie tylko dlatego, że liczba programistów aplikacji, którzy aktywnie narzekają twórcom kompilatorów na to, że ich kod działa szybciej niż oczekiwano, jest stosunkowo niewielka.
dan04
121

Dzielenie przez 5 jest takie samo jak mnożenie 1/5, co znowu jest takie samo jak mnożenie przez 4/5 i przesunięcie w prawo o 2 bity. Dana wartość jest CCCCCCCCCCCCCCCDw postaci szesnastkowej, która jest reprezentacją binarną 4/5, jeśli jest umieszczona po punkcie szesnastkowym (tzn. 0.110011001100Powtarza się wartość binarna dla czterech piątych - dlaczego poniżej). Myślę, że możesz wziąć to stąd! Możesz chcieć sprawdzić arytmetykę stałoprzecinkową (choć pamiętaj, że na końcu jest ona zaokrąglana do liczby całkowitej).

Dlaczego mnożenie jest szybsze niż dzielenie, a gdy dzielnik jest stały, jest to szybsza trasa.

Zobacz samouczek Wzajemne mnożenie, w którym znajduje się szczegółowy opis działania, wyjaśniający w kategoriach punktu stałego. Pokazuje, jak działa algorytm znajdowania odwrotności i jak radzić sobie z podpisanym podziałem i modulo.

Zastanówmy się przez chwilę, dlaczego 0.CCCCCCCC...(hex) lub 0.110011001100...binarny to 4/5. Podziel reprezentację binarną przez 4 (przesuń w prawo o 2 miejsca), a my otrzymamy, 0.001100110011...które poprzez trywialną inspekcję można dodać oryginał 0.111111111111..., który jest oczywiście równy 1, w ten sam sposób 0.9999999...w systemie dziesiętnym jest równy jeden. Dlatego wiemy, że x + x/4 = 1tak 5x/4 = 1, x=4/5. Jest to następnie przedstawiane jako CCCCCCCCCCCCDszesnastkowe dla zaokrąglania (ponieważ cyfra binarna poza ostatnią obecną byłaby a 1).

w płomieniach
źródło
2
@ user2357112 możesz opublikować własną odpowiedź, ale nie zgadzam się. Możesz myśleć o mnożeniu jako o mnożeniu 64,0 bit na 0,64 bit, co daje 128-bitową odpowiedź w punkcie stałym, z czego odrzucane są najniższe 64 bity, a następnie dzielenie przez 4 (jak wskazałem w pierwszym akapicie). Być może uda ci się znaleźć alternatywną modułową odpowiedź arytmetyczną, która równie dobrze wyjaśnia ruchy bitów, ale jestem pewien, że działa to jako wyjaśnienie.
abligh
6
W rzeczywistości jest to „CCCCCCCCCCCCCCCD”. Ostatnie D jest ważne, upewnia się, że kiedy wynik zostanie obcięty, dokładne podziały wychodzą z prawidłową odpowiedzią.
plugwash
4
Nieważne. Nie widziałem, że biorą górne 64 bity 128-bitowego wyniku mnożenia; nie można tego zrobić w większości języków, więc początkowo nie zdawałem sobie sprawy, że to się dzieje. Ta odpowiedź zostałaby znacznie poprawiona poprzez wyraźne wspomnienie, w jaki sposób pobranie 64 górnych bitów wyniku 128-bitowego jest równoznaczne z pomnożeniem przez liczbę o stałym punkcie i zaokrągleniem w dół. (Również dobrze byłoby wyjaśnić, dlaczego musi to być 4/5 zamiast 1/5 i dlaczego musimy zaokrąglać 4/5 w górę zamiast w dół.)
użytkownik2357112 obsługuje Monikę
2
Pomyśl, że musisz obliczyć, jak duży błąd jest potrzebny, aby rzucić podział o 5 w górę przez zaokrągloną granicę, a następnie porównać to z najgorszym przypadkiem błędu w Twojej kaklulacji. Prawdopodobnie programiści gcc zrobili to i doszli do wniosku, że zawsze da prawidłowe wyniki.
plugwash
3
Prawdopodobnie musisz tylko sprawdzić 5 najwyższych możliwych wartości wejściowych, jeśli te zaokrąglają poprawnie, wszystko inne również.
plugwash
60

Ogólnie rzecz biorąc, mnożenie jest znacznie szybsze niż dzielenie. Jeśli więc uda nam się uniknąć mnożenia przez odwrotność, możemy znacznie przyspieszyć dzielenie o stałą

Zmarszczka polega na tym, że nie możemy dokładnie przedstawić odwrotności (chyba że podział był potęgą dwóch, ale w takim przypadku zwykle możemy po prostu przekształcić podział na odrobinę przesunięcia). Dlatego, aby zapewnić prawidłowe odpowiedzi, musimy uważać, aby błąd w naszej wzajemności nie powodował błędów w naszym wyniku końcowym.

-3689348814741910323 to 0xCCCCCCCCCCCCCCCCCD, która jest wartością nieco ponad 4/5 wyrażoną w stałym punkcie 0,64.

Kiedy pomnożymy 64-bitową liczbę całkowitą przez stałą liczbę 0,64, otrzymamy wynik 64,64. Obcinamy wartość do 64-bitowej liczby całkowitej (skutecznie zaokrąglając ją do zera), a następnie wykonujemy dalsze przesunięcie, które dzieli się przez cztery i ponownie obcinamy. Patrząc na poziom bitów, jasne jest, że możemy traktować oba skróty jako pojedyncze obcięcie.

To wyraźnie daje nam przynajmniej przybliżenie podziału przez 5, ale czy daje nam dokładną odpowiedź poprawnie zaokrągloną do zera?

Aby uzyskać dokładną odpowiedź, błąd musi być wystarczająco mały, aby nie przesuwać odpowiedzi poza zaokrągloną granicę.

Dokładna odpowiedź na podział przez 5 zawsze będzie miała ułamkową część 0, 1/5, 2/5, 3/5 lub 4/5. Dlatego dodatni błąd mniejszy niż 1/5 w pomnożonym i przesuniętym wyniku nigdy nie przesunie wyniku poza zaokrągloną granicę.

Błąd w naszej stałej wynosi (1/5) * 2-64 . Wartość i jest mniejsza niż 2 64, więc błąd po pomnożeniu jest mniejszy niż 1/5. Po podzieleniu przez 4 błąd jest mniejszy niż (1/5) * 2 −2 .

(1/5) * 2 −2 <1/5, więc odpowiedź zawsze będzie równa dokładnemu podziałowi i zaokrągleniu do zera.


Niestety nie działa to dla wszystkich dzielników.

Jeśli spróbujemy przedstawić 4/7 jako stałą liczbę 0,64 z zaokrągleniem od zera, otrzymamy błąd (6/7) * 2-64 . Po pomnożeniu przez wartość i nieco poniżej 2 64 otrzymujemy błąd poniżej 6/7, a po podzieleniu przez cztery otrzymujemy błąd nieco poniżej 1,5 / 7, który jest większy niż 1/7.

Tak więc, aby poprawnie wdrożyć podział przez 7, musimy pomnożyć przez stałą liczbę 0,65. Możemy to zaimplementować, mnożąc przez dolne 64 bity naszego stałego numeru punktu, a następnie dodając pierwotną liczbę (może to przelać się do bitu przenoszenia), a następnie wykonując obrót przez przeniesienie.

płyn do płukania
źródło
8
Ta odpowiedź przekształciła modułowe odwrotne multiplikacje z „matematyki, która wygląda na bardziej skomplikowaną, niż chcę poświęcić czas”, na coś, co ma sens. +1 za łatwą do zrozumienia wersję. Nigdy nie musiałem robić nic innego niż używać stałych generowanych przez kompilator, więc przeglądałem tylko inne artykuły wyjaśniające matematykę.
Peter Cordes,
2
W ogóle nie widzę nic wspólnego z modularną arytmetyką. Nie wiem, skąd biorą to inni komentatorzy.
płyn do płukania,
3
Jest to moduł 2 ^ n, podobnie jak wszystkie liczby całkowite w rejestrze. en.wikipedia.org/wiki/…
Peter Cordes
4
@PeterCordes modularne odwrotne multiplikacje są używane do dokładnego podziału, afaik nie są one przydatne do ogólnego podziału
Harold
4
@PeterCordes mnożenie przez ustalony punkt wzajemności? Nie wiem, jak to wszyscy nazywają, ale prawdopodobnie nazwałbym to tak, jest dość opisowy
Harold
12

Oto link do dokumentu algorytmu, który generuje wartości i kod, które widzę w Visual Studio (w większości przypadków) i który, jak zakładam, jest nadal używany w GCC do dzielenia zmiennej całkowitej przez stałą liczbę całkowitą.

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

W artykule uword ma N bitów, słowo ma 2N bitów, n = licznik = dywidenda, d = mianownik = dzielnik, ℓ jest początkowo ustawiony na pułap (log2 (d)), shpre jest przed przesunięciem (używane przed pomnożeniem ) = e = liczba ostatnich zerowych bitów w d, shpost jest przesunięciem (używane po pomnożeniu), prec jest precyzją = N - e = N - shpre. Celem jest optymalizacja obliczeń n / d przy użyciu zmiany wstępnej, pomnożenia i zmiany następczej.

Przewiń w dół do rysunku 6.2, który definiuje sposób generowania mnożnika udword (maksymalny rozmiar to N + 1 bit), ale nie wyjaśnia dokładnie procesu. Wyjaśnię to poniżej.

Rysunek 4.2 i rysunek 6.2 pokazują, w jaki sposób mnożnik można zredukować do mnożnika N bitowego lub mniejszego dla większości dzielników. Równanie 4.5 wyjaśnia, w jaki sposób wyprowadzono wzór używany do radzenia sobie z mnożnikami bitów N + 1 na rysunkach 4.1 i 4.2.

W przypadku współczesnych X86 i innych procesorów czas mnożenia jest stały, więc wstępne przesunięcie nie pomaga w tych procesorach, ale nadal pomaga zmniejszyć mnożnik z N + 1 bitów do N bitów. Nie wiem, czy GCC czy Visual Studio wyeliminowały wstępne zmiany dla celów X86.

Wracając do rysunku 6.2. Licznik (dywidenda) dla mlow i mhigh może być większy niż słowo ud, tylko gdy mianownik (dzielnik)> 2 ^ (N-1) (gdy ℓ == N => mlow = 2 ^ (2N)), w tym przypadku zoptymalizowane zastąpienie dla n / d jest porównaniem (jeśli n> = d, q = 1, w innym przypadku q = 0), więc nie jest generowany mnożnik. Początkowe wartości mlow i mhigh będą wynosić N + 1 bitów, a do podziału każdej wartości bitowej N + 1 (mlow lub mhigh) można użyć dwóch podziałów udword / uword. Używanie X86 w trybie 64-bitowym jako przykład:

; 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

Możesz to przetestować za pomocą GCC. Już wiesz, jak obsługiwane jest j = i / 5. Zobacz, jak obsługiwane jest j = i / 7 (co powinno być przypadkiem mnożnika N + 1 bit).

W większości obecnych procesorów mnożenie ma ustalony czas, więc zmiana wstępna nie jest potrzebna. W przypadku X86 wynikiem końcowym jest sekwencja dwóch instrukcji dla większości dzielników i pięć sekwencji instrukcji dla dzielników takich jak 7 (w celu emulacji mnożnika bitów N + 1, jak pokazano w równaniu 4.5 i rysunku 4.2 pliku pdf). Przykładowy kod 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
;       ...
rcgldr
źródło
Ten artykuł opisuje implementację go w gcc, więc myślę, że to bezpieczne założenie, że ten sam algo jest nadal używany.
Peter Cordes,
Ten dokument z 1994 roku opisuje implementację go w gcc, więc był czas, aby gcc zaktualizowało swój algorytm. Na wypadek, gdyby inni nie mieli czasu, aby sprawdzić, co oznacza 94 w tym adresie URL.
Ed Grimm,
0

Odpowiem z nieco innej strony: ponieważ wolno to zrobić.

C i C ++ są zdefiniowane na maszynie abstrakcyjnej. Kompilator przekształca ten program w kategoriach abstrakcyjnej maszyny do betonu maszyny następujących po co-jeśli reguły.

  • Kompilator może dokonywać DOWOLNYCH zmian, o ile nie zmienia obserwowalnego zachowania określonego przez maszynę abstrakcyjną. Nie ma uzasadnionych oczekiwań, że kompilator przekształci Twój kod w najprostszy możliwy możliwy sposób (nawet jeśli wielu programistów C zakłada to). Zwykle robi to, ponieważ kompilator chce zoptymalizować wydajność w porównaniu z prostym podejściem (jak omówiono szczegółowo w innych odpowiedziach).
  • Jeśli w jakichkolwiek okolicznościach kompilator „zoptymalizuje” poprawny program do czegoś, co ma inne obserwowalne zachowanie, jest to błąd kompilatora.
  • Wszelkie nieokreślone zachowanie w naszym kodzie (klasyczny przepełnienie z podpisem jest klasycznym przykładem), a niniejsza umowa jest nieważna.
dmeister
źródło