ベクタライズされる基本的なプログラム例
Cコンパイラの最適化でベクタライズされるプログラムの基本的な条件を実験しながら確認してみました。
ベクタライズされるプログラムとされないプログラム
演算に使用される変数をそれぞれ配列、構造体、スカラーとしたとき、 ベクタライズされるかどうかを確認してみます。
今回の確認にはARM Linux用のGCC 12を使用しています。 特に断りがなければコンパイルオプションは次のとおりです。
-O3 -ftree-vectorize -march=armv7-a+simd
変数が配列になっているときはベクタライズ可能
変数を配列にすることはベクタライズを意識したプログラムの基本ですので まずそのとおりやってみます。
void add_array( const int32_t *restrict a, const int32_t *restrict b, int32_t *restrict c) { c[0] = a[0] + b[0]; c[1] = a[1] + b[1]; }
結果をアセンブラで確認すると期待どおり綺麗にベクタライズされています。 aとbそれぞれで32ビットの値が2つ同時に64ビットのdレジスタにロードされ、 その後の加算と結果のストアもずっと2つ同時になっています。
add_array: vld1.32 {d16}, [r0] vld1.32 {d17}, [r1] vadd.i32 d16, d16, d17 vst1.32 {d16}, [r2] bx lr
変数が構造体になっているときベクタライズされる条件
構造体の中の変数どうしの演算がベクタライズできるかどうかはやや不確かになります。 理論的には変数が配列にしたときのように連続したアドレスに配置されればできるはずです。 例えば次のようなシンプルな構造体であればまず問題ないでしょう。
struct vec2 { int32_t x; int32_t y; }; void add_struct( const struct vec2 *restrict a, const struct vec2 *restrict b, struct vec2 *restrict c) { c->x = a->x + b->x; c->y = a->y + b->y; }
結果は先ほどの変数が配列のときと同じになりました。
add_struct: vld1.32 {d16}, [r0] vld1.32 {d17}, [r1] vadd.i32 d16, d16, d17 vst1.32 {d16}, [r2] bx lr
変数がスカラーのときはベクタライズされない
変数がスカラーのときにベクタライズされることはおそらくないでしょう。 ベクタライズされない原因として単純にコンパイラがベクタライズ可能であると認識するのが難しいということもあるのでしょうが、 もし仮に認識できたとしても スカラーとしてプログラムされている変数が連続したアドレスに割り当てられることは保障できず、 個別にロードして1つのレジスタに詰め込んでから演算したのではコストが高くなり過ぎます。
void add_scalar( int32_t a_x, int32_t a_y, int32_t b_x, int32_t b_y, int32_t *c_x, int32_t *c_y) { *c_x = a_x + b_x; *c_y = a_y + b_y; }
ベクタライズされていないので加算命令addも2回でてきています。
add_scalar: add r1, r1, r3 ldr r3, [sp] add r0, r0, r2 str r0, [r3] ldr r3, [sp, #4] str r1, [r3] bx lr
ポインタはrestrictで型修飾されていないとベクタライズされないことがある
ベクタライズのプログラム例には restrictというあまり見かけない型修飾がついていることがよくあるのですが これは必須なのでしょうか?
restrictはポインタが指しているデータ領域が他のポインタから参照されているデータ領域と重複していないという意味になります。 ではベクタライズするときデータ領域が重複していると何が問題かというと、 出力と入力のデータ領域が一部重複しているときは出力したデータで入力のデータ領域を破壊することが理論的に起こり得るため、 コンパイラはベクタライズを躊躇するようになってしまいます。
下記の関数を例にすると、入力のaと入力のbはrestrictである必要はなく同じデータ領域であったとしても問題ないのですが、 出力のcと入力のa、または出力のcと入力のbが重複したデータ領域である場合には問題になります。 そのためcがrestrictであるか、またはaとbが共にrestrictである(aとbはどちらもcと重複していない)ことがベクタライズされるための条件になります。
void add_array_non_restrict( const int32_t *a, const int32_t *b, int32_t *c) { c[0] = a[0] + b[0]; c[1] = a[1] + b[1]; }
add_array_non_restrict: ldr r3, [r0] push {r4} ldr r4, [r1] add r3, r3, r4 str r3, [r2] ldr r4, [sp], #4 ldr r3, [r0, #4] ldr r1, [r1, #4] add r3, r3, r1 str r3, [r2, #4] bx lr
上記のプログラムを少し手直して、ポインタとしてではなく配列の実体を参照するようにするとrestrictが無くてもデータ領域が重複しないことが明らかなのでベクタライズされます。
void add_array_ref() { extern int32_t a[2]; extern int32_t b[2]; extern int32_t c[2]; c[0] = a[0] + b[0]; c[1] = a[1] + b[1]; }
add_array_ref: movw r1, #:lower16:a movt r1, #:upper16:a movw r2, #:lower16:b movt r2, #:upper16:b movw r3, #:lower16:c movt r3, #:upper16:c vld1.32 {d16}, [r1] vld1.32 {d17}, [r2] vadd.i32 d16, d16, d17 vst1.32 {d16}, [r3] bx lr
ループ回数が変数になっているときのベクタライズ
ループ回数が変数になっているプログラムをベクタライズするのは技術的に難しいと思うのですが、 これは圧巻の力技で解決されます。
例えば次のプログラムは不定回の代入を行うプログラムですが、 GCC 12ではループの残り回数が4以上のときは4並列で代入し、それ以外の残り回数のときは1つずつ代入するという それなりにボリュームのあるコードにベクタライズされました。 ここのところがどのようにベクタライズされるかはGCCのバージョンによりかなり異なるようです。
void variable_loop( int32_t n, int32_t *restrict a, int num) { for (int i = 0; i < num; i++) { a[i] = n; } }
variable_loop: cmp r2, #0 ble .L9 subs r3, r2, #1 cmp r3, #2 bls .L6 lsr ip, r2, #2 vdup.32 q8, r0 mov r3, r1 add ip, r1, ip, lsl #4 .L4: vst1.32 {q8}, [r3]! cmp ip, r3 bne .L4 bic r3, r2, #3 cmp r2, r3 beq .L12 .L3: add ip, r3, #1 push {lr} cmp r2, ip lsl lr, r3, #2 str r0, [r1, r3, lsl #2] ble .L1 add r1, r1, lr adds r3, r3, #2 cmp r2, r3 str r0, [r1, #4] it gt strgt r0, [r1, #8] .L1: ldr pc, [sp], #4 .L9: bx lr .L12: bx lr .L6: movs r3, #0 b .L3