0.1+0.2≠0.3について

浮動小数点数を扱う時に注意しなければいけない例として、0.1 + 0.20.3にならない、というものがあります。 具体的には、0.1 + 0.20.30000000000000004になってしまい、0.3にならないプログラミング言語が多数存在します。 この問題について、以下の記事では十進数から二進数に変換するときの誤差が原因であるとしています(この説は他にも多くのウェブサイトで唱えられています)。

qiita.com

たしかに十進数から二進数に変換するときの誤差は一因なのですが、より大きな寄与を持っているのは足し算の丸め誤差です。 そのことを確認していきたいと思います。

(わかっている読者への注:以下では、浮動小数点数と言ったらIEEE754で規定されたbinary64(二進倍精度浮動小数点数)を指すこととします)

浮動小数点数の基礎

基本的には、分子が 2^{52}以上 2^{53}未満の整数で、分母が二の整数乗であれば、浮動小数点数として表せます。 この形式で表せない数は、浮動小数点数として表せる数で近似します。 この近似を行うことを、丸めと言います。 普通は、最も近い浮動小数点数で近似します(最近接丸め。端数が0.5ぴったりの時のタイブレークの方式の違いに四捨五入や偶数丸めがあります)。 特別な用途(例えばお金が虚空から生まれてはいけないとか*1)では、「真の値を超えない範囲で」最も近い浮動小数点数に丸める(下向き丸め)などを使うこともあります。逆に「真の値を下回らない範囲で」とする方法もあります(上向き丸め)。

ULPはunit of last placeの略で、上記のような分数として表したときの、分子の1の違いを単位としたものです。 誤差は0.38ULP、のような形で誤差の解析によく使います。

計算の過程

0.1を浮動小数点数に変換する

0.1に最も近い浮動小数点数は、 \frac{7205759403792794}{72057594037927936} = \frac{\lceil0.1\times2^{56}\rceil}{2^{56}}です(十六進浮動小数点数リテラル表記で0x1.999999999999ap-4)。 この時点で、分子が7205759403792793.6であるべきところが7205759403792794になってしまっているので、+0.4ULPの誤差を生じています。

0.2を浮動小数点数に変換する

0.2に最も近い浮動小数点数は、 \frac{7205759403792794}{36028797018963968} = \frac{\lceil0.2\times2^{55}\rceil}{2^{55}}です(十六進浮動小数点数リテラル表記で0x1.999999999999ap-3)。 この時点で、分子が7205759403792793.6であるべきところが7205759403792794になってしまっているので、+0.4ULPの誤差を生じています。

0.3を浮動小数点数に変換する

0.3に最も近い浮動小数点数は、 \frac{5404319552844595}{18014398509481984} = \frac{\lfloor0.3\times2^{54}\rfloor}{2^{54}}です(十六進浮動小数点数リテラル表記で0x1.3333333333333p-2)。 この時点で、分子が5404319552844595.2であるべきところが5404319552844595になってしまっているので、-0.2ULPの誤差を生じています。

0.1と0.2を足す

丸める前の加算の結果は、 \frac{5404319552844595.5}{36028797018963968}です。 ここで、分子は 2^{52}以上 2^{53}未満ですが、整数になっていないので丸めを行う必要があります。 四捨五入なり偶数丸めなりを適用すると、 \frac{5404319552844596}{36028797018963968} = \frac{\lceil0.3\times2^{54}\rceil}{2^{54}}を得ます。

これは、0.3に最も近い浮動小数点数である \frac{5404319552844595}{18014398509481984} = \frac{\lfloor0.3\times2^{54}\rfloor}{2^{54}}と分子が1だけ異なります。

誤差の解析

0.1の指数部は0.3の指数部に対して2だけ小さいので、変換の誤差+0.4ULPが最終結果に与える影響は1/4となって+0.1ULPです。 0.2の指数部は0.3の指数部に対して1だけ小さいので、変換の誤差+0.4ULPが最終結果に与える影響は1/2となって+0.2ULPです。 加算の丸め誤差が最終結果に与える影響は+0.5ULPです。

よって、合計で+0.8ULPの誤差が生じます。

このように、この誤差の主要因は加算の丸め誤差です。

いつでもfaithfulになるか

Faithful rounding(信頼可能丸め*2)とは、真の値を上向き丸めして得られる値か真の値を下向き丸めして得られる値の、どちらかが返ってくるような丸めのことです。 言い換えると、真の値を挟む二つの浮動小数点数のうちのどちらかが返ってくるということです。 「最近接の浮動小数点数に丸めてね」というのは要求が厳しすぎるので、少し要件を緩和したということです。

先ほどの例では、真の値0.3に対し、最も近い浮動小数点数 \frac{\lfloor0.3\times2^{54}\rfloor}{2^{54}}を計算結果として得ることができませんでした。 しかし、得られた結果 \frac{\lceil0.3\times2^{54}\rceil}{2^{54}}は真の値を上向き丸めして得られる値です。 したがって、先ほどの計算は、最近接丸めではありませんでしたが信頼可能丸めではあったようです。

さて、十進小数同士の足し算結果が必ずしも最近接丸めではないということがわかりましたが、要件を信頼可能丸めに緩めれば満たされるでしょうか?

答えは否です。 まず、桁落ちが生じる場合は明らかにかなりの誤差が生じ得ます。 桁落ちが生じない場合に限っても、信頼可能丸めですらなくなる例は存在します。

0.17 + 0.28がその一例です。

0.17を浮動小数点数に変換すると、 \frac{6124895493223875}{36028797018963968} = \frac{\lceil0.17\times2^{55}\rceil}{2^{55}}となり、+0.44ULPの誤差が生じます。 0.28を浮動小数点数に変換すると、 \frac{5044031582654956}{18014398509481984} = \frac{\lceil0.28\times2^{54}\rceil}{2^{54}}となり、+0.48ULPの誤差が生じます。 真の値である0.45を浮動小数点数に変換すると、 \frac{8106479329266893}{18014398509481984} = \frac{\lceil0.45\times2^{54}\rceil}{2^{54}}となり、+0.2ULPの誤差が生じます。

二つの浮動小数点数が表す値を加算すると、 \frac{8106479329266893.5}{18014398509481984}となり、四捨五入なり偶数丸めなりをすると \frac{8106479329266894}{18014398509481984}となります。 これは真の値 \frac{8106479329266892.8}{18014398509481984} =  \frac{0.45\times2^{54}}{2^{54}}を挟む二つの浮動小数点数ですらありません。 つまり、加算を実行するだけで、信頼可能丸めにすらならないケースがあることがわかりました。

このケースの誤差は+1.2ULPです。 内訳としては、0.17を変換した時の誤差+0.44ULPが最終結果に与える影響は1/2されて+0.22ULP、0.28を変換した時の誤差+0.48ULPが最終結果に与える影響は+0.48ULP、加算の丸め誤差が0.5ULP、で合計+1.2ULPです。


このような場合、最大の誤差は1.25ULPとなります。 まず、変換の誤差は最大で0.5ULPです。 加算のオペランドの変換誤差が最終結果に与える影響は、最大で0.5ULPですが、両オペランドともそうなることはありません。 なぜなら、そのような場合は繰上りが発生して、変換誤差が最終結果に与える影響が1/2になってしまうからです。 よって最悪ケースでは、片方のオペランドの変換誤差が最終誤差に与える影響が0.5ULP、もう片方のオペランドの変換誤差が最終結果に与える影響が0.25ULPとなります。 これに加えて加算の丸め誤差が最大で0.5ULPなので、合計で最終結果に乗る誤差は最大で1.25ULPであるということになります。

最大誤差が1.0ULP未満であれば信頼可能丸めになりますが、1.0ULPを超えているため、信頼可能丸めにはなりません。 ただし、最大誤差が1.5ULP未満なので、「真の値を最近接丸めした値か、またはその前後の浮動小数点数」が計算結果となることは保証されます*3

*1:言うまでもなく、お金を扱う時に浮動小数点数を使うべきではないですが、例として

*2:忠実丸め、隣接浮動小数点数丸め、F丸め、などの訳も存在

*3:指数部の境界付近ではULPを使った議論は怪しいです。反例があったら教えてください。

gccが呼び出し規約に従わないコードを出力する例

アセンブリプログラムを書いていたら、遭遇したのでメモです。

さすがにプログラム全てをアセンブリ言語で書くのは大変なので、性能上重要ではないところはコンパイラのコード生成に任せて、性能上重要なところだけアセンブリプログラムで書くということはあると思います。 また、一からアセンブリ言語で書いたり全部イントリンシックで書くのも大変なので、コンパイラが出したコードをベースに改造することもあると思います。 この時、コンパイラが出すコードは呼び出し規約に沿っているので、性能上重要な関数を呼び出し規約に沿う範囲で自由に書き換えられると素朴に思っていたのですが、どうもそうではないということがわかりました。

問題を引き起こすコード

__attribute__((noinline)) char* id( char* x ) { return x; }

int main( int argc, char* argv[] ) {
    for( int i = 0; i < argc; ++i ) {
        argv[i] = id(argv[i]);
    }
}

gcc -O2コンパイルした場合、以下のコードになります(Compiler Explorerで確認したところ、gcc5~gcc13でどれもほぼ同じコードになりました)。

id:
        mov     rax, rdi
        ret
main:
        test    edi, edi
        jle     .L4
        movsx   rdi, edi
        lea     rdx, [rsi+rdi*8]
.L5:
        mov     rdi, QWORD PTR [rsi]
        add     rsi, 8
        call    id
        mov     QWORD PTR [rsi-8], rax
        cmp     rsi, rdx
        jne     .L5
.L4:
        xor     eax, eax
        ret

どこが問題か

このコードは、id関数を呼び出してもrsirdxが変わらないことを前提としています。 rsirdxは呼び出し側保存レジスタであり、id関数の中で書き換えても良いレジスタです。 つまり、このコードは呼び出し規約に沿っていないことになります。 おそらく、gccid関数でrsirdxが変わらないことを見抜くことで、このような積極的な最適化*1を行っているのだと思います。

こういうことをされたので、アセンブリファイルを勝手に書き換えたときにおかしくなったという話でした。

*1:ちゃんと呼び出し規約に従おうとすると、呼び出し先保存レジスタに値を入れる必要があって、その呼び出し先保存レジスタの確保のためにスピルが発生します。

x86のアドレッシングモードと使えないレジスタについて

x86CISC命令セットであり、RISC命令セットと異なりメモリ上のデータを命令のオペランドに取ることができます。 x86レジスタは(特に32ビットと64ビットは)おおよそ汎用ですが、一部のレジスタはアドレッシングに使えない制限があります。 よくわからなくなるのでまとめてみました。

