勉強して読んだ記事を転載してよく読んでいると、たくさんのメリットがあります!
私たちのプログラムの目的は、どのような状況でも安定して動作するようにすることです。高速に実行されても間違っていることが判明したプログラムは役に立ちません。プログラムの開発と最適化の過程で、コードの使用方法とそれに影響を与える重要な要素を考慮する必要があります。通常、プログラムの単純さとその実行速度の間でトレードオフを行う必要があります。今日は、プログラムのパフォーマンスを最適化する方法について説明します。
1.プログラム計算の量を減らします
1.1サンプルコード
for (i = 0; i < n; i++) {
int ni = n*i;
for (j = 0; j < n; j++)
a[ni + j] = b[j];
}
1.2分析コード
コードは上に示されています。外側のループが実行されるたびに、乗算計算を実行する必要があります。i = 0、ni = 0; i = 1、ni = n; i = 2、ni = 2n。したがって、ステップサイズとしてnを使用して、乗算を加算に置き換えることができます。これにより、外側のループのコード量が削減されます。
1.3コードを改善する
int ni = 0;
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++)
a[ni + j] = b[j];
ni += n; //乘法改加法
}
コンピューターでの乗算命令は、加算命令よりもはるかに低速です。
2.コードの共通部分を抽出します
2.1サンプルコード
画像があると想像してください。画像を2次元配列として表し、配列要素はピクセルを表します。特定のピクセルについて、東、南、西、北の4つの隣接ピクセルの合計を取得します。そして、それらの平均またはそれらの合計を見つけます。コードを以下に示します。
up = val[(i-1)*n + j ];
down = val[(i+1)*n + j ];
left = val[i*n + j-1];
right = val[i*n + j+1];
sum = up + down + left + right;
2.2分析コード
上記のコードをコンパイルすると、アセンブリコードは次のようになります。3、4、5行目には、nを乗算する3つの乗算演算があることに注意してください。上記を上下に展開すると、4セル式にi * n + jが存在することがわかります。したがって、共通部分を抽出することができ、加算および減算操作によって上、下などの値を取得できます。
leaq 1(%rsi), %rax # i+1
leaq -1(%rsi), %r8 # i-1
imulq %rcx, %rsi # i*n
imulq %rcx, %rax # (i+1)*n
imulq %rcx, %r8 # (i-1)*n
addq %rdx, %rsi # i*n+j
addq %rdx, %rax # (i+1)*n+j
addq %rdx, %r8 # (i-1)*n+j
2.3コードを改善する
long inj = i*n + j;
up = val[inj - n];
down = val[inj + n];
left = val[inj - 1];
right = val[inj + 1];
sum = up + down + left + right;
改善されたコードのコンパイルを以下に示します。コンパイル後の乗算は1つだけです。6クロックサイクル減少します(乗算サイクルは約3クロックサイクルです)。
imulq %rcx, %rsi # i*n
addq %rdx, %rsi # i*n+j
movq %rsi, %rax # i*n+j
subq %rcx, %rax # i*n+j-n
leaq (%rsi,%rcx), %rcx # i*n+j+n
...
GCCコンパイラの場合、コンパイラはさまざまな最適化レベルに応じてさまざまな最適化方法を使用でき、上記の最適化操作を自動的に完了します。以下に紹介するように、これらは手動で最適化する必要があります。
3.ループ内の非効率的なコードを排除します
3.1サンプルコード
プログラムは問題ではないようですが、非常に一般的なケースの変換コードですが、文字列入力の長さが長くなるにつれて、コードの実行時間が指数関数的に増加するのはなぜですか?
void lower1(char *s)
{
size_t i;
for (i = 0; i < strlen(s); i++)
if (s[i] >= 'A' && s[i] <= 'Z')
s[i] -= ('A' - 'a');
}
3.2分析コード
次に、コードをテストし、一連の文字列を入力します。
Lower1コードのパフォーマンステスト
入力文字列の長さが100000未満の場合、プログラムの実行時間にほとんど違いはありません。ただし、文字列の長さが長くなると、プログラムの実行時間は指数関数的に増加します。
goto形式に変換されたコードを見てみましょう。
void lower1(char *s)
{
size_t i = 0;
if (i >= strlen(s))
goto done;
loop:
if (s[i] >= 'A' && s[i] <= 'Z')
s[i] -= ('A' - 'a');
i++;
if (i < strlen(s))
goto loop;
done:
}
上記のコードは、初期化(3行目)、テスト(4行目)、更新(9、10行目)の3つの部分に分かれています。初期化は1回だけ実行されます。ただし、テストと更新は毎回実行されます。Strlenは、ループが実行されるたびに1回呼び出されます。
strlen関数のソースコードが文字列の長さを計算する方法を見てみましょう。
size_t strlen(const char *s)
{
size_t length = 0;
while (*s != '\0') {
s++;
length++;
}
return length;
}
文字列の長さを計算するstrlen関数の原則は、文字列をトラバースし、「\ 0」に遭遇するまで停止することです。したがって、strlen関数の時間計算量はO(N)です。lower1では、長さNの文字列の場合、strlenへの呼び出しの数はN、N-1、N-2 ... 1です。N回の線形時間関数呼び出しの場合、時間計算量はO(N2)に近くなります。
3.3コードを改善する
ループに現れるこの種の冗長な呼び出しについては、ループの外に移動できます。計算結果をループで使用します。改善されたコードを以下に示します。
void lower2(char *s)
{
size_t i;
size_t len = strlen(s);
for (i = 0; i < len; i++)
if (s[i] >= 'A' && s[i] <= 'Z')
s[i] -= ('A' - 'a');
}
次の図に示すように、2つの関数を比較します。lower2関数の実行時間が大幅に改善されました。
Lower1およびlower2のコード効率
4.不要なメモリ参照を排除します
4.1サンプルコード
次のコードは、配列の各行のすべての要素の合計を計算し、それをb [i]に格納するために使用されます。
void sum_rows1(double *a, double *b, long n) {
long i, j;
for (i = 0; i < n; i++) {
b[i] = 0;
for (j = 0; j < n; j++)
b[i] += a[i*n + j];
}
}
4.2分析コード
アセンブリコードを以下に示します。
# sum_rows1 inner loop
.L4:
movsd (%rsi,%rax,8), %xmm0 # 从内存中读取某个值放到%xmm0
addsd (%rdi), %xmm0 # %xmm0 加上某个值
movsd %xmm0, (%rsi,%rax,8) # %xmm0 的值写回内存,其实就是b[i]
addq $8, %rdi
cmpq %rcx, %rdi
jne .L4
これは、各ループがメモリからb [i]を読み取り、次にb [i]をメモリに書き戻す必要があることを意味します。b [i] + = b [i] + a [i * n + j];実際、各ループの開始時に、b [i]が最後の値です。なぜメモリから読み取り、毎回書き戻す必要があるのですか?
4.3コードを改善する
/* Sum rows is of n X n matrix a
and store in vector b */
void sum_rows2(double *a, double *b, long n) {
long i, j;
for (i = 0; i < n; i++) {
double val = 0;
for (j = 0; j < n; j++)
val += a[i*n + j];
b[i] = val;
}
}
アセンブリを以下に示します。
# sum_rows2 inner loop
.L10:
addsd (%rdi), %xmm0 # FP load + add
addq $8, %rdi
cmpq %rax, %rdi
jne .L10
改善されたコードは、中間結果を格納するための一時変数を導入し、最終値が計算されるときにのみ結果を配列またはグローバル変数に格納します。
5.不要な通話を減らす
5.1サンプルコード
例として、配列と配列の長さを含む構造を定義します。これは主に、配列へのアクセスが範囲外になるのを防ぐためです。data_tはint、long、その他のタイプにすることができます。詳細は以下のとおりです。
typedef struct{
size_t len;
data_t *data;
} vec;
vecベクトル図
get_vec_elementの機能は、データ配列内の要素をトラバースし、それらをvalに格納することです。
int get_vec_element (*vec v, size_t idx, data_t *val)
{
if (idx >= v->len)
return 0;
*val = v->data[idx];
return 1;
}
次のコードを例として使用して、プログラムの最適化を段階的に開始します。
void combine1(vec_ptr v, data_t *dest)
{
long int i;
*dest = NULL;
for (i = 0; i < vec_length(v); i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest * val;
}
}
5.2分析コード
get_vec_element関数の関数は、次の要素を取得することです。get_vec_element関数では、境界を越えないように、各サイクルをv-> lenと比較する必要があります。境界チェックを実行するのは良い習慣ですが、毎回実行すると効率が低下します。
5.3コードを改善する
ベクトルの長さを計算するためのコードをループの外に移動し、関数get_vec_startを抽象データ型に追加できます。この関数は、配列の開始アドレスを返します。このように、ループ本体には関数呼び出しはありませんが、配列に直接アクセスします。
data_t *get_vec_start(vec_ptr v)
{
return v-data;
}
void combine2 (vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
data_t *data = get_vec_start(v);
*dest = NULL;
for (i=0;i < length;i++)
{
*dest = *dest * data[i];
}
}
6.ループ展開
6.1サンプルコード
Combine2のコードを改善します。
6.2分析コード
アンローリング増加の各反復要素の数を、低減ループ反復を。
6.3コードを改善する
void combine3(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 acc = NULL;
/* 一次循环处理两个元素 */
for (i = 0; i < limit; i+=2) {
acc = (acc * data[i]) * data[i+1];
}
/* 完成剩余数组元素的计算 */
for (; i < length; i++) {
acc = acc * data[i];
}
*dest = acc;
}
改善されたコードでは、最初のループが配列の2つの要素を一度に処理します。つまり、反復ごとに、ループインデックスiが2ずつ増加し、1回の反復で、マージ操作が配列要素iとi +1で使用されます。一般に、これを2×1ループ展開と呼びます。この変換により、ループオーバーヘッドの影響を減らすことができます。
アクセスの制限を超えないように注意し、制限を正しく設定します。n個の要素、通常は制限n-1を設定します。
7.変数の累積、マルチチャネル並列
7.1サンプルコード
Combine3のコードを改善します。
7.2分析コード
整数の加算や乗算などの結合可能で可換な組み合わせ演算の場合、組み合わせ演算のセットを2つ以上の部分に分割し、最後に結果を結合することで、パフォーマンスを向上させることができます。
特別な注意:浮動小数点数を簡単に組み合わせないでください。浮動小数点数のエンコード形式は、他の整数とは異なります。
7.3コードを改善する
void combine4(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 = 0;
data_t acc1 = 0;
/* 循环展开,并维护两个累计变量 */
for (i = 0; i < limit; i+=2) {
acc0 = acc0 * data[i];
acc1 = acc1 * data[i+1];
}
/* 完成剩余数组元素的计算 */
for (; i < length; i++) {
acc0 = acc0 * data[i];
}
*dest = acc0 * acc1;
}
上記のコードは、2つのループ展開を使用して、各反復でより多くの要素をマージします。また、2つの並列パスを使用して、変数acc0に偶数のインデックスを持つ要素を累積し、奇数のインデックスを持つ要素を変数acc1に累積します。したがって、これを「2×2ループ展開」と呼びます。2×2ループ展開を使用します。複数の累積変数を維持することにより、このメソッドは複数の機能ユニットとそのパイプライン機能を利用します
8.組換えと変換
8.1サンプルコード
Combine3のコードを改善します。
8.2分析コード
この時点で、コードのパフォーマンスは基本的に制限に近づいています。ループ展開をさらに行っても、パフォーマンスの向上は明らかではありません。考え方を変える必要があります。combine3コードの12行目のコードに注意してください。次のベクトルの要素をマージする順序を変更できます(浮動小数点数は適用されません)。組換え前のcombine3コードのキーパスを次の図に示します。
Combine3コードのクリティカルパス
8.3コードを改善する
void combine7(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 acc = IDENT;
/* Combine 2 elements at a time */
for (i = 0; i < limit; i+=2) {
acc = acc OP (data[i] OP data[i+1]);
}
/* Finish any remaining elements */
for (; i < length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}
変換を再結合すると、計算のクリティカルパスでの操作の数を減らすことができます。この方法では、並行して実行できる操作の数が増え、機能ユニットのパイプライン機能をより有効に活用してパフォーマンスを向上させることができます。再結合後のクリティカルパスは次のとおりです。
Combine3組換え後のクリティカルパス
9条件付き転送スタイルコード
9.1サンプルコード
void minmax1(long a[],long b[],long n){
long i;
for(i = 0;i,n;i++){
if(a[i]>b[i]){
long t = a[i];
a[i] = b[i];
b[i] = t;
}
}
}
9.2分析コード
最新のプロセッサのパイプラインパフォーマンスにより、プロセッサの作業は現在実行されている命令よりもはるかに進んでいます。プロセッサの分岐予測は、比較命令に遭遇したときに次にジャンプする場所を予測します。予測が間違っている場合は、ブランチがジャンプした場所に戻る必要があります。分岐予測エラーは、プログラムの実行効率に深刻な影響を及ぼします。したがって、プロセッサが予測精度を向上できるようにする、つまり条件付き転送命令を使用できるようにするコードを作成する必要があります。改善されたコードに示すように、条件付き操作を使用して値を計算し、次にこれらの値を使用してプログラムの状態を更新します。
9.3コードを改善する
void minmax2(long a[],long b[],long n){
long i;
for(i = 0;i,n;i++){
long min = a[i] < b[i] ? a[i]:b[i];
long max = a[i] < b[i] ? b[i]:a[i];
a[i] = min;
b[i] = max;
}
}
元のコードの4行目で、a [i]とb [i]を比較してから、次のステップを実行する必要があります。その結果、毎回予測を行う必要があります。改善されたコードは、この関数を実装して、各位置iの最大値と最小値を計算し、分岐予測の代わりにこれらの値をa [i]とb [i]に割り当てます。
10.まとめ
コードの効率を改善するためにいくつかの手法を導入しました。そのうちのいくつかはコンパイラーによって自動的に最適化でき、いくつかは自分で実装する必要があります。まとめると次のようになります。
-
連続する関数呼び出しを排除します。可能であれば、計算をループの外に移動します。効率を高めるために、プログラムのモジュール性を選択的に妥協することを検討してください。
-
不要なメモリ参照を排除します。中間結果を保存するために一時変数を導入します。最終値が計算された場合にのみ、結果は配列またはグローバル変数に格納されます。
-
ループを展開し、オーバーヘッドを削減して、さらなる最適化を可能にします。
-
複数の累積変数や再結合などの手法を使用して、命令レベルの並列性を改善する方法を見つけます。
-
コンパイラが条件付きデータ転送を使用するように、条件付き操作を機能スタイルで書き直します。