最近のパソコンでは、CPUは高速でメモリもたくさんあるので、プログラマが頑張って高速な機械語を吐くように意識したコードを書くことはあまりありません。ですが、組み込みの世界ではまだまだCPUもメモリも貧弱な環境がたくさんあります。高速なCPUや大きいメモリはそのまま原価に跳ね返るためです。
そこで今回は、高速な機械語を吐くC言語のソースコードを書く練習をしてみます。やり方は、3種類の100回ループするC言語のソースコードを、それぞれアセンブラに変換して、どのくらい効率が良くなるか比較してみます。
コンパイルしたコードのアセンブラの確認
C言語のソースコードをコンパイルすると、通常はオブジェクトファイル(.o)に変換されます。
$ gcc -O2 -c hoge.c -o hoge.o
これを、オブジェクトファイルでなくアセンブラのコードに変換するには下記のように -S オプションを使います。すると .s のアセンブラファイルが生成されます。(gcc 以外のコンパイラでも大体 -S のことが多いです、コンパイラのマニュアルで確認してみてください)
$ gcc -O2 -S hoge.c
また、gccなら下記の形でもOKです。これはアセンブラ .s ファイル以外に、プリプロセス済みファイルの .iファイルも生成する優れものです。
$ gcc -O2 -save-temps hoge.c
今回はこの -save-temps オプションを使用します。
インクリメントなforループ
一般的なforループで書いてみます。
#define LOOP_NUM (100) int main(void) { volatile int i; for (i = 0; i < LOOP_NUM; i++) ; return 0; }
このコードを
$ gcc -O2 -save-temps for_incr.c
にてコンパイルすると、下記のようなアセンブラが出力されます。13命令ですね。
.section __TEXT,__text,regular,pure_instructions .macosx_version_min 10, 12 .globl _main .align 4, 0x90 _main: ## @main .cfi_startproc ## BB#0: pushq %rbp Ltmp0: .cfi_def_cfa_offset 16 Ltmp1: .cfi_offset %rbp, -16 movq %rsp, %rbp Ltmp2: .cfi_def_cfa_register %rbp movl $0, -4(%rbp) movl -4(%rbp), %eax cmpl $99, %eax jg LBB0_2 .align 4, 0x90 LBB0_1: ## %.lr.ph ## =>This Inner Loop Header: Depth=1 incl -4(%rbp) movl -4(%rbp), %eax cmpl $100, %eax jl LBB0_1 LBB0_2: ## %._crit_edge xorl %eax, %eax popq %rbp retq .cfi_endproc .subsections_via_symbols
デクリメントなforループ
次はデクリメントなforループで書いてみます。たまに「デクリメントループは読みにくいからやめるべし」という方もいますが、まあ、組込み屋ならパッとみて理解できた方が良いかなと思います。
#define LOOP_NUM (100) int main(void) { volatile int i; for (i = LOOP_NUM; i; i--) ; return 0; }
このコードを
$ gcc -O2 -save-temps for_decr.c
にてコンパイルすると、下記のようなアセンブラが出力されます。10命令に減りましたね!
.section __TEXT,__text,regular,pure_instructions .macosx_version_min 10, 12 .globl _main .align 4, 0x90 _main: ## @main .cfi_startproc ## BB#0: pushq %rbp Ltmp0: .cfi_def_cfa_offset 16 Ltmp1: .cfi_offset %rbp, -16 movq %rsp, %rbp Ltmp2: .cfi_def_cfa_register %rbp movl $100, -4(%rbp) jmp LBB0_2 .align 4, 0x90 LBB0_1: ## %.lr.ph ## in Loop: Header=BB0_2 Depth=1 decl -4(%rbp) LBB0_2: ## %.lr.ph ## =>This Inner Loop Header: Depth=1 cmpl $0, -4(%rbp) jne LBB0_1 ## BB#3: ## %._crit_edge xorl %eax, %eax popq %rbp retq .cfi_endproc .subsections_via_symbols
デクリメントなdo-whileループ
次はデクリメントなdo-whileループで書いてみます。これは組込屋さんでもぱっと見では少しわかりづらいかもしれません。コメントで「高速化のためにあえてやっている」という旨を書いておくといいかもしれません。
#define LOOP_NUM (100) int main(void) { volatile int i = LOOP_NUM; do { ; } while (--i); return 0; }
このコードを
$ gcc -O2 -save-temps do_while.c
にてコンパイルすると、下記のようなアセンブラが出力されます。8命令に減りましたね!素朴なforインクリメントループの13命令から比べるとだいぶ減りました!!!
.section __TEXT,__text,regular,pure_instructions .macosx_version_min 10, 12 .globl _main .align 4, 0x90 _main: ## @main .cfi_startproc ## BB#0: pushq %rbp Ltmp0: .cfi_def_cfa_offset 16 Ltmp1: .cfi_offset %rbp, -16 movq %rsp, %rbp Ltmp2: .cfi_def_cfa_register %rbp movl $100, -4(%rbp) .align 4, 0x90 LBB0_1: ## =>This Inner Loop Header: Depth=1 decl -4(%rbp) jne LBB0_1 ## BB#2: xorl %eax, %eax popq %rbp retq .cfi_endproc .subsections_via_symbols
まとめ
今回はループ文を例に、高速な機械語を吐くようなC言語のコードを書いてみました。このように、実際にC言語のソースコードを修正してアセンブラを確認して、、、というのを繰り返してコードを最適化できます。組み込みでは1Byte単位の勝負になることもあるので、この方法は頭の片隅に置いておくと良いかと思います。
以上、アセンブラを確認しながらC言語ソースコードを最適化する例でした!
コメント
https://godbolt.org/
オンラインでいろんなコンパイラのASM出力を確認できるサイトです
便利ですよー
おおお、すごいですねこれ!!お教えいただきありがとうございます!!!!
なぜ、このようにサイクル数が減るのか、解説頂けないでしょうか。
最適化アルゴリズよりも最適化のためにアセンブラ出力を出して比較する方法がメインなのであまり深くは立ち入らないようにしてましたが、一応簡単に説明いたします。
(最適化はCPUや処理系に思いっきり依存するので、一般的な事例をあげづらい。。。)
CPUによって挙動が違うのでここではARMと仮定して説明します。
▪️インクリメントループをデクリメントループの違い:
インクリメントループには3つのステップが必要です。
1. ループカウンタの加算
2. ループカウンタがループ回数未満かどうかの比較
3. (ループ回数未満だった場合の)ループ先頭へのジャンプ
一方、デクリメントループは下記の2つのステップでいけます。
1. ループカウンタの減算&結果が0かどうかを条件フラグにセット
2. 条件フラグがセットされていた場合の分岐
ポイントは、減算命令は実行結果が0かどうかを条件フラグにセットするということです。これによりインクリメントループのような比較処理が不要になります。
デクリメントループにすることによってループ終了条件を0との比較にすることができます。
またCPUは減算命令の時にその結果が0かどうかを条件フラグにセットできます。
▪️for文とdo-while文の違い:
for文は開始時に一回、ループカウンタとループ回数の比較が必要ですが、
do-while はそれが必要ありません。そのためより最適化しやすくなります。
こっちの例はちょっとイマイチでしたね。。。