インラインアセンブリ言語とC++コードのパフォーマンスを比較しようとしたため、サイズ2000の2つの配列を100000回追加する関数を作成しました。コードは次のとおりです。
#define TIMES 100000
void calcuC(int *x,int *y,int length)
{
for(int i = 0; i < TIMES; i++)
{
for(int j = 0; j < length; j++)
x[j] += y[j];
}
}
void calcuAsm(int *x,int *y,int lengthOfArray)
{
__asm
{
mov edi,TIMES
start:
mov esi,0
mov ecx,lengthOfArray
label:
mov edx,x
Push edx
mov eax,DWORD PTR [edx + esi*4]
mov edx,y
mov ebx,DWORD PTR [edx + esi*4]
add eax,ebx
pop edx
mov [edx + esi*4],eax
inc esi
loop label
dec edi
cmp edi,0
jnz start
};
}
main()
は次のとおりです。
int main() {
bool errorOccured = false;
setbuf(stdout,NULL);
int *xC,*xAsm,*yC,*yAsm;
xC = new int[2000];
xAsm = new int[2000];
yC = new int[2000];
yAsm = new int[2000];
for(int i = 0; i < 2000; i++)
{
xC[i] = 0;
xAsm[i] = 0;
yC[i] = i;
yAsm[i] = i;
}
time_t start = clock();
calcuC(xC,yC,2000);
// calcuAsm(xAsm,yAsm,2000);
// for(int i = 0; i < 2000; i++)
// {
// if(xC[i] != xAsm[i])
// {
// cout<<"xC["<<i<<"]="<<xC[i]<<" "<<"xAsm["<<i<<"]="<<xAsm[i]<<endl;
// errorOccured = true;
// break;
// }
// }
// if(errorOccured)
// cout<<"Error occurs!"<<endl;
// else
// cout<<"Works fine!"<<endl;
time_t end = clock();
// cout<<"time = "<<(float)(end - start) / CLOCKS_PER_SEC<<"\n";
cout<<"time = "<<end - start<<endl;
return 0;
}
次に、プログラムを5回実行して、プロセッサのサイクルを取得します。これは時間と見なすことができます。上記の関数のいずれかを呼び出すたびにのみ。
そして、ここに結果が来ます。
Debug Release
---------------
732 668
733 680
659 672
667 675
684 694
Average: 677
Debug Release
-----------------
1068 168
999 166
1072 231
1002 166
1114 183
Average: 182
リリースモードのC++コードは、アセンブリコードのほぼ3.7倍高速です。どうして?
私が書いたアセンブリコードは、GCCによって生成されたものほど効果的ではないと思います。私のような一般的なプログラマーは、コンパイラーによって生成される相手よりも速くコードを書くことは難しいのですが、それは、自分の手で書かれたアセンブリー言語のパフォーマンスを信頼し、C++に集中し、アセンブリー言語を忘れてはならないということですか?
はい、ほとんどの時間。
まず第一に、低レベル言語(この場合はアセンブリ)は常に高レベル言語(この場合はC++とC)よりも高速なコードを生成するという誤った仮定から始めます。それは真実ではない。 CコードはJavaコードよりも常に高速ですか?いいえ、別の変数があるので:プログラマー。コードの記述方法とアーキテクチャの詳細に関する知識は、パフォーマンスに大きく影響します(この例で見たように)。
alwaysは、手作りのアセンブリコードがコンパイルされたコードよりも優れている例を生成できますが、通常は、架空の例またはtrue500.000行以上のC++コードのプログラム)。コンパイラーは95%のより良いアセンブリコードを生成すると思います時々、まれにしかありません少数のアセンブリコードを書く必要があるかもしれません、短い、 非常に使用される 、- パフォーマンスクリティカル ルーチン、またはお気に入りの高レベル言語が公開しない機能にアクセスする必要がある場合。この複雑さを少し味わってみませんか?読む この素晴らしい答え SO.
なんでこれ?
まず第一に、コンパイラは想像すらできない最適化を行うことができるため( this short list を参照)、彼らはsecondsでそれらを行います(- 日数が必要な場合があります )。
アセンブリでコーディングするときは、明確に定義された呼び出しインターフェイスを使用して明確に定義された関数を作成する必要があります。ただし、 プログラム全体の最適化 および 手順間の最適化 を考慮することができます- レジスタ割り当て 、 定数伝播 、 共通部分式の除去 、 命令スケジューリング 、およびその他の複雑で明白でない最適化(たとえば、 ポリトープモデル )。 RISC アーキテクチャ担当者は、何年も前にこのことについて心配することを止めました(たとえば、命令のスケジューリングは 手作業で調整する )および最新の CISC CPUには非常に長い パイプライン もあります。
一部の複雑なマイクロコントローラーの場合、systemライブラリーでもアセンブリーではなくCで作成されます。これは、コンパイラーがより良い(そして保守が容易な)最終コードを生成するためです。
コンパイラは時々 MMX/SIMDx命令を自動的に使用する を使用することができ、使用しない場合は単に比較することはできません(他の回答は既にアセンブリコードを十分にレビューしました)。ループの場合、これは ループ最適化の短いリスト コンパイラのチェック対象一般的にです(スケジュールが決定したら、自分でそれを行うことができると思いますか) C#プログラムの場合?)Assemblyで何かを書く場合、少なくともいくつかの 単純な最適化 を考慮する必要があると思います。配列の教科書の例は、 サイクルを展開 (そのサイズはコンパイル時にわかっています)です。それを行い、テストを再度実行します。
最近では、別の理由でアセンブリ言語を使用する必要があることもめったにありません: 多数の異なるCP 。それらすべてをサポートしますか?それぞれには、特定の マイクロアーキテクチャ といくつかの 特定の命令セット があります。それらは異なる数の機能ユニットを持ち、それらをすべてビジーに保つためにアセンブリ命令を配置する必要があります。 Cで記述する場合、 PGO を使用できますが、アセンブリでは、その特定のアーキテクチャに関する十分な知識が必要になります(および別のアーキテクチャのすべてを再考してやり直し)。小規模なタスクの場合、コンパイラは通常によりよく機能し、複雑なタスクの通常は作業に返済されません(および コンパイラmayの方が良い とにかく)。
座ってコードを見ると、おそらくアセンブリに変換するよりもアルゴリズムを再設計する方が得られることがわかります(これを読んでください SOのすばらしい投稿 )アセンブリ言語に頼る前に効果的に適用できる高レベルの最適化(およびコンパイラへのヒント)です。多くの場合、組み込み関数を使用するとパフォーマンスが向上しますが、コンパイラーはほとんどの最適化を実行できることに言及する価値があります。
これは、5〜10倍のアセンブリコードを生成できる場合でも、顧客にpayの1週間your timeまたは- 50ドル高速なCPUを購入。極端な最適化は、ほとんどの場合(特にLOBアプリケーションで)必要とされることはほとんどありません。
アセンブリコードは次善であり、改善される可能性があります。
loop
命令を使用します。これは、 ほとんどの最新のCPUでは完全に遅いことが知られています (おそらく、古代のアセンブリ本を使用した結果*)したがって、アセンブラに関するスキルセットを大幅に改善しない限り、パフォーマンスのためにアセンブラコードを記述することは意味がありません。
*もちろん、あなたが本当に古代アセンブリの本からloop
の指示を本当に受けたかどうかはわかりません。しかし、実世界のコードではほとんど見られません。すべてのコンパイラーはloop
を出力しないほど賢く、IMHOの古くて古い本でしか見られません。
アセンブリを掘り下げる前であっても、より高いレベルに存在するコード変換があります。
static int const TIMES = 100000;
void calcuC(int *x, int *y, int length) {
for (int i = 0; i < TIMES; i++) {
for (int j = 0; j < length; j++) {
x[j] += y[j];
}
}
}
ループ回転 で変換できます:
static int const TIMES = 100000;
void calcuC(int *x, int *y, int length) {
for (int j = 0; j < length; ++j) {
for (int i = 0; i < TIMES; ++i) {
x[j] += y[j];
}
}
}
メモリの局所性に関する限り、これははるかに優れています。
これはさらに最適化でき、a += b
をX回実行することはa += X * b
を実行することと同等であるため、次のようになります。
static int const TIMES = 100000;
void calcuC(int *x, int *y, int length) {
for (int j = 0; j < length; ++j) {
x[j] += TIMES * y[j];
}
}
しかし、私のお気に入りのオプティマイザー(LLVM)はこの変換を実行しないようです。
[編集]restrict
およびx
へのy
修飾子がある場合、変換が実行されることがわかりました。 。確かに、この制限がなければ、x[j]
とy[j]
は同じ場所にエイリアスする可能性があり、この変換がエラーになります。 [編集の終了]
とにかく、thisは最適化されたCバージョンだと思います。すでにはるかに簡単です。これに基づいて、ここにASMでの私の亀裂があります(私はClangにそれを生成させます、私はそれで役に立たないです):
calcuAsm: # @calcuAsm
.Ltmp0:
.cfi_startproc
# BB#0:
testl %edx, %edx
jle .LBB0_2
.align 16, 0x90
.LBB0_1: # %.lr.ph
# =>This Inner Loop Header: Depth=1
imull $100000, (%rsi), %eax # imm = 0x186A0
addl %eax, (%rdi)
addq $4, %rsi
addq $4, %rdi
decl %edx
jne .LBB0_1
.LBB0_2: # %._crit_Edge
ret
.Ltmp1:
.size calcuAsm, .Ltmp1-calcuAsm
.Ltmp2:
.cfi_endproc
私はこれらのすべての命令がどこから来たのか理解していないのではないかと心配していますが、いつでも楽しんでそれを比較してみてください...しかし、私はまだコードではアセンブリ1ではなく最適化されたCバージョンを使用しますはるかにポータブル。
短い答え:はい。
長答:はい、あなたが何をしているか本当に知っていて、そうする理由がない限り。
Asmコードを修正しました:
__asm
{
mov ebx,TIMES
start:
mov ecx,lengthOfArray
mov esi,x
shr ecx,1
mov edi,y
label:
movq mm0,QWORD PTR[esi]
paddd mm0,QWORD PTR[edi]
add edi,8
movq QWORD PTR[esi],mm0
add esi,8
dec ecx
jnz label
dec ebx
jnz start
};
リリースバージョンの結果:
Function of Assembly version: 81
Function of C++ version: 161
リリースモードのアセンブリコードは、C++よりもほぼ2倍高速です。
それは、自分の手で書かれたアセンブリ言語のパフォーマンスを信頼すべきではないということですか?
はい、それはまさにそれが意味するものであり、そしてそれはevery言語に当てはまります。言語Xで効率的なコードを記述する方法がわからない場合は、Xで効率的なコードを記述する能力を信頼しないでください。したがって、効率的なコードが必要な場合は、別の言語を使用する必要があります。
アセンブリはこれに特に敏感です。なぜなら、あなたが見るものはあなたが得るものだからです。 CPUに実行させる特定の命令を記述します。高水準言語には、コードを変換し、多くの非効率性を取り除くことができるコンパイラーがあります。アセンブリを使用すると、あなた自身でしています。
最近アセンブリ言語を使用する唯一の理由は、言語がアクセスできない機能を使用することです。
これは以下に適用されます。
しかし、現在のコンパイラは非常にスマートです。Cにそのような演算子がない場合でも、d = a / b; r = a % b;
のような2つの個別のステートメントを、除算と剰余を一度に計算する単一の命令に置き換えることができます。
最新のコンパイラーがコードの最適化で素晴らしい仕事をしているのは事実ですが、それでもアセンブリーを学び続けることをお勧めします。
まず第一に、あなたは明らかにそれによって脅かされていません、それは素晴らしい、素晴らしいプラスです、次に-速度の仮定を検証または破棄するためのプロファイリング、経験者からの入力を求めていますそして、あなたは人類に知られている最大の最適化ツールを持っています:a brain。
経験が増えるにつれて、いつ、どこでそれを使用するかを学習します(通常、アルゴリズムレベルで深く最適化した後、コード内で最もタイトで最も内側のループ)。
インスピレーションについては、Michael Abrashの記事を参照することをお勧めします(彼から連絡がない場合、彼は最適化の第一人者であり、彼はQuakeソフトウェアレンダラーの最適化におけるJohn Carmack!)
「最速のコードなどはありません」-Michael Abrash
Asmコードを変更しました:
__asm
{
mov ebx,TIMES
start:
mov ecx,lengthOfArray
mov esi,x
shr ecx,2
mov edi,y
label:
mov eax,DWORD PTR [esi]
add eax,DWORD PTR [edi]
add edi,4
dec ecx
mov DWORD PTR [esi],eax
add esi,4
test ecx,ecx
jnz label
dec ebx
test ebx,ebx
jnz start
};
リリースバージョンの結果:
Function of Assembly version: 41
Function of C++ version: 161
リリースモードのアセンブリコードは、C++よりもほぼ4倍高速です。 IMHo、アセンブリコードの速度はプログラマに依存
非常に興味深いトピックです!
SashaのコードでSSEによってMMXを変更しました
私の結果は次のとおりです。
Function of C++ version: 315
Function of Assembly(simply): 312
Function of Assembly (MMX): 136
Function of Assembly (SSE): 62
SSEを含むアセンブリコードは、C++よりも5倍高速です。
ほとんどの高水準言語コンパイラは非常に最適化されており、何をしているのかを知っています。逆アセンブルコードをダンプして、ネイティブアセンブリと比較できます。あなたのコンパイラが使用しているいくつかの素晴らしいトリックを見ると思います。
ちょうどたとえば、それがもう正しいかどうかわからない場合でも:):
やること:
mov eax,0
よりも多くのサイクルがかかります
xor eax,eax
同じことをします。
コンパイラはこれらすべてのトリックを知っており、それらを使用します。
コンパイラはあなたを打ち負かしました。試してみますが、保証はいたしません。 TIMESによる「乗算」は、より関連性の高いパフォーマンステストにするためのものであり、y
とx
は16に揃えられ、length
はゼロ以外であると想定します。 4の倍数。それはおそらくとにかくすべて本当です。
mov ecx,length
lea esi,[y+4*ecx]
lea edi,[x+4*ecx]
neg ecx
loop:
movdqa xmm0,[esi+4*ecx]
paddd xmm0,[edi+4*ecx]
movdqa [edi+4*ecx],xmm0
add ecx,4
jnz loop
私が言ったように、私は何の保証もしません。しかし、はるかに高速に処理できる場合は驚かれます。ここでのボトルネックは、すべてがL1ヒットであってもメモリスループットです。
Assemblyで命令ごとにまったく同じアルゴリズムを盲目的に実装するのは、保証コンパイラーが実行できる速度より遅くなるためです。
コンパイラが行う最小の最適化でさえ、最適化をまったく行わない厳格なコードよりも優れているからです。
もちろん、コンパイラーを打ち負かすことは可能です。特に、それがコードの小さなローカライズされた部分である場合、私はそれを自分でやらなくてはなりませんでした。 4倍高速化されますが、この場合、ハードウェアに関する十分な知識と、一見すると直感に反する多数のトリックに大きく依存する必要があります。
コンパイラとして、多くの実行タスクのループを固定サイズに置き換えます。
int a = 10;
for (int i = 0; i < 3; i += 1) {
a = a + i;
}
生産します
int a = 10;
a = a + 0;
a = a + 1;
a = a + 2;
そして、最終的に「a = a + 0;」であることがわかります。役に立たないので、この行を削除します。頭の中で何かが最適化オプションをコメントとして付けてくれることを願っています。これらの非常に効果的な最適化はすべて、コンパイルされた言語を高速化します。
それはまさにそれが意味するものです。マイクロ最適化はコンパイラーに任せます。
この例は、低レベルコードに関する重要な教訓を示しているので気に入っています。はい、あなたはcan Cコードと同じくらい速いアセンブリを書きます。これはトートロジー的には正しいですが、必ずしもmean何でもありません。明らかにsomebodyは可能です。そうしないと、アセンブラは適切な最適化を知りません。
同様に、言語の抽象化の階層を上るのと同じ原則が適用されます。はい、あなたはcan迅速で汚いPerlスクリプトと同じくらい速いCでパーサーを書きます、そして多くの人がします。ただし、Cを使用したためにコードが高速になるわけではありません。多くの場合、高水準言語は、これまで考えたこともないような最適化を行います。
多くの場合、一部のタスクを実行する最適な方法は、タスクが実行されるコンテキストによって異なります。ルーチンがアセンブリ言語で書かれている場合、通常、コンテキストに基づいて一連の命令を変更することはできません。簡単な例として、次の簡単な方法を検討してください。
inline void set_port_high(void)
{
(*((volatile unsigned char*)0x40001204) = 0xFF);
}
上記の32ビットARMコードのコンパイラは、おそらく次のようにレンダリングします。
ldr r0,=0x40001204
mov r1,#0
strb r1,[r0]
[a fourth Word somewhere holding the constant 0x40001204]
多分
ldr r0,=0x40001000 ; Some assemblers like to round pointer loads to multiples of 4096
mov r1,#0
strb r1,[r0+0x204]
[a fourth Word somewhere holding the constant 0x40001000]
次のいずれかのように、手作業で組み立てられたコードでわずかに最適化できます。
ldr r0,=0x400011FF
strb r0,[r0+5]
[a third Word somewhere holding the constant 0x400011FF]
または
mvn r0,#0xC0 ; Load with 0x3FFFFFFF
add r0,r0,#0x1200 ; Add 0x1200, yielding 0x400011FF
strb r0,[r0+5]
両方の手作業によるアプローチでは、16バイトではなく12バイトのコードスペースが必要です。後者は「ロード」を「追加」に置き換え、ARM7-TDMIで2サイクル高速に実行します。したがって、コードがr0が認識しない/認識しないコンテキストで実行される場合、アセンブリ言語のバージョンはコンパイルされたバージョンよりもいくらか優れています。一方、コンパイラーが何らかのレジスターを知っていると仮定します。 r5]は、目的のアドレス0x40001204の2047バイト以内の値を保持しようとしていました。 0x40001000]、さらに他のレジスタ[e.g. r7]は、下位ビットが0xFFの値を保持しようとしていました。その場合、コンパイラーはCバージョンのコードを最適化して次のように単純化できます。
strb r7,[r5+0x204]
手作業で最適化されたアセンブリコードよりもはるかに短くて高速です。さらに、コンテキストでset_port_highが発生したとします。
int temp = function1();
set_port_high();
function2(temp); // Assume temp is not used after this
組み込みシステム用にコーディングする場合、まったく信じがたいことではありません。 set_port_high
がアセンブリコードで記述されている場合、コンパイラは、アセンブリコードを呼び出す前にr0(function1
からの戻り値を保持している)を別の場所に移動し、その後その値をr0に戻す必要があります( function2
はr0)の最初のパラメーターを予期するため、「最適化された」アセンブリコードには5つの命令が必要です。コンパイラが格納するアドレスまたは値を保持するレジスタを知らなかった場合でも、その4つの命令バージョン(利用可能なレジスタを使用するように適応できます。必ずしもr0およびr1である必要はありません)は、「最適化された」アセンブリ-言語バージョン。コンパイラが前述のようにr5とr7に必要なアドレスとデータを持っている場合、function1
はそれらのレジスタを変更しないため、set_port_high
を単一のstrb
命令に置き換えることができます--- 4つの命令が小さくて高速「手動で最適化された」アセンブリコードより。
プログラマーが正確なプログラムフローを知っている場合は、手作業で最適化されたアセンブリコードがコンパイラよりも優れている場合がありますが、コンテキストがわかる前にコードの一部が記述されている場合、またはソースコードの一部が複数のコンテキストから呼び出される[コードの50の異なる場所でset_port_high
が使用されている場合、コンパイラはそれぞれの拡張方法を個別に決定できます]。
一般に、アセンブリ言語は、非常に限られた数のコンテキストから各コードにアクセスできる場合に最大のパフォーマンスの改善をもたらす傾向があり、特定の場所でのパフォーマンスに有害な傾向があることをお勧めします。コードには多くの異なるコンテキストからアプローチできます。興味深いことに(そして便利なことに)、アセンブリがパフォーマンスにとって最も有益なケースは、多くの場合、コードが最も簡単で読みやすいケースです。アセンブリ言語のコードがゴチャゴチャになる場所は、多くの場合、アセンブリでの書き込みがパフォーマンスのメリットを最小限に抑える場所です。
[小さな注意:アセンブリコードを使用して、非常に最適化されたネバネバした混乱を生み出すことができる場所がいくつかあります。たとえば、ARMからWordをフェッチし、値の上位6ビット(多くの値)に基づいて約12のルーチンのいずれかを実行するために必要なRAMに対して行ったコード同じルーチンにマップされます)。私はそのコードを次のようなものに最適化したと思います:
ldrh r0,[r1],#2! ; Fetch with post-increment
ldrb r1,[r8,r0 asr #10]
sub pc,r8,r1,asl #2
レジスタr8は常にメインディスパッチテーブルのアドレスを保持していました(コードがその時間の98%を費やすループ内では、他の目的に使用されることはありませんでした)。 64エントリすべてが、その前の256バイトのアドレスを参照しました。ほとんどの場合、プライマリループには約60サイクルという厳しい実行時間制限があるため、9サイクルのフェッチとディスパッチは、その目標を達成する上で非常に役立ちました。 256個の32ビットアドレスのテーブルを使用すると、1サイクルは高速になりますが、1KBの非常に貴重なRAM [フラッシュにより複数の待機状態が追加されます]。 64個の32ビットアドレスを使用するには、フェッチしたWordの一部のビットをマスクする命令を追加する必要があり、実際に使用したテーブルよりも192バイト多くのバイトを消費していました。 8ビットオフセットのテーブルを使用すると、非常にコンパクトで高速なコードが得られましたが、コンパイラが思い付くことは期待できませんでした。また、コンパイラがテーブルアドレスを保持するためにレジスタを「フルタイム」専用にすることも期待していません。
上記のコードは、自己完結型システムとして実行するように設計されています。定期的にCコードを呼び出すことができますが、通信しているハードウェアが16ミリ秒ごとに約1ミリ秒の間隔で「アイドル」状態にできるのは特定の時間だけです。
正しい方法でより深い知識を持つアセンブリ言語を使用している場合を除き、C++は高速です。
ASMでコーディングするとき、論理的に可能な場合にCPUが命令の多くを並行して実行できるように、命令を手動で再編成します。たとえば、ASMでコーディングする場合、RAMをほとんど使用しません。ASMには20000行以上のコードがあり、プッシュ/ポップを使用したことがありません。
潜在的にオペコードの途中にジャンプして、自己修正コードのペナルティなしでコードと動作を自己修正できます。レジスタへのアクセスには1ティック(場合によっては.25ティック)のCPUが必要です。RAMへのアクセスには数百かかる可能性があります。
前回のASMの冒険では、変数を格納するためにRAMを一度も使用しませんでした(ASMの何千行も)。 ASMは、C++よりも想像以上に高速である可能性があります。ただし、次のような多くの可変要素に依存します。
1. I was writing my apps to run on the bare metal.
2. I was writing my own boot loader that was starting my programs in ASM so there was no OS management in the middle.
私は生産性が重要だと気付いたので、C#とC++を学んでいます!空き時間に純粋なASMのみを使用して、想像できる限り高速なプログラムを実行することができます。しかし、何かを生成するには、高レベルの言語を使用します。
たとえば、最後にコーディングしたプログラムはJSとGLSLを使用していたため、パフォーマンスの問題に気付くことはありませんでした。これは、3D向けにGPUをプログラミングするという概念だけで、コマンドをGPUに送信する言語の速度がほとんど無関係になるためです。
ベアメタル上のアセンブラーの速度だけが反論できません。 C++内部ではさらに遅くなる可能性がありますか? -それは、最初にアセンブラーを使用していないコンパイラーでアセンブリー・コードを書いているためである可能性があります。
私の個人評議会は、私がアセンブリを愛していても、それを避けることができるなら、アセンブリコードを決して書かないことです。
最近、私がやったすべての速度の最適化は、脳に損傷を与えた遅いコードを妥当なコードに置き換えることでした。しかし、物事は速度が非常に重要であり、私は何かを速くすることに真剣に努力しました。結果は常に反復プロセスであり、各反復はより少ない操作で問題を解決する方法を見つける、問題に対するより多くの洞察を与えました。最終的な速度は常に、問題に対する洞察力に依存していました。いずれかの段階でアセンブリコード、または最適化されたCコードを使用した場合、より良いソリューションを見つけるプロセスが苦しみ、最終結果が遅くなります。
ここでのすべての答えは、1つの側面を除外しているように見えます。特定の目的を達成するためのコードを書かないこともありますが、それはfunです。投資するのに時間をかけるのは経済的ではないかもしれませんが、おそらく、手動でロールされたasmの代替手段で最速のコンパイラ最適化コードスニペットを破るほど大きな満足感はありません。