http://d.hatena.ne.jp/shi3z/20150422/1429659958
の反論見てると、「いまどきはコンパイラがSIMDにしてくれるし」みたいなのがそれなりにあるのだけど、僕は基本的に自動ベクトル化とか信用していないので、私は考えが古い人間なのかと思って結構ショックだった。
(機械語知ってればデバッグ技の幅がかなり広がるので、可能なら機械語まで知っておいたほうがいいというのには大体同意できる。その話はまた気が向いたら…)
以下、自動SIMD化を信用できない理由について書いておく。
遅くなる場合がある
まだプロファイルフィードバック技術が完成していない現代では、コンパイラがループ回数わからないから、3回とか7回のループだと遅くなる場合があるんだよな。
#include <x86intrin.h> #include <stdio.h> void __attribute__((noinline,noclone)) f(int *p, int *q, int n) { int i; for (i=0; i<n; i++) { p[i] += q[i]; } } int a[7]; int b[7]; int main() { int t0, t1, i; for (i=0; i<10000000; i++) { f(a, b, 7); } }
$ gcc -O2 vec.c $ perf stat ./a.out Performance counter stats for './a.out': 55.615946 task-clock (msec) # 0.997 CPUs utilized 3 context-switches # 0.054 K/sec 0 cpu-migrations # 0.000 K/sec 47 page-faults # 0.845 K/sec 151,202,534 cycles # 2.719 GHz <not supported> stalled-cycles-frontend <not supported> stalled-cycles-backend 460,530,894 instructions # 3.05 insns per cycle 110,101,691 branches # 1979.678 M/sec 4,898 branch-misses # 0.00% of all branches 0.055802920 seconds time elapsed $ gcc -O3 vec.c $ perf stat ./a.out Performance counter stats for './a.out': 71.496307 task-clock (msec) # 0.997 CPUs utilized 1 context-switches # 0.014 K/sec 0 cpu-migrations # 0.000 K/sec 47 page-faults # 0.657 K/sec 184,097,523 cycles # 2.575 GHz <not supported> stalled-cycles-frontend <not supported> stalled-cycles-backend 560,549,898 instructions # 3.04 insns per cycle 130,104,201 branches # 1819.733 M/sec 4,710 branch-misses # 0.00% of all branches 0.071746861 seconds time elapsed
cycles | instructions | ||||
-O2(自動SIMD無し) | 151,202,534 | 460,530,894 | |||
-O3(自動SIMD有り) | 184,097,523 | 560,549,898 | |||
-O3/-O2 | 121.8 [%] | 121.7 [%] |
まあ、これは結構弱点突いてるが、それでも、経験的に小さいループをそれなりに含む巨大なプログラムを -O3 でコンパイルすると遅くなったりほとんど変わらなかったりする場合が大半なように思う。
あと出てるコード見ると
f: .LFB2469: .cfi_startproc testl %edx, %edx jle .L29 leaq 16(%rdi), %rax cmpq %rax, %rsi leaq 16(%rsi), %rax setae %cl cmpq %rax, %rdi setae %al orb %al, %cl je .L12 cmpl $8, %edx jbe .L12 movq %rdi, %rax pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 pushq %rbx .cfi_def_cfa_offset 24 .cfi_offset 3, -24 andl $15, %eax shrq $2, %rax negq %rax andl $3, %eax cmpl %eax, %edx cmovbe %edx, %eax xorl %ecx, %ecx testl %eax, %eax je .L4 movl (%rsi), %ecx addl %ecx, (%rdi) cmpl $1, %eax movl $1, %ecx je .L4 movl 4(%rsi), %ecx addl %ecx, 4(%rdi) cmpl $3, %eax movl $2, %ecx jne .L4 movl 8(%rsi), %ecx addl %ecx, 8(%rdi) movl $3, %ecx .L4: movl %edx, %r11d xorl %r8d, %r8d xorl %r10d, %r10d subl %eax, %r11d movl %eax, %eax leal -4(%r11), %r9d salq $2, %rax leaq (%rdi,%rax), %rbx addq %rsi, %rax shrl $2, %r9d addl $1, %r9d leal 0(,%r9,4), %ebp .L6: movdqu (%rax,%r8), %xmm0 addl $1, %r10d paddd (%rbx,%r8), %xmm0 movaps %xmm0, (%rbx,%r8) addq $16, %r8 cmpl %r9d, %r10d jb .L6 addl %ebp, %ecx cmpl %ebp, %r11d je .L1 movslq %ecx, %rax movl (%rsi,%rax,4), %r8d addl %r8d, (%rdi,%rax,4) leal 1(%rcx), %eax cmpl %eax, %edx jle .L1 cltq addl $2, %ecx movl (%rsi,%rax,4), %r8d addl %r8d, (%rdi,%rax,4) cmpl %ecx, %edx jle .L1 movslq %ecx, %rax movl (%rsi,%rax,4), %edx addl %edx, (%rdi,%rax,4) .L1: popq %rbx .cfi_restore 3 .cfi_def_cfa_offset 16 popq %rbp .cfi_restore 6 .cfi_def_cfa_offset 8 .L29: rep ret .p2align 4,,10 .p2align 3 .L12: xorl %eax, %eax .p2align 4,,10 .p2align 3 .L3: movl (%rsi,%rax,4), %ecx addl %ecx, (%rdi,%rax,4) addq $1, %rax cmpl %eax, %edx jg .L3 rep ret
結構「うっ…」っていう気分になるんだよね。
-O2 は↓このぐらい
f: .LFB2493: .cfi_startproc xorl %eax, %eax testl %edx, %edx jle .L1 .p2align 4,,10 .p2align 3 .L5: movl (%rsi,%rax,4), %ecx addl %ecx, (%rdi,%rax,4) addq $1, %rax cmpl %eax, %edx jg .L5
何故こうなるかというと、
というのがあって、プロファイルフィードバックが完成されるまでは原理的に回避不可能なのでコンパイラは悪くないんですよ。
(ここでJavaなら実行時情報が取れるからさらに最適化できる可能性がある説選手の登場だあ!プロファイルコードのオーバーヘッドがあるから、プロファイル取ることによって遅くなる可能性も増えてることを忘れないでほしい)
結局SIMD化されてるか確認しないといけない
どのコンパイラのバージョンで、どの関数がSIMD化されるか確認しはじめたら、それなりの作業になるから、もうそこまでやるなら手でintrinsics書いたほうが速いのだった。
たとえば、
void __attribute__((noinline,noclone)) f(int *p, int *q, int n) { int i; for (i=0; i<n; i++) { int x = p[i]; if (x == q[i]) { x += 4; } p[i] = x; } } void __attribute__((noinline,noclone)) g(int *p, int *q, int n) { int i; for (i=0; i<n; i++) { if (p[i] == q[i]) { p[i]+=4; } } }
このふたつのプログラム、同じように見えるが、g() のほうは、f()と比べると命令セットによっては自動SIMD化はかなり難しい。これは、p が readonly memory で、書き込みアクセス時に SEGV が飛ぶような状態になっていると、自動SIMD化によって意味が変わってしまうからだ。(今のSSEはpmaskmovdがあるからできる)
これを理解するには、対象プログラム、C言語、対象命令セット、コンパイラの挙動まできっちり理解していないといけない。そこまで理解してるなら、手でintrinsics書くのもそんな難しくないでしょ…
そして、これらが理解できてないと、うまく自動SIMDされないときに「なんだこのクソコンパイラ」とか言ってしまうのだ。おお…かわいそうに…コンパイラは悪くないのだ…。
現代の SIMD は正確には SIMD(Single Instruction Multiple Data) ではない
よく聞くフレーズに、「ポータブルなCで書いて、SIMD化はコンパイラにまかせて性能と移植性を両立!ワークライフバランス!」みたいなのがあるが、あれ聞くたびに「お前まじめにSIMD書いたことあんの?」とか問い詰めたくなってしまうのだった。
真面目にSIMD書くなら、かなり高い確率でシャッフル系の命令を使うと思う。シャッフル命令は、実際にはSIMD命令ではない。128bitのデータを入力として、128bitのデータを出力する、つまり、一個のデータを受け取って、一個のデータを出力する命令なのである。
これはどういうことかというと、128bitデータが定義されてるとは限らないC言語では、シャッフルは表現できない可能性が高いということだ(まあ配列使って表現するだけならできるが…)。シャッフルの使用頻度を考えると、シャッフル無しでSIMDを使うのは、かなりきつい縛りだと思われる。
他にも、水平演算みたいにC言語では表現が難しい命令はいくつかある。まあx86の水平演算は無くてもあんま困らない感じがあるが…
まとめ
まあでも、別に自動SIMDが必要無いと思ってるわけではなくて、-O2 を -O3 に変えるだけでできるのだから、ちょっと試して効果あったら採用とかでも別に良いのではないかと思う。