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以降を使えばアンローリングされないで済むので、このような遅くなる現象は発生しない

LLVMのk乗和の最適化手法について

clang-O1以上の最適化オプションをつけると、以下のコード(単純に考えるとΘ(n)命令必要そう)からループを取り除き、Θ(1)命令のコードに最適化します。

uint64_t sum( uint64_t n ) {
    uint64_t ret = 0;
    for( uint64_t i = 0; i < n; ++i ) {
        ret += i;
    }
    return ret;
}

これはclangの驚異的な最適化として挙げられることが多い例ですが、どのような仕組みで行われているのか深く調べたことがなかったので、調べてみました。

環境

以下では、clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04を使っていきます。 なお、この最適化はclangが独立したCコンパイラとなったclang 3.0の時点ですでに実装されていたようです(clang+llvm-3.0-x86_64-linux-Ubuntu-11_04/bin/clangで確認)。

実験には、以下のソースコードを使います。

// square_sum.c
#include <stdio.h>

unsigned long long n;
int main() {
        unsigned long long i, sum = 0;
        for( i = 0; i < n; ++i ) {
                sum += i * i;
        }
        printf( "%llu\n", sum );
}

clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/clang -O2 -S -masm=intel square_sum.cとしてコンパイルすると、ループ部分は以下の機械語コードになります。

        mov     rdi, qword ptr [rip + n]
        test    rdi, rdi
        je      .LBB0_1
# %bb.2:
        lea     rax, [rdi - 1]
        lea     rcx, [rdi - 2]
        mul     rcx
        mov     r8, rax
        mov     rsi, rdx
        lea     rcx, [rdi - 3]
        mul     rcx
                                        # kill: def $ecx killed $ecx killed $rcx
        imul    ecx, esi
        add     edx, ecx
        shld    rdx, rax, 63
        movabs  rax, 6148914691236517206
        imul    rax, rdx
        add     rax, rdi
        shld    rsi, r8, 63
        lea     rcx, [rsi + 2*rsi]
        lea     rsi, [rax + rcx]
        add     rsi, -1
        jmp     .LBB0_3
.LBB0_1:
        xor     esi, esi
.LBB0_3:
        # printfの第二引数はrsiで渡すことになっているのでrsiが計算結果のはず

ちゃんとループが消えていることがわかります。

どこで最適化が行われているのかを特定

前提知識

clangC言語から機械語を生成しますが、その裏側では複数のソフトウェアが動いています。 具体的には、以下のようにLLVMに含まれるいくつかのソフトウェアが順に使われます。

  1. C言語フロントエンドとしてのclangで、C言語ソースコードLLVM-IRに変換する
    • 主に言語に依存する最適化に関係します
  2. ミドルエンドであるoptで、LLVM-IRを最適化されたLLVM-IRに変換する
    • 主に言語にも機械語にも依存しない最適化に関係します
  3. バックエンドであるllcで、LLVM-IRを機械語に変換する
    • 主に機械語に依存する最適化に関係します

バックエンドではない

3.の直前のLLVM-IRを手に入れるためには、clang -S -emit-llvmとすればよいです。 clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/clang -O2 -S -emit-llvm square_sum.cとすると、main関数は以下のLLVM-IRになっていました。

; Function Attrs: nofree nounwind uwtable
define dso_local i32 @main() local_unnamed_addr #0 {
  %1 = load i64, i64* @n, align 8, !tbaa !2
  %2 = icmp eq i64 %1, 0
  br i1 %2, label %21, label %3

3:                                                ; preds = %0
  %4 = add i64 %1, -1
  %5 = zext i64 %4 to i65
  %6 = add i64 %1, -2
  %7 = zext i64 %6 to i65
  %8 = mul i65 %5, %7
  %9 = add i64 %1, -3
  %10 = zext i64 %9 to i65
  %11 = mul i65 %8, %10
  %12 = lshr i65 %11, 1
  %13 = trunc i65 %12 to i64
  %14 = mul i64 %13, 6148914691236517206
  %15 = add i64 %1, %14
  %16 = lshr i65 %8, 1
  %17 = trunc i65 %16 to i64
  %18 = mul i64 %17, 3
  %19 = add i64 %15, %18
  %20 = add i64 %19, -1
  br label %21

