1日目は、精度を高める方向のお話をしました。今回は、逆...とは言いませんが、精度を落としたうえで計算効率を高める内容です。
SIMDって?
SIMDは、簡単に言うと、1命令で複数のデータに同じ処理を適用するというものです。フリンの分類の1種ですね。
軽くイメージを。
CPUのSIMDは、レジスタを区切ってレジスタよりも低精度の複数の値に対して、1命令で同じ命令を適用するというものです。
初期のSSE命令がこんな感じ。
これは、128-bitのレジスタを32-bitごとに区切って、一つの命令で32-bitの値に対して同じ演算を実行したり、ベクトル演算を適用する事ができるというものです。
あくまでこれは例であり、最大512-bitのレジスタで64個の8-bitの数を演算することもできます。
今回は、主にCPUのベクトル拡張について扱いますが、同じくSIMDに分類されるマトリックス演算器あるいは拡張では、1024-bitなどの大きな演算器を有しており、それで64個の16-bitの値を1命令で処理できることになります。
x86_64におけるSIMD拡張命令は大きく「SSE」「AVX」「AVX-512」と分けることができます。SSEは128-bit、AVXは256-bit、AVX-512は512-bitのレジスタを必要としています。
SSEが必要とする128-bitレジスタは「XMM」と呼ばれます。
AVXが必要とする256-bitレジスタは「YMM」と呼びます。XMMとYMMは共存ではなくAVX対応CPUがSSE命令を実行するときに、YMMの下位128-bitをXMMとして利用します。
AVX-512も同様に512-bitレジスタ「ZMM」を搭載していますが、SSE命令実行時には下位128-bitをXMMとして、AVX命令実行時には下位256-bitをYMMとして利用します。
実際には、SSEは「SSE」「SSE2」「SSE3」...など、AVXは「AVX」「AVX2」などというマイナーアップデート(?)が存在しており、世代やマイクロアーキテクチャによって対応する拡張命令セットに差があります。
C++で触るぜ
さて、ここからはこれらのSIMD命令を触っていきましょう。
動作環境
- Ryzen 7 5700X(MMX/SSE1-4.2/SSSE3/AVX/AVX2)
- Windows 11 Pro
- gcc-g++
本当はAVX-512環境を用意したかったけど担当日までに用意できそうにないので、用意できたら検証します。
ヘッダーファイル
基本的に、WindowsとLinuxではCPUの組み込み命令を搭載したヘッダーファイルが既存で用意されているはずです。何もインストールせず、そのまま使えると思います(Intel Macが手元にないので、Macは検証できませんでした)。
#include<x86intrin.h>
x86intrin.h
には、すべての機能が含まれていますが、各拡張命令ごとにヘッダーファイルが別れて存在してはいます。
-
mmintrin.h
:MMX -
xmmintrin.h
:SSE -
emmintrin.h
:SSE2 -
pmmintrin.h
:SSE3 -
tmmintrin.h
:SSSE3 -
2mmintrin.h
:SSE4.1 -
nmmintrin.h
:SSE4.2 -
wmmintrin.h
:AES -
immintrin.h
:AVX/AVX2/FMA -
zmmintrin.h
:AVX-512
今回は基本的にAVX2を中心に話は進めていきます。
これらのヘッダーファイルに含まれている関数は「組み込み関数」(intrinsic)とも呼ばれており、CPUの機能が含まれています。
余談ですが、Armの場合、arm-neon.h
などを用います。残念ながら今回紹介するコードたちは使えません。
専用レジスタにロードする
まず第一歩、AVXレジスタにfloat
型のベクトルを格納してみましょう
float
型関数は32-bitですので、256-bitのAVXレジスタに8個入ります。
alignas(32) float a[8] = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0};
alignas(32) float b[8] = {8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0};
// AVXレジスタにロード
__m256 vec_a = _mm256_load_ps(a);
__m256 vec_b = _mm256_load_ps(b);
__mm256
型は、AVX用に用意されている新しい型で、32-bit浮動小数点数を8個格納します。
alignas(n)
は、アラインメントを指定しているものです。アラインメントについては後述します。
alignas(n)
の引数には、nバイト境界にアラインメントされるという値が入ります。具体的な値は以下のように設定してあげるといいです。
値 | |
---|---|
SSE4.2以前 | 16 |
AVX/AVX2 | 32 |
AVX-512 | 64 |
同様に以下の型が定義されています。
対応命令 | 型名 | 格納できる数値 |
---|---|---|
SSE以降 | __m128 |
4x FP32 |
SSE2以降 | __m128d |
2x FP64 |
SSE2以降 | __m128i |
16x 8-bit 整数 8x 16-bit 整数 4x 32-bit 整数 2x 64-bit 整数 |
AVX以降 | __m256 |
8x FP32 |
AVX以降 | __m256d |
4x FP64 |
AVX以降 | __m256i |
32x 8-bit 整数 16x 16-bit 整数 8x 32-bit 整数 4x 64-bit 整数 |
AVX-512 | __m512 |
16x FP32 |
AVX-512 | __m512d |
8x FP64 |
AVX-512 | __m512i |
64x 8-bit 整数 32x 16-bit 整数 16x 32-bit 整数 8x 64-bit 整数 |
ロード用の関数は、基本的に_mm{レジスタサイズ}_load_{格納する値の型}
の形となります。
以下のような形です。
// YMMにdouble型を格納する(AVX)
__m256d = _mm_load_pd(a);
// ZMMに整数を格納する(AVX-512)
__m512i = _mm_load_si512(a);
格納する型の部分は様々ですが、基本float
型ならps、double
型ならpd、整数型ならsi{レジスタ幅}になります。
また、別の方法として、直で値を格納する方法もあります。
__m256 a = _mm256_set_ps(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0);
四則演算する
// ベクトル加算
__m256 vec_add_result = _mm256_add_ps(vec_a, vec_b);
// ベクトル減算
__m256 vec_sub_result = _mm256_sub_ps(vec_a, vec_b);
// ベクトル乗算
__m256 vec_mul_result = _mm256_mul_ps(vec_a, vec_b);
// ベクトル除算
__m256 vec_div_result = _mm256_div_ps(vec_a, vec_b);
基本的に256の数字を変えれば、ベクトル幅を変える事ができます。psの部分は、格納する型によって変えてください。
以下、足し算のみにします。
メモリにストア
__m256
型などではそのまま出力することができません。なので、一度float
型の配列に値を戻します。
// 結果を格納するベクトル
float add_result[8];
// メモリにストアする
__mm256_store_ps(dd_result, vec_add_result);
出力は普通にfloat
型配列の出力なのでfor文を使うなどして出力してください。
結果は以下のとおりです。
add:9 9 9 9 9 9 9 9
sub:-7 -5 -3 -1 1 3 5 7
mul:8 14 18 20 20 18 14 8
div:0.125 0.285714 0.5 0.8 1.25 2 3.5 8
コードの全体像
#include <x86intrin.h>
#include <iostream>
void output(float output[8]){
for (int i = 0; i < 8; ++i) {
std::cout << output[i] << " ";
}
std::cout << std::endl;
}
int main() {
// YMMに格納する値の配列
alignas(32)float a[8] = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0};
alignas(32)float b[8] = {8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0};
// 出力用の配列
float add_result[8];
float sub_result[8];
float mul_result[8];
float div_result[8];
// AVXレジスタにロード
__m256 vec_a = _mm256_load_ps(a);
__m256 vec_b = _mm256_load_ps(b);
// ベクトル加算
__m256 vec_add_result = _mm256_add_ps(vec_a, vec_b);
// ベクトル減算
__m256 vec_sub_result = _mm256_sub_ps(vec_a, vec_b);
// ベクトル乗算
__m256 vec_mul_result = _mm256_mul_ps(vec_a, vec_b);
// ベクトル除算
__m256 vec_div_result = _mm256_div_ps(vec_a, vec_b);
// メモリにストア
_mm256_store_ps(add_result, vec_add_result);
_mm256_store_ps(sub_result, vec_sub_result);
_mm256_store_ps(mul_result, vec_mul_result);
_mm256_store_ps(div_result, vec_div_result);
// 出力
std::cout << "add:";
output(add_result);
std::cout << "sub:";
output(sub_result);
std::cout << "mul:";
output(mul_result);
std::cout << "div:";
output(div_result);
return 0;
}
追記
本来はアラインメントを指定することが望まれるみたいです。指摘されて私自身初めて気が付きました。非常に恥ずかしい(ご指摘ありがとうございます)。
具体的な対応としては、alignas()
を使用して指定する方法。ただし、これはfloat
配列を格納する際などにしか使えない気がします。
AVXの場合、32を指定してあげると良いみたいです。
// YMMに格納する値の配列
alignas(32)float a[8] = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0};
alignas(32)float b[8] = {8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0};
このような形です。
実際に、アラインメントを指定して上げることで、より高速のデータ処理が期待されます。
他方で、アラインメントを気にせずにロード・ストアする方法もあります。_mm256_loadu_ps(a)
や_mm256_storeu_ps(a,b)
を使う方法です。
// AVXレジスタにロード
__m256 vec_a = _mm256_loadu_ps(a);
__m256 vec_b = _mm256_loadu_ps(b);
/* 中略 */
// メモリにストア
_mm256_storeu_ps(add_result, vec_add_result);
_mm256_storeu_ps(sub_result, vec_sub_result);
_mm256_storeu_ps(mul_result, vec_mul_result);
_mm256_storeu_ps(div_result, vec_div_result);
ただし、この方法では、アラインメントをしないため動的メモリを扱っている事になってしまい、メモリアクセスに時間がかかることになり、速度が出ないという問題もあります。
コンパイル
g++等でコンパイルするときには、使用してほしい拡張命令セットを指定すると良いです。
使用できるオプションは以下の通り
-mmmx
,-msse
,-msse2
,-msse3
,-mssse3
,-msse4
,-msse4a
,-msse4.1
,-msse4.2
,-mavx
,-mavx2
,-mavx512f
,-mavx512cd
,-mavx512vl
,-mavx512bw
,-mavx512dq
,-mavx512ifma
,-mavx512vbmi
ちなみに、これらのオプションをつける事によって、組み込み関数を使用しなくとも、拡張命令を使用してくれる場合があります。
また、上記のオプションの代わりに、CPUのマイクロアーキテクチャをターゲット(-march=
)として、-ftree-vectorize
というオプション、あるいは-O3
最適化を指定することで、ターゲットにしたCPUに対して最適なベクトル化を行ってくれます。詳細は「自動でベクトル・SIMD化させる」の節にて。
そのあたりの詳細は、GNUのこれを見ると良いです。
自動でベクトル・SIMD化させる
次に、組み込み関数を使わずにSIMD化させる方法について紹介します。
このようなソースコードを用意しました。
void store_ary(float* __restrict a, float* __restrict b){
for(int i = 0; i < 100; ++i){
a[i] = i;
b[i] = 100 - i;
}
}
void add(const float* __restrict a, const float* __restrict b, float* __restrict c){
for (int i = 0; i < 100; ++i) {
c[i] = a[i] + b[i];
}
}
int main() {
constexpr int n = 100;
float a[n], b[n], c[n];
store_ary(a, b);
add(a, b, c);
return 0;
}
このソースコードは、2つのfloat
配列の要素を足していくという至ってシンプルなコード。これを、いくつかの方法でコンパイルしてみます。
説明の便宜上、A~Eの記号をつけてます。
# A:何も指定せずにコンパイル
g++ -o tmp.s ./tmp.cpp -S -march=znver3 -fopt-info-vec-optimized
# B:O1最適化を有効にしてコンパイル
g++ -O1 -o tmp.s ./tmp.cpp -S -march=znver3 -fopt-info-vec-optimized
# C:O2最適化を有効にしてコンパイル
g++ -O2 -o tmp.s ./tmp.cpp -S -march=znver3 -fopt-info-vec-optimized
# D:O2最適化を有効にしたうえで、-ftree-vectorize最適化も有効にする
g++ -O2 -o tmp.s ./tmp.cpp -S -march=znver3 -ftree-vectorize -fopt-info-vec-optimized
# E:O3最適化を有効にする
g++ -O3 -o tmp.s ./tmp.cpp -S -march=znver3 -fopt-info-vec-optimized
-fopt-info-vec-optimized
は、コンパイラがどのようにベクトル化を行ったかと言うのを出力してくれるオプションです。-S
はアセンブラ出力を行うオプションです。この2つのオプションについては、検証のために使用します。
-ftree-vectorize
は、ターゲットで使用可能なベクトル化を行うというオプションになります。-O
オプションは最適化レベルを示すオプションとなっています。今回のDとEの場合、ターゲットをRyzen 7 5700Xに合わせるために意図的に-march=znver3
としていますので、AVX2を使用してベクトル化を行ってくれます。
最適化レベルとベクトル化は関係しています。Gentoo LinuxのWikiを参考にすると、-O2
最適化で128-bit以内でベクトル化を行い、-O3
最適化では-ftree-vectorize
がデフォルトで有効になっており、使用可能な範囲でベクトル化を行います。
-fopt-info-vec-optimized
の出力をそれぞれ見てみます。
#A
出力なし
#B
出力なし
#C
.\simd2.cpp:4:22: optimized: loop vectorized using 16 byte vectors
.\simd2.cpp:11:23: optimized: loop vectorized using 16 byte vectors
#D
.\simd2.cpp:4:22: optimized: loop vectorized using 32 byte vectors
.\simd2.cpp:4:22: optimized: loop vectorized using 16 byte vectors
.\simd2.cpp:11:23: optimized: loop vectorized using 32 byte vectors
.\simd2.cpp:11:23: optimized: loop vectorized using 16 byte vectors
#E
.\simd2.cpp:4:22: optimized: loop vectorized using 32 byte vectors
.\simd2.cpp:4:22: optimized: loop vectorized using 16 byte vectors
.\simd2.cpp:11:23: optimized: loop vectorized using 32 byte vectors
.\simd2.cpp:11:23: optimized: loop vectorized using 16 byte vectors
この結果を見ますと、ターゲット以外を何も指定しない場合とO1最適化に置いてはベクトル化は行われず、O2最適化では、128-bit(16-byte)の範囲でベクトル化、-ftree-vectorize
を有効にするかO3最適化を指定することで、AVXの256-bit(32-byte)での最適化が行われているということがわかります。
-ftree-vectorize
については、ターゲットで使用できるベクトル化を使用するという事になっていますが、この拡張命令セットは指定することができます。これは、前節で触れたとおりです。具体的には-mavx2
とつければ、AVX2の範囲でベクトル化してくれるし、-mavx512f
とつければAVX-512の範囲でベクトル化してくれます。一例として実行環境はありませんが、-ftree-vectorize
とともに-mavx512f
と-O3
最適化をつけてコンパイルすると、
.\simd2.cpp:4:22: optimized: loop vectorized using 64 byte vectors
.\simd2.cpp:6:14: optimized: basic block part vectorized using 64 byte vectors
.\simd2.cpp:6:14: optimized: basic block part vectorized using 64 byte vectors
.\simd2.cpp:11:23: optimized: loop vectorized using 64 byte vectors
.\simd2.cpp:12:14: optimized: basic block part vectorized using 64 byte vectors
と表示されます。512-bit(64-byte)の範囲でベクトル化が行われていることがわかります。
では、Eのコマンドでコンパイルしたアセンブラ出力の一部(add関数)を見てみましょう。
_Z3addPKfS0_Pf:
.LFB1860:
.seh_endprologue
vmovups (%rcx), %ymm1
vmovups 32(%rcx), %ymm2
vaddps (%rdx), %ymm1, %ymm0
vmovups 64(%rcx), %ymm3
vmovups 96(%rcx), %ymm4
vmovups 128(%rcx), %ymm5
vmovups 160(%rcx), %ymm1
vmovups %ymm0, (%r8)
vaddps 32(%rdx), %ymm2, %ymm0
vmovups 192(%rcx), %ymm2
vmovups %ymm0, 32(%r8)
vaddps 64(%rdx), %ymm3, %ymm0
vmovups 224(%rcx), %ymm3
vmovups %ymm0, 64(%r8)
vaddps 96(%rdx), %ymm4, %ymm0
vmovups 256(%rcx), %ymm4
vmovups %ymm0, 96(%r8)
vaddps 128(%rdx), %ymm5, %ymm0
vmovups 288(%rcx), %ymm5
vmovups %ymm0, 128(%r8)
vaddps 160(%rdx), %ymm1, %ymm0
vmovups 320(%rcx), %ymm1
vmovups %ymm0, 160(%r8)
vaddps 192(%rdx), %ymm2, %ymm0
vmovups %ymm0, 192(%r8)
vaddps 224(%rdx), %ymm3, %ymm0
vmovups %ymm0, 224(%r8)
vaddps 256(%rdx), %ymm4, %ymm0
vmovups %ymm0, 256(%r8)
vaddps 288(%rdx), %ymm5, %ymm0
vmovups %ymm0, 288(%r8)
vaddps 320(%rdx), %ymm1, %ymm0
vmovups %ymm0, 320(%r8)
vmovups 352(%rcx), %ymm2
vaddps 352(%rdx), %ymm2, %ymm0
vmovups %ymm0, 352(%r8)
vmovups 384(%rdx), %xmm0
vaddps 384(%rcx), %xmm0, %xmm0
vmovups %xmm0, 384(%r8)
vzeroupper
ret
.seh_endproc
.def __main; .scl 2; .type 32; .endef
.section .text.startup,"x"
.p2align 4
.globl main
.def main; .scl 2; .type 32; .endef
.seh_proc main
これを見たところvmovups
とvaddps
という命令が使用されています。vmovups
命令では、8つの32-bitの値(float
)を256-bitのYMMレジスタにデータをロードし、vaddps
では、その8つの値が格納されたYMMを足し合わせていくというものです。
YMMには8つの値を格納しているので、vaddps
命令一つで、8つの演算を同時に処理する事ができ、SIMD化されていますね。
このような形でベクトル化させる場合、ソースコード側の配慮も必要なので、注意しましょう。
この節の記述において、@fujitanozomu様のご指摘を参考にさせていただきました。この場を借りてお礼申し上げます。
締め
結果ではなく手段の話でした。なので、実行時間の違いというのはありません。申し訳ないです。
しかし、使えるならSIMD演算を用いると、シングルコアでも並列度が上がって高速化することがあるかもしれませんのでおすすめです。
アドベントカレンダーの担当日までにAVX-512の環境を用意できなかったので、用意できたら内容が充実するので、また数カ月後に見に来てくれると嬉しいです。
以下、参考を載せておきます。基本的に、Intel C Compiler向けの内容ですが、GCCやClang、なんならVCでも使えます。
掲載当初、Int2を2-bitとしておりましたが、間違いです。Int2は2-Byteでした。
参考