16ビット

レジスタは全く汎用ではありません。基本的にはAXCXDXを算術演算に使い、BXSIDIをアドレス計算に使う、ということが想定されているようです。 基本的には、以下のアドレッシングモードが使えます。

  • [addr + offset]
    • addrは、以下の八種類
      • BX
      • BP
      • SI
      • DI
      • BX+SI
      • BX+DI
      • BP+SI
      • BP+DI
    • offsetは、以下の三種類
      • (何も足さない)
      • imm8
      • imm16
  • [imm16]

ただし、このうち[BP](つまり、addrBPで、offsetが「何も足さない」)は、そのエンコーディングが絶対16ビットアドレス指定に奪われているので、使えません。 もちろん、[BP+0]とすることで、1バイト増えてしまいますがエンコーディングすることは可能です。 なお、BPをフレームポインタとして使っている場合、[BP]に入っているのはリターンアドレスのはずなので、普通は使わないと思います。

32ビット

レジスタはほぼ汎用になりました。 基本的には、以下のアドレッシングモードが使えます。

  • [addr + offset]
    • addrは、以下の七種類
      • EAX
      • EBX
      • ECX
      • EDX
      • ESI
      • EDI
      • EBP
    • offsetは、以下の三種類
      • (何も足さない)
      • imm8
      • imm32
  • [imm32]
  • [addr + index * scale + offset](SIBバイト使用)
    • addrは、以下の八種類
      • EAX
      • EBX
      • ECX
      • EDX
      • ESI
      • EDI
      • ESP
      • EBP
    • indexは、以下の八種類
      • EAX
      • EBX
      • ECX
      • EDX
      • ESI
      • EDI
      • (零)
      • EBP
    • scaleは、以下の四種類
      • 1
      • 2
      • 4
      • 8
    • offsetは、以下の三種類
      • (何も足さない)
      • imm8
      • imm32
  • [imm32 + index * scale](SIBバイト使用)

16ビットの時からの伝統で、[EBP](つまり、addrEBPで、offsetが「何も足さない」)は、そのエンコーディングが絶対32ビットアドレス指定に奪われているので、使えません。 16ビットの時同様、[EBP+0]とすることで、1バイト増えてしまいますがエンコーディングすることが可能です。

SIBバイト利用アドレッシングでは、[EBP + index * scale](つまり、addrEBPで、offsetが「何も足さない」)は、そのエンコーディングが絶対32ビットベースアドレス指定に奪われているので、使えません。 同様に、[EBP + index * scale + 0]とすることで、1バイト増えてしまいますがエンコーディングすることが可能です。 indexEBPではなくscale1の場合は、逆順にすればバイト数増加はありません。

八つの汎用レジスタの一員であるにもかかわらず、[ESP + offset]は三種類とも、そのエンコーディングがSIBバイト利用アドレッシングに奪われているため、使えません。 これをやりたい場合は、SIBバイト利用アドレッシングの中にある[ESP + (零)* scale + offset]を使うことで、1バイト増えてしまいますがエンコーディングできます。 scaleは、1を設定するのが一般的です。

ESPは、そのエンコーディングが零レジスタ指定に奪われているので、indexとして指定することができません。 とはいえ、ESPを2倍した値というのが意味を持つ局面はないはずなので、通常は問題となりません。 なお、零レジスタを2倍・4倍・8倍する、というのも意味がないものであり、エンコーディングに無駄があります。

64ビット

汎用レジスタは16本に増えました。 アドレッシングモードは32ビットとほぼ変わりませんが、以下の三点に注意する必要があります。

  • [imm32]がなくなり、代わりに[RIP + imm32]ができるようになった
    • [imm32 + index * scale]は残存
  • RSPの“裏レジスタ”であるR12は、ModR/MでRSPが使えないことを引き継いでいる
    • indexRSPが使えない制限は引き継いでいない
  • RBPの“裏レジスタ”であるR13は、(ModR/Mとbaseで)RBPでできないことを引き継いでいる
    • indexではRBPは自由に使える

つまり、直観的でない点は、

  • [RIP + index * scale + offset]はできない
  • [R12][R12 + (零)*1]で表現されるので1バイト長い
  • [R12 + offset][R12 + (零)*1 + offset]で表現されるので1バイト長い
  • [R13][R13 + 0]で表現されるので1バイト長い
  • [R13 + index * scale][R13 + index * scale + 0]で表現されるので1バイト長い
    • scale1の場合は、逆順にすると短くできることがある

また、エンコーディングの無駄な点(rex.b01かで意味が変化しない)として、

が挙げられます(まぁそもそもrex.xはSIBバイトを使わない場合、rex.bはメモリオペランドを取らない場合、rex.wはバイト命令の場合、は無意味なのですが)。

まとめ

  • 16ビットの時は、アドレッシングに使えないレジスタが存在した
  • 32ビット以降では、基本的にすべてのレジスタがアドレッシングに使える
  • しかし、特定のレジスタを使うアドレッシングは、命令長が伸びることがある
    • [BP][EBP][RBP]は1バイトのびる(しかし、フレームポインタとして使っているならばそこに入っているのはリターンアドレスなので、あまり使われない)
    • [EBP + index * scale][RBP + index * scale]は1バイトのびる(しかし、フレームポインタとして使っていて、indexが素直な配列インデクスであれば、このパターンは出てこないはず)
    • [ESP][ESP + offset][RSP][RSP + offset]のようにESPRSPがmodR/Mで指定されるとベースだと1バイトのびる(これはよく出てくる)
    • ESPRSPindexには使えない(使われることはないはず)
  • 64ビットでは、“裏レジスタ”の関係にあるレジスタEBPESPのModR/Mとbaseにおける特性を引き継いでいる
    • indexでは制約はない
    • [R12][R12 + offset][R13][R13 + index * scale]は1バイトのびる

TAGE系分岐予測器のTAGEではない部分に関するメモ

(主にAndré Seznec氏が開発している)TAGE系分岐予測器は、TAGE部分以外の工夫にいろいろなバリエーションがあります。 しかしそれらは謎の短い略語で区別されており、覚えるのが困難です。 それに関するまとめのメモです。

cbp1(2004/12/5, 37th MICRO併設ワークショップ)

André Seznec氏はOGEHL分岐予測器を、Pierre Michaud氏はPPM-like分岐予測器を、それぞれ提出しました。 OGEHL分岐予測器は、GEHLパーセプトロン分岐予測器(hashed perceptron分岐予測器)の改良です。 GEHLは、GEometric History Length(幾何級数的履歴長)の略です。 PPM-like分岐予測器は、予測アルゴリズムがTAGE分岐予測器とほぼ同じで、学習アルゴリズムが微妙に異なるものです。 OGEHL分岐予測器は2位、PPM-like分岐予測器は5位でした。

cbp1の後

cbp1開催後にAndré Seznec氏とPierre Michaud氏が共著でTAGE分岐予測器を提案しています (2006年2月、JILP vol. 8)。

この提案されたTAGE分岐予測器は、cbp1の公開データセットで最もいい性能を示す分岐予測器(これはOGEHL分岐予測器なのですが)を上回る性能を示しました。 これがTAGE分岐予測器の原点となります。

この論文では、TAGE, ITTAGE, COTTAGEを提案しています。 ITTAGEはIndirect Target TAGEの略で、間接分岐予測器です。 COTTAGEはCOnditional and indirect Target TAGEの略で、TAGEとITTAGEを統合した分岐予測器です。 この時点でUSE_ALT_ON_NA(最近確保された(newly allocated)エントリの提供する予測を使うくらいなら第二最長一致に基づく予測(altpred)をしたほうが良いかを判断する超単純メタ予測器)が含まれています。

cbp2(2006/12/9, 39th MICRO併設ワークショップ)

今見たらウェブページが死んでいました。

André Seznec氏は現実的部門にL-TAGE分岐予測器を、非現実的部門にGTL分岐予測器を、それぞれ提出し、どちらの部門でも優勝しました。

L-TAGE分岐予測器は、TAGE分岐予測器に"L"を加えたものです。 ここで、"L"はループ予測器を意味します。 ※"L"はローカル予測器の一種です。ローカル予測器は禁忌です。

GTL分岐予測器は、GEHLパーセプトロンとTAGEのハイブリッド分岐予測器に、"L"を加えた分岐予測器です。 つまり、以下からなります。

  • GEHLパーセプトロン分岐予測器
  • TAGE分岐予測器
  • どちらを選ぶべきかを予測するメタ予測器
  • "L"("L"は予測の確信度を同時に出力するので、メタ予測器は不要です)

cbp3(2011/6/4, 38th ISCA併設ワークショップ)

André Seznec氏は方向分岐予測部門にISL-TAGE分岐予測器を、間接分岐予測部門にITTAGE間接分岐予測器を、それぞれ提出し、どちらの部門でも優勝しました。

ISL-TAGE分岐予測器は、TAGE分岐予測器に"I"と"S"と"L"を加えたものです。 ここで、"I"はImmediate Update Mimickerで、"S"はStatistical Corrector予測器で、"L"は前と変わらずループ予測器です。 Immediate Update Mimickerは、まだ解決していない予測の情報を取り込むためのものです(他の分岐予測の大会ではそもそも一瞬で学習が行われるので不要だと思います。この大会はちゃんとパイプラインをシミュレーションしていたっぽいです)。 Statistical Corrector(統計的補正)予測器は、分岐履歴とあまり強い相関がない分岐への対処です。 TAGE分岐予測器は最長一致を採用するため、分岐履歴とあまり強い相関がない場合には予測ミスが多く発生します。 これに対しては長めのカウンタを使ってどの方向の頻度が高いかの統計的情報を収集するのがよく、それを実現するのが統計的補正予測器です。 統計的補正予測器は短めの分岐履歴を使うGEHLパーセプトロン分岐予測器であり、TAGE分岐予測器の予測に強く反対した(パーセプトロンの出力の和の絶対値が大きい)場合にのみその予測が採用されます。 パーセプトロンなので、いろいろな他の情報を取り込むことが簡単にできます。 ISL-TAGEではTAGE分岐予測器で予測に使われたエントリの3bitカウンタの値を正規化した後8倍してパーセプトロンの和に算入しています。 また、バンクインタリーブが導入されています。

ITTAGE間接分岐予測器は、2006年に提案されたものとほぼ同じです。 "I"とバンクインタリーブが導入されているほか、分岐先の上位ビット(あまり種類がない)を別のテーブルに覚える容量最適化(region table)を行っています。

