Instricsを使わずして、SIMD(SSE/AVX/NEONなど)を使いつつ、アーキテクチャ依存性のないソースコードを書く方法。

 今回は趣向を変えて、プログラミングテクニックに近い事を書きます。
 最近のCPUには、SSEとかAVXとかNEONなどと呼ばれてる、要は幅の大きなレジスターを使って複数の計算を一回でやる機能…SIMDと呼ばれてる…があります。

 これ、普通にソースコードを書いてもなかなか使えるものでもなく、通常ネット上で書かれてるテクニックだとInstricsと呼ばれてる、それぞれのCPU専用の疑似命令をCやC++のソースコードに書いて使うのですが、それだと、CPUのアーキテクチャを超えて共通のソースコードを書くことがほぼ無理になります。

 しかし!今のコンパイラは非常に優秀なので、ある条件が揃ってSIMDレジスタを使う方が有利と判断すると、自動的に使うオブジェクトコードを吐き出します。

その条件とは
①レジスタとSIMDレジスタの間の演算であるか、アライメントが正当に取られてるメモリ空間とSIMDレジスタの間の演算である。
②多数のデータを一気に演算する事が可能とコンパイラが判断したか、_Pragma/#pragmaでベクター化できるループですよ。と明確に指定してある。
③コンパイラオプションで、-msse2などのSIMDを使ったコードを生成するように指定してあり、¥なおかつ、最適化オプションでベクター化最適化がONになっている。

この3つが満たされると、バンバンSIMDコードを吐いてくれるのです。

一つ一つ書いていきましょう。

①に関して。

 レジスタ対レジスタ演算は置いておきます。
 「アライメントが取られてる」と言うのはどういう事か。

 通常、CPUでのSIMD演算命令の大半は、レジスタ幅(SSEなどなら128bit=16bytes、AVX256なら256bit=32bytes)と同じようにアドレス整合・アライメントが取られてると云うことです。SSE(XMM)レジスタを使うなら、アドレスが16の倍数であること、AVX256レジスタ(YMM)なら、アドレスが32の倍数であること。です。
 この条件が無いと、SIMDレジスタを使う命令の大半は「アライメント違反」でトラップします。

 ただし、一部のmove/load/store命令は、アライメントが取られてないメモリに対しても使用可能です(例えばSSE系のmovdqu命令とか)が、CPUアーキテクチャによっては、そもそもアライメントを取れてないメモリアクセス全般がダメなものもありますが。今は知らないけど昔のMIPSとか…。

