Rust hat 128-Bit-Ganzzahlen, diese werden mit dem Datentyp i128
(und u128
für vorzeichenlose Ints) bezeichnet:
let a: i128 = 170141183460469231731687303715884105727;
Wie macht Rust diese? i128
Werte auf einem 64-Bit-System funktionieren? zB wie rechnet man damit?
Da der Wert meines Wissens nicht in ein Register einer x86-64-CPU passen kann, verwendet der Compiler irgendwie 2 Register für einen i128
Wert? Oder verwenden sie stattdessen eine große Ganzzahlstruktur, um sie darzustellen?
Antworten:
Alle Integer-Typen von Rust werden zu LLVM-Integer kompiliert . Die abstrakte LLVM-Maschine erlaubt Ganzzahlen mit einer beliebigen Bitbreite von 1 bis 2 ^ 23 - 1. * LLVM- Anweisungen arbeiten normalerweise mit Ganzzahlen jeder Größe.
Offensichtlich gibt es nicht viele 8388607-Bit-Architekturen. Wenn der Code zu nativem Maschinencode kompiliert wird, muss LLVM entscheiden, wie er implementiert werden soll. Die Semantik einer abstrakten Anweisung wie
add
wird von LLVM selbst definiert. In der Regel werden abstrakte Anweisungen mit einer einzelnen Anweisung, die im nativen Code entspricht, zu dieser nativen Anweisung kompiliert, während solche, die nicht emuliert werden, möglicherweise mit mehreren nativen Anweisungen. Die Antwort von mcarton zeigt, wie LLVM sowohl native als auch emulierte Anweisungen kompiliert.(Dies gilt nicht nur für Ganzzahlen, die größer sind, als der native Computer unterstützen kann, sondern auch für solche, die kleiner sind. Beispielsweise unterstützen moderne Architekturen möglicherweise keine native 8-Bit-Arithmetik, sodass eine
add
Anweisung für zweii8
Sekunden emuliert werden kann mit einer breiteren Anweisung werden die zusätzlichen Bits verworfen.)Auf der Ebene von LLVM IR lautet die Antwort weder:
i128
Passt in ein einzelnes Register, genau wie jeder andere einwertige Typ . Auf der anderen Seite gibt es nach der Übersetzung in Maschinencode keinen wirklichen Unterschied zwischen den beiden, da Strukturen wie Ganzzahlen in Register zerlegt werden können. Beim Rechnen ist es jedoch ziemlich sicher, dass LLVM das Ganze nur in zwei Register lädt.* Allerdings sind nicht alle LLVM-Backends gleich. Diese Antwort bezieht sich auf x86-64. Ich verstehe, dass die Backend-Unterstützung für Größen größer als 128 und Nicht-Zweierpotenzen unvollständig ist (was teilweise erklären kann, warum Rust nur 8-, 16-, 32-, 64- und 128-Bit-Ganzzahlen verfügbar macht). Laut est31 auf Reddit implementiert rustc 128-Bit-Ganzzahlen in Software, wenn es auf ein Backend abzielt, das sie nicht nativ unterstützt.
quelle
Type
Klasse bedeutet dies, dass 8 Bit zum Speichern des Typs (Funktion, Block, Ganzzahl, ...) und 24 Bit für Unterklassendaten vorhanden sind. DieIntegerType
Klasse verwendet dann diese 24 Bit, um die Größe zu speichern, sodass Instanzen ordentlich in 32 Bit passen!Der Compiler speichert diese in mehreren Registern und verwendet bei Bedarf mehrere Anweisungen, um diese Werte zu rechnen. Die meisten ISAs verfügen über eine Add-with-Carry-Anweisung wie x86,
adc
die es ziemlich effizient macht, Ganzzahlen-Add / Sub mit erweiterter Genauigkeit auszuführen .Zum Beispiel gegeben
Der Compiler generiert Folgendes, wenn er ohne Optimierung für x86-64 kompiliert:
(Kommentare von @PeterCordes hinzugefügt)
wo Sie sehen können, dass der Wert
42
inrax
und gespeichert istrcx
.(Anmerkung des Herausgebers: x86-64 C-Aufrufkonventionen geben in RDX: RAX 128-Bit-Ganzzahlen zurück. Dies
main
gibt jedoch überhaupt keinen Wert zurück. Das redundante Kopieren erfolgt ausschließlich durch Deaktivieren der Optimierung, und Rust prüft tatsächlich, ob das Debug überläuft Modus.)Zum Vergleich hier der ASM für Rust 64-Bit-Ganzzahlen auf x86-64, bei dem kein Add-with-Carry erforderlich ist, sondern nur ein einzelnes Register oder ein Stack-Slot für jeden Wert.
Das Setb / Test ist immer noch völlig redundant:
jc
(Sprung, wenn CF = 1) würde gut funktionieren.Wenn die Optimierung aktiviert ist, prüft der Rust-Compiler nicht, ob ein Überlauf
+
vorliegt.wrapping_add()
.quelle
u128
Argumente benötigt und einen Wert zurückgibt (wie diesen godbolt.org/z/6JBza0 ), anstatt die Optimierung zu deaktivieren, um den Compiler daran zu hindern Konstante Ausbreitung auf Argumenten mit konstanter Kompilierungszeit.Ja, genauso wie 64-Bit-Ganzzahlen auf 32-Bit-Computern oder 32-Bit-Ganzzahlen auf 16-Bit-Computern oder sogar 16- und 32-Bit-Ganzzahlen auf 8-Bit-Computern behandelt wurden (gilt immer noch für Mikrocontroller! ). Ja, Sie speichern die Nummer in zwei Registern oder Speicherorten oder was auch immer (es spielt keine Rolle). Addition und Subtraktion sind trivial, nehmen zwei Anweisungen und verwenden das Übertragsflag. Die Multiplikation erfordert drei Multiplikationen und einige Additionen (es ist üblich, dass 64-Bit-Chips bereits eine 64x64-> 128-Multiplikationsoperation haben, die an zwei Register ausgegeben wird). Division ... erfordert eine Unterroutine und ist ziemlich langsam (außer in einigen Fällen, in denen die Division durch eine Konstante in eine Verschiebung oder eine Multiplikation umgewandelt werden kann), funktioniert aber trotzdem. Bitweise und / oder / xor müssen lediglich auf der oberen und unteren Hälfte getrennt ausgeführt werden. Verschiebungen können durch Drehen und Maskieren erreicht werden. Und das deckt so ziemlich alles ab.
quelle
Um vielleicht ein klareres Beispiel für x86_64 zu liefern, kompiliert mit dem
-O
Flag die Funktionkompiliert zu
(Mein ursprünglicher Beitrag hatte
u128
eher als deni128
, nach dem Sie gefragt haben. Die Funktion kompiliert in beiden Fällen denselben Code. Dies ist eine gute Demonstration, dass signierte und nicht signierte Additionen auf einer modernen CPU identisch sind.)Die andere Auflistung erzeugte nicht optimierten Code. Es ist sicher, in einem Debugger durchzugehen, da dadurch sichergestellt wird, dass Sie überall einen Haltepunkt setzen und den Status einer Variablen in einer beliebigen Zeile des Programms überprüfen können. Es ist langsamer und schwerer zu lesen. Die optimierte Version kommt dem Code, der tatsächlich in der Produktion ausgeführt wird, viel näher.
Der Parameter
a
dieser Funktion wird in einem Paar von 64-Bit-Registern, rsi: rdi, übergeben. Das Ergebnis wird in einem anderen Registerpaar, rdx: rax, zurückgegeben. Die ersten beiden Codezeilen initialisieren die Summe aufa
.Die dritte Zeile fügt dem niedrigen Wort der Eingabe 1337 hinzu. Wenn dies überläuft, trägt es die 1 im Übertragsflag der CPU. Die vierte Zeile addiert Null zum oberen Wort der Eingabe - plus die 1, wenn es übertragen wurde.
Sie können sich dies als einfaches Hinzufügen einer einstelligen Nummer zu einer zweistelligen Nummer vorstellen
aber in der Basis 18.446.744.073.709.551.616. Sie fügen immer noch zuerst die niedrigste „Ziffer“ hinzu, möglicherweise mit einer 1 in die nächste Spalte, und fügen dann die nächste Ziffer plus den Übertrag hinzu. Die Subtraktion ist sehr ähnlich.
Die Multiplikation muss die Identität (2⁶⁴a + b) (2⁶⁴c + d) = 2¹²⁸ac + 2⁶⁴ (ad + bc) + bd verwenden, wobei jede dieser Multiplikationen die obere Hälfte des Produkts in einem Register und die untere Hälfte des Produkts in zurückgibt Ein weiterer. Einige dieser Begriffe werden gelöscht, da Bits über dem 128. nicht in ein passen
u128
und verworfen werden. Dies erfordert jedoch eine Reihe von Maschinenanweisungen. Die Aufteilung erfolgt ebenfalls in mehreren Schritten. Für einen vorzeichenbehafteten Wert müssten Multiplikation und Division zusätzlich die Vorzeichen der Operanden und das Ergebnis konvertieren. Diese Operationen sind überhaupt nicht sehr effizient.Bei anderen Architekturen wird es einfacher oder schwieriger. RISC-V definiert eine 128-Bit-Befehlssatzerweiterung, obwohl meines Wissens niemand sie in Silizium implementiert hat. Ohne diese Erweiterung empfiehlt das RISC-V-Architekturhandbuch eine bedingte Verzweigung:
addi t0, t1, +imm; blt t0, t1, overflow
SPARC hat Steuercodes wie die Steuerflags von x86, aber Sie müssen eine spezielle Anweisung verwenden,
add,cc
um sie festzulegen. Bei MIPS hingegen müssen Sie überprüfen, ob die Summe zweier vorzeichenloser Ganzzahlen streng kleiner als einer der Operanden ist. Wenn ja, lief die Zugabe über. Zumindest können Sie ein anderes Register ohne bedingte Verzweigung auf den Wert des Übertragsbits setzen.quelle
sub
Ergebnisses betrachten, benötigen Sie einn+1
Bit-Unterergebnis fürn
Biteingaben. Das heißt, Sie müssen sich die Ausführung ansehen, nicht das Vorzeichenbit mit dem Ergebnis gleicher Breite. Aus diesem Grund basieren x86-Verzweigungsbedingungen ohne Vorzeichen auf CF (Bit 64 oder 32 des vollständigen logischen Ergebnisses) und nicht auf SF (Bit 63 oder 31).x - (a*b)
, wobei der Rest aus Dividende, Quotient und Divisor berechnet wird. (Dies ist auch für konstante Teiler nützlich, die eine multiplikative Inverse für den Teilungsteil verwenden). Ich hatte nicht über ISAs gelesen, die div + mod-Anweisungen zu einer einzigen divmod-Operation zusammenführen. das ist ordentlich.mul r64
2 Uops, wobei das zweite die hohe RDX-Hälfte schreibt).adc
,sbb
undcmov
auf jeweils 2 Uops. (Haswell führte 3-Input-Uops für FMA ein, Broadwell erweiterte dies auf Integer.)