cbp3の後

André Seznec氏はTAGE-LSC分岐予測器を提案しました(2011/12/5, 44th MICRO)。 ここで、"LSC"はLocal history Statistical Corrector予測器で、短めのローカル分岐履歴を使うGEHL(LGEHL)パーセプトロン分岐予測器です。※禁忌です

よく読んでいませんが、あとは大体ISL-TAGEと同じだと思います。

cbp4(2014/6/14, 41th ISCA併設ワークショップ)

André Seznec氏は現実的部門と非現実部門にTAGE-SC-L分岐予測器を提出しました。 また、Pierre Michaud氏は非現実的部門にFive poTAGEs and a COLT分岐予測器を提出しました。 さらに、André Seznec氏とPierre Michaud氏は共著で非現実的部門にMulti-poTAGE+SC分岐予測器を提出しました。 なお、Jorge Albericio氏、Joshua San Miguel氏、Natalie Enright Jerger氏、Andreas Moshovos氏は共著で現実的部門と非現実部門にWormhole予測器を提出しました(正確には、提出したのはISL-TAGE + Wormholeです。Wormhole予測器は、Loop予測器と同様に、TAGEの出力を上書きするタイプの予測器です)。

現実的部門の優勝はTAGE-SC-L分岐予測器、非現実的部門の優勝はMulti-poTAGE+SCでした。

TAGE-SC-L分岐予測器は、TAGE分岐予測器に"SC"と"L"を加えたものです。 ここで、"L"は今までと同じくループ予測器であり、"SC"はグローバル履歴もローカル履歴も使うようにしたStatistical Corrector予測器です。※どちらも禁忌です

"SC"はなんでもかんでも突っ込んでいるので、よくわかりませんが、いい感じの予測をするようです。 また、TAGEの出力の確信度とSCの出力の確信度の比較にも工夫が入りました。

残りの説明しないといけない謎の略語は、"po"と"COLT"です。

"COLT"は、Combined Output Lookup Tableの略で、ハイブリッド予測器のメタ予測器に変わる由緒正しい方法(2002/9/25, PACT2002)です。 この方法は、予測器がN個あるとき、2Nエントリの予測テーブルにそれを入力し、その出力を使うという方法です。 メタ予測器による選択のトーナメントを作る方法と比べて、複数の予測器の総合的な意見の調停が可能な点が優れています。 実際にはさらにPCと履歴も予測テーブルのインデクス作成に使われるので、例えば、「このPCと分岐履歴の時は、基本的にはgshareの言うことを信用すればよい」とか「とはいえgshare以外みんなが同じことを言っていれば、そちらを信用するべき」とかを動的に学習可能です。

"po"は、post-predictorで、USE_ALT_ON_NAの発展版です。 "po"も"COLT"と同じように、予測器の出力Nビットを2Nエントリの予測テーブルに入れて、その出力を使うという方法です。 poTAGEは、TAGEの出力(最長一致のカウンタ3bit、第二最長一致のカウンタ3bit、第三最長一致のカウンタ3bit、最長一致のuビット1bit)を"po"に入力した答えを出力する分岐予測器です。

Five poTAGEs and COLT分岐予測器は、普通のグローバル履歴を使うTAGEで作られたpoTAGEと、グローバル履歴以外の不思議な履歴を使うTAGEで作られた4つのpoTAGE、あわせて5つのpoTAGEの出力をCOLT予測器に入れて最終的な分岐予測を行います。

Multi-poTAGE+SC分岐予測器は、Five poTAGEs and COLT分岐予測器の後ろに"SC"をつけたものです。

cbp4の後

André Seznec氏、Joshua San Miguel氏、Jorge Albericio氏は共著で、TAGE-GSC-IMLI分岐予測器を提案しました(2015/12/8, 48th MICRO)。 TAGE-GSC-IMLI分岐予測器は、TAGE分岐予測器に"GSC"と"IMLI"を加えたものです。 ここで、"GSC"はグローバル履歴しか使わないStatistical Corrector予測器、"IMLI"はInner Most Loop Iterationカウンタを用いた予測器です。

著者にJoshua San Miguel氏とJorge Albericio氏が入っていることからわかるように、IMLIはWormhole分岐予測器をより洗練させたものです。 そもそもWormhole分岐予測器は、二重ループ(外側のループカウンタをi、内側のループカウンタをjとします)に関する相関を捉えようとした分岐予測器です。 普通のローカル履歴では、近いjに対する相関を捉えることはできますが、近いiに対する相関を捉えることは困難です。 Wormhole分岐予測器では、二次元的なローカル履歴を持つことで、近いiに対する相関も捉えます。 これにより、例えばi == jの時だけ分岐が成立、とかのパターンを正確に予測することができるようになります。 ※禁忌です

IMLIは、Wormhole分岐予測器がやりたいことを、禁忌を犯さずに行うことを可能にします。 IMLIカウンタはその名の通り、内側のループについて何周目であるかをカウントしています。 重要なのは、これはグローバルな値であり、またループ周回数に対して対数ビットで表せるので、グローバル分岐履歴と同様に巻き戻しが容易であるという点です。

この論文では、IMLI-SICとIMLI-OHの二つの部品(hashed perceptronに追加するパーセプトロン)を提案しています。

IMLI-SIC(Same Iteration Count)は、「iには依存せず、jだけに依存している」パターンを捉えるものです。 これは、単にPCとIMLIカウンタを使った予測器を使えば実現可能、つまりローカル履歴は一切不要にもかかわらず、とても効果が高いです。 ループ予測器は、基本的にこれに内包される点もうれしい点です。

IMLI-OH(Outer History)は、Wormholeと同様、二次元のローカル履歴を使い、さらに幅広いパターン(例えばi == jの時だけ分岐が成立、とか、j == K && i % 2 == 0の時だけ分岐が成立、とか)を捉えるものです。 二次元のローカル履歴は、普通に考えると禁忌を犯さずに作ることができませんが、うまい方法があるそうです。 まず、同じi・近いjの分岐に対する相関を捉えるのは、グローバル履歴で代用可能です。 問題になるのは近いが異なるi・近いjの分岐ですが、これは何十命令も前のはずで、もうとっくに確定しているはずです(それより近いならば、グローバル分岐履歴で相関が取れそうです)。 これを利用すると、分岐履歴の投機的更新をサボって、確定時更新としてしまうことができます。 このようにしても「グローバル分岐履歴では遠すぎて(間に余分な分岐命令が入ることで履歴パターンが増えてTAGEでは)相関がとりづらいので二次元ローカル履歴に頼りたい」という場合の威力は衰えないとしています。

このように、禁忌を犯さないことが主眼の分岐予測器なので、Statistical Corrector予測器はグローバル履歴しか使わない"GSC"としています。

cbp5(2016/6/18, 43th ISCA併設ワークショップ)

André Seznec氏は現実的部門にTAGE-SC-L分岐予測器を再び提出し、非現実的部門にはMTAGE+SC分岐予測器を提出しました。 どちらの部門でも優勝しています。

TAGE-SC-L分岐予測器の大枠の構成はcbp4のものと同じです。 ただし、IMLIカウンタを用いたパーセプトロンがSCに追加されています。 IMLIカウンタがあればループ予測器は不要そうですが、なぜか残存していますし、そもそもIMLIはcbp5では役に立たなかったとしています。

MTAGE+SC分岐予測器(名称由来不明。たぶんmulti-TAGEとかだと思いますが)は、以下の要素からなります。

  • Five poTAGEs
  • global backward historyを用いたpoTAGE
  • TAGE prediction combiner(COLTにパーセプトロンを加える改良)
  • いろいろごった煮のSC(Statistical Corrector予測器)

つまり、ほとんどMulti-poTAGE+SCと同じ構成です。 違いは、global backward historyを用いたpoTAGEが追加され、COLTがやや進化し、SCによくわからない部品がいろいろ追加された、ということになります。

まとめ

  • USE_ALT_ON_NA: 最近確保された(newly allocated)エントリの提供する予測を使うくらいなら第二最長一致に基づく予測(altpred)をしたほうが良いかを判断する超単純メタ予測器。PPM-likeとTAGEの主要な違いのうちの一つ
  • "L"(L-TAGE, ISL-TAGE, TAGE-SC-L): ループ予測器
  • "I"(ISL-TAGE): Immediate Update Mimicker。まだ解決していない分岐に対する予測を取り込むための機構
  • "LSC"(TAGE-LSC): ローカル履歴だけを使う統計的補正予測器
  • "S"(ISL-TAGE), "GSC"(TAGE-GSC-IMLI): グローバル履歴だけを使う統計的補正予測器
  • "SC"(TAGE-SC-L): いろいろごった煮の統計的補正予測器
  • "po": post predictor。TAGEの第三最長一致までのカウンタ値と最長一致のuビットの値を総合して予測を出力する予測器。USE_ALT_ON_NAの発展版
  • COLT: Combined Output Lookup Table。多数の分岐予測器の出力を総合して予測を出力する予測器
  • IMLI: Inner Most Loop Iteration。IMLIカウンタを使うとローカル履歴を使わずにローカル予測器と似たことができるのでうれしい
  • "IMLI-SIC": IMLIでSame Iteration Count(内側のループ回数が同じ場合)の相関を取る部品。単にPCとIMLIカウンタでインデクスされた表を使うだけで簡単なのがうれしい。実質的にループ予測器の代替となる
  • "IMLI-OH": Wormhole分岐予測器でとらえられるような、二次元的ローカル履歴を用いた予測を提供する部品

参考文献

絶対値のLeading Zero Anticipatory (LZA)の勉強

Leading Zero Anticipatory (LZA)は、二進法で表されたNビット符号つき整数A, Bを受け取り、A+Bを直に計算することなしにA+B+1の上位に0が何ビット並ぶか(Leading Zero Count, LZC)を(多少の誤差の範囲で)予測することです。 以前の記事(Leading Zero Anticipation (LZA) の勉強 - よーる)では、適宜左右オペランドを交換することでA+Bが常に非負となることを仮定する方式を紹介しました。 この方式の欠点は、Aの絶対値とBの絶対値の大小比較というΘ(log N)段の回路の出力を待つ必要がある点です。

よく考えてみると、左右オペランドの交換は不要です。 A+Bと-A-Bを並列に計算しておいて、最後の方でA+Bの符号を見て選択すればよいからです。 LZAの計算もLZC(A+B+1)とLZC(~A+~B+1)の両方を予測しておいて、最後で選択すればよいです。