21:                                               ; preds = %3, %0
  %22 = phi i64 [ 0, %0 ], [ %20, %3 ]
  %23 = tail call i32 (i8*, ...) @printf(i8* nonnull dereferenceable(1) getelementptr inbounds ([6 x i8], [6 x i8]* @.str, i64 0, i64 0), i64 %22)
  ret i32 0
}

この時点でループが消失しており、x86特有の最適化というわけではなさそうです。

ミドルエンドで行われている

clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/clang -O0 -S -emit-llvm square_sum.cとして、フロントエンドでは最適化しないようにします。

; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @main() #0 {
  %1 = alloca i32, align 4
  %2 = alloca i64, align 8
  %3 = alloca i64, align 8
  store i32 0, i32* %1, align 4
  store i64 0, i64* %3, align 8
  store i64 0, i64* %2, align 8
  br label %4

4:                                                ; preds = %14, %0
  %5 = load i64, i64* %2, align 8
  %6 = load i64, i64* @n, align 8
  %7 = icmp ult i64 %5, %6
  br i1 %7, label %8, label %17

8:                                                ; preds = %4
  %9 = load i64, i64* %2, align 8
  %10 = load i64, i64* %2, align 8
  %11 = mul i64 %9, %10
  %12 = load i64, i64* %3, align 8
  %13 = add i64 %12, %11
  store i64 %13, i64* %3, align 8
  br label %14

14:                                               ; preds = %8
  %15 = load i64, i64* %2, align 8
  %16 = add i64 %15, 1
  store i64 %16, i64* %2, align 8
  br label %4, !llvm.loop !2

17:                                               ; preds = %4
  %18 = load i64, i64* %3, align 8
  %19 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([6 x i8], [6 x i8]* @.str, i64 0, i64 0), i64 %18)
  %20 = load i32, i32* %1, align 4
  ret i32 %20
}

この時点ではまだループが残っています。

ここで、このLLVM-IRファイル(square_sum.ll)からoptnoneを消します。 optnoneが最適化を強制的に妨げるためです。 二つありますが、一つ目はコメントの中なので、消しても消さなくても同じです。 二つ目を消すのが大事です。

その後、clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/opt -O2 -S square_sum.llとしてoptコマンドを適用します。 すると、以下のようにループが消えます。

; Function Attrs: nofree noinline nounwind uwtable
define dso_local i32 @main() local_unnamed_addr #0 {
  %1 = load i64, ptr @n, align 8
  %.not = icmp eq i64 %1, 0
  br i1 %.not, label %._crit_edge, label %.lr.ph.preheader

.lr.ph.preheader:                                 ; preds = %0
  %2 = add i64 %1, -1
  %3 = zext i64 %2 to i65
  %4 = add i64 %1, -2
  %5 = zext i64 %4 to i65
  %6 = mul i65 %3, %5
  %7 = add i64 %1, -3
  %8 = zext i64 %7 to i65
  %9 = mul i65 %6, %8
  %10 = lshr i65 %9, 1
  %11 = trunc i65 %10 to i64
  %12 = mul i64 %11, 6148914691236517206
  %13 = add i64 %1, %12
  %14 = lshr i65 %6, 1
  %15 = trunc i65 %14 to i64
  %16 = mul i64 %15, 3
  %17 = add i64 %13, %16
  %18 = add i64 %17, -1
  br label %._crit_edge

._crit_edge:                                      ; preds = %.lr.ph.preheader, %0
  %.0.lcssa = phi i64 [ 0, %0 ], [ %18, %.lr.ph.preheader ]
  %19 = tail call i32 (ptr, ...) @printf(ptr noundef nonnull dereferenceable(1) @.str, i64 noundef %.0.lcssa)
  ret i32 0
}

したがって、この最適化はミドルエンドで行われているようです。

