Introduction : Les grands nombres dans un petit monde
Imaginez : vous écrivez un algorithme cryptographique, une simulation de processus physiques ou vous calculez simplement la distance jusqu'à la galaxie la plus proche en nanomètres. Et soudain, vous réalisez que le i64 standard (de -9 quintillions à 9 quintillions) est catastrophiquement insuffisant. Rust a une bouée de sauvetage : le type i128. Mais comment fonctionne-t-il sur un processeur qui ne peut physiquement pas gérer des nombres 128 bits ? Est-ce de la magie ?
Indice : il n'y a pas de magie, il y a une ingénierie géniale. Dans cet article, nous allons voir comment Rust (ou plus précisément, LLVM sous le capot) émule i128 sur une architecture 64 bits. Vous découvrirez pourquoi ce n'est pas aussi lent qu'il n'y paraît et comment écrire du code efficace avec ces nombres géants. Ce n'est pas de la théorie sèche — nous passerons de la question à l'application réelle avec des exemples.
1. La réalité matérielle : pourquoi 128 bits est un défi
Les processeurs 64 bits modernes (x86-64, ARM64) ont des registres à usage général de 64 bits. C'est leur taille de mot « native ». Pour travailler avec un nombre 128 bits, le processeur doit effectuer plusieurs opérations sur deux moitiés 64 bits : la partie basse (low) et la partie haute (high).
Dans Rust, le type i128 (et son homologue non signé u128) n'est pas un type matériel, mais un type agrégé. Il est stocké en mémoire comme deux mots 64 bits contigus. Le compilateur (via LLVM) génère du code qui traite ces mots comme un tout.
Un exemple simple :
fn main() { let big: i128 = 123456789012345678901234567890; println!("{}", big);}Au niveau assembleur, cela se transforme en une séquence d'instructions travaillant avec deux registres 64 bits (par exemple, rax et rdx). LLVM sait très efficacement « coller » ces opérations.
2. Anatomie de l'addition : comment Rust additionne i128
Regardons l'opération la plus basique — l'addition de deux i128. Écrivons une fonction simple et regardons sous le capot avec cargo-show-asm ou Compiler Explorer.
fn add_big(a: i128, b: i128) -> i128 { a + b}Compilons pour x86-64 (sans optimisations, pour plus de clarté). LLVM générera approximativement ce pseudo-code :
- Charger les 64 bits bas de
adans le registrerax, les bits hauts dansrdx. - Charger les 64 bits bas de
bdansrbx, les bits hauts dansrcx. - Additionner les parties basses :
add rax, rbx— résultat dansrax, le drapeau de retenue (CF) est défini en cas de débordement. - Additionner les parties hautes avec retenue :
adc rdx, rcx—adc(add with carry) ajoute 1 si le drapeau CF était défini. - Résultat : les 64 bits bas dans
rax, les bits hauts dansrdx.
C'est cela, l'émulation. Deux instructions au lieu d'une. Pour la multiplication et la division, le code sera plus complexe, mais le principe reste le même — la décomposition en opérations 64 bits.
3. Et les performances ? Mythes et réalité
Beaucoup pensent que i128 est deux fois plus lent que i64. Ce n'est pas tout à fait vrai. Oui, chaque opération se transforme en 2-3 instructions au lieu d'une. Mais les processeurs modernes ont :
- Des pipelines et la superscalarité — ils peuvent exécuter plusieurs instructions par cycle.
- La prédiction de branchement — particulièrement importante pour les comparaisons.
- De grands caches — le travail avec la mémoire n'en souffre pas, car
i128ne prend que 16 octets.
Test pratique (Rust, arithmétique simple en boucle) :
use std::time::Instant;
fn main() { let n = 100_000_000; // i64 let start = Instant::now(); let m