Einleitung: Große Zahlen in einer kleinen Welt
Stellen Sie sich vor: Sie schreiben einen kryptografischen Algorithmus, eine Simulation physikalischer Prozesse oder berechnen einfach die Entfernung zur nächsten Galaxie in Nanometern. Und plötzlich stellen Sie fest – der Standard-i64 (von -9 Trillionen bis 9 Trillionen) reicht Ihnen katastrophal nicht aus. In Rust gibt es einen Rettungsring – den Typ i128. Aber wie funktioniert er überhaupt auf einem Prozessor, der physikalisch nicht mit 128-Bit-Zahlen arbeiten kann? Ist das Magie?
Spoiler: Es gibt keine Magie, es gibt geniale Ingenieurskunst. In diesem Artikel werden wir analysieren, wie Rust (bzw. LLVM unter der Haube) i128 auf einer 64-Bit-Architektur emuliert. Sie erfahren, warum dies nicht so langsam ist, wie es scheint, und wie Sie effizienten Code mit diesen riesigen Zahlen schreiben. Dies ist keine trockene Theorie – wir gehen den Weg von der Frage bis zur praktischen Anwendung anhand von Beispielen.
1. Hardware-Realität: Warum 128 Bit eine Herausforderung sind
Moderne 64-Bit-Prozessoren (x86-64, ARM64) haben Allzweckregister mit einer Größe von 64 Bit. Dies ist ihre „native“ Wortgröße. Um mit einer 128-Bit-Zahl zu arbeiten, muss der Prozessor mehrere Operationen mit zwei 64-Bit-Hälften durchführen: der niedrigen (low) und der hohen (high).
In Rust ist der Typ i128 (und sein vorzeichenloser Bruder u128) kein Hardware-Typ, sondern ein aggregierter Typ. Er wird im Speicher als zwei benachbarte 64-Bit-Wörter gespeichert. Der Compiler (über LLVM) generiert Code, der diese Wörter als eine Einheit verarbeitet.
Ein einfaches Beispiel:
fn main() { let big: i128 = 123456789012345678901234567890; println!("{}", big);}Auf Assembly-Ebene wird dies in eine Sequenz von Anweisungen umgewandelt, die mit zwei 64-Bit-Registern arbeiten (z. B. rax und rdx). LLVM kann solche Operationen sehr effizient „zusammenkleben“.
2. Anatomie der Addition: Wie Rust i128 addiert
Schauen wir uns die grundlegendste Operation an – die Addition zweier i128. Schreiben wir eine einfache Funktion und werfen wir mit cargo-show-asm oder Compiler Explorer einen Blick unter die Haube.
fn add_big(a: i128, b: i128) -> i128 { a + b}Kompilieren wir für x86-64 (ohne Optimierungen, zur Veranschaulichung). LLVM wird ungefähr diesen Pseudocode generieren:
- Laden der unteren 64 Bit von
ain das Registerrax, der oberen inrdx. - Laden der unteren 64 Bit von
binrbx, der oberen inrcx. - Addition der unteren Teile:
add rax, rbx– Ergebnis inrax, das Übertragsflag (CF) wird gesetzt, falls ein Überlauf auftritt. - Addition der oberen Teile unter Berücksichtigung des Übertrags:
adc rdx, rcx–adc(add with carry) addiert 1, wenn das CF-Flag gesetzt war. - Ergebnis: untere 64 Bit in
rax, obere inrdx.
Das ist die Emulation. Zwei Anweisungen statt einer. Für Multiplikation und Division wird der Code komplexer, aber das Prinzip bleibt dasselbe – die Aufteilung in 64-Bit-Operationen.
3. Und was ist mit der Leistung? Mythen und Realität
Viele denken, dass i128 doppelt so langsam ist wie i64. Das stimmt nicht ganz. Ja, jede Operation wird in 2-3 Anweisungen statt einer umgewandelt. Aber moderne Prozessoren haben:
- Pipelines und Superskalarität – sie können mehrere Anweisungen pro Takt ausführen.
- Sprungsvorhersage – besonders wichtig für Vergleiche.
- Große Caches – die Arbeit mit dem Speicher leidet nicht, da
i128nur 16 Bytes belegt.
Praktischer Test (Rust, einfache Arithmetik in einer Schleife):
use std::time::Instant;
fn main() { let n = 100_000_000; // i64 let start = Instant::now(); let m