これに対しIntelは、この二つのLZAの計算には重複する部分があるので同時にやることで回路量を減らせる、という特許を取りました。 この特許の方式では、|A+B+1|の上位に0が何ビット並ぶかを(多少の誤差の範囲で)予測します。 以下では、この方式を「絶対値のLZA」と呼ぶことにします。

この特許US7024439B2 - Leading Zero Anticipatory (LZA) algorithm and logic for high speed arithmetic units - Google Patentsは2023年8月にExpiredになったようです(でもよく見てみると2018年5月の時点で料金未納により失効している?)。 やや旬を過ぎた感がありますが、この方式についても勉強してみます。

※特許については法律の専門家や技術の専門家によく確認することを強く推奨します。

LZAの基本的な考え方

LZAは、基本的に次のアルゴリズムになっています。

  1. AとBを入力とするΘ(1)段の回路で、A+B+1の推定値Eを作成する
  2. LZC(E)を出力する

LZCの計算方法は確立されているため、Eをどのように作るか、という部分がアルゴリズムの重要な点です。

絶対値のLZAであっても、考え方はほぼ同じです。 つまり、AとBを入力とするΘ(1)段の回路で|A+B+1|の推定値Eを作り、LZC(E)を出力すればよいです。 なお、Intelの特許ではこの推定値はLと呼ばれているので、以下ではLで統一します。

絶対値のLZAのアルゴリズム

この方式では、まず次のX[i], P[i], G[i], N[i], O[i], Z[i]の六つを計算します。

  • X[i] = A[i] xnor B[i](xnorのXらしい)
  • P[i] = A[i] xor B[i](いわゆるキャリー伝搬条件P)
  • G[i] = A[i] and B[i](いわゆるキャリー生成条件G)
  • N[i] = A[i] nand B[i](nandのNらしい)
  • O[i] = A[i] or B[i](orのOらしい)
  • Z[i] = A[i] nor B[i](zeroのZらしい)

次に、L[i]を次のように計算します。

  • L[0]は1
  • L[1]は0
  • 2≦i<Nについて、L[i]は次の三つのnandの出力のnand
    • nand( X[i], N[i-1] or N[i-2], O[i-1] or O[i-2] )
      • OAIゲートを使うことを想定しているっぽい
    • nand( P[i], G[i-1], O[i-2] )
    • nand( P[i], Z[i-1], N[i-2] )

このLについて、普通にLZCを求めます。 その結果は、LZC(|A+B+1|)に対して-0か-1か-2だそうです。 つまり、浮動小数点数の正規化に使う場合は、最後に余計に1bitまたは2bit左シフトする必要がある場合があるということです。

手を動かしてみる

以前に紹介した普通のLZAは、入力の二桁分(A[i]とB[i]とA[i-1]とB[i-1])からEを生成しています。 これに対して符号ビットを追加で考慮しないといけないので、もう一桁見ないといけないというのは直観的にわかります。 とはいえ、実際に何をやっているのかは意味不明です。 いろいろ実験してみることにします。

正の数と正の数を足す場合

4bit整数同士を足す実験をしてみましょう。 そうすると、以下のようになっていることがわかります。

  • A+B+1が1か15~17か31の時、L[3:2]は00になる
  • A+B+1が7~9か23~25の時、L[3:2]は10になる
  • A+B+1が2か14か18か30の時、L[3:2]は01になる
  • A+B+1が3か13か19か29の時、L[3:2]は01か11になる
  • それ以外の時、L[3:2]は11になる

和が1になるときと16や31になるときで出力が同じなのを不思議に思うかもしれません。 これは、おそらく2bit幅広の加算器が前提だからだと思います。 加算した時の繰上りに備えて+1bit、符号ビットで+1bit、で合わせて+2bitです。

これを踏まえると、0~3と0~3を足したときだけがオーバーフローしてないことになります。 この範囲では、

  • オペランドが0だと、L[3:2]は00
    • A+B+1は1なので、LZC-0を出力することになる
  • それ以外で両オペランドが1以下だと、L[3:2]は01
    • A+B+1は2~3なので、LZC-1を出力することになる
  • それ以外だと、L[3]は1
    • A+B+1は3~7なので、LZC-2かLZC-1を出力することになる

となっています。 ここから読み取れる基本的な戦略としては、「A[i]とA[i-1]とB[i]とB[i-1]が0でA[i-2]かB[i-2]の少なくとも一方が1であればL[i]を立てる」ということでしょう。 A+B+1が 2^{i-2}以上であることが確定するので、繰上りがなければLZC-2が、繰上りがあればLZC-1が出力されることになります。

特許文献の用語でいえば、ZZPとZZGのパターンになります。

負の数と負の数を足すとき

上述の実験結果は、15を境として対称になっていました。 つまりL(A,B)=L(~A,~B)が成り立ちます。 言い換えると、AとBを同時に反転しても同じ結果が出力されます。 実際、AとBを同時に反転した場合、GとZが入れ替わり、NとOも入れ替わり、XとPは同じままです。 回路がこの変換に対して不変であることを確かめるのは容易です。

よって、負の数と負の数を足すときの戦略は、「A[i]とA[i-1]とB[i]とB[i-1]が1でA[i-2]かB[i-2]の少なくとも一方が0であればL[i]を立てる」ということになります。

特許文献の用語で言えば、GGPとGGZのパターンになります。

負の数と正の数を足すとき

一番難しいのがこれです。 0~15に-1~-16を足す実験をしてみると、以下のようになります。

図1: L[4:2]の出力を可視化したもの

これがどういう法則に従っているかを一言で表すのはかなり難しいです。 ただ、本来斜め線で区切らなければいけない範囲を、垂直な線と水平な線を組み合わせて何とか区切ろうと頑張っていることがわかります。 また、斜めに走る0から遠くなればなるほど雑に区切っている(すべてのビットを見なくてよい仕組みになっている)こともわかります。

ここから読み取れる戦略としては、

  1. A[i]とB[i]が0、A[i-1]とB[i-1]が1、A[i-2]とB[i-2]の少なくとも一方が0、のときL[i]を立てる
  2. A[i]とB[i]が1、A[i-1]とB[i-1]が0、A[i-2]とB[i-2]の少なくとも一方が1、のときL[i]を立てる
  3. A[i]とB[i]が異なり、A[i-1]とB[i-1]が0、A[i-2]とB[i-2]の少なくとも一方が0、のときL[i]を立てる
  4. A[i]とB[i]が異なり、A[i-1]とB[i-1]が1、A[i-2]とB[i-2]の少なくとも一方が1、のときL[i]を立てる

となるでしょうか。 ここで、1.は2.でAとBを同時に反転したもの、3.は4.でAとBを同時に反転したものです。

なお、提案されている回路では追加で以下の場合にもL[i]が立つようです(L[i+1]も立つので特に意味はありません)。 これは回路の高速化に貢献しているようです(後述)。

  1. A[i]とB[i]が0、A[i-1]^B[i-1]が1
  2. A[i]とB[i]が1、A[i-1]^B[i-1]が1

特許文献の用語でいえば、1.はZGZとZGP、2.はGZPとGZG、3.はPZZとPZP、4.はPGPとPGG、5.はZPZとZPPとZPG、6.はGPZとGPPとGPGのパターンになります。

よくわかりませんが、Lに1を立てないといけないケースは"PGP, PGG, PZP, PZZ, ZZP, ZZG, ZPZ, ZPP, ZPG, GZP, GZG, GPZ, GPP, GPG, GGZ, GGP, ZGZ, and ZGP"と書かれているので、これで網羅したようです。

なにをやっているのか

3ビットと3ビットとキャリー入力(cin)を加算した結果3ビットの中に、01のパターンまたは10のパターンが必ず出現するかを判定しています。

手を動かしてみる

何も考えないと43=64通りを考えなければいけませんが、各桁の計算において0+1と1+0を区別する必要はないため桁当たり3通りを考えればよく、33=27通りを確認すれば十分です。 以下では特許文献にならい、各桁の計算が0+0であることをZ、0+1か1+0であることをP、1+1であることをGと表します。 3ビット加算器の入力は、PGZのようにこのアルファベットを並べて表します。 以下では、アルファベット3文字のことを局所パターンと呼びます。

ZZZ

000 + 000 + cin の計算です。結果は000か001になります。000であれば、当該パターンは出現しないので不可です。

ZZP

000 + 001 + cin の計算です。結果は001か010になります。条件を満たします。

ZZG

001 + 001 + cin の計算です。結果は010か011になります。条件を満たします。

ZPZ

010 + 000 + cin の計算です。結果は010か011になります。条件を満たします。


この辺まで来て、パターンが見えてきたと思います。結果はtまたはt+1になるのです。 よって、tがいくつの場合に条件を満たすかを考えたほうが早いです。

まず、上で見たようにtが000だと当該パターンが出現せず不可です。 また、tが110だとt+1は111で当該パターンが出現せず不可です。 さらに、tが111の時はt+1が000となりどちらにせよ当該パターンが出現しないので不可です。 他の場合は、常に01または10のパターンが出現するので条件を満たします。

よって、

  • ZZZ→t=0で不可
  • ZZP→t=1で可
  • ZZG→t=2で可
  • ZPZ→t=2で可
  • ZPP→t=3で可
  • ZPG→t=4で可
  • ZGZ→t=4で可
  • ZGP→t=5で可
  • ZGG→t=6で不可
  • PZZ→t=4で可
  • PZP→t=5で可
  • PZG→t=6で不可
  • PPZ→t=6で不可
  • PPP→t=7で不可
  • PPG→t=0で不可
  • PGZ→t=0で不可
  • PGP→t=1で可
  • PGG→t=2で可
  • GZZ→t=0で不可
  • GZP→t=1で可
  • GZG→t=2で可
  • GPZ→t=2で可
  • GPP→t=3で可
  • GPG→t=4で可
  • GGZ→t=4で可
  • GGP→t=5で可
  • GGG→t=6で不可

となって、特許文献の主張と一致します。

なぜこれでよいのか

000.....0001や111.....1110などのパターンを発見したいという目的に対して、01のパターンや10のパターンがある場合にLに立てればよい、という説明は直観的にわかりやすいものです。

気になるのは、01のパターンまたは10のパターンがない可能性があれば、本当は01のパターンまたは10のパターンを含むにもかかわらず、Lにビットが立たないことがある点です。 つまり、

  • 01/10のパターンが絶対にある場合→Lに1を立てる。よさそう
  • 01/10のパターンが絶対にない場合→Lに1を立てない。よさそう
  • 01/10のパターンがcinによってあったりなかったりする場合→Lに1を立てない。大丈夫かな?🤔

