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


128

Rustには128ビットの整数があり、これらはデータ型i128(およびu128unsigned int)で示されます。

let a: i128 = 170141183460469231731687303715884105727;

Rustはこれらのi128値を64ビットシステムでどのように機能させますか。たとえば、これらをどのように計算しますか?

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



54
指が10本しかない場合、2桁の整数はどのように機能しますか?
イェルクWミッターク

27
@JorgWMittag:ああ-古い「10本の指しかない2桁の数字」の策略。へへへ。あなたはあの古いもので私をだますことができると思っていましたね?さて、私の友人、2年生はあなたに言うことができるように-それがつま先の目的です!(ピーター・セラーズとレディ・リットンへのすみません謝罪で...-レディ・リットン:-)
ボブ・ジャービス-モニカを復活させる

1
FWIWほとんどのx86マシンには、SIMD操作用の128ビット以上の特別なレジスターがあります。en.wikipedia.org/wiki/Streaming_SIMD_Extensionsを参照してくださいEdit:どういうわけか@eckesのコメントを見逃しました
Ryan1729

4
@JörgWMittagいや、コンピュータ科学者は個々の指を下げたり伸ばしたりして、バイナリでカウントします。そして今、132歳、私は家に帰ります;-D
Marco13

回答:


141

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

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

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

コンパイラは何らかの方法で1つのi128値に2つのレジスタを使用しますか?または、それらを表すためにある種の大きな整数構造体を使用していますか?

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


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


1
ええと、なぜそれがより一般的な2 ^ 32ではなく2 ^ 23なのか疑問に思います(まあ、これらの数値が出現する頻度で広く言えば、コンパイラバックエンドでサポートされる整数の最大ビット幅ではありません...)
基金モニカの訴訟

26
@NicHartley LLVMの一部のベースクラスには、サブクラスがデータを格納できるフィールドがあります。Typeクラスの場合、これは、その種類(関数、ブロック、整数など)を格納するための8ビットと、サブクラスデータ用の24ビットがあることを意味します。次に、IntegerTypeクラスはこれらの24ビットを使用してサイズを格納し、インスタンスを32ビットにきちんと適合させます。
トッドSewell

56

コンパイラーは、これらを複数のレジスターに保管し、必要に応じて複数の命令を使用してそれらの値の演算を行います。ほとんどの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に保存されているraxrcx

(編集者注:x86-64 C呼び出し規約はRDX:RAXで128ビット整数を返します。ただし、これmainは値をまったく返しません。冗長なコピーはすべて、純粋に最適化を無効にすることによるものであり、Rustはデバッグでオーバーフローを実際にチェックしますモード。)

比較のため、x86-64でのRust 64ビット整数のasmを以下に示します。キャリー付きのキャリーは必要ありません。値ごとに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 / testはまだ完全に冗長です:jc(CF = 1の場合はジャンプ)で問題なく動作します。

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


4
@Anushいいえ、rax / rsp / ...は64ビットのレジスタです。各128ビットの数値は2つのレジスタ/メモリロケーションに格納され、その結果、64ビットが2つ追加されます。
ManfP

5
@Anush:いいえ、最適化を無効にしてコンパイルされているため、多くの命令を使用しています。あなたは参照してくださいねずっとあなたが2つのかかった機能コンパイルされた場合(単に追加/ ADCのような)単純なコードをu128引数にして(このような値が返さgodbolt.org/z/6JBza0を)代わりにやってから、コンパイラを停止するように最適化を無効にするので、コンパイル時定数引数の定数伝播。
Peter Cordes

3
@ CAD97リリースモードラッピング演算を使用しますが、デバッグモードのようにオーバーフローとパニックをチェックしません。この動作はRFC 560で定義されています。UBではありません。
trentcl

3
@PeterCordes:具体的には、Rustはオーバーフローが指定されていないことを言語で指定し、rustc(唯一のコンパイラー)はパニックまたはラップの2つの動作を指定します。理想的には、パニックがデフォルトで使用されます。実際には、コード生成が最適ではないため、リリースモードのデフォルトはラップであり、長期的な目標は、コード生成が(もしあれば)主流の使用に「十分」であるときにパニックに移行することです。また、すべてのRust整数型は、動作を選択するための名前付き操作(checked、wrapping、saturatingなど)をサポートしているため、選択した動作を操作ごとにオーバーライドできます。
Matthieu M.

