J'ai lu sur div
et mul
Opérations d'assemblage, et j'ai décidé de les voir en action en écrivant un programme simple en 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 le code de langue Assembly avec:
gcc -S division.c -O0 -masm=intel
Mais en regardant le fichier division.s
généré, il ne contient aucune opération div! Au lieu de cela, il fait une sorte de magie noire avec des nombres décalés 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 du tout div? Comment génère-t-il ce nombre magique et pourquoi tout fonctionne-t-il?
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 allant jusqu’à des dizaines de cycles et un débit médiocre. (Pour x86, voir Tableaux d'instructions et guide de 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) ayant un effet équivalent. Même si plusieurs opérations sont nécessaires, cela reste souvent bien plus rapide que la division entière elle-même.
Implémenter l'opérateur C /
de cette manière au lieu d'une séquence d'instructions multiples impliquant div
n'est que la méthode par défaut de GCC pour la division par constantes. Cela ne nécessite aucune optimisation sur toutes les opérations et ne change rien même pour le débogage. (Utiliser -Os
pour une petite taille de code oblige cependant GCC à utiliser div
.) Utiliser un inverse multiplicatif au lieu d'une division revient à utiliser lea
au lieu de mul
et add
En conséquence, vous avez seulement tendance à voir div
ou idiv
dans la sortie si le diviseur n'est pas connu au moment de la compilation.
Pour obtenir des informations sur la manière dont le compilateur génère ces séquences, ainsi que du code vous permettant de les générer vous-même (presque certainement inutile si vous ne travaillez pas avec un compilateur braindead), voir libdivide .
Diviser par 5 équivaut à multiplier 1/5, ce qui revient à multiplier par 4/5 et décaler de 2 bits vers la droite. La valeur concernée est CCCCCCCCCCCCD
en hex, ce qui correspond à 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 est 0.110011001100
récurrent - voir ci-dessous pour savoir pourquoi). Je pense que vous pouvez le prendre d'ici! Vous voudrez peut-être vérifier arithmétique en virgule fixe (notez bien que cette valeur est arrondie à un entier à la fin.
Quant à savoir pourquoi, la multiplication est plus rapide que la division, et lorsque le diviseur est fixe, la route est plus rapide.
Voir Multiplication réciproque, un tutoriel pour une description détaillée de son fonctionnement, expliquant en termes de point fixe. Il montre comment fonctionne l'algorithme de recherche de l'inverse et comment gérer la division signée et le modulo.
Examinons un instant pourquoi 0.CCCCCCCC...
(hex) ou 0.110011001100...
binaire vaut 4/5. Divisez la représentation binaire par 4 (déplacez-vous à droite de 2 places) et vous obtiendrez 0.001100110011...
auquel l'inspection triviale peut être ajoutée à l'original pour obtenir 0.111111111111...
, ce qui est évidemment égal à 1, de la même manière 0.9999999...
en décimal est égal à un. Par conséquent, nous savons que x + x/4 = 1
, donc 5x/4 = 1
, x=4/5
. Ceci est alors représenté par CCCCCCCCCCCCD
en hexadécimal pour l'arrondir (car le chiffre binaire au-delà du dernier présent serait un 1
).
En général, la multiplication est beaucoup plus rapide que la division. Donc, si nous pouvons nous contenter de multiplier par la réciproque, nous pouvons accélérer considérablement la division de manière constante.
Un problème est que nous ne pouvons pas représenter exactement la réciproque (à moins que la division ne soit divisée par une puissance de deux, mais dans ce cas, nous pouvons généralement simplement convertir la division en décalage minime). Donc, pour assurer des réponses correctes, nous devons faire attention à ce que l'erreur dans notre réciproque ne provoque pas d'erreur dans notre résultat final.
-3689348814741910323 est 0xCCCCCCCCCCCCCCCD, une valeur d'un peu plus de 4/5 exprimée en 0.64 point fixe.
Lorsque nous multiplions un entier de 64 bits par un nombre de points fixes de 0,64, nous obtenons un résultat de 64,64. Nous tronquons la valeur en un entier de 64 bits (en l’arrondissant effectivement vers zéro), puis effectuons un autre décalage qui se divise en quatre et se tronque à nouveau. En regardant au niveau du bit, il est clair que nous pouvons traiter les deux troncatures comme une simple 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 à zéro?
Pour obtenir une réponse exacte, l'erreur doit être suffisamment petite pour que la réponse ne soit pas dépassée.
La réponse exacte à une division par 5 aura toujours une fraction 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-delà d'une limite d'arrondi.
L'erreur dans notre constante est (1/5) * 2-64. La valeur de i est inférieure à 264 de sorte que l'erreur après la 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, la réponse sera donc toujours égale à une division exacte et à l’arrondi vers zéro.
Malheureusement, cela ne fonctionne pas pour tous les diviseurs.
Si nous essayons de représenter 4/7 comme un nombre de points fixes de 0,64 avec arrondi à zéro, nous obtenons une erreur de (6/7) * 2-64. Après avoir multiplié par une valeur d'i juste en dessous de 264 nous nous retrouvons avec une erreur un peu moins de 6/7 et après avoir divisé par quatre, nous obtenons une erreur d’un peu moins de 1,5/7, ce qui est supérieur à 1/7.
Donc, pour appliquer correctement la division par 7, nous devons multiplier par un nombre de points fixes de 0,65. Nous pouvons implémenter cela en multipliant par les 64 bits les plus bas de notre nombre à virgule fixe, puis en ajoutant le numéro d'origine (cela peut déborder dans le bit de retenue), puis en effectuant une rotation.
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 toujours utilisés dans GCC pour la division d'un entier variable par un entier constant.
http://gmplib.org/~tege/divcnst-pldi94.pdf
Dans cet article, un mot a N bits, un mot ud a 2N bits, n = numérateur = dividende, d = dénominateur = diviseur, est initialement défini sur ceil (log2 (d)), shpre est pre-shift (utilisé avant ) = e = nombre de bits zéro de fin dans d, shpost est post-shift (utilisé après multiplication), preci est précision = N - e = N - shpre. L’objectif est d’optimiser le calcul de n/d à l’aide des paramètres pré-décalage, multiplication et 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ée, mais n'explique pas clairement le processus. Je vais expliquer cela ci-dessous.
Les figures 4.2 et 6.2 montrent comment réduire le multiplicateur à 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 de bits N + 1 dans les figures 4.1 et 4.2 a été dérivée.
Dans le cas des processeurs X86 modernes et autres, le temps de multiplication est fixe; le pré-décalage n'aide donc pas ces processeurs, mais contribue néanmoins à 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 peut être supérieur à udword uniquement lorsque dénominateur (diviseur)> 2 ^ (N-1) (lorsque ℓ == N => mlow = 2 ^ (2N)), dans ce cas le Le remplacement optimisé pour n/d est une comparaison (si n> = d, q = 1, sinon q = 0), 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 bit (mlow ou mhigh). Utilisation de X86 en mode 64 bits à titre d'exemple:
; 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
Vous pouvez tester cela avec GCC. Vous avez déjà vu comment j = i/5 est traité. Regardez comment j = i/7 est traité (ce qui devrait être le cas du multiplicateur de bits N + 1).
Sur la plupart des processeurs actuels, multiply a un timing fixe, un pré-décalage n'est donc 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 les diviseurs tels que 7 (afin d’émuler un multiplicateur de N + 1 bits, comme indiqué dans l’équation 4.5 et la figure 4.2 du fichier pdf). Exemple de code 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
; ...