なぜこれで大丈夫のでしょうか。

局所的な説明

本当には01/10のパターンを含むにもかかわらず、Lにビットが立たないことがないことを示します。

GZZ(t=0)の場合

GZZが本当には01のパターンを含むのは、cinが1で答えが001だった場合です。 この時、下側のペアが01のパターンを含んでいるので、一つ下の桁の上側のペアも01のパターンを含んでいます。 よって、一つ下の桁でLにビットを立てたかを確認します。 GZZZでは下からキャリーが上がってこないので、GZZPかGZZGだったはずです。 ZZP (t=1)もZZG(t=2)もLにビットを立てますから、問題ありません。

PGZ(t=0)の場合

t=0なので、上と同じ状況です。 PGZZでは下からキャリーが上がってこないので、PGZPかPGZGだったはずです。 GZP(t=1)もGZG(t=2)もLにビットを立てますから、問題ありません。

PPG(t=0)の場合

t=0なので同じです。 PPGZでは下からキャリーが上がってこないので、PPGPかPPGGだったはずです。 PGP(t=1)もPGG(t=2)もLにビットを立てますから、問題ありません。

ZZZ(t=0)の場合

t=0なので同じです。 ZZZZでは下からキャリーが上がってこないので、ZZZPかZZZGだったはずです。 ZZP(t=1)もZZG(t=2)もLにビットを立てますから、問題ありません。

ZGG(t=6)の場合

ZGGが本当には10のパターンを含むのは、cinが0で答えが110だった場合です。 この時、下側のペアが10のパターンを含んでいるので、一つ下の桁の上側のペアも10のパターンを含んでいます。 よって、一つ下の桁でLにビットを立てたかを確認します。 ZGGGでは下からキャリーが上がってきてしまうので、ZGGPかZGGZだったはずです。 GGP(t=5)もGGZ(t=4)もLにビットを立てますから、問題ありません。

PZG(t=6)の場合

t=6なので同じです。 PZGGでは下からキャリーが上がってきてしまうので、PZGPかPZGZだったはずです。 ZGP(t=5)もZGZ(t=4)もLにビットを立てますから、問題ありません。

PPZ(t=6)の場合

t=6なので同じです。 PPZGでは下からキャリーが上がってきてしまうので、PPZPかPPZZだったはずです。 PZP(t=5)もPZZ(t=4)もLにビットを立てますから、問題ありません。

GGG(t=6)の場合

t=6なので同じです。 GGGGでは下からキャリーが上がってきてしまうので、GGGPかGGGZだったはずです。 GGP(t=5)もGGZ(t=4)もLにビットを立てますから、問題ありません。

PPP(t=7)の場合

111 + 000 + cin などの計算なので、結果は111か000になります。01/10パターンを絶対に含まないので、そもそも問題ではありません。


というわけで、01/10のパターンを含むか怪しくてLを立てないケースはcinが絡んで下側のペアに01/10が含まれるかを決定できないパターンのみであることがわかりました。 また、その場合は一つ下の桁で正しく処理できているはずなので大丈夫、ということがわかりました。

大域的な説明

各パターンでどういった性質が成り立つのでLを立てて大丈夫、といったことを示します。

LZAのアルゴリズム的に、最上位の1より下のビットはどうでもいいということを多用します。 つまり、証明すべきは

  • 必要な位置にビットを立てられるか
  • 必要でない位置にビットを立てないか

の二点です。 ビットを立てた位置が必要な位置かどうか(つまり、「逆」)は示さなくていいということです。

ZZPとZZG

議論が全く同じになるので、ZZPで話を進めます。

  • 上の桁まで見てPZZPだった場合はPZZの桁でLが立つので、この桁はどうでもいいです
  • 上の桁まで見てGZZPだった場合はもう一つ上の桁を見ます
    • ZGZZPかGGZZPならその桁でLが立つので、この桁はどうでもいいです
    • PGZZPならさらにもう一桁上まで見て、ZPGZZPかGPGZZPならその桁でLが立つので、この桁はどうでもいいです
    • PPGZZPならさらにもう一桁上まで見て、ZPPGZZPかGPPGZZPならその桁でLが立つので、この桁はどうでもいいです
    • この考え方を続けていくと、PPP.....PPPGZZPとなっている場合のみが興味があるということになります
  • 上の桁まで見てZZZPだった場合はもう一つ上の桁を見ます
    • PZZZPだった場合はPZZの桁でLが立つので、この桁はどうでもいいです
    • GZZZPだった場合は(GZZが同じなので)結局PPP.....PPPGZZZPになっている場合のみが興味があるということになります
    • ZZZZPだった場合は(ZZZが同じなので)結局PPP.....PPPGZZZ.....ZZZPになっている場合とZZZ.....ZZZPになっている場合のみに興味があるということになります

というわけで、最後に残るのはPPP.....PPPGZZZ.....ZZZPとZZZ.....ZZZPのパターンです。

この時、次の性質が成り立ちます。

  • A+B+1は正(上位に0が並ぶので)
  • A+B+1は最低でも 2^{i-2}+1はある(Pの桁由来)
  • A+B+1は最大でも 3\times2^{i-2}より小さい(Pの桁より下の桁の和の最大値は 2(2^{i-2}-1)なので)

よって、L[i]を立てておけば、LZC-1かLZC-2が出力されます。

また、ZZGでも話はまったく同様で、次の性質が成り立ちます。

  • A+B+1は正
  • A+B+1は最低でも 2\times2^{i-2}+1はある(Gの桁由来)
  • A+B+1は最大でも 4\times2^{i-2}より小さい

よって、L[i]を立てておけば、LZC-1が出力されます。

GGPとGGZ

ZZP/ZZGのパターンの反転なのではしょっていきます。 GGPについて同様に興味があるパターンを探索してみると、GGG.....GGGPのパターンとPPP.....PPPZGGG...GGGPのパターンだということがわかります。

この時、以下の性質が成り立ちます(負の数なので直観的ではないですが)。

  • A+B+1は負(上位に1が並ぶので)
  • A+B+1は最低でも -3\times2^{i-2}+1はある(GPの桁で111.....1101=-3)
  • A+B+1は最大でも -2^{i-2}よりは小さい(GGG.....GGGG+1が-1なので、これと比べてGをPにした分だけ小さくなっていると思えばよい)

よって、L[i]を立てておけば、LZC-1かLZC-2が出力されます。

また、GGZの場合も同様に議論すれば、興味あるパターンで次の性質が成り立つことがわかります。

  • A+B+1は負
  • A+B+1は最低でも -4\times2^{i-2}+1はある(GZの桁で111.....1100=-4)
  • A+B+1は最大でも -2\times2^{i-2}よりは小さい(GGG.....GGG+1が-1なので、これと比べてGをZした分だけ小さくなっていると思えばよい)

よって、L[i]を立てておけば、LZC-1が出力されます。

ZGZとZGP

ZGZについて同様に興味あるパターンを探すと、PPP.....PPPZGZのみが該当します。 このとき、

  • A+B+1は負
  • A+B+1は最低でも -4\times2^{i-2}+1はある(GZの桁で111.....1100=-4)
  • A+B+1は最大でも -2\times2^{i-2}よりは小さい

よって、L[i]を立てておけば、LZC-1が出力されます。

ZGPの場合はそれよりも 2^{i-2}だけ大きい(ゼロに近い)ので、L[i]を立てておけばLZC-1かLZC-2が出力されます。

GZPとGZG

ZGZ/ZGPの反転パターンなので省略します。

PZZとPZP

PZZについて同様に興味あるパターンを探すと、PPP.....PPPZZのみが該当します。 このとき、

  • A+B+1は負
  • A+B+1は最低でも -4\times2^{i-2}+1ある(ZZの桁で111.....1100=-4)
  • A+B+1は最大でも -2\times2^{i-2}よりは小さい

よって、L[i]を立てておけば、LZC-1が出力されます。

PZPの場合はそれよりも 2^{i-2}だけ大きい(ゼロに近い)ので、L[i]を立てておけばLZC-1かLZC-2が出力されます。

PGPとPGG

PZZ/PZPの反転パターンなので省略します。

ZPZとZPPとZPG

ZPZについて上の桁を見るとZZPZかPZPZかGZPZかになりますが、いずれの場合でも上の桁でLが立つので、この桁はどうでもいいです。

ZPPとZPGの場合もまったく同様です。

GPZとGPPとGPG

GPZについて上の桁を見るとZGPZかPGPZかGGPZかになりますが、いずれの場合でも上の桁でLが立つので、この桁はどうでもいいです。

GPPとGPGの場合もまったく同様です。

念のため:L[n-1:2]=0だった場合

上でほとんどすべての議論が尽くされましたが、Lが全く立たなかったコーナーケースを考える必要があります。 少なくとも一番下の3ビットが以下の組み合わせであることが必要であり、そこから上の桁を見ていってもずっとLが立たないパターンを探します。 すると、以下の結論を得ます。

ZZZ

Lが全く立たないパターンは、ZZZ.....ZZZとPPP.....PPPGZZZ.....ZZZの二つです。いずれもA+B+1は1であり、L[1:0]=01のおかげでLZC-0が出力されます。

GGG

Lが全く立たないパターンは、GGG.....GGGとPPP.....PPPZGGG.....GGGの二つです。いずれもA+B+1は-1であり、L[1:0]=01のおかげでLZC-0が出力されます。

ZGG

Lが全く立たないパターンは、PPP.....PPPZGGです。A+B+1は-1であり、L[1:0]=01のおかげでLZC-0が出力されます。

GZZ

Lが全く立たないパターンは、PPP.....PPPGZZです。A+B+1は1であり、L[1:0]=01のおかげでLZC-0が出力されます。

PZG

Lが全く立たないパターンは、PPP.....PPPZGです。A+B+1は-1であり、L[1:0]=01のおかげでLZC-0が出力されます。

PGZ

Lが全く立たないパターンは、PPP.....PPPGZです。A+B+1は1であり、L[1:0]=01のおかげでLZC-0が出力されます。

PPZ

Lが全く立たないパターンは、PPP.....PPPZです。A+B+1は-1であり、L[1:0]=01のおかげでLZC-0が出力されます。

PPG

Lが全く立たないパターンは、PPP.....PPPGです。A+B+1は1であり、L[1:0]=01のおかげでLZC-0が出力されます。

PPP

Lが全く立たないパターンは、PPP.....PPPです。A+B+1がちょうど0になるパターンであり、L[1:0]=01のおかげでLZC-1が出力されたことになるのだと思います。

L[1:0]=10でいいのでは?