Induction Variable Simplificationで行われている

さて、-O2はいくつかの最適化パスの集合体です。 具体的にどのような最適化が行われているかを調べるには、--print-after-allをつけて最適化パスの名前とその時点でのLLVM-IRを出力すればよさそうです(llvm opt optionsでググって出てきたoptの概要 - nothingcosmos wikiに書いてありました)。 これにより、*** IR Dump After Induction Variable Simplification ***の後に出力されるLLVM-IRではループが消失していることがわかります。

Induction Variable Simplificationだけではダメ

clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/opt --help | grep 'Induction Variable Simplification'とすると、Induction Variable Simplificationは--indvarsというオプションを指定すれば実行されるということがわかります。 clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/opt --indvars -S square_sum.llとすればよいようです。 ※llvm-16.0.0ではオプションの文法が違い、The `opt -passname` syntax for the new pass manager is not supported,と怒られます。--passes=indvarsなどとすればよいようです。

しかし、これは残念ながらループのままです。

Induction Variable Simplificationの前提条件

最適化パスは他の最適化パスによりLLVM-IRが整えられていることを前提としていることがあります。 どの最適化パスがInduction Variable Simplificationの前提条件となっているかを調べていきます。

少なくとも、-O2と同じ順番でInduction Variable Simplificationまで最適化パスを実行すれば同じ結果が得られるはずです。 実行されているパスの一覧は、以下のようにして得られます。

clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/opt -S -O2 --print-after-all square_sum.ll 2>&1 | grep '\*\*\*'`

以下のような出力を得ます。

*** IR Dump After Module Verifier ***
*** IR Dump After Instrument function entry/exit with calls to e.g. mcount() (pre inlining) ***
*** IR Dump After Simplify the CFG ***
*** IR Dump After SROA ***
(以下略。計121行)

これに対するオプションを調べていきます。 対応するオプションを以下のようにして得ます。

for i in $(seq 121); do clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/opt --help 2>&1 | grep -- "- $(clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/opt square_sum.ll -S -O2 --print-after-all 2>&1 | grep '\*\*\*' | sed 's/.*After \(.*\) \*\*\*/\1/' | head -n $i | tail -n 1)\$" | awk '{print$1}'; done

llvm-16.0.0では末尾に親切な情報がついてくるため、このコマンドではうまくいきません。

--verify
--ee-instrument
--simplifycfg
--early-cse
--lower-expect
--annotation2metadata
--forceattrs
--inferattrs
--ipsccp
--called-value-propagation
--globalopt
--mem2reg
--deadargelim
--instcombine
--simplifycfg
--prune-eh
--inline
--openmpopt
--function-attrs
--early-cse-memssa
--jump-threading
--correlated-propagation
--simplifycfg
--instcombine
--libcalls-shrinkwrap
--tailcallelim
--simplifycfg
--reassociate
--loop-simplify
--lcssa-verification
--lcssa
--loop-rotate
--licm
--loop-unswitch
--simplifycfg
--instcombine
--loop-simplify
--lcssa-verification
--lcssa
--loop-idiom
--indvars
(以下略)

後はこれを適当に消していきます。すると、以下のように二つの最適化パスだけでうまくいくことがわかります。

clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/opt --licm --indvars -S square_sum.ll

--licmは、Loop Invariant Code Motionです。 Loop Invariantはループ不変条件を意味しますが、そんなに大層なことはやっておらず、単にループ中で変わらない変数の計算をループの外に追い出す最適化がかかるだけのようです。 元のコードはループの終了条件にグローバル変数が使われており、毎回ロードが必要だったのが、Induction Variable Simplificationで最適化がかからなかった原因のようです。

実際、以下のコードに変えてみると、clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/opt --sroa --indvars -S square_sum_with_end.llでうまくいくことがわかります(optnoneを消すのをお忘れなく)。 ここで、--sroaはローカル変数endをスタックではなくレジスタに割り付けるために指定しています。

// square_sum_with_end.c
#include <stdio.h>

unsigned long long n;
int main() {
        unsigned long long i, sum = 0, end = n;
        for( i = 0; i < end; ++i ) {
                sum += i * i;
        }
        printf( "%llu\n", sum );
}

これらのことから、ループの終了条件がループ中で変わらないとすぐにわかる(=レジスタに割り付けられた)変数であることが前提条件となっていることが推測できます。

Induction Variable Simplificationの仕組み

ソースコード上の場所を特定

ここからはLLVMソースコードを見ていきます。 かなり昔から行われている最適化なので、かなり安定していると考えられます。 今のバージョンのソースコードを見てもそう違いはないでしょう。

GitHub - llvm/llvm-projectでindvarsと検索すると、llvm-project/llvm/lib/Transforms/Scalar/IndVarSimplify.cppが最適化パスのソースコードのようです。 2000行以上あって読むのが大変ですが、bool IndVarSimplify::run(Loop *L)が本体なので、コメントをたよりにそこを見ていきます。 そうすると、rewriteLoopExitValuesというのがいかにも怪しそうだとわかります。

rewriteLoopExitValuesググるLoopUtils.cppの中にある関数のようです。 1315行目からを眺めていくと、1425行目ExitValue = AddRec->evaluateAtIteration(ExitCount, *SE);でループが終わるときの値を計算しているとみて良さそうです。

evaluateAtIterationググるllvm::SCEVAddRecExprというクラスメンバ関数のようです。 ドキュメントを見てみると、isAffineisQuadratic*1などの誘導変数最適化関連っぽいメンバ関数があり、最適化がここで行われている期待が持てます。 肝心のevaluateAtIteration関数は978行目にあって、BinomialCoefficient関数を呼び出しています。 BinomialCoefficient(It, K, SE, ResultTy)関数は、863行目のコメントにあるように、 \frac{It(It-1)\cdots(It-K+1)}{K!}を計算するコードを生成します。 これは、コンビネーションの記法を用いれば、 {}_{It}C_{K}です。

LLVM-IRを確認

clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/opt --sroa --indvars -S square_sum_with_end.llをやってみると、以下のようになります。

define dso_local i32 @main() #0 {
  %1 = load i64, i64* @n, align 8
  %2 = zext i64 %1 to i65
  %3 = add i64 %1, -1
  %4 = zext i64 %3 to i65
  %5 = mul i65 %2, %4
  %6 = add i64 %1, -2
  %7 = zext i64 %6 to i65
  %8 = mul i65 %5, %7
  %9 = lshr i65 %8, 1
  %10 = trunc i65 %9 to i64
  %11 = mul i65 %2, %4
  %12 = lshr i65 %11, 1
  %13 = trunc i65 %12 to i64
  br label %14

14:                                               ; preds = %16, %0
  br i1 false, label %15, label %17

15:                                               ; preds = %14
  br label %16

16:                                               ; preds = %15
  br label %14, !llvm.loop !2

17:                                               ; preds = %14
  %18 = mul i64 %10, 6148914691236517206
  %19 = add i64 %18, %13
  %20 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([6 x i8], [6 x i8]* @.str, i64 0, i64 0), i64 %19)
  ret i32 0
}

計算過程を平易に記述してみると、

  1. nをロードする(%1)
  2. n - 1を計算する(%3)
  3. n * (n - 1)を65bit整数として計算する(%5)
  4. n - 2を計算する(%7)
  5. n * (n - 1) * (n - 2)を65bit整数として計算する(%8)
  6. n * (n - 1) * (n - 2) / 2を計算する。64bit整数に収まるはず(%10)
  7. n * (n - 1)を65bit整数として計算する(%11。%5と同じなのにもう一度計算しているのは、共通部分式最適化を施す前の自動生成されたコードそのままであるため)
  8. n * (n - 1) / 2を計算する。64bit整数に収まるはず(%13)
  9. n * (n - 1) * (n - 2) / 3を定数乗算テクニックで計算する(%18)
    • 3の倍数を2/3倍するには、0x5555555555555556を掛けます。この定数は \frac{2^{64}}3+\frac23で、被乗数が3の倍数であれば最初の項は \mod 2^{64}で消えるので、2/3倍できます。
  10. n * (n - 1) * (n - 2) / 3 + n * (n - 1) / 2を計算する。これが求めるものである(%19)

となります。 つまり、 2\,{}_{n}C_{3} + {}_{n}C_{2}を計算するという非常に単純な仕組みです。 この係数21がどこから出てきたのかを、次に説明します。

ループ誘導変数最適化の仕組み

ループ誘導変数最適化の基礎

ループ誘導変数最適化は、典型的な演算強度低減最適化です。 例えば、以下のコードを考えます。

uint32_t sum( uint32_t* arr, size_t n ) {
    uint32_t ret = 0;
    for( size_t i = 0; i < n; ++i ) {
        ret += arr[i];
    }
    return ret;
}

これを素直にコンパイルすると、以下のようになるはずです。

uint32_t sum( uint32_t* arr, size_t n ) {
    uint32_t ret = 0;
    for( size_t i = 0; i < n; ++i ) {
        char* p = (char*)arr + i * 4;
        uint32_t val = *(uint32_t*)p;
        ret += val;
    }
    return ret;
}

乗算が必要になっている点に注意します。 x86ではlea命令があるのであまり気になりませんが、乗算は一般に高コストであるので、可能であれば避けたいです。 そこで、コンパイラは以下のように最適化します。

uint32_t sum( uint32_t* arr, size_t n ) {
    uint32_t ret = 0;
    char* p = (char*)arr;
    for( size_t i = 0; i < n; ++i, p += 4 ) {
        uint32_t val = *(uint32_t*)p;
        ret += val;
    }
    return ret;
}

乗算がなくなり、+= 4という加算になりました。 このような最適化を(ループ誘導変数に関連する)演算強度低減最適化と言います。

発展的なループ誘導変数最適化

この考え方を発展させます。 以下のコードについて考えます。

uint64_t square_sum( uint64_t n ) {
    uint64_t ret = 0;
    for( uint64_t i = 0; i < n; ++i ) {
        ret += i * i;
    }
    return ret;
}

これを誘導変数最適化すると以下のようになります。

uint64_t sum( uint64_t n ) {
    uint64_t ret = 0;
    for( uint64_t i = 0, square = 0; i < n; square += 2 * i + 1, ++i ) {
        ret += square;
    }
    return ret;
}

本物の乗算から定数乗算へと、演算強度が低減されたことがわかります。

ここに現れる定数21こそが、 2\,{}_{n}C_{3} + {}_{n}C_{2}の係数だったのです。 この係数はどう求めるかというと、ループ誘導変数同士の乗算の公式を使っています(ScalarEvolution.cppの3311行目から3314行目)。 Clang の k 乗和の最適化を眺める - えびちゃんの日記では競プロerらしく(?)第二種 Stirling 数との関連を指摘&高速に計算する方法を考察していますが、そんなに高尚なものではないです。 愚直な計算なのでオーダーはおそらくΘ(k3)ですが、そんなに大きな次数の多項式が現れることはありえなさそうです。 そもそも、そういう場合は係数がオーバーフローしてあきらめるので、遅さが問題となることはありません。 逆に、この公式を使うと有理数演算が出てこないのがうれしいのだと思います。

ループ誘導変数同士の乗算の公式の説明の前に、まずループ誘導変数を定式化します。

ループ誘導変数の定式化

三乗和のコードを手で最適化して考えてみる

実際のコードで出てくることはほぼありませんが、二乗だとまだ一般化が見えづらいため、以下のように三乗が出てくるループを考えます。

uint64_t cube_sum( uint64_t n ) {
    uint64_t ret = 0;
    for( uint64_t i = 0, square = 0; i < n; ++i ) {
        ret += i * i * i;
    }
    return ret;
}

 (i+1)^3 = i^3 + 3i^2 + 3i +1であることから、これを以下のように変形できます。

uint64_t cube_sum( uint64_t n ) {
    uint64_t ret = 0;
    for( uint64_t i = 0, cube = 0; i < n; cube += 3 * i * i + 3 * i + 1, ++i ) {
        ret += cube;
    }
    return ret;
}

さて、ここでi * iが出てきました。これに対しても再帰的に適用することで、以下のようになります。

uint64_t cub_sum( uint64_t n ) {
    uint64_t ret = 0;
    for( uint64_t i = 0, square = 0, cube = 0; i < n; cube += 3 * square + 3 * i + 1, square += 2 * i + 1, ++i ) {
        ret += cube;
    }
    return ret;
}

3 * square3 * iに対して演算強度低減最適化をしましょう。

uint64_t cube_sum(uint64_t n) {
    uint64_t ret = 0;
    for( uint64_t i = 0, three_i = 0, three_square = 0, cube = 0; i < n; cube += three_square + three_i + 1, three_square += 6 * i + 3, three_i += 3, ++i ) {
        ret += cube;
    }
    return ret;
}

three_square + three_iはまとめられそうです。

uint64_t cube_sum( uint64_t n ) {
    uint64_t ret = 0;
    for( uint64_t i = 0, tmp = 0, cube = 0; i < n; cube += tmp + 1, tmp += 6 * i + 6, ++i ) {
        ret += cube;
    }
    return ret;
}

6 * iも演算強度低減最適化しましょう。

uint64_t cube_sum( uint64_t n ) {
    uint64_t ret = 0;
    for( uint64_t i = 0, six_i = 0, tmp = 0, cube = 0; i < n; cube += tmp + 1, tmp += six_i + 6, six_i += 6, ++i ) {
        ret += cube;
    }
    return ret;
}

ここで現れる係数(1, 6, 6)が求めるものです。 つまり、retの最終値 {}_nC_2 + 6\,{}_nC_3 + 6\,{}_nC_4だということです。 実際、optの出力はこれを計算するものになっていました。 この式を展開すると  \left(-\frac12n+\frac12n^2\right)+\left(2n-3n^2+n^3\right)+\left(-\frac32n+\frac{11}4n^2-\frac32n^3+\frac14n^4\right)になっていて、  \sum_{i=0}^{n-1} i^3= \frac14n^2 - \frac12n^3 + \frac14n^4と一致しています。

ここで出てきた誘導変数は、cubetmpsix_iでした。 これらのいずれも、cube += tmp + 1tmp += six_i + 6six_i += 6のように、更新の式が(他の誘導変数または0)+(定数)を加算する形になっています。 そこで、Aが誘導変数であるということを、次のように定義しましょう。

  • Aが誘導変数であるとは、他の誘導変数Bを用いて、更新の式がA += B + cのようになることである
    • ここで、増分cはループ不変式である必要がある
    • B0でもよい

※これは正確には加法的な誘導変数に関する定義です。指数関数を乗算に落とす演算強度最適化を行うと、更新の式がA *= B * cB1でもよい)のような乗法的な誘導変数が出現します。LLVMはこれを取り扱えず、 \sum_{i=0}^{n-1}2^iを計算するコードはSIMDを駆使して頑張る命令列になります。

このように定義すると、Aを記述するのに必要な情報は、Aの初期値と、各誘導変数の更新式に現れる増分cだけであり、簡潔に記述することができます。 LLVMの流儀(SCEVAddRecExprの出力フォーマット)では、iのことを{0,+,1}(初期値が0で1ずつ増える)、six_iのことを{0,+,6}(初期値が0で6ずつ増える)、tmpのことを{0,+,6,+,6}(初期値が0で、6+「初期値が0で6ずつ増える値」ずつ増える)、cubeのことを{0,+,1,+,6,+,6}(初期値が0で、1+『初期値が0で6+「初期値が0で6ずつ増える値」ずつ増える値』ずつ増える)、retのことを{0,+,0,+,1,+,6,+,6}(初期値が0で、0+【初期値が0で1+『初期値が0で6+「初期値が0で6ずつ増える値」ずつ増える値』ずつ増える値】ずつ増える)と表記しているようです。 なお、途中に出てくる誘導変数の初期値はすべて0に正規化出来ます。一つ外側の誘導変数の増分cを変えればよいからです。

実際の計算手順(ループ誘導変数同士の乗算の公式を使う方法)

上記やり方は、「three_square + three_iはまとめられそう」のようにあまり系統立った方法ではありませんでした。 実はもう一通りやり方があって、それはまずi * iだけを誘導変数とみなす方法です。 この方法で最適化すると、まず一回目では以下のようになります。

uint64_t cube_sum( uint64_t n ) {
    uint64_t ret = 0;
    for( uint64_t i = 0, square = 0, cube = 0; i < n; square += 2 * i + 1, ++i ) {
        ret += square * i;
    }
    return ret;
}

ここで、squareLLVMの記法でいうと{0,+,1,+,2}で、i{0,+,1}です。 問題は、{0,+,1,+,2}{0,+,1}の乗算をどう取り扱うかです。 ようするに{a,+,b,+,c} a\,{}_iC_0 + b\,{}_iC_1 + c\,{}_iC_2なので、この形同士の乗算を考えればいいわけですが、これには公式があるようです。 ソースコードの方が信頼できるのでScalarEvolution.cppの3340行目から3364行目を眺めると、以下のような計算をしているようです。

// X = { 0, 1, 2 };
// Y = { 0, 1 };
ret = {};
for( x = 0; x < X.size() + Y.size() - 1; ++x ) {
  sum = 0;
  for( y = x; y < 2*x + 1; ++y ) {
    Coeff1 = Choose(x, 2*x - y);
    for( z = max(y-x, y-X.size()+1), z < min(x+1, Y.size()); ++z )
      Coeff2 = Choose(2*x - y, x - z);
      sum += Coeff1*Coeff2*X[y-z]*Y[z];
    }
  }
  ret.push_back(sum);
}

ということはScalarEvolution.cppの3311行目から3314行目choose(x, 2x)*choose(2x-y, x-z)と書いてあるところは間違い(choose(x-1, 2x-y-1)*choose(2x-y-1, x-z)が正しい?)のようです。

これを動かすと、以下のようになります。

x = 0
  y = 0
    z = 0
      Choose(0,0)Choose(0,0)X[0]Y[0]
x = 1
  y = 1
    z = 0
      Choose(1,1)Choose(1,1)X[1]Y[0]
    z = 1
      Choose(1,1)Choose(1,0)X[0]Y[1]
  y = 2
    z = 1
      Choose(1,0)Choose(0,0)X[1]Y[1]
x = 2
  y = 2
    z = 0
      Choose(2,2)Choose(2,2)X[2]Y[0]
    z = 1
      Choose(2,2)Choose(2,1)X[1]Y[1]
  y = 3
    z = 1
      Choose(2,1)Choose(1,1)X[2]Y[1]
  y = 4
    満たすzなし
x = 3
  y = 3
    z = 1
      Choose(3,3)Choose(3,2)X[2]Y[1]
  y = 4
    満たすzなし
  y = 5
    満たすzなし

これを計算すると、一つ目の係数は0、二つ目の係数はChoose(1,0)Choose(0,0)X[1]Y[1]=1、三つ目の係数はChoose(2,2)Choose(2,1)X[1]Y[1]+Choose(2,1)Choose(1,1)X[2]Y[1]=6、四つ目の係数はChoose(3,3)Choose(3,2)X[2]Y[1]=6、となり、無事に{0,+,1,+,6,+,6}が導出できました。

retcubeの総和なので、もう一レベル上がって{0,+,0,+,1,+,6,+,6}になります。これは  0\times{}_iC_0 + 0\times{}_iC_1 + 1\times{}_iC_2+ 6\times{}_iC_3+ 6\times{}_iC_4です。 ループ脱出時にはi == nになっているので、  0\times{}_nC_0 + 0\times{}_nC_1 + 1\times{}_nC_2+ 6\times{}_nC_3+ 6\times{}_nC_4になっているはずです。 これで、結論に達することができました。

clangの出力が汚い理由

上では、clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/opt --sroa --indvars -S square_sum_with_end.llという、本質部分だけを抜き出した最適化により得られたきれいなコードを確認しました。 しかし普通にclang -O2としたときに出力される機械語コードのアルゴリズムは、これより汚いです。 同様にオプションを一つずつ削ることで、アルゴリズムが汚くなる原因が--loop-rotateにあることがわかります。 この最適化パスは、while文をdo文に変えるような最適化を行うようです。 つまり、

// whileっぽい方式
    unsigned long long sum = 0, i = 0, end = n;
loop:
    if( i >= end ) { goto loop_end; }
    sum += i;
    ++i;
    goto loop;
loop_end:
    printf( "%llu\n", sum );

とするとループ一周当たりに分岐が二回(最後以外成立しないif文と、loopに戻るgoto文)ですが、

// doっぽい方式
    unsigned long long sum = 0, i = 0, end = n;
    if( end == 0 ) { goto loop_end; }
loop:
    sum += i * i;
    ++i;
    if( i < end ) { goto loop; }
loop_end:
    printf( "%llu\n", sum );

のようにすれば、ループ脱出の条件分岐とloopに戻る分岐を兼ねられて、ループ一周当たりに分岐が一回になります。 この最適化をLoop Rotation最適化というようです。

if( i >= end ) goto loop_end; sum += i * i; ++i;                               goto loop;

                              sum += i * i; ++i; if( i >= end ) goto loop_end; goto loop;

に変わる(ループ本体が文単位で見て左ローテートになっている)あたりが、Rotationと呼ばれる理由なのでしょう。

この最適化は分岐命令の実行数を減らす点では有用ですが、どうもInduction Variable Simplificationとの相性が悪いようです。 clang+llvm-12.0.1-x86_64-linux-gnu-ubuntu-16.04/bin/opt --loop-rotate --sroa --indvars -S square_sum_with_end.llとすると、条件がfalseの分岐が取り残されます。 これを見るに、必ず一回は実行されるループ構造になっているのが原因なのか、最後の一周が取り残されているような雰囲気があります。 実際、optの出力が計算しているのは  {}_{n-1}C_{1} + 3\,{}_{n-1}C_{2} + 2\,{}_{n-1}C_{3}で、一周分少なくなっていることがわかります。 おそらく、{0,+,0,+,1,+,2}(ループの最終周が始まるときのretの値)と{0,+,1,+,2}(ループの最終周におけるi * iの値)を足して{0,+,1,+,3,+,2}になったのでしょう(誘導変数の加算は要素ごとの加算でできます)。

まとめ

  • clangは誘導変数最適化の枠組みで、 \sum_{i=0}^{n-1} i = \frac{n(n-1)}2のような最適化を行っている
    • 具体的には、optの中のInduction Variable Simplification最適化パスで計算されている
  • clangは、SCEVAddRecExprという形で加法的な誘導変数を高度に解析している
    • 加法的な誘導変数であれば、それが k次の誘導変数であってもループの各回や終了時点の値を直接算出できる
      • cube += tmp + 1tmp += six_i + 6six_i += 6のように、誘導変数の連鎖で表す
      •  {}_{i}C_{\kappa} (0\le\kappa\le k)を基底とした表現で持っているのと実質的に同じ
    • 誘導変数同士の演算結果がどのような誘導変数になるかが計算できる
      • 加算は自明(係数の要素ごと加算でよい)
      • 乗算は有理数演算が不要な公式がある
  • 普通に-O2などの最適化オプションを使う場合、Rotate Loop最適化パスが先に実行されるためにきれいなコードとならない
    •  {}_{n-1}C_{\kappa} (0\le\kappa\le k)を基底とした表現で計算されてしまう

関連情報

*1:i*i<5みたいな条件式の時、二次方程式を解けばiの範囲を特定することができます。isQuadratic関数は、このために使われるようです。