「アセンブリ」と言うと、「高速なプログラム」の代名詞的な響きがありますが… これは余りお薦めしません。 最近のコンパイラは性能が高いので、自分でアセンブリコードを書いても太刀打ち出来ません。 下手に書くと絶対にコンパイラの吐くコードよりも遅くなりますし、 ちゃんと勉強してペアリングなども考慮して記述したとしても、 コンパイラの吐くコードと殆ど同じ速度のコードにしかなりません。
アセンブリで書いて効果があるのは、
位の物です。
どうしてもアセンブリで書きたいというのであれば、
勿論、ペアリングなども考慮する
程度の事はしないと駄目だと思います。 やった事無いので本当にこれで速いコードが書けるのかどうかは分かりませんが。
まあ、速度の為ではなくて、 教養の為にアセンブリで書くというのならば、 アセンブリはそんなに難しくはないのですが…。
資料は、Intel 日本語資料のダウンロードにあります。 日本語の資料があるというのはありがたい事です。興味ある人は是非読んでみると良いです。 見るべきは以下の物です。
一つ一つの命令に関しては、二番目のと三番目のに載っています。 アセンブリで何か書く時は、この二番目と三番目を片手に書く事になるでしょう。 速度を追求するのであれば五番目の物は必読です (僕は勿論読んでいません)。 各命令のクロック数も五番目のに載っていたと思います。 四番目のは、OS を作ってやるぜという人が読む為の物です。 上中下の下だけ仲間外れにするのも何なので載せましたが、読まなくて良いです。
世の中には、アセンブリの適当な入門サイトが色々あります。 然し、或る程度分かったら、結局の所 Intel のマニュアルが一番見易いと思います。 /* んー、C 言語の入門サイトは良い物が沢山あるのに、 アセンブリの入門サイトが余りないのは Intel の資料が完璧すぎるからなのかも。 */
最適化序でに、コンパイラの行う最適化についても覗いてみる事にしましょう。 茲では、VC++ による出力を見てみる事にします。
折角計算しても、その結果がプログラムの実行結果に影響を与えないのであれば、 元々計算する必要はありません。 コンパイラは、そう言った計算を消し去ってしまいます。
#include <stdio.h> int main(){ int a[10]; for(int i=0;i<10;i++)a[i]=i*i; for(int i=0;i<10;i++)a[i]+=i; // ↑ 何か一生懸命計算して居るみたいだが… // 実行した結果は何処にも出力されない return 0; }
;------------------------------------------------------------------------------- ; 最適化なし ;------------------------------------------------------------------------------- 00401000: push ebp ; プロローグ 00401001: mov ebp, esp ; 00401003: sub esp, 30 ;---------------------------------- 00401006: mov dword ptr ss:[ebp-2C], 0 ; i = 0 0040100D: jmp short 00401018 ; goto 00401018 0040100F: mov eax, ss:[ebp-2C] ; + eax = i 00401012: add eax, 1 ; | eax += 1 00401015: mov ss:[ebp-2C], eax ; | i = eax 00401018: cmp dword ptr ss:[ebp-2C], 0A ; | i と 10 を比較 0040101C: jge short 0040102E ; | if(≧)goto 0040102E 0040101E: mov ecx, ss:[ebp-2C] ; | ecx = i 00401021: imul ecx, ss:[ebp-2C] ; | ecx *= i 00401025: mov edx, ss:[ebp-2C] ; | edx = i 00401028: mov ss:[ebp+edx*4-28], ecx ; | a[edx] = ecx 0040102C: jmp short 0040100F ; + goto 0040100F 0040102E: mov dword ptr ss:[ebp-30], 0 ; i = 0 00401035: jmp short 00401040 ; goto 00401040 00401037: mov eax, ss:[ebp-30] ; + eax = i 0040103A: add eax, 1 ; | eax += 1 0040103D: mov ss:[ebp-30], eax ; | i = eax 00401040: cmp dword ptr ss:[ebp-30], 0A ; | i と 10 を比較 00401044: jge short 00401059 ; | if(≧)goto 00401059 00401046: mov ecx, ss:[ebp-30] ; | ecx = i 00401049: mov edx, ss:[ebp+ecx*4-28] ; | edx = a[10] 0040104D: add edx, ss:[ebp-30] ; | edx += i 00401050: mov eax, ss:[ebp-30] ; | eax = i 00401053: mov ss:[ebp+eax*4-28], edx ; | a[eax] = edx 00401057: jmp short 00401037 ; + goto 00401037 00401059: xor eax, eax ; 戻り値 = 0 0040105B: mov esp, ebp ;---------------------------------- 0040105D: pop ebp ; エピローグ 0040105E: ret ; ;------------------------------------------------------------------------------- ; 最適化あり ;------------------------------------------------------------------------------- 00401000: xor eax, eax ; 戻り値 = 0 00401002: ret ; return
細かい事は分からなくても良いです。各行の意味はコメントに書いてあります。 プロローグとエピローグは、関数の呼出規約で定められた物です。
見て分かる様に、最適化する前ではちゃんと計算するコードが出力されていますが、 最適化を実行すると処理が綺麗に消えて無くなってしまいます。 計算しても計算結果が何処にも反映されていないからです。
一番基本の最適化です。 定数の計算は初めから結果が分かっているので、 コンパイル時に可能な所まで実行する事が出来ます。
#include <stdio.h> int main(){ int a=10; int b=100; int c=a*b+a; printf("%d\n",c); return 0; }
; コンパイル結果 (アセンブリ) ;------------------------------------------------------------------------------- ; 最適化無しの時の main 関数 ;------------------------------------------------------------------------------- 00401000: push ebp ; プロローグ 00401001: mov ebp, esp ; 00401003: sub esp, 0C ;----------------------------------- 00401006: mov dword ptr ss:[ebp-4], 0A ; a=10 0040100D: mov dword ptr ss:[ebp-8], 64 ; b=100 00401014: mov eax, ss:[ebp-4] ; eax=a 00401017: imul eax, ss:[ebp-8] ; eax*=b 0040101B: add eax, ss:[ebp-4] ; eax+=a 0040101E: mov ss:[ebp-C], eax ; c=eax 00401021: mov ecx, ss:[ebp-C] ; ecx=c 00401024: push ecx ; 引数2 = ecx 00401025: push 0040A150 ; 引数1 = "%d\n" (数値は "%d\n" という文字列のアドレス) 0040102A: call 00401038 ; call printf (数値は printf のアドレス) 0040102F: add esp, 8 ;----------------------------------- 00401032: xor eax, eax ; エピローグ 00401034: mov esp, ebp ; 00401036: pop ebp ; 00401037: ret ; ;------------------------------------------------------------------------------- ; 最適化ありの時の main 関数 ;------------------------------------------------------------------------------- 00401000: push 3F2 ; 引数2 = 1010 00401005: push 0040A150 ; 引数1 = "%d\n" 0040100A: call 00401015 ; call printf 0040100F: add esp, 8 ;----------------------------------- 00401012: xor eax, eax ; エピローグ 00401014: ret ;
上を見れば分かる様に、最適化が有効になっていると、 いきなり 1010 という値が printf に渡されているという事が分かります。 (実は、一番強い最適化にしたら printf 関数まで inline 展開されて滅茶苦茶になったので、 ちょっと最適化を緩めています。)
更に、定数伝播の結果、条件分岐が確定してしまう事もあります。 その場合には、行く事が出来ない分岐先は丸ごと消えて無くなります。
(一般に、到達不能と判定されたコードは全て消えて無くなります。)
#include <stdio.h> int main(){ int a=10; int b=100; if(a*10==b){ printf("hello!"); }else{ printf("world!"); } return 0; }
;------------------------------------------------------------------------------- ; 最適化なし ;------------------------------------------------------------------------------- 00401000: push ebp 00401001: mov ebp, esp 00401003: sub esp, 8 00401006: mov dword ptr ss:[ebp-4], 0A 0040100D: mov dword ptr ss:[ebp-8], 64 00401014: mov eax, ss:[ebp-4] 00401017: imul eax, eax, 0A 0040101A: cmp eax, ss:[ebp-8] ; eax==b かどうか 0040101D: jnz short 0040102E ; if(!equals)goto 0040102E 0040101F: push 0040A150 ; "hello!" 00401024: call 00401041 ; printf 00401029: add esp, 4 0040102C: jmp short 0040103B ; goto 0040103B 0040102E: push 0040A158 ; "world!" 00401033: call 00401041 ; printf 00401038: add esp, 4 0040103B: xor eax, eax 0040103D: mov esp, ebp 0040103F: pop ebp 00401040: ret ;------------------------------------------------------------------------------- ; 最適化あり ;------------------------------------------------------------------------------- 00401000: push 0040A150 ; "hello!" 00401005: call 00401010 ; printf 0040100A: add esp, 4 0040100D: xor eax, eax 0040100F: ret
上のプログラムの場合 a*10==b という事が実行する前から分かるので、 if 文の真の節しかコンパイルされていません。 然も、a の中身や b の中身は他の場所で使われていないので、 結局整数に関する計算は全て無くなりました。
同じ部分式が一つの式の中に存在する時、(その式に副作用がなければ) 一回だけ計算してその結果を使い回す様に変更する事が可能です。
#include <stdio.h> int main(){ int s=0; for(int i=0;i<100;i++) s+=(s+i)*(s+i)*(s+i); // ← s+i が三回登場 printf("%d\n",s); return 0; }
00401000: xor edx, edx ; s = 0 00401002: xor ecx, ecx ; i = 0 00401004: push esi 00401005: lea eax, ds:[ecx+edx] ; eax=i+s ← 先に s+i だけ計算しておく 00401008: mov esi, eax ; esi=eax 0040100A: imul esi, eax ; esi*=eax 0040100D: imul esi, eax ; esi*=eax 00401010: inc ecx ; i++; 00401011: add edx, esi ; s+=esi 00401013: cmp ecx, 64 ; i と 100 を比較 00401016: jl short 00401005 ; if(<)goto 00401005 00401018: push edx ; 引数2 = s 00401019: push 0040A150 ; 引数1 = "%d\n" 0040101E: call 0040102A ; call printf 00401023: add esp, 8 00401026: xor eax, eax 00401028: pop esi 00401029: ret
ループの中で毎回同じ事を計算していると、 その部分はループの外に追い出されてしまいます。
#include <stdio.h> #include <time.h> int main(){ int t=time(NULL)&0xFF; int s=0; for(int i=0;i<100;i++) s+=(s+i)*(t+5); // ← t+5 はループ中変化しない printf("%d\n",s); return 0; }
00401010: push 0 ; + 00401012: call 00401047 ; | eax=time(0) 00401017: add esp, 4 ; + 0040101A: and eax, 0FF ; eax&=0xFF 0040101F: xor edx, edx ; s=0 00401021: xor ecx, ecx ; i=0 00401023: add eax, 5 ; eax+=5 // ← ループ外で 5 を足している 00401026: push esi ; 00401027: lea esi, ds:[ecx+edx] ; + esi=i+s 0040102A: imul esi, eax ; | esi*=eax 0040102D: inc ecx ; | i++ 0040102E: add edx, esi ; | s+=esi 00401030: cmp ecx, 64 ; | i と 100 00401033: jl short 00401027 ; + if(<)goto 00401027 00401035: push edx 00401036: push 0040A150 ; "%d\n" 0040103B: call 00401098 ; printf 00401040: add esp, 8 00401043: xor eax, eax 00401045: pop esi 00401046: ret
条件分岐は、前に述べた様に分岐予測失敗の可能性があるので、出来るだけ少ない方がよいです。
同じ条件による分岐が存在したら、それをくっつける事が可能です。
#include <stdio.h> #include <time.h> int main(){ int t=time(NULL)&1; int a=t==0?t+4 :t-5; // ← 分岐1 int b=t!=0?t+10:t-5; // ← 分岐2 printf("%d\n",a+b); return 0; }
00401010: push 0 ;┐ 0 00401012: call 00401055h ;│ eax=time( ); 00401017: add esp,4 ;┘ 0040101A: and eax,1 ; eax&=1; 0040101D: jne 0040103Ch ; if(eax==0){ // ← 分岐 0040101F: mov ecx,4 ; ecx= 4; 00401024: mov eax,0FFFFFFFBh ; eax=-5; 00401029: add eax,ecx ; eax+=ecx; 0040102B: push eax ;┐ eax 0040102C: push 0040A150h ;│ "%d\n" 00401031: call 004010A6h ;│ printf( , ); 00401036: add esp,8 ;┘ 00401039: xor eax,eax ;┐ 0 0040103B: ret ;┘ return ; ; }else{ 0040103C: lea ecx,[eax-5] ; ecx=eax-5; 0040103F: add eax,0Ah ; eax=10; 00401042: add eax,ecx ; eax+=ecx; 00401044: push eax ;┐ eax 00401045: push 0040A150h ;│ "%d\n" 0040104A: call 004010A6h ;│ printf( , ); 0040104F: add esp,8 ;┘ 00401052: xor eax,eax ;┐ 0 00401054: ret ;┘ return ; ; }
ループを部分的に展開する事によって、条件分岐の回数を少なくする事が出来ます。 更に、各ループ間の最適化を実行できるようになると言う期待も出来ます。
#include <stdio.h> int main(){ int s=0; for(int i=0;i<100;i++)s+=i; printf("%d\n",s); return 0; }
00401000: push esi 00401001: push edi 00401002: xor edi,edi ; s=0; 00401004: xor eax,eax ; i=0; 00401006: xor esi,esi ; s3=0; 00401008: xor edx,edx ; s2=0; 0040100A: xor ecx,ecx ; s1=0; 0040100C: lea esp,[esp] ; esp=esp; /* ...何がしたいんだ?? */ ; do{ 00401010: add edi,eax ; s+=i; 00401012: lea ecx,[ecx+eax+1] ; s1+=i+1; 00401016: lea edx,[edx+eax+2] ; s2+=i+2; 0040101A: lea esi,[esi+eax+3] ; s3+=i+3; 0040101E: add eax,4 ; i+=4; 00401021: cmp eax,64h ;┐ eax 100 00401024: jl 00401010 ;┘ }while( <= ); 00401026: add esi,edx ; s3+=s2; 00401028: add esi,ecx ; s3+=s1; 0040102A: add edi,esi ; s+=s3; 0040102C: push edi 0040102D: push 0040A150 00401032: call 0040103F 00401037: add esp,8 0040103A: pop edi 0040103B: xor eax,eax 0040103D: pop esi 0040103E: ret
// 要するに ↓ の様な感じに変換された // 条件分岐判定回数は 25 回に減る #include <stdio.h> int main(){ int s0=0,s1=0,s2=0,s3=0; for(int i=0;i<100;i+=4){ s0+=i; s1+=i+1; s2+=i+2; s3+=i+3; } printf("%d\n",s0+s1+s2+s3); return 0; }
インライン展開は、関数呼出の代わりに呼び出したい関数の中身をその場で評価するという物です。 インライン化すると、
という利点があります。
※ 然し、欠点も存在します。 例えば、巨大な関数を inline 展開してもコードサイズの肥大化を招き、 プログラム自体がキャッシュミスしてしまう可能性が高くなります。 また、巨大でなくても色々な所から呼び出されている関数をインライン展開してしまうと、 呼出毎に関数のコピーが出来てしまい、これもコードサイズの肥大化を招きます。 (インライン化した方が良いか、しない方が良いかというのは、コンパイラが判断してくれます。)
#include <stdio.h> int f(int i){ if(i==1){ return 10; }else{ return 20; } } int main(){ printf("%d\n",f(1)); return 0; }
00401010: push 0Ah ;┐ 10 00401012: push 0040A150h ;│ "%d\n" 00401017: call 00401022h ;│ printf( , ); 0040101C: add esp,8 ;┘ 0040101F: xor eax,eax ;┐ 0 00401021: ret ;┘ return ;
上記では、f の中身が main の中に展開され、 更に、条件分岐が確定して結果の 10 が直接 printf に渡されています。
inline
キーワード最適化する設定にしていると inline というキーワードは余り意味を為さなくなります。 インライン展開した方が良いか、インライン展開をしない方が良いかというのは全てコンパイラが判断する為です。 なので、巨大な関数に inline を指定してもインライン展開してはくれませんし、 軽量な関数であれば inline を指定しなくても勝手にインライン化してくれます。
従って、inline は単にオブジェクトファイルに関数実体を含めるか含めないか、 という事を指定するキーワードと思うと良いです。
或る命令を実行する代わりに、別の命令を使って等価な結果を得る事が出来る場合があります。 より少ない命令数、 より少ないクロック数、 より少ない命令語長 (命令の長さは可変です。) で結果が得られる場合には、 代替命令を使って計算が実現されます。
#include <stdio.h> volatile unsigned t=15; int main(){ printf("%u\n",t*1024); printf("%u\n",t/4); printf("%u\n",t*2); printf("%u\n",t*3); printf("%u\n",123+t*4); return 0; }
00401000: mov eax,dword ptr ds:[0040C000h] ; eax=t; 00401005: shl eax,0Ah ; eax<<=10; 00401008: push eax ;┐ eax 00401009: push 40A150h ;│ "%u\n" 0040100E: call 0040106B ;┘ printf( , ); 00401013: mov ecx,dword ptr ds:[0040C000h] ; ecx=t; 00401019: shr ecx,2 ; ecx>>=2; 0040101C: push ecx ;┐ ecx 0040101D: push 40A154h ;│ "%u\n" 00401022: call 0040106B ;┘ printf( , ); 00401027: mov edx,dword ptr ds:[0040C000h] ; edx=t; 0040102D: add edx,edx ; edx+=edx; 0040102F: push edx ;┐ edx 00401030: push 40A158h ;│ "%u\n" 00401035: call 0040106B ;┘ printf( , ); 0040103A: mov eax,dword ptr ds:[0040C000h] ; eax=t; 0040103F: lea eax,[eax+eax*2] ; eax=eax+2*eax; 00401042: push eax ;┐ eax 00401043: push 40A15Ch ;│ "%u\n" 00401048: call 0040106B ;┘ printf( , ); 0040104D: mov ecx,dword ptr ds:[0040C000h] ; ecx=t; 00401053: lea edx,[ecx*4+0000007Bh] ; edx=ecx*4+123; 0040105A: push edx ;┐ edx 0040105B: push 40A160h ;│ "%u\n" 00401060: call 0040106B ;┘ printf( , ); 00401065: add esp,28h ; (引数後始末) 00401068: xor eax,eax ; eax^=eax; 0040106A: ret ; return eax;
更に、茲では lea 命令が使われています。 元々 lea 命令は配列要素などのアドレス計算の為の命令ですが、整数の計算に用いられる事も多いです。
上記の lea eax,[eax+eax*2]
というのは、本来の用途で解釈すると、
「short*
型の eax
に対し
eax=&eax[eax];
を実行せよ」という物になります。
lea
命令を巧みに用いた計算です。xor eax,eax
で実行します。
(自分自身に xor
すると 0 になります。)
速度的には mov eax,0
でもどちらでも良いのですが、
命令の長さが mov eax,0
は 5B で、xor eax,eax
は 2B なので、
xor
を使った方が短くて済みます。
これは、一定の大きさ以上の型を戻り値にした関数呼出に関する最適化です。
struct P{int x,y,z;}; // 12B の型 P f(int n){ P ret={0}; ret.x=n*10; ret.y=n*100; return ret; } P g(); int h(){ P val=g(); return val.x+val.z; }
;------------------------------------------------------------------------------- ; 最適化なし ;------------------------------------------------------------------------------- ; struct P __cdecl f(int n); 00401000: push ebp ; 00401001: mov ebp,esp ; 00401003: sub esp,0Ch ; 00401006: mov dword ptr [ebp-0Ch],0 ; ret.x=0; ┐ 0040100D: xor eax,eax ; eax=0; │ 0040100F: mov dword ptr [ebp-8],eax ; ret.y=eax; │ 00401012: mov dword ptr [ebp-4],eax ; ret.z=eax; ┘ ret={0}; 00401015: mov ecx,dword ptr [ebp+0Ch] ; ecx=n; 00401018: imul ecx,ecx,0Ah ; ecx*=10; 0040101B: mov dword ptr [ebp-0Ch],ecx ; ret.x=ecx; 0040101E: mov edx,dword ptr [ebp+0Ch] ; edx=n; 00401021: imul edx,edx,64h ; edx*=100; 00401024: mov dword ptr [ebp-8],edx ; ret.y=edx; 00401027: mov eax,dword ptr [ebp+8] ; eax=_ret; ┐ 0040102A: mov ecx,dword ptr [ebp-0Ch] ; ecx=ret.x; │ 0040102D: mov dword ptr [eax],ecx ; eax->x=ecx; │ 0040102F: mov edx,dword ptr [ebp-8] ; edx=ret.y; │ 00401032: mov dword ptr [eax+4],edx ; eax->y=edx; │ 00401035: mov ecx,dword ptr [ebp-4] ; ecx=ret.z; │ // 戻り値書き込み先にコピー 00401038: mov dword ptr [eax+8],ecx ; eax->z=ecx; ┘ *_ret=ret; 0040103B: mov eax,dword ptr [ebp+8] ; eax=_ret; 0040103E: mov esp,ebp 00401040: pop ebp 00401041: ret ; int __cdecl h(void) 00000050: push ebp 00000051: mov ebp,esp 00000053: sub esp,28h ; P _ret; int padding; P tmp,val; 00000056: lea eax,[ebp-28h] ; eax=&_ret; // _ret は戻り値書き込み先 00000059: push eax ;┐ eax 0000005A: call ?g@@YA?AUP@@XZ ;│ g( ); 0000005F: add esp,4 ;┘ 00000062: mov ecx,dword ptr [eax] ;┐ ┐ 00000064: mov dword ptr [ebp-18h],ecx ;┘ tmp.x=eax->x; │ 00000067: mov edx,dword ptr [eax+4] ;┐ │ 0000006A: mov dword ptr [ebp-14h],edx ;┘ tmp.y=eax->y; │ 0000006D: mov eax,dword ptr [eax+8] ;┐ │ 00000070: mov dword ptr [ebp-10h],eax ;┘ tmp.z=eax->z; ┘ tmp=(戻り値); コピー 00000073: mov ecx,dword ptr [ebp-18h] ;┐ ┐ 00000076: mov dword ptr [ebp-0Ch],ecx ;┘ val.x=tmp.x; │ 00000079: mov edx,dword ptr [ebp-14h] ;┐ │ 0000007C: mov dword ptr [ebp-8],edx ;┘ val.y=tmp.y; │ 0000007F: mov eax,dword ptr [ebp-10h] ;┐ │ 00000082: mov dword ptr [ebp-4],eax ;┘ val.z=tmp.z; ┘ val=tmp; コピー 00000085: mov eax,dword ptr [ebp-0Ch] ; eax=val.x; 00000088: add eax,dword ptr [ebp-4] ; eax+=val.z; 0000008B: mov esp,ebp 0000008D: pop ebp 0000008E: ret
上に挙げているのは、最適化していない状態でのコードです。
無駄にコピーを頻発している事が分かると思います。 戻り値最適化ではこのコピーを省略します。 (C++ 言語レベルで operator= の呼出を削除してしまいます。)
;------------------------------------------------------------------------------- ; 最適化あり ;------------------------------------------------------------------------------- ; struct P __cdecl f(int n); 00000000: mov eax,dword ptr [esp+4] ; eax=_ret; 00000004: xor ecx,ecx ; ecx=0; 00000006: mov dword ptr [eax+8],ecx ; eax->z=0; // 戻り値書き込み先に直接書込 00000009: mov ecx,dword ptr [esp+8] ; ecx=n; 0000000D: lea edx,[ecx+ecx*4] ; edx=ecx+ecx*4; 00000010: imul ecx,ecx,64h ; ecx*=100; 00000013: add edx,edx ; edx+=edx; 00000015: mov dword ptr [eax],edx ; eax->x=edx; // 戻り値書き込み先に直接書込 00000017: mov dword ptr [eax+4],ecx ; eax->y=ecx; // 戻り値書き込み先に直接書込 0000001A: ret ; int __cdecl h(void) 00000020: sub esp,18h ; P val,???; 00000023: lea eax,[esp] ; eax=&val; 00000026: push eax ;┐ eax // 戻り値書き込み先として val を直接指定 00000027: call ?g@@YA?AUP@@XZ ;┘ g( ); 0000002C: mov edx,dword ptr [eax+4] ; edx=eax-&y; 0000002F: mov ecx,dword ptr [eax] ; ecx=eax-&x; 00000031: mov eax,dword ptr [eax+8] ; eax=eax-&z; 00000034: mov dword ptr [esp+14h],edx ; ???=edx; 00000038: add eax,ecx ; eax+=ecx; 0000003A: add esp,1Ch 0000003D: ret