L[n-1:2]=0だった場合、用意されたL[1:0]=01のおかげでLZC-0かLZC-1が出力されます。 一方で、L[n-1:2]≠1の場合、LZC-1かLZC-2が出力されます。 つまり、L[1:0]=10とすることでL[n-1:2]=0だった場合にもLZC-1かLZC-2が出力されるようにすれば、出力の誤差範囲を狭くできるはずです。

実際、局所パターンはiビット目とi-1ビット目とi-2ビット目を見ていると考えるのではなく、iビット目とi-1ビット目に加えてその上(符号ビット相当)を見ている、と考えるほうが自然です。 この考えのもとでは、上の方式は実はL[n-1:2]ではなくL'[n-2:1]を計算していた、ということになります。 この時、L'[n-1]=0とL'[0]=1を合わせるとLZC-0かLZC-1が出力される方式になります。

なぜこうなっていないのかは謎です。

実際にLZC-0かLZC-1しか出力されないことを確かめるコードを以下に示します。

#include <iostream>
#include <cassert>

int get_estimate( int i, int j ) {
        int P = i ^ j;
        int X = ~P;
        int G = i & j;
        int N = ~G;
        int O = i | j;
        int Z = ~O;

        int n = N | N<<1;
        int o = O | O<<1;

        int L = X>>1 & n & o
              | P>>1 & G & O<<1
              | P>>1 & Z & N<<1 ;

        return L & 0x1fffe | 1;
}

long long count[2];

void check( int i, int j ) {
        int abs = std::abs(i + j + 1);
        int est = get_estimate(i, j);
        int diff = __builtin_clzll(abs) - __builtin_clzll(est);
        assert( 0 <= diff && diff <= 1 );
        ++count[diff];
}

int main() {
        for( int i = 0; i < 65536; ++i ) {
                for( int j = 0; j < 65536; ++j ) {
                        check(i,j);
                        check(i,-j);
                        check(-i,-j);
                }
        }
        std::cout << "LZA == LZC   " << count[0] << std::endl;
        std::cout << "LZA == LZC-1 " << count[1] << std::endl;
}

パターンの一覧

局所パ
ターン
t L[i] 分類 この局所パターンの責任が
重大な場合の大域パターン
その時のA+B+1
ZZZ 0 0 L[i]が0 ZZZ.....ZZZ
PPP.....PPPGZZZ.....ZZZ
1
ZZP 1 1 正+正 PPP.....PPPGZZZ.....ZZZPxxx.....xxx
ZZZ.....ZZZPxxx.....xxx
 [1\times2^{i-2}+1, 3\times2^{i-2})
ZZG 2 1 正+正 PPP.....PPPGZZZ.....ZZZGxxx.....xxx
ZZZ.....ZZZGxxx.....xxx
 [2\times2^{i-2}+1, 4\times2^{i-2})
ZPZ 2 1 正+負の5 必ずL[i+1]=1となるため、存在しない。
L[i]=1とするのは回路高速化のため。
ZPP 3 1 正+負の5 必ずL[i+1]=1となるため、存在しない。
L[i]=1とするのは回路高速化のため。
ZPG 4 1 正+負の5 必ずL[i+1]=1となるため、存在しない。
L[i]=1とするのは回路高速化のため。
ZGZ 4 1 正+負の1 PPP.....PPPZGZxxx.....xxx  [-4\times2^{i-2}+1, -2\times2^{i-2})
ZGP 5 1 正+負の1 PPP.....PPPZGZxxx.....xxx  [-3\times2^{i-2}+1, -1\times2^{i-2})
ZGG 6 0 L[i]が0 PPP.....PPPZGG -1
PZZ 4 1 正+負の3 PPP.....PPPZZxxx.....xxx  [-4\times2^{i-2}+1, -2\times2^{i-2})
PZP 5 1 正+負の3 PPP.....PPPZGZxxx.....xxx  [-3\times2^{i-2}+1, -1\times2^{i-2})
PZG 6 0 L[i]が0 PPP.....PPPZG -1
PPZ 6 0 L[i]が0 PPP.....PPPZ -1
PPP 7 0 L[i]が0 PPP.....PPP 0
PPG 0 0 L[i]が0 PPP.....PPPG 1
PGZ 0 0 L[i]が0 PPP.....PPPGZ 1
PGP 1 0 正+負の4 PPP.....PPPGPxxx.....xxx  [1\times2^{i-2}+1, 3\times2^{i-2})
PGG 2 0 正+負の4 PPP.....PPPGPxxx.....xxx  [2\times2^{i-2}+1, 4\times2^{i-2})
GZZ 0 0 L[i]が0 PPP.....PPPGZZ 1
GZP 1 1 正+負の2 PPP.....PPPGZPxxx.....xxx  [1\times2^{i-2}+1, 3\times2^{i-2})
GZG 2 1 正+負の2 PPP.....PPPGZGxxx.....xxx  [2\times2^{i-2}+1, 4\times2^{i-2})
GPZ 2 1 正+負の6 必ずL[i+1]=1となるため、存在しない。
L[i]=1とするのは回路高速化のため。
GPP 3 1 正+負の6 必ずL[i+1]=1となるため、存在しない。
L[i]=1とするのは回路高速化のため。
GPG 4 1 正+負の6 必ずL[i+1]=1となるため、存在しない。
L[i]=1とするのは回路高速化のため。
GGZ 4 1 負+負 GGG.....GGGZxxx.....xxx
PPP.....PPPZGGG...GGGZxxx.....xxx
 [-4\times2^{i-2}+1, -2\times2^{i-2})
GGP 5 1 負+負 GGG.....GGGPxxx.....xxx
PPP.....PPPZGGG...GGGPxxx.....xxx
 [-3\times2^{i-2}+1, -1\times2^{i-2})
GGG 6 0 L[i]が0 GGG.....GGG
PPP.....PPPZGGG.....GGG
-1

回路の高速化について

L[i]の生成回路の局所パターン27通りでの動作を確認します。 以下では、 n_i=N_{i-1}+N_{i-2} o_i=O_{i-1}+O_{i-2}とします。

局所パターン i i-1 i-2  n_i  o_i  X_in_io_i  P_iG_{i-1}O_{i-2}  P_iZ_{i-1}N_{i-2} L[i]
ZZZ X,N,Z X,N,Z X,N,Z 1 0 0 0 0 0
ZZP X,N,Z X,N,Z P,N,O 1 1 1 0 0 1
ZZG X,N,Z X,N,Z X,G,O 1 1 1 0 0 1
ZPZ X,N,Z P,N,O X,N,Z 1 1 1 0 0 1
ZPP X,N,Z P,N,O P,N,O 1 1 1 0 0 1
ZPG X,N,Z P,N,O X,G,O 1 1 1 0 0 1
ZGZ X,N,Z X,G,O X,N,Z 1 1 1 0 0 1
ZGP X,N,Z X,G,O P,N,O 1 1 1 0 0 1
ZGG X,N,Z X,G,O X,G,O 0 1 0 0 0 0
PZZ P,N,O X,N,Z X,N,Z 1 0 0 0 1 1
PZP P,N,O X,N,Z P,N,O 1 1 0 0 1 1
PZG P,N,O X,N,Z X,G,O 1 1 0 0 0 0
PPZ P,N,O P,N,O X,N,Z 1 1 0 0 0 0
PPP P,N,O P,N,O P,N,O 1 1 0 0 0 0
PPG P,N,O P,N,O X,G,O 1 1 0 0 0 0
PGZ P,N,O X,G,O X,N,Z 1 1 0 0 0 0
PGP P,N,O X,G,O P,N,O 1 1 0 1 0 1
PGG P,N,O X,G,O X,G,O 0 1 0 1 0 1
GZZ X,G,O X,N,Z X,N,Z 1 0 0 0 0 0
GZP X,G,O X,N,Z P,N,O 1 1 1 0 0 1
GZG X,G,O X,N,Z X,G,O 1 1 1 0 0 1
GPZ X,G,O P,N,O X,N,Z 1 1 1 0 0 1
GPP X,G,O P,N,O P,N,O 1 1 1 0 0 1
GPG X,G,O P,N,O X,G,O 1 1 1 0 0 1
GGZ X,G,O X,G,O X,N,Z 1 1 1 0 0 1
GGP X,G,O X,G,O P,N,O 1 1 1 0 0 1
GGG X,G,O X,G,O X,G,O 0 1 0 0 0 0

ZPZ, ZPP, ZPG(正+負の5), GPZ, GPP, GPG(正+負の6)ではL[i]を何にしてもよいはずですが、L[i]=1とすることで回路が高速化することを見ていきます。 ZPZ, ZPP, ZPG, GPZ, GPP, GPGでL[i]=1とした場合、 X_in_io_iの項は X_i(N_{i-1}+N_{i-2})(O_{i-1}+O_{i-2})となります。 これの反転はOAIゲート一段で作ることができます。 一方で、L[i]=0とした場合、 X_i(Z_{i-1}O_{i-2}+G_{i-1}N_{i-2})か、同じことですが X_iZ_{i-1}O_{i-2}+X_iG_{i-1}N_{i-2}となります。 前者は(反転を許しても)ゲート一段で作ることができないので、遅いです。 後者の各項の反転はNANDゲートで作ることができますが、二項になるので、反転出力をORする意味合いで使われている最後のNANDゲートの入力数が増えてしまって、これも遅いです。 このような意味合いで、ZPZ, ZPP, ZPG, GPZ, GPP, GPGでL[i]=1とすることは回路の高速化につながっています。

普通のLZAとの比較

以前の記事(Leading Zero Anticipation (LZA) の勉強 - よーる)で紹介した、普通のLZAのアルゴリズム(LZC(|A+B+1|)ではなくLZC(A+B+1)を予測するもの)と比較します。 普通のLZAは、今回のZ, P, Gを使った解釈ではどのような動作をしているのでしょうか。

紹介した方式では、  E_i = \lnot (A_i \oplus B_i) \land (A_{i-1} \lor B_{i-1})としていました。 つまり、今回の表記に合わせれば、 E_i = X_iO_{i-1}としているということになります。

二桁しか見ていないので、局所パターンは32=9通りになります。 この9通りについて、先と同じ考察をすると、以下のようになります。

局所パターン t E[i] この局所パターンの責任が重大な場合の大域パターン その時のA+B+1
ZZ 0 0 ZZZ.....ZZZ
PPP.....PPPGZZZ.....ZZZ
1
ZP 1 1 ZZZ.....ZZZPxxx.....xxx
PPP.....PPPGZZZ.....ZZZPxxx.....xxx
 [1\times2^i+1, 3\times2^i)
