Rust a des entiers de 128 bits, ceux-ci sont indiqués avec le type de données i128
(et u128
pour les entiers non signés):
let a: i128 = 170141183460469231731687303715884105727;
Comment Rust fait fonctionner ces i128
valeurs sur un système 64 bits; par exemple, comment fait-il de l'arithmétique sur ces derniers?
Puisque, pour autant que je sache, la valeur ne peut pas tenir dans un registre d'un processeur x86-64, le compilateur utilise-t-il en quelque sorte 2 registres pour une i128
valeur? Ou utilisent-ils plutôt une sorte de grande structure entière pour les représenter?
Réponses:
Tous les types entiers de Rust sont compilés en entiers LLVM . La machine abstraite LLVM autorise les entiers de n'importe quelle largeur de bits de 1 à 2 ^ 23 - 1. * Les instructions LLVM fonctionnent généralement sur des entiers de n'importe quelle taille.
De toute évidence, il n'y a pas beaucoup d'architectures 8388607 bits, donc lorsque le code est compilé en code machine natif, LLVM doit décider comment l'implémenter. La sémantique d'une instruction abstraite comme
add
est définie par LLVM lui-même. En règle générale, les instructions abstraites qui ont un équivalent à instruction unique dans le code natif seront compilées dans cette instruction native, tandis que celles qui ne le sont pas seront émulées, éventuellement avec plusieurs instructions natives. La réponse de mcarton montre comment LLVM compile les instructions natives et émulées.(Cela ne s'applique pas seulement aux entiers qui sont plus grands que la machine native ne peut prendre en charge, mais aussi à ceux qui sont plus petits. Par exemple, les architectures modernes peuvent ne pas prendre en charge l'arithmétique native 8 bits, donc une
add
instruction sur deuxi8
s peut être émulée avec une instruction plus large, les bits supplémentaires sont supprimés.)Au niveau de LLVM IR, la réponse n'est ni l'un ni l'autre:
i128
s'inscrit dans un seul registre, comme tout autre type à valeur unique . D'un autre côté, une fois traduit en code machine, il n'y a pas vraiment de différence entre les deux, car les structures peuvent être décomposées en registres comme des entiers. Lorsque vous faites de l'arithmétique, cependant, il y a fort à parier que LLVM chargera simplement le tout dans deux registres.* Cependant, tous les backends LLVM ne sont pas créés égaux. Cette réponse concerne x86-64. Je comprends que le support backend pour les tailles supérieures à 128 et les non-puissances de deux est irrégulier (ce qui peut expliquer en partie pourquoi Rust n'expose que des entiers de 8, 16, 32, 64 et 128 bits). Selon est31 sur Reddit , rustc implémente des entiers de 128 bits dans le logiciel lorsqu'il cible un backend qui ne les prend pas en charge nativement.
la source
Type
classe, cela signifie qu'il y a 8 bits pour stocker le type de type (fonction, bloc, entier, ...) et 24 bits pour les données de sous-classe. LaIntegerType
classe utilise ensuite ces 24 bits pour stocker la taille, ce qui permet aux instances de s'adapter parfaitement à 32 bits!Le compilateur les stockera dans plusieurs registres et utilisera plusieurs instructions pour faire de l'arithmétique sur ces valeurs si nécessaire. La plupart des ISA ont une instruction add-with-carry comme celle de x86,
adc
ce qui rend assez efficace pour faire des add / sub entiers de précision étendue.Par exemple, étant donné
le compilateur génère les éléments suivants lors de la compilation pour x86-64 sans optimisation:
(commentaires ajoutés par @PeterCordes)
où vous pouvez voir que la valeur
42
est stockée dansrax
etrcx
.(Note de l'éditeur: les conventions d'appel x86-64 C renvoient des entiers de 128 bits dans RDX: RAX. Mais cela
main
ne renvoie pas du tout de valeur. Toute la copie redondante provient uniquement de la désactivation de l'optimisation, et que Rust vérifie en fait le dépassement de capacité lors du débogage mode.)À titre de comparaison, voici l'asm pour les entiers Rust 64 bits sur x86-64 où aucun ajout avec report n'est nécessaire, juste un seul registre ou emplacement de pile pour chaque valeur.
Le setb / test est toujours totalement redondant:
jc
(sauter si CF = 1) fonctionnerait très bien.Avec l'optimisation activée, le compilateur Rust ne vérifie pas le dépassement de capacité et
+
fonctionne donc comme.wrapping_add()
.la source
u128
arguments et renvoyait une valeur (comme celle-ci godbolt.org/z/6JBza0 ), au lieu de désactiver l'optimisation pour empêcher le compilateur de faire propagation constante sur les arguments à constante de compilation.Oui, tout comme les entiers 64 bits sur les machines 32 bits ont été traités, ou les entiers 32 bits sur les machines 16 bits, ou même les entiers 16 et 32 bits sur les machines 8 bits (toujours applicable aux microcontrôleurs! ). Oui, vous stockez le numéro dans deux registres, ou emplacements de mémoire, ou autre chose (cela n'a pas vraiment d'importance). L'addition et la soustraction sont triviales, prenant deux instructions et utilisant le drapeau de retenue. La multiplication nécessite trois multiplications et quelques ajouts (il est courant pour les puces 64 bits d'avoir déjà une opération de multiplication 64x64-> 128 qui sort sur deux registres). La division ... nécessite un sous-programme et est assez lente (sauf dans certains cas où la division par une constante peut être transformée en décalage ou en multiplication), mais cela fonctionne toujours. Bitwise et / ou / xor doivent simplement être effectués séparément sur les moitiés supérieure et inférieure. Les décalages peuvent être accomplis par rotation et masquage. Et cela couvre à peu près les choses.
la source
Pour fournir peut-être un exemple plus clair, sur x86_64, compilé avec l'
-O
indicateur, la fonctioncompile en
(Mon message d'origine avait
u128
plutôt que ce quei128
vous avez demandé. La fonction compile le même code dans les deux cas, une bonne démonstration que l'ajout signé et non signé est le même sur un processeur moderne.)L'autre liste a produit du code non optimisé. Il est sûr d'intervenir dans un débogueur, car cela garantit que vous pouvez placer un point d'arrêt n'importe où et inspecter l'état de n'importe quelle variable sur n'importe quelle ligne du programme. C'est plus lent et plus difficile à lire. La version optimisée est beaucoup plus proche du code qui fonctionnera réellement en production.
Le paramètre
a
de cette fonction est passé dans une paire de registres 64 bits, rsi: rdi. Le résultat est renvoyé dans une autre paire de registres, rdx: rax. Les deux premières lignes de code initialisent la somme àa
.La troisième ligne ajoute 1337 au mot bas de l'entrée. Si cela déborde, il porte le 1 dans le drapeau de retenue du CPU. La quatrième ligne ajoute zéro au mot haut de l'entrée, plus le 1 s'il a été porté.
Vous pouvez considérer cela comme une simple addition d'un nombre à un chiffre à un nombre à deux chiffres
mais en base 18 446 744 073 709 551 616. Vous ajoutez toujours le «chiffre» le plus bas en premier, en portant éventuellement un 1 à la colonne suivante, puis en ajoutant le chiffre suivant plus le report. La soustraction est très similaire.
La multiplication doit utiliser l'identité (2⁶⁴a + b) (2⁶⁴c + d) = 2¹²⁸ac + 2⁶⁴ (ad + bc) + bd, où chacune de ces multiplications renvoie la moitié supérieure du produit dans un registre et la moitié inférieure du produit dans un autre. Certains de ces termes seront supprimés, car les bits au-dessus du 128e ne rentrent pas dans a
u128
et sont supprimés. Même ainsi, cela nécessite un certain nombre d'instructions de la machine. La division prend également plusieurs étapes. Pour une valeur signée, la multiplication et la division auraient en outre besoin de convertir les signes des opérandes et le résultat. Ces opérations ne sont pas du tout très efficaces.Sur d'autres architectures, cela devient plus facile ou plus difficile. RISC-V définit une extension de jeu d'instructions de 128 bits, bien qu'à ma connaissance personne ne l'ait implémentée en silicium. Sans cette extension, le manuel d'architecture RISC-V recommande une branche conditionnelle:
addi t0, t1, +imm; blt t0, t1, overflow
SPARC a des codes de contrôle comme les indicateurs de contrôle de x86, mais vous devez utiliser une instruction spéciale,,
add,cc
pour les définir. MIPS, d'autre part, vous oblige à vérifier si la somme de deux entiers non signés est strictement inférieure à l'un des opérandes. Si tel est le cas, l'addition a débordé. Au moins, vous pouvez définir un autre registre à la valeur du bit de retenue sans branche conditionnelle.la source
sub
résultat, vous avez besoin d'unn+1
sous-résultat den
bit pour les entrées de bit. c'est-à-dire que vous devez regarder le report, pas le bit de signe du résultat de même largeur. C'est pourquoi les conditions de branchement non signées x86 sont basées sur CF (bit 64 ou 32 du résultat logique complet), et non sur SF (bit 63 ou 31).x - (a*b)
, en calculant le reste à partir du dividende, du quotient et du diviseur. (Cela est utile même pour les diviseurs constants utilisant un inverse multiplicatif pour la partie de division). Je n'avais pas lu les ISA qui fusionnent les instructions div + mod en une seule opération divmod; c'est chouette.mul r64
2 uops, le deuxième écrivant la moitié haute du RDX).adc
,sbb
etcmov
à 2 uops chacun. (Haswell a introduit des uops à 3 entrées pour FMA, Broadwell a étendu cela à un entier.)