SIMD演算

C言語でSSEやSSE2を使う方法について。ポイントがいくつかあります。

  • xmmintrin.hやemmintrin.hをインクルードする

 SSE命令のみならxmmintrin.h、SSE2命令も使うならemmintrin.h

  • SIMD命令でアクセスするメモリは16バイトアラインメントにする

 gccの場合、_mm_malloc関数を使えばよい。

  • MMX命令はAMDのCPUでは使えない

Pentium4以降に限定されますが、SSEやSSE2を使うのが良いでしょう。


これらを踏まえて、画像の各ピクセルのRGB値を反転させる例を示します。
ピクセルは8bitのRGB値が並んでいるものとします。8bitの整数演算を行うので、8bit計算を16個並列で行うSSE2命令を使用します。


最初はSSE2のインクルードファイル。

#include <emmintrin.h>


つづいて16バイトアラインメントれたメモリを確保するために、_mm_mallocを使ってメモリを確保します。

  data = _mm_malloc(SIZE,sizeof(__m128i));

__m128iは128bit(=16バイト)のデータをあらわす型です。


ピクセルの反転は255-R, 255-G,255-Bで行います。SSE2なので8bitの16個並列引き算命令が使えます。n1を255、*pInは各ピクセルのR,G,B値とすると以下のようになります。

  _mm_subs_epu8 (n1 , *pIn)


コンパイルgccの場合 -msse2オプションをつけます。

  gcc -O2 -msse2 img_inv.c


以下のソースでそれぞれの処理を100回ループした結果は以下のとおり。SSEが速い。でも、16個同時だからもっと速くなると思ったけどこのくらいなのね。

通常     2.0[秒]
SSE2使用 1.5[秒]


以下はソースコードhttp://www.codeproject.com/KB/recipes/mmxintro.aspxを参考にしました。

/*
  gcc -O2 -msse2 img_inv.c
 */
#include <stdio.h>
#include <time.h>
#include <emmintrin.h>

void InvertImage(unsigned char* pSource,
                 unsigned char* pDest,
                 int nNumberOfPixels)
{
  int i;
  for(i = 0; i < nNumberOfPixels; i++ ) {
    *pDest = 255 - *pSource;
    pDest++;
    pSource++;
  }
}

void InvertImage_SSE2(unsigned char* pSource,
                     unsigned char* pDest,
                     int nNumberOfPixels)
{
  int nLoop = nNumberOfPixels/sizeof(__m128i);
  int i;
  /* ポインタとして扱う */
  __m128i* pIn  = (__m128i*) pSource;
  __m128i* pOut = (__m128i*) pDest;
 
  /* r0-r16のレジスタに255を設定 */
  __m128i n1 = _mm_set1_epi8 (255);

  for(i = 0; i < nLoop; i++ ) {
    *pOut = _mm_subs_epu8 (n1 , *pIn);  /* 255 - *pIn*/

    /*次の16バイト*/
    pIn++;
    pOut++;
  }
}

long diffMillis(struct timespec* t1, struct timespec* t2){
  long diff = 0;
  diff = (t1->tv_sec - t2->tv_sec) * 1000;
  diff = diff + (t1->tv_nsec - t2->tv_nsec) / 1000000;
  return diff;
}

#define SIZE 3*3200*2400

int main(void){
  struct timespec t1, t2; 
  
  unsigned char *data;
  unsigned char *out;

  /* 16バイトアラインメントでメモリを確保 */
  data = _mm_malloc(SIZE,sizeof(__m128i));
  out  = _mm_malloc(SIZE,sizeof(__m128i));

  clock_gettime(CLOCK_REALTIME, &t1);
  InvertImage(data,out,SIZE);
  clock_gettime(CLOCK_REALTIME, &t2);
  printf("InvertImage=%d\n",diffMillis(&t2,&t1));

  clock_gettime(CLOCK_REALTIME, &t1);
  InvertImage_SSE2(data,out,SIZE);
  clock_gettime(CLOCK_REALTIME, &t2);
  printf("InvertImage_SSE2=%d\n",diffMillis(&t2,&t1));
}

アセンブラコード
gcc -Sでアセンブラのコードを見てみました。
まずは、通常計算。あれ?notbで計算してる!反転命令使ってるじゃん。恐るべし最適化。

L5:
	movzbl	(%ebx), %eax
	addl	$1, %ebx
	notb	%al
	movb	%al, (%ecx)
	addl	$1, %ecx
	subl	$1, %edx
	jne	L5

つづいてSSE2。こちらはコードに書いたとおり_mm_subs_epu8がpsubusbとなっている。通常の演算がnotbを使ってたんで、XORを計算する_mm_xor_si128を使ってみたけど、実行速度は変わらず。

L15:
	movdqa	%xmm1, %xmm0
	psubusb	(%ebx), %xmm0
	addl	$16, %ebx
	movdqa	%xmm0, (%ecx)
	addl	$16, %ecx
	subl	$1, %eax
	jne	L15

SSE2使用で1.5秒はあんまり速くない気がする。このデータ量だと512Mピクセル/sの計算を行っていることになる。メモリはread/write行うので 512×3×2=3G/s の帯域を使っている。このくらいが限界なのか?
CPUキャッシュに入るくらいのデータ量にすると、SSE2使用が3Gピクセル/s、通常計算が390Mピクセル/sとなり7倍程度の差が出た。メモリ帯域がネックのようだ。


ちょっと思い当たって以下のところを見直してみた。

*pOut = _mm_subs_epu8 (n1 , *pIn);  /* 255 - *pIn*/

これ、元データを破壊していいなら以下のように書ける

*pIn = _mm_subs_epu8 (n1 , *pIn);  /* 255 - *pIn*/

こっちの方がキャッシュが効くので速くないか?と思って試したら、かなり速くなった。

通常     2.0[秒]
SSE2使用 1.2[秒]

もうちょっと調べたらSSE2にはキャッシュにデータを残さないでメモリに転送する命令があった。これを使ったコードは以下のとおり。

n2 = _mm_subs_epu8(n1, *pIn);  /* 255 - *pIn*/
_mm_stream_si128(pIn,n2);

結果としては 1.2[秒]→0.9[秒]に! SSEは奥が深いな。