ZG 2 1 ZZZ.....ZZZGxxx.....xxx
PPP.....PPPGZZZ.....ZZZGxxx.....xxx
 [2\times2^i+1, 4\times2^i)
PZ 2 0 必ず上位のどこかでEに1が立つため、存在しない。
E[i]=0とするのは回路単純化のため。
PP 3 0
PG 0 0 PPP.....PPPG 1
GZ 0 0 PPP.....PPPGZ 1
GP 1 1 PPP.....PPPGPxxx.....xxx  [1\times2^i+1, 3\times2^i)
GG 2 1 PPP.....PPPGGxxx.....xxx  [2\times2^i+1, 4\times2^i)

この動作は局所パターン中に1が絶対に含まれるか(00になることが絶対にないか)を判定していると解釈できます。 発展版と同じ枠組みで考えてみると面白いですね。

ところで、PZがドントケアであることに注意しなければ、この解釈はできないことも面白いです。 絶対値のLZAでもドントケアはあったものの、偶然にも素直に条件を解釈した実装が回路的にも最適でした。 普通ののLZAでは、ドントケア部分についてあえて条件の解釈と異なる真理値を設定する方が回路が簡単になります。

※PPP.....PPPは入力を交換している制約から発生しないので、絶対値のLZAの時と同じ表の書き方をすると「責任が重大な場合の大域パターン」は存在しないことになる。 実際には、PPP.....PPPxxx.....xxxはA+B+1が [1, 2^{i-1})になるので、「1を立ててはいけない」という責任が常に存在する。 L[i]やE[i]が0になる局所パターンは「責任が重大な場合の大域パターン」として局所パターンが最下位に出現する場合のみ示していたが、実際にはこれとまったく同様に、それより下位にxxx.....xxxが続く場合も含めて常に「1を立ててはいけない」という責任がある。

まとめ

LZC(|A+B+1|)を予測するタイプのLZAのアルゴリズムを紹介し、その仕組みを詳しく明らかにしました。 また、予測誤差を小さくできる、ささやかな改良を提案しました。

RISC-VのZicondとかいう排除アートについて

2023年9月にZicond拡張というのが、ろくに議論もされずに*1承認されたようです。 RISC-Vもその場の思い付きで命令セットを拡張するような、歴史に学ばない命令セットに堕したということですね。

Zicond拡張の機能はRISC命令セットとして素晴らしいものですが、そのエンコーディングは敵であるcmov命令を追い出すために非常に悪意のあるものとなっています。 また、cmov命令を追い出すだけならまだましで、そのエンコーディングの歪みのせいでRISC-V全体を使いづらくしています。 このようなものは、排除アートと呼んで差し支えないでしょう。 命令セットは芸術ですし、英語ではhostile instruction-set architectureとすればよさそうです。

Zicond拡張

Zicond拡張は、czero.eqz命令とczero.nez命令の二つだけからなる拡張です。 czero.eqz命令は、dst = rs2 == 0 ? 0 : rs1;を実行する命令です。 czero.nez命令は、dst = rs2 != 0 ? 0 : rs1;を実行する命令です。

これらの命令は、条件演算や条件転送を実現するために有用です。

条件演算や条件転送は、分岐予測ミスを避けるために有用です。 一方で、条件演算命令や条件転送命令は、RISC的な考え方と相性が悪いです。 条件演算を実現するためによく用いられるのはフラグレジスタですが、フラグレジスタのような暗黙のオペランドRISCの考え方と相性が悪いです。 また、条件転送は四オペランド命令になるので、これもRISCの標準的な命令フォーマットと相性が悪いです。 命令フォーマットに押し込めるだけであれば破壊的な命令にしてしまえばいいのですが、レジスタを自由に選べないのはRISCの考え方には反します。 そもそも、レジスタファイルの読み出しポートが三つ必要になるという本質的な問題が解決困難です。

Zicond拡張の提供する二命令は、この問題をうまく解決しています。 条件加算はczero.nez rd, rs2, rc; add rd, rs1, rdのように二命令で実現できます。 条件転送はczero.nez rd, rs1, rc; czero.eqz rtmp, rs2, rc; or rd, rd, rtmpのように三命令で実現できます。 このように複数命令に分解するときに役に立つ、汎用的なパーツとなる命令を見つけたというのがZicond拡張の優れている点なのでしょう。 単純な命令ではありますが、RISC命令セットの歴史を変える、偉大な一歩であることは間違いないと思います。

エンコーディング

czero.eqz命令とczero.nez命令のエンコーディングは、非常に邪悪なものになっています。 敵であるcmov命令への嫌がらせとしてこのエンコーディングを採用したものと思われます。

cmov命令に対する嫌がらせ

czero.eqz命令は、0000111_xxxxx_xxxxx_101_xxxxx_0110011というエンコーディングを持っています。 cmov命令は、xxxxx_11_xxxxx_xxxxx_101_xxxxx_0110011というエンコーディングを持つことが予定されていました。 これら二つは両立しません。

cmov命令は四オペランド命令であり、まとまったエンコーディング空間を要求します。 一方で、czero.eqz命令は普通の三オペランド命令であり、そのエンコーディングはかなり自由に決められたはずです。 それにもかかわらずピンポイントでこのエンコーディングを選んだということは、どうしてもcmov命令を追い出したいという政治的な意図が含まれているとが感じられます。

また、czero.eqz rd, rs1, rs2命令は、cmov rd, rs2, rs1, zero命令と同じ動作をします*2。 したがって、czero.eqz命令を00000_11_[rs2]_[rs1]_101_[ rd]_0110011というエンコーディングにしておけば、将来的にcmov命令を採用する余地が残されたはずです。 なぜなら、[rs3]_11_[rs2]_[rs1]_101_[ rd]_0110011というエンコーディングを持つcmov命令のrs3にゼロレジスタを指定したビットパターンと同じだからです。 それにもかかわらず、あえて00001_11_xxxxx_xxxxx_101_xxxxx_0110011というエンコーディングにしたことは、絶対にcmovを追い出すぞという意志が感じられます。

なお、このような将来の拡張を見越したエンコーディングは、zext.h命令で行われています。 zext.h命令は0000100_00000_[rs1]_100_[ rd]_011x011というエンコーディングを持ち、これは0000100_[rs2]_[rs1]_100_[ rd]_011x011というエンコーディングを持つpack[w]命令のrs2にゼロレジスタを指定したビットパターンと同じです。 他にも、rev8命令とorc.b命令が将来の拡張を見越したエンコーディングを持ちます。 これらの工夫が意図的に行われていることは、以下から明らかです。

  • 他の単項命令が全て0110000_xxxxx_xxxxx_001_xxxxx_001x011というエンコーディングを持っているにもかかわらず、それを使っていない
    • 単項命令判定回路の負担よりも拡張性を優先した
  • RV32とRV64で動作が同一なのにもかかわらず、zext.h命令のエンコーディングが異なる

デコーダへの無配慮

cmov命令を排除したいがための歪んだエンコーディングは、デコーダにも負担をかけています。

funct7

RISC-VのB拡張の命令は、funct7(ins[31:25])を次のように使っています。

  • ins[31]は、常に0
  • ins[30]は、標準的な機能の切り替えに使われることが多い
    • 標準ではadd/subの切り替え、srl/sraの切り替えに使われている
    • B拡張では、and/andnor/ornxor/xnorの切り替えに使われている
    • 他の命令では単に便利な1bitとして使っている気がする
  • ins[29]は、さらなる拡張を区別する便利な1bitとして使われている
  • ins[28]は、常に0
  • ins[27]は、ビット演算らしい*3命令を区別するビットとして使われることが多い(ビット演算らしい命令であれば1)
  • ins[26]は、四オペランド命令を区別するためのビットとして使われている(三入力一出力の命令であれば1)
  • ins[25]は、乗算系の命令を区別するためのビットとして使われることが多い(乗算系の命令であれば1)

Zicond拡張に含まれる命令は、この方針を完全に無視しています。 特にわざわざins[26]を1にしていることには、四オペランド命令拡張を追い出す意図が感じられます。

funct3

funct7と比べると、funct3は多少はデコーダに対して配慮があるエンコーディングになっています。 とはいえ、RISC-Vの新しい拡張命令セットが考え無しのエンコーディングを持っていることには変わりがありません。

RISC-Vの標準的な命令は、条件が反転した命令をfunct3[0]で区別していました。 例えば、beq/bne000/001を、blt/bge100/101を、bltu/bgeu110/111を、それぞれfunct3として持ちます。 またアトミック演算命令はfunct7の最上位三ビットで実質的なfunct3をエンコードすることを意図しており、amomin/amomax100/101を、amominu/amomaxu110/111を、それぞれfunct7の最上位三ビットとして持ちます*4。 つまり、条件が反転した命令をfunct3[0]で、符号の有無をfunct3[1]で、それぞれ区別しています。

