- ドメイン特化圧縮器の最適エンコーディング経路をたどるループに 条件分岐を1つ追加 し、合成ベンチマークの実行時間を320µsから80µsに短縮
- 各反復でメモリアドレスが直前の反復の
j に依存するため、単一の mov 命令であっても 命令レベル並列性 を活用できず、メモリアクセスのレイテンシに縛られる
next_j[i][j] が大半の場合で既存の j と同じだという性質を分岐予測に反映すると、CPU が反復間の依存を迂回し、複数の反復を 投機実行 できる
- コンパイラが意味的には不要な
if を削除できないように volatile キャストを使用。LLVM では [[unlikely]] や __builtin_expect(..., 0) でも機能するが、volatile のほうがより良いコードを生成し、GCC でも利用できる
- 現実的な実験では、LLVM の最適でないコード生成が原因の可能性はあるものの 約2倍の性能向上 を得られ、
j の予測が難しいならレイテンシ 1 サイクルの pshufb を代替として使える
圧縮器の最適経路をたどる
- 入力文字列を複数のチャンクに分け、各チャンクごとに最小のエンコーディングを選ぶ必要がある
- エンコーディングごとに圧縮が得意な文字が異なるため、チャンク境界をすぐには決めにくい
- 前回の記事 のアルゴリズムは、この問題を格子上の 最短経路探索 に変換する
- 各セルには、次に移動する最適なセルを記録する
- 最初のセルから最後のセルまで参照をたどれば、最適なエンコーディング順序を得られる
next_j を埋める最初のループは、すでに SIMD で最適化されている
- 実際のボトルネックは、次のような単純な経路走査ループである
uint8_t encoding[n_symbols];
uint8_t j = 0;
for (int i = 0; i < n_symbols; i++) {
j = next_j[i][j];
encoding[i] = j;
}
- 書き込みを除いた中核演算
j = next_j[i][j] は、単一の mov 命令にコンパイルされる
単一命令でも遅くなる依存関係
- 現代の CPU は 命令レベル並列性 を利用し、複数の命令や異なる反復の処理を同時に実行する
- 一般的なループで
i < n_symbols の判定や i++ のコストが他の処理を妨げないのはこのためである
- しかし、相互に 依存する命令 は同時に実行できない
- 現在の反復のメモリアドレス
next_j[i][j] を計算するには、直前の反復で得た j が必要になる
- 次の反復は前の反復の結果が出るまで開始できず、データがキャッシュにあってもメモリアクセスのレイテンシの影響を受ける
分岐予測を値予測として使う
- この圧縮器ではチャンク数は多くないと見込まれるため、
next_j[i][j] が既存の j と同じである場合がほとんどである
- CPU に
j が維持されると直接アドレス予測させることはできないが、分岐予測 を使えば同じ効果を生み出せる
for (int i = 0; i < n_symbols; i++) {
if (j != next_j[i][j]) {
j = next_j[i][j];
}
encoding[i] = j;
}
- CPU が
if 本体を実行しないと予測すると、j を更新する反復間の依存がないものとして複数の反復を投機実行する
- 条件が実際には真なら 分岐予測ミスからの回復 が発生する
- 誤った推測で行った書き込みを破棄する
- 正しい
j を使って実行を再開する
j がほとんど変わらない間、ループのボトルネックはレイテンシからスループットへと移る
コンパイラに if を削除させない方法
- コンパイラの観点では、条件分岐を加えたコードと元のコードは意味的に同じである
j がメモリ上にあるなら不要な書き込みや読み取り専用メモリへの書き込みを避けられるが、ここでは レジスタ値 である
- 共通部分式除去 (CSE) のような最適化で、条件や重複アクセスが除去されうる
- 一般的なコンパイラヒントは分岐をなくすために使われるが、ここでは逆に分岐のないコードを 分岐つきコードのまま維持 する必要がある
- 条件判定と代入のメモリアクセスが独立しているように見せるため、代入側に
volatile キャストを適用する
for (int i = 0; i < n_symbols; i++) {
if (j != next_j[i][j]) {
j = *(uint8_t volatile *)&next_j[i][j];
}
encoding[i] = j;
}
- 7月13日の追記確認では、ibookstein が見つけた方法 のように
[[unlikely]] または __builtin_expect(..., 0) を使っても LLVM では同じ効果が得られる
volatile 方式はより良いコードを生成し、GCC でも動作 する
ベンチマークで確認した性能変化
- 合成ベンチマークではループ実行時間が 320µs から 80µs に減少し、4倍高速化した
- 1回あたりの実行時間差は小さいが、圧縮処理の中でループが何度も実行されるため、全体コストに積み上がる
- より現実的な実験では約 2倍の性能向上 を記録した
- LLVM が最適でないコードを生成した影響の可能性はあるが、それでも適用する価値がある水準だった
ビットマスクベースの表現という代替案
- このアルゴリズムでは
next_j[i][j] は 2 つの値のどちらかしか取らない
- 大半は現在の
j
- もう一方は
j に無関係で、i にのみ依存する値である
- 8 要素配列である
next_j[i] を、代替値と ビットマスク の組に置き換えられる
- この表現では
if が意味的に必要なので、volatile の回避策を使う必要がない
- ただし x86 では可変位置のビットを検査する演算が比較より遅い可能性があり、全体性能がかえって低下するおそれがある
j を予測できないとき
- 値予測で連結リスト走査を高速化する記事 でも、同じ方法で性能を高める手法が扱われている
j の予測が難しいなら、ベクタインデックス演算である pshufb を使える
pshufb のレイテンシは 1 サイクルなので、投機実行なしでこれ以上短くするのは難しい
- ベクタ演算によって、可能な各開始
j の経路を並列に計算できる
- 作業を複数スレッドに分割し、結果をマージする方式も可能である
1件のコメント
Lobste.rsのコメント
C++20の
[[unlikely]]属性を if 文に付ければ、clangがそのコードを削除できないようにするには十分そうです: https://clang.godbolt.org/z/r4xYWfPfeほかのコンパイラでは確認していません
ただし
[[unlikely]]はコードを高速パスの外へ移動させるため、LLVMは以下のようにやや非効率なコードを生成します 次の形のほうが妥当に見えます また、メモリから値を何度も読み出さないよう最適化する一方で、ホットループに命令を1つ追加することもあります。そのためvolatileを使う手法は依然として有用ですが、元のコードもそのまま動作し得ると分かったのはうれしい点ですこの手法は別のところでは 値投機(value speculation) と呼ばれています: https://mazzo.li/posts/value-speculation.html
これから2〜3年後には、開発者はLLMの出力にますます無関心になっていくでしょうが、こういう種類のブログ記事にも引き続き関心を持つのか気になります
それでも関心のある人にとっては、今も非常に有用で良い記事です
細部は重要ではないと考える開発者は、良い開発者にはなりにくいです。優れた土木技術者が大規模プロジェクトのボルトやナットにまで気を配るように、優れた開発者も小さな細部まで掘り下げるべきです。全体は細部から成り立っているので、自分が作る成果物を重視するなら避けて通れません
AIが書いたコードのコンパイラ出力を見る人はさらに減るかもしれませんが、基本的な構図は変わらないと思います
10年前にBashスクリプトが少し遅いからといって、命令レベル並列性やCPUの分岐予測から考え始めることはなかったはずです。コンパイルと最適化が可能な言語で書かれた性能上の中核部分があり、その中に細かく調べるべきホットパスがあり、さらにその中にベクトル化や最新CPUの挙動差を確認する価値のあるホットループが1つか2つ存在する、ということはあり得ます
LLMを使っていても、性能上もっともホットな箇所は例外的に直接レビューする価値があります。成果物の低い品質を意識的に受け入れる、あるいはLLMで70%を作って残り30%を手で直すやり方が85:15まで改善されることはあっても、この記事は依然として上位1%レベルの細部領域を扱っていることになります