②に関して。

 コンパイラは、ソースコードを文脈的に分析して、「ベクトル化(SIMDレジスタを使う)可能なループだ」と判断するとSIMDレジスタを使うことがあります。
 それと同時に、コンパイラを制御するためのpragma(#pragma とか _Pragma)で、このループはベクトル化可能ですよ。と指定しておくと、①の条件を満たしてる変数に対してベクトル化ループと見なすわけです。

③に関して。

 これは言わずもがな。
 CPUアーキテクチャを適切に指定し、最適化フラグを設定すれば、ベクトル化できるところはしますよ。と言う事ですね。
 大抵のコンパイラでは、-O2で穏当な自動ベクトル化を、-O3で過激めのベクトル化を、知らぬ間に実行しようとします。

具体的なコードに行きましょう。

 まず、変数のアライメントは、こんな形で可能です

 ・C++11以降 → alignas(アライメント幅) 変数形 foo[bar];
 ・それ以前のGCC,CLANG → __attribute__((aligned(アライメント幅))) 変数形 foo[bar];
 ・それ以前のVisual C++ → __declspec(align(アライメント幅)) 変数形 foo[bar];
 
 又、C++17以降では、newに対してアライメント幅を設定したりすることも可能なようです。

 又、ベクトル化可能なループですよと示すには、

 ・GCC → _Pragma("GCC ivdep")
 ・CLANG → _Pragma("clang loop vectorize(enable) distribute(enable)")

 などと、ループ(大抵はforループ)の前の行で指定してやるといいです。
 ※Visual C++などは、わからない。調べてくださいね。

 実例に行ってみましょう。

*src + *dst -> *dst

と言う、16ビット32ワードの符号なし加算を行うコードを書いてみます。

実例として、CLANGで行きましょう。


 void xx(uint16_t* src, uint16_t*dst)
 {
     uint16_t* p = src;
	  uint16_t* q = dst;
     for(int i = 0; i < 32; i++) {
	      *q = *q + *p;
		  q++;
		  p++;
     }
  }

 これだと、多分、ちまちまと16ビットごとに加算していくコードを吐くでしょう。

 なぜか?*p も *qも、アライメントが取られるか取られないか、不確定だからですね。

 これを、ベクトル化してみましょう。


 void xx2(uint16_t *src, uint16_t *dst)
 {
     __attribute__((aligned(16))) uint16_t p[32]; // 128bit アライメントを保証する
     __attribute__((aligned(16))) uint16_t q[32]; // 128bit アライメントを保証する
	  
     // ベクトル化ループに出来ますよと明示的に指定して、
_Pragma("clang loop vectorize(enable) distribute(enable)")
     for(int i = 0; i < 32; i++) { // まず、ローカル変数にload
	      p[i] = src[i];
     }
	  
_Pragma("clang loop vectorize(enable) distribute(enable)")
     for(int i = 0; i < 32; i++) { // もう一つ、ローカル変数にload
	      q[i] = dst[i];
     }
_Pragma("clang loop vectorize(enable) distribute(enable)")
     for(int i = 0; i < 32; i++) { // 計算しましょう
	      q[i] = p[i] + q[i];
     }
	  
_Pragma("clang loop vectorize(enable) distribute(enable)")
     for(int i = 0; i < 32; i++) { // 最後に、ローカル変数からstore
	      dst[i] = q[i];
     }
 }

だいたいこんな感じです。

_Pragma 部分や__attribute__部分は、コンパイラや吐こうとするSIMDレジスタの仕様で違ってくる事がありますので、適当なヘッダ作ってマクロにしちゃったほうがいいでしょう。

 私がやってる、CSP/Qtでは、こんな感じにしてます。

https://osdn.net/projects/csp-qt/

 source/src/common.h から:

// hint for SIMD
#if defined(__clang__)

	#define __DECL_VECTORIZED_LOOP   _Pragma("clang loop vectorize(enable) distribute(enable)")

#elif defined(__GNUC__)

	#define __DECL_VECTORIZED_LOOP	_Pragma("GCC ivdep")

#else

	#define __DECL_VECTORIZED_LOOP

#endif


#ifdef _MSC_VER

	#define __DECL_ALIGNED(foo) __declspec(align(foo))

	#ifndef __builtin_assume_aligned

		#define __builtin_assume_aligned(foo, a) foo

	#endif

#elif defined(__GNUC__)

	// C++ >= C++11

	#define __DECL_ALIGNED(foo) __attribute__((aligned(foo)))

	//#define __DECL_ALIGNED(foo) alignas(foo)

#else

	// ToDo

	#define __builtin_assume_aligned(foo, a) foo

	#define __DECL_ALIGNED(foo)

#endif

 

 xx(uint16_t*, uint16_t*)とxx2(uint16_t* ,uint16_t*)とでは、かなり違うコードが吐かれてるはずです(面倒くさいので初版を書く時は逆アセンブルは省略しますが)。
 追記:32ワード一気だと、旧いコンパイラではベクトル化しないかも…。

 又、_Pragmaや__attribute__がなくても、xx2は、一旦、「キャッシュ内のローカル変数にデータを入れる」パスを設けて、繰り返し演算をキャッシュ内で行うので、この程度の式だと目に見えないでしょうけど、中の計算が複雑であれば、ベクトル化されなくても、スピード上のメリットが相当出てくるはずです。

※この文章も、150円での投げ銭購入や「サポート」でのご支援をお願いしますm(_ _)m

尚、配布は自由です(CC-BY-SA)

https://creativecommons.org/licenses/by-sa/3.0/deed.ja


ここから先は

0字

¥ 150

この記事が気に入ったらサポートをしてみませんか?