SSE2のコード生成サンプル
さて、おなじみのコーナーがやってまいりました。前回のAMD64に引き続き、SSE2のネイティブコードを生成できるモジュールを作ってみましょう。
XMMレジスタの指定方法は汎用レジスタと同じにできるため、機械語のフォーマットは比較的きれいな形になります。要は、以前解説を行った__op_format
関数にSSE2のオペコードを引き渡すだけで、上記で紹介したようなSSE2に関するネイティブコードを生成できてしまうという訳です。
ただ単にSSE2を活用した何らかの浮動小数演算プログラムを作ってもつまらないので、同様の動きをFPUで行うプログラムも別途用意してみます。さりげなくベンチマークにも挑戦してみましょう!
第5回『x86系CPUのネイティブコードを解析する』で作成した「opcode.cpp」に下記の関数を追加します。これらは、先ほど解説したSSE2に対応している機械語命令を生成するための関数です。
void op_movlpd_MR(int xmm_reg,int base_reg,int offset,char mod){ //movlpd qword ptr[base_reg+offset],xmm_reg __op_format((char)0x66,(char)0x0F,(char)0x13, xmm_reg,base_reg,offset,mod); } void op_movlpd_RM(int xmm_reg,int base_reg,int offset,char mod){ //movlpd xmm_reg,qword ptr[base_reg+offset] __op_format(0,(char)0x66,(char)0x0F,(char)0x12, xmm_reg,base_reg,offset,mod); } void op_movsd_RR(int xmm_reg1,int xmm_reg2){ if(xmm_reg1==xmm_reg2) return; //movsd xmm_reg1,xmm_reg2 __op_format((char)0xF2,(char)0x0F,(char)0x10, xmm_reg1,xmm_reg2,0,MOD_REG); } void op_movsd_MR(int xmm_reg,int base_reg,int offset,char mod){ //movsd qword ptr[reg+offset],xmm_reg //movsd qword ptr[reg],xmm_reg __op_format(0,(char)0xF2,(char)0x0F,(char)0x11, xmm_reg,base_reg,offset,mod); } void op_movss_RR(int xmm_reg1,int xmm_reg2){ if(xmm_reg1==xmm_reg2) return; //movss xmm_reg1,xmm_reg2 __op_format((char)0xF3,(char)0x0F,(char)0x10, xmm_reg1,xmm_reg2,0,MOD_REG); } void op_movss_RM(int xmm_reg,int base_reg,int offset,char mod){ //movss xmm_reg,dword ptr[base_reg+offset] __op_format((char)0xF3,(char)0x0F,(char)0x10, xmm_reg,base_reg,offset,mod); } void op_movss_MR(int xmm_reg,int base_reg,int offset,char mod){ //movss dword ptr[reg+offset],xmm_reg //movss dword ptr[reg],xmm_reg __op_format((char)0xF3,(char)0x0F,(char)0x11, xmm_reg,base_reg,offset,mod); } void op_movd_RX(int reg,int xmm_reg){ __op_format((char)0x66,(char)0x0F,(char)0x7E, xmm_reg,reg,0,MOD_REG); } void op_cvtsd2ss(int xmm_reg1,int xmm_reg2){ //cvtsd2ss xmm_reg1,xmm_reg2 __op_format((char)0xF2,(char)0x0F,(char)0x5A, xmm_reg1,xmm_reg2,0,MOD_REG); } void op_cvtss2sd(int xmm_reg1,int xmm_reg2){ //cvtss2sd xmm_reg1,xmm_reg2 __op_format((char)0xF3,(char)0x0F,(char)0x5A, xmm_reg1,xmm_reg2,0,MOD_REG); } void op_cvttsd2si(int reg,int xmm_reg){ //cvttsd2si reg,xmm_reg __op_format((char)0xF2,(char)0x0F,(char)0x2C, reg,xmm_reg,0,MOD_REG); } void op_cvttss2si(int reg,int xmm_reg){ //cvttss2si reg,xmm_reg __op_format((char)0xF3,(char)0x0F,(char)0x2C, reg,xmm_reg,0,MOD_REG); } void op_cvtsi2sd(int xmm_reg,int reg){ //cvtsi2sd xmm_reg,reg __op_format((char)0xF2,(char)0x0F,(char)0x2A, xmm_reg,reg,0,MOD_REG); } void op_cvtsi2ss(int xmm_reg,int reg){ //cvtsi2ss xmm_reg,reg __op_format((char)0xF3,(char)0x0F,(char)0x2A, xmm_reg,reg,0,MOD_REG); } void op_comisd(int xmm_reg1,int xmm_reg2){ //comisd xmm_reg1,xmm_reg2 __op_format((char)0x66,(char)0x0F,(char)0x2F, xmm_reg1,xmm_reg2,0,MOD_REG); } void op_comiss(int xmm_reg1,int xmm_reg2){ //comiss xmm_reg1,xmm_reg2 __op_format(0,(char)0x0F,(char)0x2F,xmm_reg1, xmm_reg2,0,MOD_REG); } void op_addsd_RR(int xmm_reg1,int xmm_reg2){ if(xmm_reg1==xmm_reg2) return; //addsd xmm_reg1,xmm_reg2 __op_format((char)0xF2,(char)0x0F,(char)0x58, xmm_reg1,xmm_reg2,0,MOD_REG); } void op_subsd_RR(int xmm_reg1,int xmm_reg2){ if(xmm_reg1==xmm_reg2) return; //subsd xmm_reg1,xmm_reg2 __op_format((char)0xF2,(char)0x0F,(char)0x5C, xmm_reg1,xmm_reg2,0,MOD_REG); } void op_mulsd_RR(int xmm_reg1,int xmm_reg2){ if(xmm_reg1==xmm_reg2) return; //mulsd xmm_reg1,xmm_reg2 __op_format((char)0xF2,(char)0x0F,(char)0x59, xmm_reg1,xmm_reg2,0,MOD_REG); } void op_divsd_RR(int xmm_reg1,int xmm_reg2){ if(xmm_reg1==xmm_reg2) return; //divsd xmm_reg1,xmm_reg2 __op_format((char)0xF2,(char)0x0F,(char)0x5E, xmm_reg1,xmm_reg2,0,MOD_REG); }
それでは、これらの関数を活用して簡単な計算プログラムを組み立ててみましょう。下記のソースコードは、
i=(i+2-1)*10/10
という計算を行い、iの値が1億になったところで計算をストップするという四則演算を活用した単純なベンタマークプログラムです。ようは、足し算、引き算、乗算、除算をそれぞれ1億回行う処理をします。
void create_native_buffer(void){ //ネイティブコードを生成 //mov eax,1 op_mov_RV(REG_EAX,1); //mov ecx,2 op_mov_RV(REG_ECX,2); //mov edx,10 op_mov_RV(REG_EDX,10); //mov ebx,100000000 op_mov_RV(REG_EBX,100000000); //cvtsi2sd xmm1,eax op_cvtsi2sd(REG_XMM1,REG_EAX); //cvtsi2sd xmm2,ecx op_cvtsi2sd(REG_XMM2,REG_ECX); //cvtsi2sd xmm3,edx op_cvtsi2sd(REG_XMM3,REG_EDX); //cvtsi2sd xmm4,ebx op_cvtsi2sd(REG_XMM4,REG_EBX); //movsd xmm0,xmm1 op_movsd_RR(REG_XMM0,REG_XMM1); //loop: //addsd xmm0,xmm2 op_addsd_RR(REG_XMM0,REG_XMM2); //subsd xmm0,xmm1 op_subsd_RR(REG_XMM0,REG_XMM1); //mulsd xmm0,xmm3 op_mulsd_RR(REG_XMM0,REG_XMM3); //divsd xmm0,xmm3 op_divsd_RR(REG_XMM0,REG_XMM3); //comisd xmm0,xmm4 op_comisd(REG_XMM0,REG_XMM4); //jne -22(loopへ) op_jne(-22); //以下、メッセージボックスを表示するためのコード //push 0 op_push_V(0); //push dword ptr["SSE2 TEST"] op_push_V(0x00403013); //push dword ptr["計算が終了しました"] op_push_V(0x00403000); //push 0 op_push_V(0); //call dword ptr[MessageBoxA in import_table] op_call_M(0x0040205E); //ret 0 op_ret(); }
今回は、前回出てこなかったjne
命令(op_jne
関数で生成)を追加しています。このような小修正はソースコードをダウンロードしてご確認ください。
実行してみよう
さて、コーディングを終えたら、プログラムを実行してターゲットとなる「test.exe」を生成してみましょう。
生成される「test.exe」を実行すると、1億回、四則演算が行われた後、「計算が終了しました」というメッセージが表示されるはずです。CPU速度により、処理を終えるまでの時間はバラバラですが、筆者の環境では4秒程度で処理を終えました。
FPUと比較
では実際にFPUを活用して同様の処理をさせたとき、実行速度に差が出るのかを検証してみます。ここからはVC++の機能の一つである、インラインアセンブラを使ってプログラミングしますので、「test.exeを生成して…」というややこしいことは行いません。
// FpuTest.cpp : コンソール アプリケーションのエントリ // ポイントを定義します。 // #include "stdafx.h" int _tmain(int argc, _TCHAR* argv[]) { int i1,i2,i3,i4; _asm{ mov eax,1 mov i1,eax mov eax,2 mov i2,eax mov eax,10 mov i3,eax mov eax,100000000 mov i4,eax fild i1 start: fild i2 faddp st(1),st fild i1 fsubp st(1),st fild i3 fmulp st(1),st fild i3 fdivp st(1),st fild i4 fcomp fnstsw ax test ah,0x40 je start } MessageBox(0,"計算が終了しました","FPU TEST",MB_OK); return 0; }
このプログラムをコンパイル&実行し、どれくらいの時間で処理を終えるのかを確認してみましょう。
筆者の環境での測定結果は、SSE2のものと同じく、4秒程度でした。SSE2とFPUとで処理速度に差が出ることを信じていたのですが、残念ながらここまで単純なプログラムだとその差を体感することはできませんでした。やはり、SSE2の威力を発揮するためには、並列演算をバカみたいに繰り返さなければならないという結論にたどり着くのですね……。
まとめ
最後に、ちょっとしっくりとしない終わり方になってしまいましたが、SSE2の魅力が並列処理に隠されていることは明確なようです。今回紹介したSSE2対応の機械語命令は、倍精度浮動小数点に関するものばかりでしたので、興味がある方は、並列演算に対応した機械語命令について突っ込んでいけば面白い展開になりそうです。
さて、今回まで8回分の内容を通して、Windows実行ファイル「EXE」の謎に迫ってきました。結局のところ、EXEファイルにはネイティブコード以外にもさまざまな情報が格納されており、それらを統括管理しているのがPEヘッダ・セクション情報であるということがお分かりいただけたのではないかと思います。EXEファイルの一部であるネイティブコードに関しても、数え切れないほどのx86命令が存在し、昨今投入されたAMD64が拡張を推し進めているという非常に目まぐるしい状況となっています。その中でも、SSE2などの新たな浮動小数演算のための機構に着目し、どのような点に新アーキテクチャのメリットがあるのかを少しだけでも実感できたこの企画、筆者の私も今一度、勉強させられました。
Windows実行ファイル「EXE」の謎に迫る今回の企画はこれで一区切りとさせていただきます。相変わらずですが、筆者はこれらの技術を活用し、新たな言語ソフトウェアの開発を進めていくことにします。
今回の企画で紹介したさまざまな情報、テクニックを活用して自分だけのアセンブルソフトやコンパイラを開発される方が一人でも多くいれば、私としては大満足です。