世界を動かす技術を、日本語で。

2^51基数トリック (2017)

概要

  • 現代CPU での大きな整数の加算・減算を高速化する技術を解説
  • キャリー伝播 が並列化の障害となる理由と、CPU命令の違いによる性能差を説明
  • 基数変換(radix 2⁵¹表現) を利用したキャリー伝播の遅延方法を紹介
  • 実際のアセンブリコード例と、正規化処理の仕組みを具体的に解説
  • 減算への応用や、パフォーマンス向上の理由も説明

現代CPUにおける高速な加算・減算

  • 紙の筆算 では右端(1の位)から順に加算し、キャリーを左へ伝播する手順
  • 大きな整数 (例: 256ビット以上)の加算も、基本的に同様のアルゴリズムを採用
  • なぜ右端から始めるかというと、 キャリー(繰り上がり) の伝播が必要なため
  • 左端から加算すると、途中でキャリーが発生した場合に 再計算 が必要となり、効率が悪化
  • 並列化 が難しく、計算の各段階が前段階の結果に依存する構造

CPUでの加算命令とキャリー処理

  • CPU は10進数ではなく、通常は64ビット整数を単位に演算
  • 256ビット整数を加算する場合、 64ビットごとに分割 (リムブ化)し、対応する部分同士を加算
  • 各リムブでオーバーフローが発生する場合、 キャリー を次のリムブに伝播する必要
  • x86系CPUには adc(add with carry)命令 があり、前回のキャリーを自動的に加算
  • 例:
    add D, H
    adc C, G
    adc B, F
    adc A, E
    
    このように、 下位から上位へ キャリーを伝播

キャリー伝播による性能低下

  • adc命令 は通常のadd命令よりも 実行が遅い 傾向
  • Haswell世代CPU では、add命令は最大4命令同時実行可能
  • しかしadc命令は 逐次実行 が必要なため、並列化できず性能が大幅に低下
  • SIMD命令 (例: vpaddq)を使えば、複数の加算を同時実行可能だが、adcによるキャリー伝播がボトルネック

キャリーを遅延させるアイデア(紙上の例)

  • キャリー発生を抑えるために、 各桁の取りうる値の範囲を拡張 (例: 0-9, A-Z, * の37種類)
  • これにより、 複数の数値をキャリーなしで加算 可能
  • ただし、最終的には正規化して 通常の表現に戻す 必要
  • この手法は キャリー伝播を遅延 させるアイデアの本質

コンピュータにおけるキャリー遅延(radix 2⁵¹表現)

  • ハードウェアの制約で 取りうる値の範囲自体は変えられない

  • 代わりに、リムブの ビット幅を減らし、余ったビットをキャリー用に確保

  • 例: 256ビット整数を 5つのリムブ(52, 51, 51, 51, 51ビット) に分割

  • これにより、 キャリー発生を遅延 し、複数回の加算をキャリーなしで並列実行可能

  • この方式は radix 2⁵¹表現 と呼ばれ、 暗号分野 でよく使われる

    • 各リムブの上位ビットをキャリー用に確保
    • 例えば13ビット余裕があれば、 213回まで加算 をキャリーなしで実行可能

実装例と正規化処理

  • 加算は単純なadd命令のみを使い、 並列処理 が可能
  • 加算後は 正規化(キャリー伝播) 処理を実施
    • 各リムブの余剰ビットを次のリムブに加算
    • and命令でリムブのビット幅を制限
  • 例:
    mov T, E
    shr T, 51
    add D, T
    and E, 0x0007FFFFFFFFFFFF
    
  • 変換コストを含めても、従来方式より高速 な場合が多い

減算への応用

  • 減算 も同様の手法を拡張可能
  • 減算では 負のキャリー が発生するため、リムブを 符号付き整数 として扱う
  • 各リムブの最上位ビットを 符号ビットとして確保
  • その結果、加算よりも 1ビット分だけ余裕が減少 するが、実用上は問題なし

