読者です 読者をやめる 読者になる 読者になる

SIMDの使い方メモ

C++

勉強を始めたばかりなので間違ってるところもあるかもしれないので、ツッコミがあると嬉しいかも。

SIMD - Simple Instruction Multiple Data

単一命令で複数データを扱う仕組み。イメージとしては
2+5=7
3+6=9
9+4=13
12+16=28
と4回足し算するよりも
\left(\begin{array}{cc}2\\3\\9\\12\end{array}\right)+\left(\begin{array}{cc}5\\6\\4\\16\end{array}\right)=\left(\begin{array}{cc}7\\9\\13\\28\end{array}\right)
の方が足し算一回で速いだろ?って感じ。
実際の実装としてはIntelのSSE(=Streaming SIMD Extensions)とかその後継のAVX(=Advanced Vector eXtensions)がある。
例えばSSEは128bitレジスタをCPUに用意していて、単精度浮動点小数点数(float型)の場合なら32bit×4個の計算を同時に行うことができる。

実際に見てみると、例えばHaswellだと浮動点小数点数の加算命令FADDのレイテンシは3クロックであるのに対して、SSEの加算命令ADDPSも同じく3クロックである。
演算結果をメモリに読み書きするタイミングや回数さえ注意すれば、理論上4倍速にすることができるというわけだ。
【参考】http://www.agner.org/optimize/instruction_tables.pdf

実際に使う

それでは、一番最初にイメージとして出した計算をSSEで計算してみる。

#include <cstdio>
#include <xmmintrin.h>

int main()
{
	__m128 a, b, c;
	a = _mm_setr_ps(2.0f, 3.0f, 9.0f, 12.0f);
	b = _mm_setr_ps(5.0f, 6.0f, 4.0f, 16.0f);
	c = _mm_add_ps(a, b);

	float* r = (float*)&c;
	printf("%.2f, %.2f, %.2f, %.2f\n", r[0], r[1], r[2], r[3]);
}

実行結果

7.00, 9.00, 13.00, 28.00

こういった感じ。SSEを使う際にはxmmintrin.hというヘッダをインクルードする。g++ならあとはそのままコンパイルできる。
__m128というのが128bitのデータ型みたい。これがAVXになると__m256とか__m512とか出てくる。
gccの場合はコンパイル時に-msseオプションが必要。

ちなみに、上記のプログラムを-O3で最適化して逆アセンブルするとこんな感じ。

	.section	__TEXT,__text,regular,pure_instructions
	.section	__TEXT,__literal4,4byte_literals
	.align	2
LCPI0_0:
	.long	1094713344              ## float 12
LCPI0_1:
	.long	1098907648              ## float 16
	.section	__TEXT,__text,regular,pure_instructions
	.globl	_main
	.align	4, 0x90
_main:                                  ## @main
	.cfi_startproc
## BB#0:
	pushq	%rbp
Ltmp2:
	.cfi_def_cfa_offset 16
Ltmp3:
	.cfi_offset %rbp, -16
	movq	%rsp, %rbp
Ltmp4:
	.cfi_def_cfa_register %rbp
	subq	$160, %rsp
	leaq	L_.str(%rip), %rdi
	leaq	-144(%rbp), %rax
	movl	$1073741824, -68(%rbp)  ## imm = 0x40000000
	movl	$1077936128, -72(%rbp)  ## imm = 0x40400000
	movl	$1091567616, -76(%rbp)  ## imm = 0x41100000
	movl	$1094713344, -80(%rbp)  ## imm = 0x41400000
	movss	LCPI0_0(%rip), %xmm0
	movss	-72(%rbp), %xmm1
	unpcklps	%xmm0, %xmm1    ## xmm1 = xmm1[0],xmm0[0],xmm1[1],xmm0[1]
	movss	-76(%rbp), %xmm0
	movss	-68(%rbp), %xmm2
	unpcklps	%xmm0, %xmm2    ## xmm2 = xmm2[0],xmm0[0],xmm2[1],xmm0[1]
	unpcklps	%xmm1, %xmm2    ## xmm2 = xmm2[0],xmm1[0],xmm2[1],xmm1[1]
	movaps	%xmm2, -96(%rbp)
	movaps	%xmm2, -112(%rbp)
	movl	$1084227584, -4(%rbp)   ## imm = 0x40A00000
	movl	$1086324736, -8(%rbp)   ## imm = 0x40C00000
	movl	$1082130432, -12(%rbp)  ## imm = 0x40800000
	movl	$1098907648, -16(%rbp)  ## imm = 0x41800000
	movss	LCPI0_1(%rip), %xmm0
	movss	-8(%rbp), %xmm1
	unpcklps	%xmm0, %xmm1    ## xmm1 = xmm1[0],xmm0[0],xmm1[1],xmm0[1]
	movss	-12(%rbp), %xmm0
	movss	-4(%rbp), %xmm2
	unpcklps	%xmm0, %xmm2    ## xmm2 = xmm2[0],xmm0[0],xmm2[1],xmm0[1]
	unpcklps	%xmm1, %xmm2    ## xmm2 = xmm2[0],xmm1[0],xmm2[1],xmm1[1]
	movaps	%xmm2, -32(%rbp)
	movaps	%xmm2, -128(%rbp)
	movaps	-112(%rbp), %xmm0
	movaps	%xmm0, -48(%rbp)
	movaps	%xmm2, -64(%rbp)
	movaps	-48(%rbp), %xmm0
	addps	-64(%rbp), %xmm0
	movaps	%xmm0, -144(%rbp)
	movq	%rax, -152(%rbp)
	movq	-152(%rbp), %rax
	cvtss2sd	(%rax), %xmm0
	movq	-152(%rbp), %rax
	cvtss2sd	4(%rax), %xmm1
	movq	-152(%rbp), %rax
	cvtss2sd	8(%rax), %xmm2
	movq	-152(%rbp), %rax
	cvtss2sd	12(%rax), %xmm3
	movb	$4, %al
	callq	_printf
	movl	$0, %ecx
	movl	%eax, -156(%rbp)        ## 4-byte Spill
	movl	%ecx, %eax
	addq	$160, %rsp
	popq	%rbp
	ret
	.cfi_endproc

	.section	__TEXT,__cstring,cstring_literals
L_.str:                                 ## @.str
	.asciz	"%.2f, %.2f, %.2f, %.2f\n"


.subsections_via_symbols

ちょっと読む体力がないけど、addpsのようなSSE命令が使われていることは確認できる。

【参考】
SIMD - primitive: blog
Intel Intrinsics Guide