C++ コードから最高のパフォーマンスを引き出すことは、細心のプロファイリング、複雑なメモリ アクセス調整、キャッシュの最適化を必要とする困難な作業です。これを少し簡素化するコツはありますか?幸いなことに、適切な洞察があり、何をしているのかを理解していれば、最小限の労力で大幅なパフォーマンスの向上を達成する近道があります。コンパイラの最適化を導入すると、コードのパフォーマンスが大幅に向上します。
最新のコンパイラは、特に自動並列化において、最適なパフォーマンスを目指すこの取り組みにおいて不可欠な協力者として機能します。これらの洗練されたツールは、特にループ内の複雑なコード パターンを精査し、最適化をシームレスに実行する能力を備えています。
この記事は、人気が高く広く使用されていることで知られるインテル C++ コンパイラーに焦点を当て、コンパイラー最適化の有効性に焦点を当てることを目的としています。
このストーリーでは、思ったよりも手作業での介入を必要とせず、コードを高性能の傑作に変換できるコンパイラの魔法の層を解き明かします。
ハイライト:コンパイラの最適化とは何ですか? | -オン |対象となるアーキテクチャ |プロシージャ間の最適化 | -fno-エイリアシング |コンパイラ最適化レポート
コンパイラの最適化には、コンパイラがコンパイル中にソース コードに適用するさまざまな手法と変換が含まれます。しかし、なぜ?パフォーマンス、効率を向上させ、場合によっては結果として得られるマシンコードのサイズを向上させるため。これらの最適化は、速度、メモリ使用量、エネルギー消費など、コード実行のさまざまな側面に影響を与える上で極めて重要です。
どのコンパイラも、高レベルのソース コードを低レベルのマシン コードに変換するための一連の手順を実行します。これらには、字句分析、構文分析、意味分析、中間コード生成 (または IR)、最適化、およびコード生成が含まれます。
最適化フェーズでは、コンパイラーはプログラムを変換する方法を細心の注意を払って探し、より少ないリソースを使用するか、より高速に実行する意味的に同等の出力を目指します。このプロセスで使用される手法には、定数の折りたたみ、ループの最適化、関数のインライン化、デッド コードの削除などが含まれますが、これらに限定されません。
利用可能なすべてのオプションについて説明するつもりはありませんが、コードのパフォーマンスを向上させる特定の最適化を実行するようにコンパイラーに指示する方法について説明します。それで、解決策は???コンパイラのフラグ。
開発者はコンパイル プロセス中にコンパイラ フラグのセットを指定できます。これは、GCC でデバッグやプロファイリング情報に「 -g」または「-pg」などのオプションを使用する人にはよく知られた手法です。先に進むにつれて、インテル C++ コンパイラーでアプリケーションをコンパイルする際に使用できる同様のコンパイラー・フラグについて説明します。これらは、コードの効率とパフォーマンスの向上に役立つ可能性があります。
無味乾燥な理論を掘り下げたり、すべてのコンパイラ フラグをリストした退屈なドキュメントを大量に提供したりするつもりはありません。代わりに、これらのフラグがなぜ、どのように機能するのかを理解してみましょう。
どうすればこれを達成できるでしょうか?
ヤコビ反復の計算を担当する最適化されていない C++ 関数を取り上げ、各コンパイラ フラグの影響を段階的に解明していきます。この探索に沿って、最適化フラグなし (-O0) から始めて、各反復をベース バージョンと体系的に比較することにより、速度向上を測定します。
高速化 (または実行時間) は、インテル® Xeon® Platinum 8174 プロセッサーマシンで測定されました。ここで、ヤコビ法は、長方形グリッド上の熱分布をモデル化するための 2 次元偏微分方程式 (ポアソン方程式) を解きます。
u(x,y,t) は、時刻 t における点 (x,y) の温度です。
分布が変化しなくなったときの安定状態を解決します。
一連のディリクレ境界条件が境界に適用されています。
基本的に、可変サイズのグリッド (解像度と呼びます) でヤコビ反復を実行する C++ コーディングがあります。基本的に、グリッド サイズ 500 は、サイズ 500x500 の行列を解くことを意味します。
1 回のヤコビ反復を実行する関数は次のとおりです。
/* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }
残差がしきい値に達するまで (ループ内で) ヤコビ反復を実行し続けます。残差の計算としきい値の評価はこの関数の外部で行われるため、ここでは関係ありません。それでは、部屋の中の象について話しましょう。
最適化を行わない場合 (-O0)、次の結果が得られます。
ここでは、MFLOP/秒の観点からパフォーマンスを測定します。これが比較の基礎となります。
MFLOP/s は、「1 秒あたり 100 万回の浮動小数点演算」を表します。これは、浮動小数点演算の観点からコンピュータまたはプロセッサのパフォーマンスを定量化するために使用される測定単位です。浮動小数点演算には、浮動小数点形式で表現された 10 進数または実数を使用した数学的計算が含まれます。
MFLOP/s は、特に複雑な数学的計算が普及している科学および工学アプリケーションで、ベンチマークまたはパフォーマンスの指標としてよく使用されます。 MFLOP/秒の値が高いほど、システムまたはプロセッサの浮動小数点演算の実行が速くなります。
注 1:安定した結果を提供するために、解像度ごとに実行可能ファイルを 5 回実行し、MFLOP/s 値の平均値を取得します。
注 2:インテル C++ コンパイラーのデフォルトの最適化は -O2 であることに注意することが重要です。したがって、ソース コードをコンパイルするときに -O0 を指定することが重要です。
さまざまなコンパイラ フラグを試してみると、実行時間がどのように変化するかを見てみましょう。
これらは、コンパイラの最適化を開始するときに最も一般的に使用されるコンパイラ フラグの一部です。理想的なケースでは、パフォーマンスはOfast > O3 > O2 > O1 > O0 です。ただし、これは必ずしも起こるわけではありません。これらのオプションの重要な点は次のとおりです。
-O1:
-O2:
-O3:
-オファスト:
公式ガイドでは、これらのオプションがどのような最適化を提供するのかについて詳しく説明しています。
Jacobi コードでこれらのオプションを使用すると、次の実行ランタイムが得られます。
これらすべての最適化が、ベース コード (「-O0」を使用) よりもはるかに高速であることは明らかです。実行ランタイムは、基本ケースより 2 ~ 3 倍短くなります。 MFLOP/秒はどうですか??
まあ、それは何かです!
基本ケースの MFLOP/s と最適化した場合の MFLOP/s の間には大きな違いがあります。
全体としては、わずかではありますが、「-O3」のパフォーマンスが最も優れています。
「 -Ofast 」(「 -no-prec-div -fp-model fast=2 」)で使用される追加のフラグは、さらなる高速化をもたらしません。
このマシンのアーキテクチャは、コンパイラの最適化に影響を与える極めて重要な要素として際立っています。コンパイラが利用可能な命令セットとハードウェアでサポートされる最適化 (ベクトル化や SIMD など) を認識している場合、パフォーマンスを大幅に向上させることができます。
たとえば、私の Skylake マシンには 3 つの SIMD ユニットがあります: 1 つの AVX 512 ユニットと 2 つの AVX-2 ユニット。
この知識を使って本当に何かできるでしょうか?
答えは戦略的なコンパイラ フラグにあります。 「 -xHost 」、より正確には「 -xCORE-AVX512 」などのオプションを試してみると、マシンの機能を最大限に活用し、最適なパフォーマンスを得るために最適化を調整できる可能性があります。
これらのフラグの概要を簡単に説明します。
-xホスト:
-xCORE-AVX512:
目標:インテル アドバンスト・ベクター・エクステンション 512 (AVX-512) 命令セットを利用するコードを生成するようにコンパイラーに明示的に指示します。
主な特長: AVX-512 は、AVX2 などの以前のバージョンと比較して、より幅広いベクトル レジスタと追加の演算を提供する高度な SIMD (単一命令、複数データ) 命令セットです。このフラグを有効にすると、コンパイラーはこれらの高度な機能を活用してパフォーマンスを最適化できます。
考慮事項:ここでも移植性が問題です。 AVX-512 命令で生成されたバイナリは、この命令セットをサポートしていないプロセッサでは最適に動作しない可能性があります。まったく機能しない可能性があります。
AVX-512 セット命令は、512 ビット幅のレジスタのセットである Zmm レジスタを使用します。これらのレジスタはベクトル処理の基礎として機能します。
デフォルトでは、「 -xCORE-AVX512 」は、プログラムが zmm レジスターの使用から恩恵を受ける可能性は低いと想定しています。コンパイラは、パフォーマンスの向上が保証されない限り、zmm レジスタの使用を回避します。
zmm レジスターを制限なしで使用する場合は、「 -qopt-zmm-usage 」を高く設定できます。私たちもそうするつもりです。
詳しい手順については公式ガイドを必ず確認してください。
これらのフラグがコードに対してどのように機能するかを見てみましょう。
うおおお!
現在、最小解像度の 1200 MFLOP/秒のマークを超えています。他の解像度の MFLOP/s 値も増加しています。
注目すべき点は、大幅な手動介入を行わずに、アプリケーションのコンパイル プロセス中にいくつかのコンパイラ フラグを組み込むだけで、これらの結果を達成したことです。
ただし、コンパイルされた実行可能ファイルは、同じ命令セットを使用するマシンとのみ互換性があることを強調することが重要です。
特定の命令セット用に最適化されたコードは、異なるハードウェア構成間での移植性を犠牲にする可能性があるため、最適化と移植性のトレードオフは明らかです。だから、自分が何をしているのかを必ず知ってください!!
注:ハードウェアが AVX-512 をサポートしていなくても心配する必要はありません。インテル C++ コンパイラーは、AVX、AVX-2、さらには SSE の最適化をサポートします。 ドキュメントには、知っておくべきことがすべて記載されています。
プロシージャ間の最適化には、個々の関数の範囲を超えて、複数の関数またはプロシージャにわたるコードの分析と変換が含まれます。
IPO は、プログラム内のさまざまな関数またはプロシージャ間の相互作用に焦点を当てた複数ステップのプロセスです。 IPO には、前方置換、間接呼び出し変換、インライン化など、さまざまな種類の最適化を含めることができます。
インテル コンパイラーは、単一ファイルのコンパイルと複数ファイルのコンパイル (プログラム全体の最適化) という 2 つの一般的なタイプの IPO をサポートしています [ 3 ]。それぞれを実行する 2 つの一般的なコンパイラ フラグがあります。
-ipo:
目標:プロシージャ間の最適化を有効にし、コンパイラがコンパイル中に個々のソース ファイルを超えてプログラム全体を分析および最適化できるようにします。
主な機能:-プログラム全体の最適化: 「 -ipo 」は、プログラム全体にわたる関数とプロシージャ間の相互作用を考慮して、すべてのソース ファイルにわたって分析と最適化を実行します。- 関数間およびモジュール間の最適化: フラグにより、関数のインライン化、同期が容易になります。最適化、およびさまざまなプログラム部分にわたるデータ フロー分析。
考慮事項:別のリンク手順が必要です。 「 -ipo 」でコンパイルした後、最終的な実行可能ファイルを生成するには、特定のリンク ステップが必要です。コンパイラは、リンク中にプログラム全体のビューに基づいて追加の最適化を実行します。
-ip:
目標:プロシージャ間の分析伝播を有効にし、コンパイラが別のリンク ステップを必要とせずにプロシージャ間の最適化を実行できるようにします。
主な機能:-分析と伝播: 「 -ip 」を使用すると、コンパイラはコンパイル中にさまざまな関数およびモジュール間で調査とデータ伝播を実行できます。ただし、完全なプログラム ビューを必要とするすべての最適化が実行されるわけではありません。 - コンパイルの高速化: 「 -ipo 」とは異なり、「 -ip 」では別個のリンク手順が必要ないため、コンパイル時間が短縮されます。これは、迅速なフィードバックが不可欠な開発中に有益です。
考慮事項:関数のインライン化など、一部の限られたプロシージャー間の最適化のみが行われます。
-ipo は一般に、別個のリンク ステップを必要とするため、より広範なプロシージャ間の最適化機能を提供しますが、コンパイル時間が長くなります。 [ 4 ]
-ip は、別のリンク ステップを必要とせずにプロシージャ間の最適化を実行する、より迅速な代替手段であり、開発およびテストのフェーズに適しています。[ 5 ]
ここではパフォーマンスとさまざまな最適化、コンパイル時間、または実行可能ファイルのサイズについてのみ説明しているため、「 -ipo 」に焦点を当てます。
上記の最適化はすべて、ハードウェアをどれだけよく理解し、どれだけ実験するかによって異なります。しかし、それだけではありません。コンパイラーがコードをどのように認識するかを特定しようとすると、他の潜在的な最適化が特定される可能性があります。
もう一度コードを見てみましょう。
/* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }
jacobi() 関数は、パラメータとして使用するいくつかのポインタを受け取り、ネストされた for ループ内で何かを実行します。コンパイラがソース ファイル内でこの関数を認識する場合は、細心の注意を払う必要があります。
なぜ??
uを使用してunnew を計算する式には、4 つの隣接するu値の平均が含まれます。 uとunnew の両方が同じ場所を指している場合はどうなりますか?これは、エイリアス化されたポインタの古典的な問題になります [ 7 ]。
最新のコンパイラは非常に賢く、安全性を確保するために、エイリアシングが可能であることを前提としています。また、このようなシナリオでは、コードのセマンティクスと出力に影響を与える可能性のある最適化が回避されます。
私たちの場合、 uとunew は異なるメモリ場所であり、異なる値を保存することを目的としていることがわかっています。したがって、ここにエイリアスが存在しないことをコンパイラに簡単に知らせることができます。
どうやってそれを行うのでしょうか?
方法は 2 つあります。 1 つ目は C の「 restrict 」キーワードです。ただし、コードを変更する必要があります。今のところそれは望んでいません。
何か簡単なことはありますか? 「 -fno-alias 」を試してみましょう。
-fno-エイリアス:
目標:プログラム内でエイリアスを想定しないようにコンパイラーに指示します。
主な機能:エイリアシングがないと仮定すると、コンパイラーはコードをより自由に最適化できるため、パフォーマンスが向上する可能性があります。
考慮事項:不当なエイリアスの場合、プログラムが予期しない出力を与える可能性があるため、開発者はこのフラグの使用に注意する必要があります。
詳細については、 公式ドキュメントを参照してください。
これは私たちのコードでどのように機能するのでしょうか?
さて、これで何かができました!!!
ここでは、以前の最適化のほぼ 3 倍という驚くべき高速化を達成しました。このブーストの背後にある秘密は何ですか?
エイリアスを想定しないようにコンパイラーに指示することで、強力なループ最適化を自由に実行できるようになりました。
アセンブリ コード (ただし、ここでは共有しません) と生成されたコンパイル最適化レポート (以下を参照) を詳しく調べると、コンパイラがループ交換とループ展開を巧みに応用していることがわかります。これらの変換は高度に最適化されたパフォーマンスに貢献し、コード効率に対するコンパイラ ディレクティブの大きな影響を示しています。
すべての最適化が相互にどのように実行されるかは次のとおりです。
インテル C++ コンパイラーは、最適化を目的として行われたすべての調整を要約した最適化レポートを生成できる貴重な機能を提供します [ 8 ]。この包括的なレポートは YAML ファイル形式で保存され、コード内でコンパイラーによって適用される最適化の詳細なリストが表示されます。詳細な説明については、「 -qopt-report 」に関する公式ドキュメントを参照してください。
実際に何もせずにコードのパフォーマンスを大幅に向上させることができるいくつかのコンパイラ フラグについて説明しました。唯一の前提条件は、やみくもに何もしないことです。自分が何をしているのか必ず理解してください!!
このようなコンパイラ フラグは何百もあり、この話ではそのうちのほんの一部について説明します。したがって、お好みのコンパイラの公式コンパイラ ガイド (特に最適化に関連するドキュメント) を参照する価値があります。
これらのコンパイラ フラグとは別に、ベクトル化、SIMD 組み込み、 プロファイル ガイド付き最適化、 ガイド付き自動並列処理など、コードのパフォーマンスを驚くほど向上させるテクニックが多数あります。
同様に、インテル C++ コンパイラー (およびすべての一般的なコンパイラー) は、非常に優れた機能であるプラグマ ディレクティブもサポートしています。 ivdep、Parallel、simd、vector などのいくつかのプラグマについては、 「Intel-Specific Pragma Reference」で確認する価値があります。
[2]カイザースラウテルン ランダウ大学の「Elwetrich」によるハイ パフォーマンス コンピューティング (rptu.de)
[6] SPEChpc で使用するインテル コンパイラー、最適化、およびその他のフラグ
UnsplashのIgor Omilaevによる注目の写真。
ここでも公開されています。