プログラムはまず正確性を保証する必要があり、正確性の保証に基づいてパフォーマンスも重要な考慮事項となります。高性能プログラムを作成するには、まず、適切なアルゴリズムとデータ構造を選択する必要があります。次に、コンパイラが効果的に最適化して効率的な実行可能コードに変換できるソース コードを作成する必要があります。これを行うには、コンパイラの機能とコンパイラの機能を理解する必要があります。制限、第三に、ハードウェアがどのように動作するかを理解し、ハードウェアの特性を最適化する必要があります。この記事では 2 番目と 3 番目の点に焦点を当てます。
コンパイラを簡単に理解する
高性能のコードを作成するには、まずコンパイラの基本を理解する必要があります。その理由は、最新のコンパイラは強力な最適化機能を備えていますが、一部のコード コンパイラは最適化できないからです。コンパイラの基本を理解していなければ、コンパイラに適した高パフォーマンスのコードを作成できます。
コンパイラ最適化オプション
GCC
たとえば、GCC は次の最適化レベルをサポートしています。
-
-O<数値>、数値は 0/1/2/3 で、数値が大きいほど最適化レベルが高くなります。デフォルトは -O0 です。
-
-Ofast は、-O3 のすべての最適化オプションをオンにすることに加えて、-ffast-math および -fallow-store-data-races もオンにします。これら 2 つのオプションにより、プログラム実行エラーが発生する可能性があることに注意してください。
-ffast-math: オプション -fno-math-errno、-funsafe-math-optimizations、-ffinite-math-only、-fno-rounding-math、-fno-signaling-nans、-fcx-limited-range、および-fexcess-precision=高速。IEEE または ISO の数学関数の規則/仕様の正確な実装に依存するプログラムでは、不正確な出力が発生する可能性があります。ただし、これらの仕様の保証を必要としないプログラムでは、より高速なコードが生成される可能性があります。
-fallow-store-data-races: 変数が他のスレッドから同時にアクセスできないことを証明することなく、ストアで新しいデータ競合を引き起こす可能性のある最適化をコンパイラーが実行できるようにします。ローカル データの最適化には影響しません。グローバル データが複数のスレッドによってアクセスされないことがわかっている場合は、このオプションを安全に使用できます。
- -Og、コードのデバッグ時に推奨される最適化レベル。
gcc -Q --help=optimizer -Ox は、各最適化レベルで有効になっている最適化オプションを表示できます。
参考リンク:https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
コンパイラの制限事項
プログラム動作の正確性を保証するために、コンパイラはコードの使用シナリオについていかなる仮定も行わないため、一部のコード コンパイラは最適化を行いません。さらに 2 つのあいまいな例を次に示します。
1、メモリエイリアシング
void twiddle1(long *xp, long *yp) {
*xp += *yp;
*xp += *yp;
}
void twiddle2(long *xp, long *yp) {
*xp += 2 * *yp;
}
xp
と がyp
同じメモリを指している場合(メモリ エイリアス)、twiddle1
と はtwiddle2
2 つの完全に異なる関数であるため、コンパイラはtwiddle1
に最適化しようとしませんtwiddle2
。本来の目的が の機能を実現したいのであれば、の代わりにの形twiddle2
で書くべきで、読み込み4回、書き込み2回必要なところ、読み込み2回、書き込み1回で済みます。twiddle2
twwidle1
twiddle2
twiddle1
__restrict
明示的に を使用してポインタを変更し、変更されたポインタと同じメモリを指すポインタがないことを示すことができます。この場合、コンパイラはとtwiddle3
と同等になるように最適化しますtwiddle2
。さらに理解を深めるために、逆アセンブリを通じてアセンブリ コードを観察できます。
void twiddle3(long *__restrict xp, long *__restrict yp) {
*xp += *yp;
*xp += *yp;
}
2、副作用
long f();
long func1() {
return f() + f() + f() + f();
}
long func2() {
return 4 * f();
}
f
関数の実装は以下のようなものが存在する可能性があるためside effect
、コンパイラはfunc1
最適化を行いませんfunc2
。本来の目的がfunc2
バージョンを実装することである場合は、func2
フォームに直接記述する必要があり、これにより 3 つの関数呼び出しを減らすことができます。
long counter = 0;
long f() {
return counter++;
}
プログラムのパフォーマンスの最適化
これを導入する前に、まずプログラム パフォーマンス メトリックを導入します每元素的周期数(Cycles Per Element, CPE)
。これは要素の処理にかかるサイクル数であり、プログラムのパフォーマンスを表し、パフォーマンスの最適化をガイドできます。
以下では例を使用して、プログラムのパフォーマンスを最適化するいくつかの手段を紹介します。最初にデータ構造ベクトルといくつかの補助関数を定義します。ベクトルは連続的に格納される配列を使用して実装され、typedef
要素のデータ型は を通じて指定できますdata_t
。
typedef struct {
long len;
data_t *data;
} vec_rec, *vec_ptr;
/* 创建vector */
vec_ptr new_vec(long len) {
vec_ptr result = (vec_ptr)malloc(sizeof(vec_rec));
if (!result)
return NULL;
data_t *data = NULL;
result->len = len;
if (len > 0) {
data = (data_t*)calloc(len, sizeof(data_t));
if (!data) {
free(result);
return NULL;
}
}
result->data = data;
return result;
}
/* 根据index获取vector元素 */
int get_vec_element(vec_ptr v, long index, data_t *dest) {
if (index < 0 || index >= v->len)
return 0;
*dest = v->data[index];
return 1;
}
/* 获取vector元素个数 */
long vec_length(vec_ptr v) {
return v->len;
}
次の関数の機能は、何らかの演算を使用して、ベクトル内のすべての要素を 1 つの要素に結合することです。以下のIDENT
と はOP
マクロ定義であり、累積演算を行い、#define IDENT 0
累積乗算演算を行います。#define OP +
#define IDENT 1
#define OP *
void combine1(vec_ptr v, data_t *dest) {
long i;
*dest = IDENT;
for (i = 0; i < vec_length(v); i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest OP val;
}
}
上記に対してcombine1
、次の 3 つの基本的な最適化を実行できます。
1. 複数回実行されて同じ結果が返される関数については、一時変数を使用して保存します。
combine1
の実装は、ループ テスト条件で関数を繰り返し呼び出しますvec_length
。このシナリオでは、 を複数回呼び出しても同じ結果が返されるため、最適化のための実装vec_length
として書き換えることができます。combine2
極端な場合には、同じ結果を返す関数を繰り返し呼び出さないように注意した方が効率的です。たとえば、文字列の長さをテストする関数がループ終了条件で呼び出された場合、その関数の時間計算量は通常 0 になります。文字列の長さがO(n)
変わらないことが明らかな場合、繰り返し呼び出しによってエラーが発生します。追加のオーバーヘッドが大量に発生します。
void combine2(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
*dest = IDENT;
for (i = 0; i < length; i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest OP val;
}
}
2. プロシージャコールを減らす
プロシージャ (関数) 呼び出しでは、パラメータの受け渡し、クロバー レジスタの保存と復元、制御の転送など、特定のオーバーヘッドが発生します。get_vec_start
したがって、配列の先頭へのポインタを返す関数を追加し、ループ内での関数の呼び出しを回避できますget_vec_element
。この最適化にはトレードオフがあり、プログラムのパフォーマンスを向上できる一方で、ベクトル データ構造の実装の詳細を知る必要があるため、プログラムの抽象化が破壊されます。配列を使用せずにデータを格納するようにベクターを変更したら、同時に の実装を変更する必要がありますcombine3
。
data_t *get_vec_start(vec_ptr v) {
return v->data;
}
void combine3(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
data_t *data = get_vec_start(v);
*dest = IDENT;
for (i = 0; i < length; i++) {
*dest = *dest OP data[i];
}
}
3. 不要なメモリ参照を削除する
上記の実装では、各ループで 1 回の読み取りと 1 回の書き込みがdest
行われる可能性があるためmemory aliasing
、コンパイラは慎重に最適化します。以下に、最適化レベル-O1
と-O2
最適化レベルcombine3
におけるループ部分のアセンブリ コードを示しますfor
。-O2 最適化が有効になっている場合、コンパイラは、-O1 最適化が有効になっている場合のように毎回メモリから読み取るのではなく、中間結果を一時変数 (レジスタ %xmm0) に保存するのに役立ちます。memory aliasing
-O2 最適化ではループごとに中間結果をメモリに保存する必要がある場合でも。
// combine3 -O1
.L1:
vmovsd (%rbx), %xmm0
vmulsd (%rdx), %xmm0, %xmm0
vmovsd %xmm0, (%rbx)
addq $8, %rdx
cmpq %rax, %rdx
jne .L1
// combine3 -O2
.L1
vmulsd (%rdx), %xmm0, %xmm0
addq $8, %rdx
cmpq %rax, %rdx
vmovsd %xmm0, (%rbx)
jne .L1
メモリの頻繁な読み取りと書き込みを避けるために、combine4
図に示すように、一時変数を人為的に使用して中間結果を保存できます。
void combine4(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
data_t *data = get_vec_start(v);
data_t acc = IDENT;
for (i = 0; i < length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}
// combine4 -O1
.L1
vmulsd (%rdx), %xmm0, %xmm0
addq $8, %rdx
cmpq %rax, %rdx
jne .L1
上記の最適化手法の効果は、CPE によって測定できます。Intel Core i7 Haswell でのテスト結果は次のとおりです。テスト結果から判断すると:
-
combin1 バージョンにはさまざまなコンパイル最適化レベルがあり、-O1 のパフォーマンスは -O0 の 2 倍であり、適切なコンパイル最適化レベルをオンにする必要があることを示しています。
-
combin2 が vec_length をループの外に移動し、同じ最適化レベルでコンパイルされると、combine1 と比較してパフォーマンスがわずかに向上します。
-
ただし、combine3 は、combine2 と比較してパフォーマンスの向上がありません。その理由は、get_vec_element の呼び出しにかかる時間をループ内の他の操作の時間でカバーできるためです。カバーできる理由は、CPU サポートとこれら 2 つは、この
分支预测
記事の後半で簡単に紹介します乱序执行
。 -
同様に、combine3 の -O2 バージョンは、-O1 バージョンよりもはるかに優れたパフォーマンスを持っています。アセンブリ コードからわかるように、-O2 は、-O1 と比較して、(%rbx) の読み取りをサイクルごとに 1 回削減します。さらに重要なのは、これにより、(%rbx) リードアフターライトのメモリアクセス依存性が不要になります。
-
中間結果を一時変数に一時的に格納するためにcombine4を最適化した後、-O1のコンパイル最適化を使用した場合でも、コンパイル最適化のパフォーマンスはcombine3 -O2のパフォーマンスより優れていることがわかります。強力な最適化機能を備えているため、細部に注意を払う必要があり、高パフォーマンスのコードを記述することも非常に必要です。
次のテスト データは、『コンピュータ システムの徹底理解』の第 5 章から引用されています。
関数 | 最適化 | 整数 + | int * | 浮動小数点 + | 浮く * |
---|---|---|---|---|---|
結合1 | -O0 | 22.68 | 20.02 | 19.98 | 20.18 |
結合1 | -O1 | 10.12 | 10.12 | 10.17 | 11.14 |
結合2 | 移動 vec_length -O1 | 7.02 | 9.03 | 9.02 | 11.03 |
結合3 | プロシージャコールを減らす -O1 | 7.17 | 9.02 | 9.02 | 11.03 |
結合3 | プロシージャコールを減らす -O2 | 1.60 | 3.01 | 3.01 | 5.01 |
結合4 | 一時変数 -O1 に蓄積 | 1.27 | 3.01 | 3.01 | 5.01 |
命令レベルの並列処理
上記の最適化は、ターゲット マシンの特性に依存せず、プロシージャ呼び出しのオーバーヘッドを削減し、コンパイラの最適化を困難にする「最適化の阻害要因」を取り除くだけです。さらに最適化するには、いくつかのハードウェア特性を理解する必要があります。次の図は、Intel Core i7 Haswell のハードウェア構造のバックエンド部分です。
Intel Core i7 Haswell の完全なハードウェア構造については、https: //en.wikichip.org/w/images/c/c7/haswell_block_diagram.svgを参照してください。
ハードウェア性能
この CPU は次の機能をサポートしています。
-
命令レベルの並列処理: つまり、命令パイプライン技術を通じて、複数の命令の同時評価をサポートします。
-
アウトオブオーダー実行: 命令の実行順序は、命令が書き込まれた順序と一致しない場合があり、これによりハードウェアがより優れた命令レベルの並列処理を実現できる可能性があります。主にアウトオブオーダー実行とシーケンシャルサブミットの仕組みにより、シーケンシャル実行と一致した結果を得ることが可能です。
-
分岐予測: 分岐が発生すると、ハードウェアは分岐の方向を予測します。予測が成功すると、プログラムの実行を高速化できます。ただし、予測が失敗した場合は、早期の実行結果を確認する必要があります。正しい命令を実行するために破棄され、再ロードされるため、比較的大きな結果が生じます。予測エラーのペナルティ。
上の図では、複数の機能ユニットで構成される実行ユニット (EU) に主に焦点が当てられています。機能ユニットのパフォーマンスは延迟
、发射时间
およびによって測定できます容量
。
-
レイテンシ: 命令を完了するために必要なクロック サイクル数。
-
起動時間: 同じタイプの 2 つの連続した操作の間に必要なクロック サイクルの最小数。
-
容量: 特定のタイプの実行ユニットの数。上の図からわかるように、
EUs
4 つの整数加算ユニット (INT ALU)、1 つの整数乗算ユニット (INT MUL)、1 つの浮動小数点加算ユニット (FP ADD)、および 2 つの浮動小数点乗算ユニット (FP MUL) があります。
Intel Core i7 Haswell の機能ユニットのパフォーマンスデータ (単位はサイクル) は次のとおりです (『コンピュータシステムの徹底理解』の第 5 章から引用)。
手術 | 遅延(int) | 起動時間(int) | 容量(int) | ディレイ(フロート) | 発光時間(浮動小数点数) | 容量(フロート) |
---|---|---|---|---|---|---|
追加 | 1 | 1 | 4 | 3 | 1 | 1 |
乗算 | 3 | 1 | 1 | 5 | 1 | 2 |
これらの算術演算のレイテンシー、起動時間、および容量は、combine
上記の関数のパフォーマンスに影響を与えるため、この影響を説明するために CPE の 2 つの境界を使用します。スループット限界は、理論上の最適なパフォーマンスです。
-
遅延限界: 操作を厳密な順序で完了する必要がある関数
combine
に必要な最小 CPE 、機能単位の遅延に等しい。 -
スループット限界: 機能ユニットが結果を生成できる最大速度。によって
容量/发射时间
決定されます。CPE メトリックを使用する場合、それは の容量/发射时间
逆数に等しくなります。
この関数はデータをロードする必要があるためcombine
、ロード単位の制限も受けます。ロード ユニットが 2 つしかなく、それらの放出時間が 1 サイクルであるため、この場合、整数加算のスループット限界は 0.25 ではなく 0.5 のみになります。
限界 | 整数 + | int * | 浮動小数点 + | 浮く * |
---|---|---|---|---|
遅れ | 1.0 | 3.0 | 3.0 | 5.0 |
ためらい | 0.5 | 1.0 | 1.0 | 0.5 |
プロセッサ動作の抽象モデル
最新のプロセッサ上で実行されるマシンレベルのプログラムのパフォーマンスを分析するために、数据流图
さまざまな操作間のデータの依存関係が実行順序をどのように制限するかをグラフィカルに表現した を導入します。关键路径
これらの制限は、一連の機械命令を実行するために必要なクロック サイクルの下限である図 を形成します。
通常、for ループはプログラムの実行時間のほとんどを占めますが、次の図はcombine4
for ループに対応するデータ フロー図です。矢印はデータの流れを示します。レジスタは 4 つのカテゴリに分類できます。
-
読み取り専用: これらのレジスタはソース値としてのみ使用され、この場合、ループ中には変更されません
%rax
。 -
書き込み専用: データ転送目的。この例にはそのようなレジスタはありません。
-
ローカル: ループ内で変更および使用され、反復間には関係なく、条件コードは比例して登録されます。
-
ループ: これらのレジスタは、ソース値と宛先値の両方として機能します。1 つの反復で生成された値は、次の反復で使用されます (この場合は と
%rdx
)%xmm0
。このようなレジスタでの操作は、反復間のデータの依存関係により、プログラムのパフォーマンスを制限する要因となることがよくあります。
上図を整理し、ループレジスタに関係するパスのみを残すと、簡略化されたデータフロー図が得られます。
以下に示すように、単純化されたデータ フロー図を繰り返すだけでクリティカル パスを取得できます。計算が浮動小数点乗算の場合combine4
、命令レベルの並列処理のサポートにより、浮動小数点乗算の遅延が整数加算 (ポインタの移動、図の右半分のパス) の遅延をカバーできるため、 CPE の理論的な下限は浮動小数点乗算の遅延combine4
.5.0 であり、これは基本的に上記のテスト データ 5.01 と一致します。
ループ展開
これまでのところ、プログラムのパフォーマンスはレイテンシの限界に達しているだけですが、次の浮動小数点乗算は最後の乗算が完了するまで待たなければならず、ハードウェアの命令レベルの並列性を十分に活用できないためです。ループアンローリング技術を利用することで、クリティカルパスの命令並列性を向上させることができます。
void combine5(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
long limit = length - 1;
data_t *data = get_vec_start(v);
data_t acc0 = IDENT;
data_t acc1 = IDENT;
for (i = 0; i < limit; i += 2) {
acc0 = acc0 OP data[i];
acc1 = acc1 OP data[i + 1];
}
for (; i < length; ++i) {
acc0 = acc0 OP data[i];
}
*dest = acc0 OP acc1;
}
combine5
クリティカル パスのデータ フロー図は次のとおりです。図には 2 つのクリティカル パスがありますが、2 つのクリティカル パスは命令レベルで並列化できます。各クリティカル パスには 1 つの演算しか含まれていないため、パフォーマンスは遅延を突破できます。理論的には、浮動小数点n/2
乗算の CPE はおよそ です5.0/2=2.5
。
一時変数の数が増加し、ループ展開の数がさらに増加すると、理論的には命令の並列度が増加し、最終的にはスループットの制限に達する可能性があります。ただし、ループ展開の数は無制限に増やすことはできません。まず、ハードウェアの機能単位が限られているため、CPE の下限はスループットの制限によって制限されます。あるレベルに達した後、増やし続けても度合いを向上させることはできません。命令の並列性の低下、第 2 に、レジスタ リソースが限られているため、ループ アンローリングの回数が増加し、レジスタの使用量が増加します。使用されるレジスタの数がハードウェアによって提供されるレジスタ リソースを超えると、レジスタ オーバーフローが発生します。レジスタメモリを一時的にメモリに保存し、使用時にメモリからレジスタに戻すため、パフォーマンスが低下します。下表に示すように、ループを 20 回展開した場合のパフォーマンスは、アンロールした場合よりもわずかに低下します。 10回。幸いなことに、ほとんどのハードウェアはレジスタ オーバーフローが発生する前にスループットの限界に達します。
関数 | 拡張時間 | 整数 + | int * | 浮動小数点 + | 浮く * |
---|---|---|---|---|---|
結合5 | 2 | 0.81 | 1.51 | 1.51 | 2.51 |
結合5 | 10 | 0.55 | 1.00 | 1.01 | 0.52 |
結合5 | 20 | 0.83 | 1.03 | 1.02 | 0.68 |
遅延限界 | / | 1.00 | 3.00 | 3.00 | 5.00 |
スループット限界 | / | 0.50 | 1.00 | 1.00 | 0.50 |
SIMD(シングルインストラクションマルチデータ)
SIMD
これは、命令レベルの並列処理とは異なる、もう 1 つの効果的なパフォーマンス最適化手法です数据级并行
。SIMD は Single structive Multiple Data の略で、1 つの命令で一連のベクトル データを操作し、ハードウェア サポートが必要です。X86 アーキテクチャ CPU は AVX 命令セットをサポートし、ARM CPU は NEON 命令セットをサポートします。当社が開発したディープラーニングコンパイラMegCCでは、SIMD技術が広く使われています。MegCCは、Megvii Tianyuan チームによって開発された深層学習コンパイラーです。MegEngine 形式のモデルを入力として受け入れ、モデルの実行に必要なすべてのカーネルを出力するため、モデルのデプロイが容易になります。高性能かつ軽量です。ユーザーが他の形式のモデルを MegEngine 形式のモデルに変換しやすくするために、Megvii Tianyuan チームはモデル変換ツール MgeConvert も提供しています。モデルを変換してから、MgeConvert を使用して MegEngine 形式のモデルに変換できますonnx
。同時に、デバイス上の特定の命令のスループットとレイテンシをテストして最適化をガイドしたい場合は、MegPeakを使用できます。
MegCC には、多くの高性能深層学習演算子が実装されています。畳み込みと行列乗算は、典型的な計算集約型演算子です。同時に、行列乗算 (im2col/winograd アルゴリズムなど) を利用して畳み込み演算を実装することもできます。
MegCC は、 ARM プラットフォーム上の NEON DOTおよびI8MM命令によって実装される行列の乗算と畳み込みをサポートします。1 つのDOT
命令で 32 回の乗算と加算 (16 回の乗算と 16 回の加算) の乗算と加算を実行でき、1 つのI8MM
命令で 64 回の乗算と加算の演算 (32 回の乗算と 32 回の加算) を実行できます。これが SIMD テクノロジーによってコンピューティングを高速化できる方法です。
参考文献
-
ランダル・E・ブライアント、デヴィッド・R・オハラロン。コンピュータ システム: プログラマーの視点、第 5 章。
-
アントニオ・ゴンサレス、フェルナンド・ラトーレ、グリゴリオス・マグリス。プロセッサーのマイクロアーキテクチャ: 実装の観点、第 1 章。