HN 日本語サマリー

← 一覧へ戻る
プログラミング

PGOか否か、それがジレンマ。ステップ2

PGO or not PGO this is the dilemma. Step 2 (percona.com)

18 pointsby hnp9j9qtda2 コメント

要約

この記事は、プロファイルガイド付き最適化(PGO)を適用するかどうかのジレンマについて、シリーズの第2弾として解説しています。PGOはソフトウェアのパフォーマンス向上に寄与する可能性がありますが、その導入には検討すべきトレードオフが存在します。本稿では、その具体的な課題と解決策に焦点を当てています。

全文翻訳

PGOか否か、それが問題だ。ステップ2

ステップ2:仕組み

この記事のトピックの複雑さと長さを考慮し、3つの異なるブログ記事に分割しました。

  1. PGOとは何か
  2. PGOの仕組み
  3. なぜsysbenchでトレーニングしたビルドが負けるのか、そして正しく行う方法

PGOの仕組み: PGOは二段階のビルドです。最初の段階では、インストルメンテーション(-fprofile-generate)を付けてコンパイルします。これにより、すべての基本ブロックと分岐にカウンタが追加されます。
トレーニングワークロードを実行すると、カウンタの値がprofrawファイルにダンプされます。
二段階目では、これらのカウント値を使用して再コンパイルし、インライン展開の決定、分岐レイアウト(ホットパスはそのまま通り、コールドパスは分岐させる)、ホット/コールド関数の分割、icache/iTLBの局所性のためのコード順序付け、ループ展開、間接呼び出しの昇格を行います。
重要なのは、PGOは「トレーニングされたワークロードを速くする」ことではなく、「コンパイラにどのコードがホットでどれがコールドかを伝え、それに応じてバイナリ全体を再構築させる」ことです。

では、どのように機能するのか?

フェーズ1:実際に記録されるもの。 インストルメントされたバイナリには、コンパイル時にカウンタが注入されます。これは関数ごとではなく、制御フローグラフの各エッジに1つずつです。
つまり、すべての分岐、すべてのループバックエッジ、すべての呼び出しサイトに対して、そのパスが実行されるたびにインクリメントされるカウンタが存在します。
これは「関数XがN回呼び出された」よりも細かい粒度であり、「この分岐に到達したとき、左に950,000回、右に50回進んだ」という情報です。
このエッジごとの粒度により、第二段階は単に「関数Xはホット、関数Yはコールド」という大まかな判断ではなく、外科的な決定を下すことができます。

フェーズ2:コンパイラがこれらのカウントをどう利用するか。 同じカウントデータによって駆動される、いくつかの異なる変換が行われます。

インライン展開。
通常、コンパイラは静的なヒューリスティックに基づいてインライン展開を行います。

  • 関数のサイズ
  • 呼び出しサイトの数
  • 推定されるコスト/ベネフィット。

プロファイルデータを使用すると、これらのヒューリスティックを上書きできます。実行時に明らかにコストを上回るベネフィットがある場合、静的なコストルールでは「高すぎる」ように見える呼び出しサイトでも、インライン展開されます。なぜなら、実行時のベネフィットがコードサイズのコストを明らかに上回るからです。

技術的にはインライン展開可能だが、デッドコールドコード内に存在する呼び出しサイトは、バイナリを無駄に肥大化させるだけなので、実際の呼び出しのまま残されます。

分岐レイアウト。
コード内のすべての「if」は、2つの可能な結果を持つ分岐命令にコンパイルされます。

  • 次の命令にまっすぐ進む
  • どこかへジャンプする。

まっすぐ進むことは基本的に無料です。CPUはすでに命令を順序通りにフェッチしているため、追加のコストはかかりません。
ジャンプは無料ではありません。CPUは事前にどちらの方向に分岐するかを推測する必要があり、そうしないと、すでにキューアップしていた作業を破棄して正しい場所からやり直す必要があります。
これが「パイプラインバブル」、つまり小さなストールです。したがって、「ストレートスルー」パスは安価であり、「ジャンプ」パスは推測が間違った場合にペナルティを伴います。

コンパイラはホットパスをフォールスルーとして配置し、コールドパスをインラインから外します。これは、バイナリ内の別の場所に、しばしば.text.unlikelyセクションに物理的に移動されます。
そのため、if (unlikely_error_condition) { …rare handling… }のようなブロックは、ホットパスを中断してインラインに配置されるのではなく、完全に別の場所に移動され、ホットパスは分岐のない命令の直線的な実行になります。

