C++ SIMD命令入門(初心者向け)
SIMD命令を使ったコードが書けるようになるための手引き
1.記事の概要・対象読者
本記事はSIMD命令を使ったことのない人が、C++でSIMD命令入りのコードを自前で実装できるようになるための手引きです。 C++の基本的な構文は理解していて、SIMD命令を使ってみたいが、どうすれば良いのかが分からない人を対象読者としています。
なお、本記事はSIMD命令入りコードの自前実装を目的とするため、効率的で高速に実行されるような調整については解説しません。 そちらは別記事にて解説を行っていますので、そちらを参照してください。
2.執筆の動機
SIMD命令はあくまで計算を高速化するための命令であり、速度を考慮しない場合、SIMD命令は必ずもっと基本的な命令のみで置き換えられます。 そのため初心者がSIMD命令を意図的に含んだコードを書く場面はまずありません。
それゆえにSIMD命令をテーマにした記事自体が少ないです。加えて命令が搭載されているかどうかの環境依存性も高いので、記事の執筆時期や環境の違いにより参考にならない場合も散見されます。
そこでこれまでの記事を踏まえ、執筆現在におけるできるだけ環境依存性の低いSIMD命令の実装方法をまとめ、時が経っても初心者の参考になる記事を残そうと思い本記事を作成しました。
3.前置き
SIMD命令周りは(特にコンパイラの種類やバージョンの)環境依存が強い ので、私が使用している環境を記しておきます。ざっくり言えばOSはWindows11かLinux Mint、CPUはintel CPU、コンパイラはg++(14.2.0)またはnvcc(12.6)です。
動作環境①
名称 | 説明 |
---|---|
CPU | i5-13600K (AVX2までは使用可) |
メモリ | DDR4-3200 32GB×2 |
GPU | RTX3060ti |
OS | Windows11(24H2) or Linux Mint |
コンパイラ | GCC(14.2.0) or nvcc(12.6) |
動作環境①
名称 | 説明 |
---|---|
CPU | i7-1165G7 (AVX512まで使用可) |
メモリ | DDR4-4267 16GB |
OS | Windows11(24H2) |
コンパイラ | GCC(14.2.0) |
またこれ以降の説明で厳密さに欠けていたり、大幅に省略していたりする場合がありますが、「初心者が動かせるようになること」を目的として情報をできるだけ絞っているので、大目に見てもらえると助かります(コメントでの指摘は大歓迎です。補足にもなりますし。)
4.前提知識(SIMD命令とは)
SIMD命令について調べると他では見かけない用語がかなり出てくるので、それらを説明しておきます。正直詳細を覚えても使わないのでざっくりとした説明のみにとどめます。
まずSIMDとは1つの命令を同時に複数のデータ(よくあるのは配列)に対して行うことです。これを行うSIMD命令を利用することで、1命令で複数データ分を一度に処理できて処理速度が向上します。
そのSIMD命令たちに、登場時期や用途ごとにつけた名前がAVX・SSEです。下に書いたものはいずれも命令群の名前なので、これらが出てきたらSIMD命令を指しているということだけ知っておけば問題ありません。
命令群名 | 説明 |
---|---|
AVX・AVX2 | 2025年現在もっとも使う命令群。2014年以降のPCなら大抵使える。「AVX=SIMD命令」という認識でも取り合えず困りません |
FMA | 複数の命令を一括で行う命令群。AVXの亜種という認識でOK |
AVX512 | サーバーや一部PC用の命令群。使えないPCが多いので気にしなくて良いです。 |
MMX・SSE | 昔の命令群。名前だけ覚えておきましょう |
NEON | Arm用の命令群。忘れましょう |
AMX | 初心者は使いません。忘れましょう |
またビット幅という用語もたびたび出てきますが、これはSIMD命令で扱えるデータの大きさ のことを指します。2025年現在256bitが主流(AVX・AVX2は256bit)であり、今後断りがなければビット幅を256bit=32Bとして解説していきます。
このビット幅から1回の命令で扱えるデータ数を計算することができて、float型やint型のような4Bのデータなら
5.SIMD命令を実行しよう
準備は整いましたのでSIMD命令を実行してみるところまでやってみましょう。
5.1.サンプルコードの実行
SIMD命令は最も簡単なコードでも手続きが多いので、簡単なコードを基に各行がどんな手続きを行っているのかを説明していきます。
//2つの配列の各要素の和を求める
#include <stdio.h>
#include <immintrin.h> //SIMD命令を使うためのライブラリ
int main(void) {
constexpr int simd_len = sizeof(__m256i)/sizeof(int);
// 各要素の和を求めたい配列
int req1[simd_len] = {3,-14,15,92,-65,35,89,-79};
int req2[simd_len] = {2,7,18,28,18,28,45,90};
// 与えられた配列のデータをSIMD命令用の変数に移す
__m256i A = _mm256_loadu_si256(reinterpret_cast<__m256i*>(req1));
__m256i B = _mm256_loadu_si256(reinterpret_cast<__m256i*>(req2));
// SIMD加算命令の実行
__m256i C = _mm256_add_epi32(A, B);
// 結果を配列に戻す
int ans[simd_len];
_mm256_storeu_si256(reinterpret_cast<__m256i*>(ans), C);
// 加算結果表示
printf("SIMD calculation result\n");
for (int i = 0; i < simd_len; ++i) {
printf("%d + %d = %d\n", req1[i], req2[i], ans[i]);
}
return 0;
}
説明の前に早速実行してみましょう!といいたいところですが、GCC環境で下のように単純にコンパイルをすると、多分エラーを吐きます。 (MSVCだと問題ないはずなので、出力結果のところまで読み飛ばしてください)
g++ simd1.cpp
色々書いてありますが要約すると「実行環境でSIMD命令が使えるか不明ですが、本当にSIMD命令使って良いんですか?」ってことです。
となると動作環境で使える命令セットを明示する必要があり、基本的には「-mavx,-mavx2,-mavx512f」といったようなコンパイルオプションをつけることで明示します。しかしいちいち使った命令がどの命令セットのものか調べるのは面倒なので
g++ simd1.cpp -march=native
と書きましょう。 こちらのオプションはコンパイル環境で実行可能な命令セットを自動で判定して、さっき挙げたコンパイルオプションを付けた場合と同様の効果を発揮してくれるので、こちらを使う方がはるかに簡単です。 もしうまくいかなければ-mavx2または-mavxをつければ大抵うまくいきます。
出力されたファイルを実行してみると以下のような出力になるはずです。
SIMD calculation result
3 + 2 = 5
-14 + 7 = -7
15 + 18 = 33
92 + 28 = 120
-65 + 18 = -47
35 + 28 = 63
89 + 45 = 134
-79 + 90 = 11
1回の加算命令_mm256_add_epi32()で8個の整数加算が行われており、SIMD命令が機能していることが分かります。
ここからは各部分の説明に移っていきます。
5.2.SIMD命令用の標準ライブラリ
#include <immintrin.h>
まずこれがSIMD命令用のライブラリです。SIMD命令を扱うために必要な関数が入っています。
命令群名 | ヘッダファイル |
---|---|
MMX | mmintrin.h |
SSE | xmmintrin.h |
SSE2 | emmintrin.h |
SSE3 | pmmintrin.h |
AVX、AVX2、FMA | immintrin.h |
AVX-512 | zmmintrin.h |
全部入り | x86intrin.h |
命令群ごとに使うものが違いますが、使うのは大抵AVXやFMAなのでimmintrin.hをインクルードしておけばまず問題ありません。 全部入りのものを使うという手もありますが、CUDA C++ では見つからないと言われたので、immintrin.hを使う方が無難だと思います。
5.3.SIMD命令用の型
__m256i
これはSIMD命令を行うデータを格納する変数です
ビット幅 | 整数型(大きさ不問) | float型(32bit) | double型(64bit) | 半精度浮動小数点型(16bit) |
---|---|---|---|---|
128bit | __m128i | __m128 | __m128d | __m128h |
256bit | __m256i | __m256 | __m256d | __m156h |
512bit | __m512i | __m512 | __m512d | __m512h |
この表を見れば分かる通り命名規則は単純で、「__m<ビット幅><元の型を示す記号 (i,無印,d,h)>」 といった形です。今回はAVX2(256bit)を使って整数(i)を処理したいので「__m256i」となるわけです。
5.4.通常変数とSIMD変数の相互変換(load・store関数)
constexpr int simd_len = sizeof(__m256i)/sizeof(int);
ここは1回のSIMD命令で処理できるデータ数を計算しているだけです。
__m256i A = _mm256_loadu_si256(reinterpret_cast<__m256i*>(req1));
_mm256_storeu_si256(reinterpret_cast<__m256i*>(ans), C);
これらはSIMD変数と通常変数の変換を行う関数です。load関数が「通常変数→SIMD変数」 、 store関数が「SIMD変数→通常変数」 を行います。いずれも引数として渡す場合はポインタなことに注意です。面倒な書き方をしていますがプログラマから見た結果自体はmemcpyと変わらないので
__m256i A,B,C;
// A = _mm256_loadu_si256(reinterpret_cast<__m256i*>(req1));
std::memcpy(&A,req1,sizeof(__m256i))
// _mm256_storeu_si256(reinterpret_cast<__m256i*>(ans), C);
std::memcpy(ans,&C,sizeof(__m256i))
と動作は一緒です。
またloadu、storeuのように関数名にuが入っていますが、uがないだけの関数も実はあります。詳細は「6.メモリアライメント」で出てきますが、初心者はとりあえずu付きとu無しがあったら前者を使いましょう。 memmoveとmemcpyのような関係でu付きの方が制約が少ないので。
5.5.SIMD演算命令
__m256i C = _mm256_add_epi32(A, B);
最後にここがメインのSIMD命令部分です。32bit整数型として、A,Bの加算を行い、その結果をCに格納しています。
関数がどんな演算を行うかは参考文献[1]を見ていただくのが最も良いですが、一々見るのは面倒なので、命名規則を説明しておきます。
まず最初の 「_mm256」はビット幅を示します。 今回は256bitなので _mm256、128bitの場合は _mm128となります。
次の 「_add」は命令の内容です。 今回は加算なのでaddです。よく使いそうなものとその軽い説明だけおいておきます
命令名 | 説明 |
---|---|
add,sub,mul,div | 加減乗除。末尾にsがついた場合飽和演算 |
and,andonot,or | ビット演算 |
slli,srli | 左シフト・右シフト |
max,min | 最大値・最小値 |
cmp | 大きさ比較 |
そして最後の「_epi32」はデータ型が何かを示します。
命令名 | 説明 |
---|---|
ps | float型(32bit浮動小数点) |
pd | double型(64bit浮動小数点) |
epi32 | 符号付き32bit型 |
epu16 | 符号なし16bit型 |
si256 | 256bit(後述) |
最後のsi256というのが少々分かりにくいのですが、簡単にいえばどの整数型として処理しても結果の変わらない関数 です。一番分かりやすい例はbit演算です。これは各bitごとに処理されますので、元の整数型の大きさは結果に全く影響がありません。他には先ほど出てきたload・store命令なども、やっていることはmemcpyなので整数型の大きさは関係ない、というわけです。
6.メモリアライメント
「5.SIMD命令を実行しよう」でSIMD命令入りコードの要素について1通り説明したわけですが、真面目にSIMD命令を活用しようとすると必ずメモリアライメントというものが出てくるので解説しておきます。
6.1.メモリアライメントとは何か
メモリアライメントとは簡単にいえば メモリアドレスの位置を特定の数字の倍数に揃えることです。SIMD命令で扱うデータのメモリアドレスは、原則としてその大きさの倍数でなければなりません。 大抵データ型の大きさは256bit=32Bなので、メモリアドレスも32の倍数でなければならないということです。
そのため変数の宣言やメモリ確保の際に、メモリアライメントは必ず行われています。先ほどのコードでは明示されていないのですが
__m256i A,B;
のように変数が宣言された場合、そのメモリアドレスは原則としてその大きさのサイズにアライメントされます。 これはSIMD変数特有のものではなく、int型なら4の倍数に、double型なら8の倍数にアライメントされます。そのため特別なことをしなくても問題ないわけです。
ただこれがメモリの動的確保になってくると話が変わってきます。mallocやnewを用いて動的な確保をする場合、基本的には64bit=8B以上のアライメントには対応していません。 そのためそれより大きなSIMD変数では、特別な方法を用いてメモリ確保を行う必要があります。
ただこれには大問題があり、特定の環境でしか使えないメモリ確保方法が多数乱立しています。つまりある人のSIMD命令コードを参考にしたら環境の違いでメモリ確保が行えない、ということが非常に起こりやすいのです。 実際私が知っているアライメントされたメモリ確保の方法は以下の通りです。
メモリ確保方法 | 説明 |
---|---|
alignas() | 静的宣言時のアライメント用 |
_aligned_malloc() | Windows用 |
posix_memalign() | POSIX用 |
aligned_alloc() | C11用 |
_mm_malloc() | intelコンパイラ用 |
__mingw_aligned_malloc() | MINGW用 |
std::align() | C++11以降 |
new() | C++17以降 |
手動調整 | 最終手段 |
そのうえこれらのほとんどは動作環境以外の違いはほとんどなく、ただ環境依存性を上げる結果となっています。こうなった原因はメモリ確保のための標準化された簡潔な方法が長らくなかったことです。C++11でstd::align()はありますがこれを用いた方法は一手間必要であり、標準機能として実装されたのはC++17になってからです。このせいで各環境ごとに独自に関数が用意され、環境依存性が無駄に高くなってしまったというわけです。
しかし現在はnew()によるメモリアライメントが可能になったため、テーブルのうち太字で書いた3種類のみで事足りるはずです。よってここではその3種類のみを解説していきます。
6.2.alignas によるメモリアライメント
まず alignas()はアライメントされたメモリの静的確保を行う際に使用します。 通常静的確保した場合はその変数の大きさでアライメントされるので特段要らないのですが、例えば「int型を__m256i型と同じ32B境界にアライメントしたい」といった場合に使います。
alignas(__m256i) int a[8] = {1, 2, 3, 4, 5, 6, 7, 8};
のように書くことで、int型であるaのアドレスが__m256i型と同じ32B境界にアライメントされます。
これの何が嬉しいかというと、load命令やstore命令を行う通常変数側がSIMD変数と同じアライメント条件を満たす場合、より高速なuなし関数を利用できるのです。 load・store命令の項で「uなし関数には制約がある」と言いましたが、その制約というのがメモリアライメントです。
挙げたサンプルコードではreq1,req2,ansなどに特別なメモリアライメントは行っていないので、loaduやstoreuを使う必要があります。しかしこれらはu無しよりも遅いので、alignas()であらかじめアライメントしておくことで高速化が見込めるというわけです。
6.3.new によるメモリアライメント
次に new()はアライメントされたメモリの動的確保を行う際に使用します。 SIMD命令は高速に計算できることが強みなので、大抵SIMD命令を使用するデータも膨大になります。そのためSIMD変数を動的確保したい場合は多く、実際のコードではalignas()よりこちらの方が良く出てきます。
C++17以降ではnew演算子のオーバーロードにメモリアライメントが追加されたため、用意された手法のうち最も環境依存性が低い方法です。
//#include<new>が必要
__m256i* b = new(std::align_val_t{sizeof(__m256i)}) __m256i[1000];
このsizeof(__m256i)の部分がメモリアライメントの基準値です。このコードなら32になります。
6.4.最終手段(手動メモリアライメント)
しかしこの方法でうまくいかない場合もあります(CUDA C++だとおそらくnew演算子のオーバーロードが準備されていない)ので、その場合は最終手段として手動でアライメントを行いましょう。 要は先頭のポインタをある数字の倍数にすることがメモリアライメントなので、メモリを少し大きめに確保し、そこから先頭ポインタが倍数となるように切り出せば自前でも行えます。
constexpr size_t alignment = sizeof(__m256i);
void* c = std::malloc(1000*sizeof(__m256i)+alignment);
__m256i* aligned_c = reinterpret_cast<__m256i*>((reinterpret_cast<size_t>(c) + alignment - 1)&~(alignment-1));
std::free(c);
もちろん動作環境に合わせた用意された関数を使うのも手ですが、それらが正常に動作するか確かめるのも面倒なので、new演算子でうまくいかなければこちらの方が良いと思います。これならば動的メモリ確保ができる環境ならば問答無用で実行可能です。
これでいかなる場合でもアライメントされたメモリ確保が行えるはずです。SIMD入りのコードが動かなくなる原因で一番多いのはこのメモリ確保部分なので、何か問題があれば真っ先に疑いましょう。 他のコードコピペしてきたら実装方法が違っていてうまくいかないとか、単純にメモリ管理でミスしてるとか、本当にミスが起こりやすいので。
7.おわりに
SIMD命令入りのコードを書けるようになるまでの手引きとしてこの記事を作成しましたが、さらに命令セットが追加されたり、メモリ確保の主流が変わればこの記事の内容も古いものとなるはずです。しかしできるだけ新しく汎用的な手法を紹介したはずなので、多少違っていても参考になると願っています。
またSIMD命令入りコードの本番はその計算能力を引き出せるような高速化チューニングにあります。 それにはメモリやキャッシュへの理解、演算速度の見積もり等も必要になってきますので、コードを書けるようになったら高速化もやってみましょう。どんな計算をするかによってかなりやり方が変わって難しいのですが、私が知っている一般的な手法や知識は「1.記事の概要・対象読者」にある別記事にまとめてあるので、必要ならばそちらを参考にしていただければと思います。
これを読んでくれた皆さんがSIMD命令を扱えるようになり、この記事がさらなる計算速度を手に入れる一助となれば幸いです。
8.参考文献
[1] 「Intel Intrinsics Guide」(SIMD命令の一覧)
(2025/08/12 参照)[2] 「c — _mm_mallocを使用する理由」(アライメントされたメモリの動的確保の手法)
(2025/08/12 参照)[3] 「アライメント指定されたデータの動的メモリ確保 [P0035R4] 」(アライメントされたメモリの動的確保の手法)
(2025/08/13 参照)[4] 北川 洋幸 著,『AVX命令入門 Intel CPU のSIMD命令を使い倒せ』,シナノ書籍印刷株式会社,初版第1刷,ISBN 978-4-87783-369-5
Discussion