まとめ

  • リムブ数の増加や操作数の増加 にも関わらず、 キャリー伝播の遅延 によって大幅な性能向上
  • 現代CPUの並列実行能力 を最大限活用できる手法
  • 暗号分野 などでの大きな整数演算の高速化に有効
  • 直感に反するが、分割と余裕を持たせることで逆に高速化 が実現可能
  • キャリーの遅延と正規化処理 の組み合わせが鍵

Hackerたちの意見

余談だけど、なんで12ビットじゃなくて13ビットなの?私たちの目的では、最上位のリムのキャリーを無視することにしてるから、2256 - 1を超えたときに数字がラップするんだ。これは、Cの通常の整数型での符号なし加算と同じ感じ。だから、最上位のリムに52ビットを割り当てて、他のリムよりもキャリーのためのスペースが足りなくなることを無視できるんだ。じゃあ、最上位のリムに64ビット、他の4つのリムにそれぞれ48ビットを与えたらどうなるの?ノーマライズする前にもっと加算できるし、命令セットに何か便利なものがあれば、分割やノーマライズの際にワードアライメントを利用できるし、オーバーフローの特性も同じだよね?

そうなると、OPの5ではなく256ビットの値を保持するのに6つのワードが必要になるし、それに伴って加算するための命令も増えるね。

2つのエンコードされた数の上位ビットを足すと、すぐにオーバーフローしちゃうから。例えば、両方を2^63に設定すると、すぐにオーバーフローするんだ。ラップアラウンド算術には問題ないかもしれないけど、一般的にはそうじゃないよね。

adcが現代のCPUでaddよりも本質的に遅いとは思えないんだけど、キャリービットによるデータハザードを除けばね。この記事のポイントがデータハザードに関するものだってことは分かるから、これは本当に小さな指摘だね。

uops.infoによると、(Alder Lake)でのレイテンシはどちらも1サイクルだけど、スループット(低い方が良い)はaddが0.20(つまり1サイクルで5回)、adcが0.50(つまり1サイクルで2回)だから、これが正しいみたい。これは、addがポート0、1、5、6、Bで利用できるのに対して、adcはポート0と6だけで利用できることの結果みたいだね。だから、個々の命令としては悪くないけど、依存しない命令でもOoO実行では悪化するだろうね(これは単一の命令として見るよりも現実的だよ)。

その通りだね。同じALUでできるけど、キャリーフラグへのデータ依存性があるから、CPUの観点から見ると本当に異なる命令になる。データ依存性が3つになるからね。CPUにとっては、命令を異なる扱いにするのが有利なんだ。

CPUって本当に面白くて、変わったものだよね。プログラマーの私たちは毎日それを使ってて、たくさんの仮定をしてるし、コンパイラからランタイム、ループやメソッドの動作まで、いろいろ考えちゃう。自分のC#でエンティティコンポーネントシステムを作ってるんだけど、最初からやり直して、あらゆる仮定をテストしなきゃいけなかった。直感が正しかったのはほんの数回で、ほとんどは驚くべき落とし穴があちこちに隠れてるんだよね。

C(++?)コンパイラがこの最適化を実装するのは合法なのかな?

C++にはuint256のネイティブサポートがあるの?

うん、as-ifルールに従ってるよ。挙動に観察可能な違いはないからね。例えば、32ビットや16ビットのアーキテクチャでループ内で64ビットの加算をサポートする場合にも当てはまるよ。

予期しない最適化がサイドチャネル(最も一般的にはタイミング)を引き起こすことがあります。これは安全だけど、「コンパイラにどれを使わないか教えるのはどうするの?」っていうのは別の話だよね。

x86_64で完全に作業している誰かが、RISC-Vがキャリーフラグを省略するのが間違っていないことを見事に示しているね。

Hacker Newsで議論の続きを見る