明確にしておくと、ソースコードでif/elseの順序を入れ替えても、コンパイラがマシンコードをどのように配置するかは通常変わりません。最適化コンパイラは、プロファイルデータ(PGO)または静的なヒューリスティックに基づいて分岐レイアウトを自身で決定するのであり、ソースコードで最初に記述した分岐順序には依存しません。したがって、ifブロックの順序を手動で入れ替えても、コンパイル結果にほとんど、あるいは全く影響はありません。

ホット/コールド関数分割。
これは、単一の関数内で同じアイデアを適用したものです。
関数には、ホットなコアループと、めったにヒットしないエラー処理の末尾がある場合があります。
コンパイラは関数を物理的に2つの部分に分割します。

  • ホットな部分は.text.hotに残ります。
  • コールドな部分は.text.unlikelyに移動されます。

関数は(必要に応じてジャンプで接続され)同じように機能しますが、ホットな部分はより小さく、より密になり、より多くの命令が命令キャッシュラインに収まるようになります。ほとんど触れられないコールドコードは、隣接して配置されることでicacheスペースを無駄にしません。

バイナリ全体の関数再配置。
ここで「バイナリ全体を再構築する」が文字通りの意味になります。
リンク時(特にMySQLのPGOビルドが有効にするLTOの場合)、関数は最終的な実行可能ファイル内で物理的に再配置され、頻繁に呼び出し合う関数や、ホットワークロード中に順次実行される関数がメモリ上で互いに近接して配置されます。
これにより、命令キャッシュとiTLBの局所性が最大化されます。CPUのフェッチユニットは、100MBを超えるバイナリ全体をジャンプするのではなく、ホットな関数のタイトなクラスターをプルします。
mysqldのようなサイズの大きいものにとって、これはしばしば最大のパフォーマンス向上をもたらします。なぜなら、通常のビルドでは、関数はソースファイルのコンパイル順序に従って配置されますが、これは実行時の呼び出しパターンとは全く関係がありません。

間接呼び出しの昇格。
プロファイルデータにより、仮想呼び出しまたは関数ポインタ呼び出しが、圧倒的多数の時間で同じターゲットに解決されることが示された場合(C++のvtableで一般的で、例えばInnoDBのみが登録されているストレージエンジンのインターフェースなど)、コンパイラはガード付き直接呼び出しを挿入します。「ターゲットがこの特定のアドレスと一致する場合、直接呼び出しを行い、間接ジャンプをスキップします。それ以外の場合は、間接呼び出しにフォールバックします。」
直接呼び出しは、間接ジャンプテーブルよりも、分岐予測器にとって安価で予測可能です。

レジスタ割り当てとコード密度とのトレードオフ。
ホットコードは速度を優先してコンパイルされ、より積極的な展開、ホットパスの値に専念するレジスタが増えます。
コールドコードは、特に-fprofile-partial-trainingとコールドパス処理を使用する場合、サイズを優先してコンパイルされ、レジスタ数が少なく、展開も少なくなります。なぜなら、実行されることがほとんどないからです。そのため、実行時のコストは関係ありませんが、バイナリ内でのフットプリントは無料ではありません(ディスク/ページキャッシュスペースを占有し、不注意に配置された場合はホットラインをキャッシュから追い出す可能性があり、これはまさに、最適化せずにそのまま配置するのではなく、.text.unlikelyに分離される理由です)。

switch/ジャンプテーブルの低下。
多数のケースを持つswitch文は、ジャンプテーブル(高速、O(1)ですが、完全なテーブルロードと間接ジャンプが必要です)または比較の連鎖(ケースごとに遅いですが、1つのケースが支配的であれば分岐予測が向上します)としてコンパイルできます。
プロファイルデータは、コンパイラに実際にどのケースが支配的であるかを伝え、それに応じて選択させます。

上記を踏まえると、「バイナリ全体を再構築する」というのは比喩ではありません。コンパイラは、実行可能ファイル内のマシンコードの物理的なレイアウトを再描画しています。どの命令が互いに隣接しているか、どのコードセクションがホットでタイトに詰め込まれているか、コールドで脇に追いやられているか、どの呼び出しが直接か間接か、そしてCPUのフェッチ/予測の労力がどこに費やされるかです。
これらのいずれも、トレーニング中に処理された値が本番トラフィックに類似している必要はありません。必要なのは、制御フローの形状だけです。
どの分岐、関数、パスが頻繁に実行されるか、本番トラフィックに類似しているかです。
これが、MTRの広範だが異なるデータカバレッジがうまく転送される主な理由です。実際のクエリやデータはTPCCワークロードとは全く異なりますが、mysqldのほぼすべてのコードパスをウォークします。そして、制御フローの形状こそがPGOが最適化するものです。

なぜsysbenchでトレーニングしたビルドが負けるのか、そして正しく行う方法