一方で、新しい拡張として取り入れられたmin/maxはこの方針を踏襲していません。 min/max100/110というfunct3を、minu/maxu101/111というfunct3を持ちます。 つまり、符号の有無をfunct3[0]で、条件の反転をfunct3[1]で、それぞれ区別しており、標準的な命令のエンコーディングと異なります。 どうやらslt/sltuの対立(符号の有無をfunct3[0]で区別している)に合わせてしまったらしいです(Regularize MIN vs. MINU encoding to match SLT vs. SLTU by aswaterman · Pull Request #88 · riscv/riscv-bitmanip · GitHub)。 一応、div/divuなどの対立もfunct3[0]で区別しているため、一見合理性があるように見えます。 しかし、slt/sltudiv/divurem/remuはいずれも条件が反転した命令を持たない命令であり、そうでない命令と同列に扱うべきではありません。 min/max/minu/maxuエンコーディングblt/bge/bltu/bgeuamomin/amomax/amominu/amomaxuエンコーディングに合わせるべきでした。 czero.eqz/czero.nez命令のエンコーディングは、この失敗を上塗りするものとなっています。

そもそも、funct3を101/111とすること自体が全く理解ができません。 素直に考えればbeq/bneと揃えた000/001エンコーディングを持つべきですし、001が貴重であることを重視すれば010/011エンコーディングも悪くなかったでしょう。 条件の反転をfunct3[1]で区別することを重視したとしても、000/010100/110は十分に余っています。 あえてczero.eqz/czero.nezのfunct3を101/111としているのには、なにがなんでもcmov命令を排除するぞという意志が感じられます。

今後の拡張への無配慮

整数命令のオペコードマップを見ると、Zicond拡張がいかに傍若無人な位置に居座っているかがわかると思います。 他に入れる場所はいくらでもあったのに、なぜこのエンコーディングを選んだのか、非常に疑問です。 だいたい、Zbtを放棄する*5からと言って、こんなところに穴をあけてしまったら貴重な連続オペコード空間が失われるわけで、甚だ考え無しと言わざるを得ません。

図1: RISC-Vの整数命令のオペコードマップ(一部)

まとめ

  • Zicond拡張という、条件実行を容易にする拡張がろくに議論もされずに承認された
  • 敵であるcmovに対する嫌がらせとしか考えられないエンコーディングを持つ排除アートであり、RISC-Vの価値を大きく毀損した

参考文献

BMEXT命令とBMDEP命令のニーモニックについては、以下のコミットを参考にしました。

それ以外のRISC-V命令のニーモニック、動作、およびエンコーディングの仕様については、以下の文献(いずれもCC-BY 4.0)によります。

  • RISC-V Integer Conditional (Zicond) operations extension Version 1.0(RISC-V International)
  • RISC-V Bit-Manipulation ISA-extensions Version 1.0.0(著作権者不明。RISC-V(暗黙にRISC-V Internationalを意味する可能性もあり)またはJacob Bachmeyer, Allen Baum, Ari Ben, Alex Bradbury, Steven Braeger, Rogier Brussee, Michael Clark, Ken Dockser, Paul Donahue, Dennis Ferguson, Fabian Giesen, John Hauser, Robert Henry, Bruce Hoult, Po-wei Huang, Ben Marshall, Rex McCrary, Lee Moore, Jiří Moravec, Samuel Neves, Markus Oberhumer, Christopher Olson, Nils Pipenbrinck, Joseph Rahmeh, Xue Saw, Tommy Thorn, Philipp Tomsich, Avishai Tvila, Andrew Waterman, Thomas Wicki, and Claire Wolf)
  • RISC-V Bitmanip Extension Document Version 0.93(Jacob Bachmeyer, Allen Baum, Ari Ben, Alex Bradbury, Steven Braeger, Rogier Brussee, Michael Clark, Ken Dockser, Paul Donahue, Dennis Ferguson, Fabian Giesen, John Hauser, Robert Henry, Bruce Hoult, Po-wei Huang, Ben Marshall, Rex McCrary, Lee Moore, Jiří Moravec, Samuel Neves, Markus Oberhumer, Christopher Olson, Nils Pipenbrinck, Joseph Rahmeh, Xue Saw, Tommy Thorn, Avishai Tvila, Andrew Waterman, Thomas Wicki, and Claire Wolf)
  • The RISC-V Instruction Set Manual Volume I: Unprivileged ISA Document Version 20191213(Arvind, Krste Asanović, Rimas Avižienis, Jacob Bachmeyer, Christopher F. Batten, Allen J. Baum, Alex Bradbury, Scott Beamer, Preston Briggs, Christopher Celio, Chuanhua Chang, David Chisnall, Paul Clayton, Palmer Dabbelt, Ken Dockser, Roger Espasa, Shaked Flur, Stefan Freudenberger, Marc Gauthier, Andy Glew, Jan Gray, Michael Hamburg, John Hauser, David Horner, Bruce Hoult, Bill Huffman, Alexandre Joannou, Olof Johansson, Ben Keller, David Kruckemyer, Yunsup Lee, Paul Loewenstein, Daniel Lustig, Yatin Manerkar, Luc Maranget, Margaret Martonosi, Joseph Myers, Vijayanand Nagarajan, Rishiyur Nikhil, Jonas Oberhauser, Stefan O’Rear, Albert Ou, John Ousterhout, David Patterson, Christopher Pulte, Jose Renau, Josh Scheid, Colin Schmidt, Peter Sewell, Susmit Sarkar, Michael Taylor, Wesley Terpstra, Matt Thomas, Tommy Thorn, Caroline Trippel, Ray VanDeWalker, Muralidaran Vijayaraghavan, Megan Wachs, Andrew Waterman, Robert Watson, Derek Williams, Andrew Wright, Reinoud Zandijk, and Sizhuo Zhang)

追記(20223/10/09 00:30)

どうやら当初は1000000_[rs2]_[rs1]_01x_[rd ]_0110011というエンコーディングを想定していたようです(riscv-zicond/zicondops.adoc at 042bb651d2044510893a469b305f99324676f7c3 · riscv/riscv-zicond · GitHub)。ins[31]を1にしているのは慣習と乖離していますが(そもそも貴重なオペコード空間に穴をあけています)、排除アートにはなっていなかったようです。 その後、ARの割り当てにより、排除アートとなったようです(fix funct7 to match with AR (0b111 instead of 0b11) assignment · riscv/riscv-zicond@87b0c71 · GitHub)。 ARは、おそらくArchitecture Review Committeeだと思われます(RISC-V Lifecycle Guideに書いてある用語)。 なので、憎むべき相手はPhilipp TomsichさんではなくArchitecture Reviewのチームだということになります。

*1:Fast Trackなので議論時間が短かったのは事実です(参考:Specification Status - Home - RISC-V International)。

*2:cmov命令のアセンブリ表現では、条件演算子と似た見た目になるように条件オペランドrs2がrdの次に来ます。

*3:主観

*4:ただし、amoxor/amoor/amoandはこの方針と一貫していません。

*5:What's new for RISC-V in LLVM 16 - Muxupに"the previously proposed but now abandoned Zbt extension (part of the earlier bitmanip spec)"と書かれています。ただし、出典が明記されていないため、RISC-Vの公式の見解であるとは確認できませんでした。

variable tracking size limit exceededと出てコンパイルが遅い場合の対処法

以下の短いコードは、g++ -O2 -g main.cpp -cなどとコンパイルすると長い時間待たされた後、note: variable tracking size limit exceeded with ‘-fvar-tracking-assignments’, retrying withoutと出力され、その直後にコンパイルが終わります。

#include <array>

int f();

struct Elem {
    Elem()  { f(); }
};

struct NTD {
    ~NTD();
};

struct Foo {
    NTD ntd;
    std::array<Elem, 5500> arr;
    Foo();
};

Foo::Foo() : arr{} {}

この現象の発生するコンパイラ

Compiler Explorerで確認したところ、この現象が起こるg++のバージョンは、gcc-4.7.1(-std=c++11が使える最初のg++)からgcc-11.4.0まででした。

この現象の発生条件

以下をすべて満たしたときだと思います。

  • 次をすべて満たすクラス(例ではFoo)がある
    • 次の条件を満たすクラス(例ではElem)が要素のstd::arrayで、それなりの要素数のもの(例ではarr)をメンバ変数として持つ
      • デフォルトコンストラクタ(例ではElem::Elem())の中で例外を送出しうる関数呼び出し(例ではf())を行う
        • 移譲先やメンバ初期化の右辺であっても該当する
        • noexcept指定されていれば該当しない
        • noexcept指定されていなくても、中身を見て例外が出ないと明らかであれば、該当しない
    • そのstd::arrayよりも前に、実質的な中身のあるデストラクタを持つメンバ変数(例ではntd)を持つ
      • 基底クラスも「前」にあるので該当する
      • 非自明だが空のデストラクタ(~NTD() {}とか)は該当しない
      • というか、実質的に何もしないデストラクタは(~NTD() { int i; }とかも含め)全て該当しない
    • コンストラクタ(例ではFoo::Foo())の中でそのstd::arrayを値初期化({}で初期化)する
    • そのコンストラクタのコードが生成される
      • クラス内で定義したけど実際には使わない、とかだとコードが生成されない
  • 最適化オプションをつける
  • -gをつける

出力コードを見たり条件を見たりするとわかりますが、どうやらarrの初期化中に例外が送出されてntdのデストラクタを呼ぶ場合があるかを気にしているようです。

コンパイルにかかる時間

私の環境(Ubuntu 20.04.6 LTS on WSL2, g++-9 (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0, Intel(R) Core(TM) i9-12900K)では、配列サイズを変えたときのコンパイル時間は以下のようになりました。

配列サイズ コンパイル時間
5500 1.4秒
10000 2.5秒
20000 6.2秒
40000 18秒
80000 90秒

となりました。コンパイル時間が超線形に増大していくことがわかります。

note: variable tracking size limit exceeded with ‘-fvar-tracking-assignments’, retrying withoutというエラーメッセージを見ると、コンパイルの途中でなんかの上限に達したからあきらめる、というような動作を想像します。 しかし、このコンパイル時間を見ると、そうではないようです。 どうやら最後まで処理してから、その結果が上限を超えているのでやりなおす、という動作のようです。 なので、上限を変更したりせずにg++をデフォルトの設定で使っているだけなのに、ものすごくコンパイルが遅くなる現象が発生します。

原因

g++でコンパイルが非常に遅くなる短い例 - よーると似ています。

根本的な原因は、std::arrayを値初期化するときに配列要素のコンストラクタ呼び出しループがアンローリングされ、大量のデバッグ情報が生成されることです。

対処法

g++でコンパイルが非常に遅くなる短い例 - よーるとほぼ同じです。 つまり、

  1. 最適化をかけないでコンパイルする
  2. -gをつけないでコンパイルする
  3. g++ではなくclang++を使ってコンパイルする
  4. コンストラクタでの初期化を値初期化ではなくデフォルト初期化(何も書かない)にする(std::arrayはそうなっている。ただし、intのような非クラス型のデフォルト初期化は「未初期化」なので注意しなければいけない)

しかし、4年前と違い、今であれば根本的な解決策があります。

それは、g++-12以降を使うことです。 g++-12以降ではアンローリングされずにちゃんとループになるので、このような問題は発生しません。

なお、variable tracking size limit exceededが出ないようにしたいだけであれば、noexceptをちゃんとつける、といった方法があります。 ただし、結局デバッグ情報が大量生成されることには変わりがない(g++でコンパイルが非常に遅くなる短い例 - よーるの問題が残る)ため、コンパイルは遅いままです。

まとめ

  • 素数が大きいstd::arrayを値初期化すると、コンパイルが遅いことがある
    • 今回紹介したのはnote: variable tracking size limit exceeded with ‘-fvar-tracking-assignments’, retrying withoutと出るパターン
    • 配列要素の初期化中に例外が送出されて配列よりも前に初期化した変数のデストラクタを呼ばれる可能性を追跡しているっぽい
    • 配列要素のコンストラクタ呼び出しループがアンローリングされ、それに伴ってデバッグ情報が大量生成されることが遅くなる原因
  • g++-12以降を使えばアンローリングされないで済むので、このような遅くなる現象は発生しない