web-dev-qa-db-fra.com

Comment l'entier 128 bits de Rust `i128` fonctionne-t-il sur un système 64 bits?

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 rend ces i128 les valeurs fonctionnent sur un système 64 bits; par exemple. comment fait-il l'arithmétique sur ces derniers?

Puisque, pour autant que je sache, la valeur ne peut pas tenir dans un registre d'un CPU x86-64, le compilateur utilise-t-il en quelque sorte 2 registres pour un i128 valeur? Ou utilisent-ils à la place une sorte de grande structure entière pour les représenter?

123
ruohola

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. * LLVM instructions fonctionne généralement sur des entiers de toute 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 d'une seule instruction en code natif seront compilées pour cette instruction native, tandis que celles qui ne le seront pas seront émulées, éventuellement avec plusieurs instructions natives. réponse de mcarton montre comment LLVM compile des instructions natives et émulées.

(Cela ne s'applique pas uniquement aux entiers 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 instruction add en deux i8s peut être émulé avec une instruction plus large, les bits supplémentaires supprimés.)

Le compilateur utilise-t-il en quelque sorte 2 registres pour un i128 valeur? Ou utilisent-ils une sorte de grande structure entière pour les représenter?

Au niveau de LLVM IR, la réponse n'est ni: i128 tient dans un seul registre, comme tous les autres 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 tout comme les entiers. Lorsque vous faites de l'arithmétique, cependant, il y a fort à parier que LLVM va simplement charger 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 la prise en charge du backend pour les tailles supérieures à 128 et les non-puissances de deux est inégale (ce qui peut expliquer en partie pourquoi Rust expose uniquement 8-, 16-, 32-, 64- et 128 --- bits entiers). Selon est31 sur Reddit , rustc implémente des entiers 128 bits dans le logiciel lors du ciblage d'un backend qui ne les supporte pas nativement.

135
trentcl

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 x86's adc qui rend assez efficace la fonction add/sub entière de précision étendue.

Par exemple, étant donné

fn main() {
    let a = 42u128;
    let b = a + 1337;
}

le compilateur génère les éléments suivants lors de la compilation pour x86-64 sans optimisation:
(commentaires ajoutés par @PeterCordes)

playground::main:
    sub rsp, 56
    mov qword ptr [rsp + 32], 0
    mov qword ptr [rsp + 24], 42         # store 128-bit 0:42 on the stack
                                         # little-endian = low half at lower address

    mov rax, qword ptr [rsp + 24]
    mov rcx, qword ptr [rsp + 32]        # reload it to registers

    add rax, 1337                        # add 1337 to the low half
    adc rcx, 0                           # propagate carry to the high half. 1337u128 >> 64 = 0

    setb    dl                           # save carry-out (setb is an alias for setc)
    mov rsi, rax
    test    dl, 1                        # check carry-out (to detect overflow)
    mov qword ptr [rsp + 16], rax        # store the low half result
    mov qword ptr [rsp + 8], rsi         # store another copy of the low half
    mov qword ptr [rsp], rcx             # store the high half
                             # These are temporary copies of the halves; probably the high half at lower address isn't intentional
    jne .LBB8_2                       # jump if 128-bit add overflowed (to another not-shown block of code after the ret, I think)

    mov rax, qword ptr [rsp + 16]
    mov qword ptr [rsp + 40], rax     # copy low half to RSP+40
    mov rcx, qword ptr [rsp]
    mov qword ptr [rsp + 48], rcx     # copy high half to RSP+48
                  # This is the actual b, in normal little-endian order, forming a u128 at RSP+40
    add rsp, 56
    ret                               # with retval in EAX/RAX = low half result

où vous pouvez voir que la valeur 42 est stockée dans rax et rcx.

(NDLR: les conventions d'appel x86-64 C renvoient des entiers 128 bits dans RDX: RAX. Mais ce main ne renvoie aucune valeur. Toutes les copies redondantes proviennent uniquement de la désactivation de l'optimisation, et cela = Rust vérifie en fait le débordement en mode débogage.)

À titre de comparaison, voici l'asm pour Rust Entiers 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.

playground::main:
    sub rsp, 24
    mov qword ptr [rsp + 8], 42           # store
    mov rax, qword ptr [rsp + 8]          # reload
    add rax, 1337                         # add
    setb    cl
    test    cl, 1                         # check for carry-out (overflow)
    mov qword ptr [rsp], rax              # store the result
    jne .LBB8_2                           # branch on non-zero carry-out

    mov rax, qword ptr [rsp]              # reload the result
    mov qword ptr [rsp + 16], rax         # and copy it (to b)
    add rsp, 24
    ret

.LBB8_2:
    call panic function because of integer overflow

Le setb/test est toujours totalement redondant: jc (saut si CF = 1) fonctionnerait très bien.

Lorsque l'optimisation est activée, le compilateur Rust ne vérifie pas le débordement, donc + Fonctionne comme .wrapping_add().

54
mcarton

Oui, de la même manière que les entiers 64 bits sur les machines 32 bits, 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 applicables aux microcontrôleurs! ). Oui, vous stockez le numéro dans deux registres, ou emplacements de mémoire, ou autre (peu importe). L'addition et la soustraction sont triviales, prenant deux instructions et utilisant le drapeau de report. La multiplication nécessite trois multiplications et quelques ajouts (il est courant que les puces 64 bits aient 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. Les bits et/ou/xor doivent simplement être effectués séparément sur les moitiés supérieure et inférieure. Les changements peuvent être accomplis par rotation et masquage. Et cela couvre à peu près les choses.

29
hobbs

Pour fournir peut-être un exemple plus clair, sur x86_64, compilé avec le -O flag, la fonction

pub fn leet(a : i128) -> i128 {
    a + 1337
}

compile en

example::leet:
  mov rdx, rsi
  mov rax, rdi
  add rax, 1337
  adc rdx, 0
  ret

(Mon message d'origine avait u128 plûtot que le i128 vous avez demandé. La fonction compile le même code dans les deux cas, une bonne démonstration que l'addition signée et non signée est la même sur un processeur moderne.)

L'autre liste a produit du code non optimisé. Il est sûr de parcourir un débogueur, car il garantit que vous pouvez placer un point d'arrêt n'importe où et inspecter l'état de n'importe quelle variable à 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 s'exécutera réellement en production.

Le paramètre a de cette fonction est transmis 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 transport 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 voir cela comme la simple addition d'un nombre à un chiffre à un nombre à deux chiffres

  a  b
+ 0  7
______
 

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 supérieurs au 128e ne rentrent pas dans un u128 et sont supprimés. Néanmoins, cela nécessite un certain nombre d'instructions machine. La division prend également plusieurs mesures. Pour une valeur signée, la multiplication et la division devraient en outre 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 drapeaux 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 c'est le cas, l'addition a débordé. Au moins, vous pouvez définir un autre registre sur la valeur du bit de retenue sans branche conditionnelle.

23
Davislor