1
@MatthieuM .:はい、プリミティブ型のラッピング、チェック、飽和のadd / sub / shift / whateverメソッドが好きです。Cの署名なしのラッピングよりもはるかに優れており、UB署名はそれに基づいて選択するように強制します。とにかく、一部のISAはパニックを効率的にサポートします。たとえば、操作のシーケンス全体の後で確認できるスティッキーフラグです。(0または1で上書きされるx86のOFまたはCFとは異なります)例:Agner Fogが提案したForwardCom ISA(agner.org/optimize/blog/read.php?i=421#478)しかし、それでも、最適化が制約されて計算が行われないようにします。 Rustソースはそうしませんでした。:/
Peter Cordes

30

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


26

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

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

コンパイルする

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

(私の元の投稿はu128i128あなたが尋ねたものではありませんでした。この関数はどちらの方法でも同じコードをコンパイルします。最新のCPUでも、符号付きと符号なしの加算は同じであることを示す優れたデモです。)

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

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

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

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

  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はaに収まらず、破棄されるため、これらの用語の一部は削除されます。それでも、これにはいくつかの機械語命令が必要です。分割もいくつかのステップを踏みます。符号付きの値の場合、乗算と除算では、さらにオペランドの符号と結果を変換する必要があります。これらの操作は非常に効率的ではありません。

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

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


1
最後の段落:結果の上位ビットを見て、2つの符号なし数値のどちらが大きいかを検出するには、ビット入力のビットサブ結果subが必要です。つまり、同じ幅の結果の符号ビットではなく、キャリーアウトを調べる必要があります。そのため、x86の符号なし分岐条件は、SF(ビット63または31)ではなく、CF(完全な論理結果のビット64または32)に基づいています。n+1n
Peter Cordes

1
re:divmod:AArch64のアプローチは、除算とintegerを実行する命令を提供x - (a*b)し、被除数、商、除数から剰余を計算することです。(これは、除算部分に乗法逆数を使用する定数除数に対しても役立ちます)。div + mod命令を単一のdivmod操作に融合するISAについては読んだことがありませんでした。それはきちんとしている。
Peter Cordes

1
re:フラグ:はい、フラグ出力は、OoO exec +レジスター名変更が何らかの方法で処理する必要がある2番目の出力です。x86 CPUは、FLAGS値が基づいている整数結果でいくつかの追加ビットを保持することによってそれを処理します。したがって、おそらく必要に応じて、ZF、SF、およびPFがその場で生成されます。これについてはIntelの特許があると思います。そのため、個別に追跡する必要がある出力の数が1に戻ります(Intel CPUでは、mul r64uopは2つ以上の整数レジスターを書き込むことはできません。たとえば、2つ目のuopsで、2つ目はRDXの上位半分を書き込みます)。
Peter Cordes

1
しかし、効率的な拡張精度のためには、フラグは非常に優れています。主な問題は、スーパースカラーの順序実行のためのレジスタ名の変更がないことです。フラグはWAWハザードです(書き込み後に書き込み)。もちろん、キャリー付き命令は3入力であり、これも追跡する重要な問題です。Broadwellマイクロアーキテクチャの前にインテルは、デコードadcsbbおよびcmov2つのuop毎に。(HaswellはFMAに3入力uopsを導入し、Broadwellはそれを整数に拡張しました。)
Peter Cordes

1
フラグ付きのRISC ISAは通常、フラグ設定をオプションにして、追加のビットで制御します。例えばARMとSPARCはこのようなものです。PowerPCはいつものようにすべてをより複雑にします:8つの条件コードレジスター(保存/復元用に1つの32ビットレジスターにまとめられます)があるため、cc0またはcc7などと比較できます。そして、ANDまたはOR条件コードを一緒に!分岐およびcmov命令は、読み取るCRレジスタを選択できます。したがって、これにより、x86 ADCX / ADOXのように、複数のフラグdepチェーンを同時に実行することができます。 alanclements.org/power%20pc.html
Peter Cordes
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.