web-dev-qa-db-ja.com

Rustの128ビット整数 `i128`は64ビットシステムでどのように機能しますか?

Rustには128ビットの整数があり、これらはデータ型i128(およびu128符号なし整数の場合):

let a: i128 = 170141183460469231731687303715884105727;

Rust make these i128値は64ビットシステムで機能します。例えばこれらの計算はどのように行われますか?

私の知る限り、値はx86-64 CPUの1つのレジスタに収まらないため、コンパイラは1つのi128値?または、代わりに何らかの大きな整数構造体を使用してそれらを表現していますか?

120
ruohola

Rustのすべての整数型は LLVM整数 にコンパイルされます。 LLVM抽象マシンでは、1〜2 ^ 23-1. *の任意のビット幅の整数を使用できます。LLVM 命令 は、通常、任意のサイズの整数で機能します。

明らかに、8388607ビットアーキテクチャはそれほど多くないため、コードをネイティブマシンコードにコンパイルするとき、LLVMはそれを実装する方法を決定する必要があります。 add のような抽象命令のセマンティクスは、LLVM自体によって定義されます。通常、ネイティブコードと同等の単一命令を持つ抽象命令は、そのネイティブ命令にコンパイルされますが、エミュレートしない命令は、おそらく複数のネイティブ命令でエミュレートされます。 mcarton's answer は、LLVMがネイティブ命令とエミュレートされた命令の両方をコンパイルする方法を示しています。

(これは、ネイティブマシンがサポートできる整数より大きいだけでなく、小さい整数にも適用されます。たとえば、最新のアーキテクチャはネイティブ8ビット演算をサポートしていない可能性があるため、add命令2つのi8sはより広い命令でエミュレートされ、余分なビットは破棄されます。

コンパイラは1つのi128値?それとも、それらを表現するために何らかの大きな整数構造体を使用していますか?

LLVM IRのレベルでは、答えはどちらでもありません:i128は、他のすべての 単一値型 と同様に、単一のレジスタに収まります。一方、構造体は整数のようにレジスタに分解される可能性があるため、マシンコードに変換された後は、実際には2つの間に違いはありません。ただし、算術演算を行う場合、LLVMが2つのレジスターにすべてをロードするのはかなり安全です。


*ただし、すべてのLLVMバックエンドが同等に作成されるわけではありません。この回答はx86-64に関連しています。 128より大きいサイズと2のべき乗以外のサイズのバックエンドサポートはむらがあることを理解しています(Rustが8、16、32、64、および128のみを公開する理由を部分的に説明するかもしれません-ビット整数) Redditのest31によると 、ネイティブにサポートしていないバックエンドを対象とする場合、rustcはソフトウェアに128ビット整数を実装します。

132
trentcl

コンパイラはこれらを複数のレジスタに保存し、必要に応じて複数の命令を使用してこれらの値の算術演算を行います。ほとんどのISAには x86のadc のようなキャリー付き加算命令があり、拡張精度の整数加算/加算をかなり効率的に実行できます。

たとえば、与えられた

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

コンパイラは、最適化なしでx86-64用にコンパイルするときに次を生成します。
(@ 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
_

ここで、値_42_がraxおよびrcxに格納されていることがわかります。

(編集者のメモ:x86-64 Cの呼び出し規則はRDX:RAXで128ビット整数を返します。しかし、このmainは値をまったく返しません。 Rustは、デバッグモードで実際にオーバーフローをチェックします。)

比較のために、ここでは、Rust 64ビット整数のx86-64で、add-with-carryが不要で、値ごとに1つのレジスタまたはスタックスロットのみが必要です。

_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
_

Setb /テストはまだ完全に冗長です:jc(CF = 1の場合ジャンプ)はうまく動作します。

最適化を有効にすると、Rustコンパイラーはオーバーフローをチェックしないため、_+_は.wrapping_add()と同様に機能します。

53
mcarton

はい、32ビットマシン上の64ビット整数、16ビットマシン上の32ビット整数、または8ビットマシン上の16ビットおよび32ビット整数と同様に処理されます(マイクロコントローラーにも適用可能です! )。はい、2つのレジスター、メモリーの場所、またはその他に数値を保存します(実際には関係ありません)。加算と減算は簡単で、2つの命令を取り、キャリーフラグを使用します。乗算には3つの乗算といくつかの追加が必要です(64ビットチップでは、2つのレジスタに出力する64x64-> 128の乗算演算が既にあることが一般的です)。除算...にはサブルーチンが必要で、非常に低速です(定数による除算をシフトまたは乗算に変換できる場合を除きます)が、それでも動作します。ビット単位および/または/ xorは、単に上半分と下半分で別々に行う必要があります。シフトは、回転とマスキングで実現できます。そして、それはほとんど物事をカバーしています。

28
hobbs

おそらくより明確な例を提供するために、x86_64で_-O_フラグを使用してコンパイルし、関数

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

にコンパイルする

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

(私の元の投稿には、あなたが尋ねた_u128_ではなく_i128_がありました。関数はどちらの方法でも同じコードをコンパイルします。

他のリストは、最適化されていないコードを生成しました。ブレークポイントを任意の場所に配置して、プログラムの任意の行で変数の状態を検査できるため、デバッガーでステップスルーしても安全です。読むのが遅くて難しいです。最適化されたバージョンは、実際に運用環境で実行されるコードに非常に近いものです。

この関数のパラメータaは、1対の64ビットレジスタrsi:rdiで渡されます。結果は、別のレジスタペアrdx:raxに返されます。コードの最初の2行は、合計をaに初期化します。

3行目は、入力の下位ワードに1337を追加します。これがオーバーフローすると、CPUのキャリーフラグに1が含まれます。 4行目は、入力の上位ワードに0を追加します。プラスされた場合は1を追加します。

これは、1桁の数字を2桁の数字に単純に加算したものと考えることができます。

_  a  b
+ 0  7
______
 
_

しかし、ベース18,446,744,073,709,551,616で。最下位の「桁」を最初に追加し、次の列に1を付けてから、次の桁と桁上げを追加します。減算は非常に似ています。

乗算では、単位(2⁶⁴a+ b)(2⁶⁴c+ d)=2¹²⁸ac+2⁶⁴(ad + bc)+ bdを使用する必要があります。これらの乗算はそれぞれ、1つのレジスタの積の上半分と別の。 128番目を超えるビットは_u128_に収まらず、破棄されるため、これらの用語の一部は削除されます。それでも、これには多くの機械命令が必要です。除算もいくつかの手順を実行します。符号付きの値の場合、乗算と除算では、オペランドと結果の符号を変換する必要があります。これらの操作はまったく効率的ではありません。

他のアーキテクチャでは、より簡単または難しくなります。 RISC-Vは128ビットの命令セット拡張を定義していますが、私の知る限り、シリコンに実装されている人はいません。この拡張がなければ、 RISC-Vアーキテクチャマニュアルが推奨 条件分岐:_addi t0, t1, +imm; blt t0, t1, overflow_

SPARCにはx86の制御フラグのような制御コードがありますが、特別な命令_add,cc_を使用して設定する必要があります。一方、MIPSは 2つの符号なし整数の合計がオペランドの1つより厳密に小さいかどうかを確認する必要があります。 その場合、加算がオーバーフローしました。少なくとも、条件分岐なしで別のレジスタをキャリービットの値に設定できます。

23
Davislor