J'ai lu div
et mul
les opérations d'assemblage, et j'ai décidé de les voir en action en écrivant un programme simple en C:
Fichier division.c
#include <stdlib.h>
#include <stdio.h>
int main()
{
size_t i = 9;
size_t j = i / 5;
printf("%zu\n",j);
return 0;
}
Et puis générer du code en langage assembleur avec:
gcc -S division.c -O0 -masm=intel
Mais en regardant le division.s
fichier généré , il ne contient aucune opération div! Au lieu de cela, il fait une sorte de magie noire avec un décalage de bits et des nombres magiques. Voici un extrait de code qui calcule 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
Que se passe t-il ici? Pourquoi GCC n'utilise-t-il pas div du tout? Comment génère-t-il ce nombre magique et pourquoi tout fonctionne-t-il?
-3689348814741910323
convertitCCCCCCCCCCCCCCCD
en unuint64_t
ou à peu près (2 ^ 64) * 4/5.div
instruction à-O0
. (cc @ clifford)Réponses:
La division entière est l'une des opérations arithmétiques les plus lentes que vous puissiez effectuer sur un processeur moderne, avec une latence pouvant atteindre des dizaines de cycles et un mauvais débit. (Pour x86, voir les tableaux d'instructions et le guide microarch d'Agner Fog ).
Si vous connaissez le diviseur à l'avance, vous pouvez éviter la division en le remplaçant par un ensemble d'autres opérations (multiplications, ajouts et décalages) qui ont l'effet équivalent. Même si plusieurs opérations sont nécessaires, c'est souvent beaucoup plus rapide que la division entière elle-même.
L'implémentation de l'
/
opérateur C de cette façon au lieu d'une séquence multi-instructions impliquantdiv
est juste la façon par défaut de GCC de faire la division par constantes. Il ne nécessite pas d'optimisation entre les opérations et ne change rien, même pour le débogage. (Utiliser-Os
pour une petite taille de code permet cependant à GCC d'utiliserdiv
.) L'utilisation d'un inverse multiplicatif au lieu de la division revient à utiliser à lalea
place demul
etadd
Par conséquent, vous avez tendance à voir
div
ou à afficher uniquementidiv
si le diviseur n'est pas connu au moment de la compilation.Pour plus d'informations sur la façon dont le compilateur génère ces séquences, ainsi que le code pour vous permettre de les générer vous-même (presque certainement inutile, sauf si vous travaillez avec un compilateur braindead ), voir libdivide .
la source
-O3
. Le compilateur doit créer un code qui donne des résultats corrects pour toutes les valeurs d'entrée possibles. Cela ne change que pour la virgule flottante avec-ffast-math
, et AFAIK il n'y a pas d'optimisations entières "dangereuses". (Avec l'optimisation activée, le compilateur pourrait être en mesure de prouver quelque chose sur la plage de valeurs possible, ce qui lui permet d'utiliser quelque chose qui ne fonctionne que pour les entiers signés non négatifs, par exemple.)-O0
(mais pas avec-Os
). D'autres compilateurs (comme clang) utiliseront DIV pour les constantes non puissance de 2 à-O0
. connexes: Je pense que j'ai inclus un paragraphe à ce sujet dans ma réponse asm manuscrite de Collatz-conjectureDiviser par 5 équivaut à multiplier 1/5, ce qui équivaut à nouveau à multiplier par 4/5 et à décaler de 2 bits vers la droite. La valeur concernée est
CCCCCCCCCCCCCCCD
en hexadécimal, qui est la représentation binaire de 4/5 si elle est placée après un point hexadécimal (c'est-à-dire que le binaire pour les quatre cinquièmes est0.110011001100
récurrent - voir ci-dessous pour savoir pourquoi). Je pense que vous pouvez le prendre d'ici! Vous voudrez peut-être vérifier l' arithmétique à virgule fixe (bien que notez qu'il est arrondi à un entier à la fin.Quant à savoir pourquoi, la multiplication est plus rapide que la division, et lorsque le diviseur est fixe, c'est un itinéraire plus rapide.
Voir Reciprocal Multiplication, un tutoriel pour une description détaillée de son fonctionnement, expliquant en termes de virgule fixe. Il montre comment fonctionne l'algorithme pour trouver la réciproque, et comment gérer la division signée et le modulo.
Voyons une minute pourquoi
0.CCCCCCCC...
(hex) ou0.110011001100...
binaire est 4/5. Divisez la représentation binaire par 4 (décalage à droite de 2 places), et nous obtiendrons0.001100110011...
lequel, par inspection triviale, peut être ajouté l'original à obtenir0.111111111111...
, qui est évidemment égal à 1, de la même manière0.9999999...
en décimal est égal à un. Par conséquent, nous savons quex + x/4 = 1
, si5x/4 = 1
,x=4/5
. Ceci est alors représenté commeCCCCCCCCCCCCD
en hexadécimal pour l'arrondi (comme le chiffre binaire au-delà du dernier présent serait a1
).la source
En général, la multiplication est beaucoup plus rapide que la division. Donc, si nous pouvons échapper à la multiplication par l'inverse, nous pouvons accélérer considérablement la division par une constante
Une ride est que nous ne pouvons pas représenter exactement l'inverse (à moins que la division soit par une puissance de deux, mais dans ce cas, nous pouvons généralement simplement convertir la division en un décalage de bits). Donc, pour garantir des réponses correctes, nous devons veiller à ce que l'erreur dans notre réciproque ne cause pas d'erreurs dans notre résultat final.
-3689348814741910323 est 0xCCCCCCCCCCCCCCCD qui est une valeur d'un peu plus de 4/5 exprimée en 0,64 point fixe.
Lorsque nous multiplions un entier 64 bits par un nombre à virgule fixe de 0,64, nous obtenons un résultat de 64,64. Nous tronquons la valeur à un entier 64 bits (l'arrondissant effectivement vers zéro), puis effectuons un autre décalage qui divise par quatre et tronque à nouveau.En regardant le niveau de bits, il est clair que nous pouvons traiter les deux troncatures comme une seule troncature.
Cela nous donne clairement au moins une approximation de la division par 5, mais cela nous donne-t-il une réponse exacte correctement arrondie vers zéro?
Pour obtenir une réponse exacte, l'erreur doit être suffisamment petite pour ne pas pousser la réponse au-dessus d'une limite d'arrondi.
La réponse exacte à une division par 5 aura toujours une partie fractionnaire de 0, 1/5, 2/5, 3/5 ou 4/5. Par conséquent, une erreur positive inférieure à 1/5 dans le résultat multiplié et décalé ne poussera jamais le résultat au-dessus d'une limite d'arrondi.
L'erreur dans notre constante est (1/5) * 2 -64 . La valeur de i est inférieure à 2 64, donc l'erreur après multiplication est inférieure à 1/5. Après la division par 4, l'erreur est inférieure à (1/5) * 2 −2 .
(1/5) * 2 −2 <1/5 donc la réponse sera toujours égale à faire une division exacte et à arrondir vers zéro.
Malheureusement, cela ne fonctionne pas pour tous les diviseurs.
Si nous essayons de représenter 4/7 comme un nombre à virgule fixe de 0,64 avec un arrondi à partir de zéro, nous nous retrouvons avec une erreur de (6/7) * 2 -64 . Après avoir multiplié par une valeur i d'un peu moins de 2 64, nous nous retrouvons avec une erreur un peu moins de 6/7 et après avoir divisé par quatre, nous nous retrouvons avec une erreur d'un peu moins de 1,5 / 7 qui est supérieure à 1/7.
Donc, pour implémenter correctement la division par 7, nous devons multiplier par un nombre à 0,65 point fixe. Nous pouvons implémenter cela en multipliant par les 64 bits inférieurs de notre nombre à virgule fixe, puis en ajoutant le nombre d'origine (cela peut déborder dans le bit de retenue) puis en effectuant une rotation par report.
la source
Voici un lien vers un document d'un algorithme qui produit les valeurs et le code que je vois avec Visual Studio (dans la plupart des cas) et que je suppose est toujours utilisé dans GCC pour la division d'un entier variable par un entier constant.
http://gmplib.org/~tege/divcnst-pldi94.pdf
Dans l'article, un uword a N bits, un udword a 2N bits, n = numérateur = dividende, d = dénominateur = diviseur, ℓ est initialement défini sur ceil (log2 (d)), shpre est pré-décalage (utilisé avant multiplication ) = e = nombre de bits de fin à zéro dans d, shpost est post-shift (utilisé après multiplication), prec est précision = N - e = N - shpre. Le but est d'optimiser le calcul de n / d en utilisant un pré-décalage, une multiplication et un post-décalage.
Faites défiler jusqu'à la figure 6.2, qui définit comment un multiplicateur udword (la taille maximale est N + 1 bits), est généré, mais n'explique pas clairement le processus. Je vais l'expliquer ci-dessous.
Les figures 4.2 et 6.2 montrent comment le multiplicateur peut être réduit à un multiplicateur N bits ou moins pour la plupart des diviseurs. L'équation 4.5 explique comment la formule utilisée pour traiter les multiplicateurs N + 1 bits dans les figures 4.1 et 4.2 a été dérivée.
Dans le cas du X86 moderne et d'autres processeurs, le temps de multiplication est fixe, donc le pré-décalage n'aide pas sur ces processeurs, mais il aide toujours à réduire le multiplicateur de N + 1 bits à N bits. Je ne sais pas si GCC ou Visual Studio ont éliminé le pré-décalage pour les cibles X86.
Revenons à la figure 6.2. Le numérateur (dividende) pour mlow et mhigh ne peut être plus grand qu'un udword uniquement lorsque le dénominateur (diviseur)> 2 ^ (N-1) (lorsque ℓ == N => mlow = 2 ^ (2N)), dans ce cas, le le remplacement optimisé de n / d est une comparaison (si n> = d, q = 1, sinon q = 0), donc aucun multiplicateur n'est généré. Les valeurs initiales de mlow et mhigh seront N + 1 bits, et deux divisions udword / uword peuvent être utilisées pour produire chaque valeur N + 1 bits (mlow ou mhigh). Utiliser X86 en mode 64 bits comme exemple:
Vous pouvez tester cela avec GCC. Vous avez déjà vu comment j = i / 5 est géré. Jetez un œil à la façon dont j = i / 7 est géré (qui devrait être le cas du multiplicateur N + 1 bits).
Sur la plupart des processeurs actuels, la multiplication a un timing fixe, donc un pré-décalage n'est pas nécessaire. Pour X86, le résultat final est une séquence de deux instructions pour la plupart des diviseurs, et une séquence de cinq instructions pour des diviseurs comme 7 (afin d'émuler un multiplicateur N + 1 bits comme indiqué dans l'équation 4.5 et la figure 4.2 du fichier pdf). Exemple de code X86-64:
la source
Je vais répondre sous un angle légèrement différent: Parce qu'il est permis de le faire.
C et C ++ sont définis par rapport à une machine abstraite. Le compilateur transforme ce programme en termes de machine abstraite en machine concrète en suivant la simulation règle .
la source