はじめに
- この記事はひとりNEONアドベントカレンダー2020 2日目の記事です
- 昨日の概要に続き、今日は実際にコードを書いてみます。
実装例
main.cpp
#include <arm_neon.h>
#include <iostream>
int main(int argc, char** argv)
{
unsigned char src0[] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15};
unsigned char src1[] = {100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115};
unsigned char dst [] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
uint8x16_t s0 = vld1q_u8(src0);
uint8x16_t s1 = vld1q_u8(src1);
uint8x16_t d = vaddq_u8(s0, s1); // ここで足し算
vst1q_u8(dst, d);
for( auto i = 0;i < 16;i++)
{
std::cout << i << '\t' << (int)src0[i] << '\t' << (int)src1[i] << '\t' << (int)dst[i] << std::endl;
}
return 0;
}
- 単一のファイルなので横着してコマンドベタ書きします
build(ARmv7)
g++ -mfpu=neon main.cpp
- Arm v7 では、コンパイラに明示的にNEONを使うように指定する必要があります(
-mfpu=neon
) - Arm v8 では、基本命令セットに入っているので、指定する必要はありません
build(ARmv8)
g++ main.cpp
- 実行結果
0 0 100 100
1 1 101 102
2 2 102 104
3 3 103 106
4 4 104 108
5 5 105 110
6 6 106 112
7 7 107 114
8 8 108 116
9 9 109 118
10 10 110 120
11 11 111 122
12 12 112 124
13 13 113 126
14 14 114 128
15 15 115 130
- 2つのベクトルに入っている要素が足されていることが分かります。
- 実際に加算処理は
vaddq_u8
の1回しか行われていないのですが、16個のデータが一度に処理されています
解説
include文
#include <arm_neon.h>
- 基本的な命令はこいつを
include
するだけですべて使えるようになります - GCCの場合、だいたい
/usr/lib/gcc/aarch64-linux-gnu/<GCCのバージョン>/include/arm_neon.h
- Raspberry Pi 4の場合は
/usr/lib/gcc/aarch64-linux-gnu/7.5.0/include/arm_neon.h
データ型について
- コード中、
uint8x16_t
という型を使っています-
<データ型>x<レーン数>_t
というフォーマットで指定します。
-
uint8x16_t s0 = vld1q_u8(src0);
- 基本的な型は以下の9つです
型名 | 各要素の型 | レーン数 |
---|---|---|
uint8x16_t |
uint8_t |
16 |
uint16x8_t |
uint16_t |
8 |
uint32x4_t |
uint32_t |
4 |
uint64x2_t |
uint64_t |
2 |
int8x16_t |
int8_t |
16 |
int16x8_t |
int16_t |
8 |
int32x4_t |
int32_t |
4 |
int64x2_t |
int64_t |
2 |
float16x8_t |
float16_t |
8 |
float32x4_t |
float32_t |
4 |
float64x2_t |
float64_t |
2 |
- 例えば
uint16
は16bit幅の符号なし整数なので、一般的にunsigned short
と呼ばれる型です。-
uint16x8_t
は、unsigned short
8個分がまとまって処理できます
-
- 上の9つの型は情報量として128bit分保持できます。
- Arm v8 では128bitレジスタを使い、Arm v7 では64bitレジスタ2つをくっつけて128bitレジスタとして振る舞います
- また、全型は、その半分の64bit幅のデータ型が存在します
型名 | 各要素の型 | レーン数 |
---|---|---|
uint8x8_t |
uint8_t |
8 |
uint16x4_t |
uint16_t |
4 |
uint32x2_t |
uint32_t |
2 |
uint64x1_t |
uint64_t |
1 |
int8x8_t |
int8_t |
8 |
int16x4_t |
int16_t |
4 |
int32x2_t |
int32_t |
2 |
int64x1_t |
int64_t |
1 |
float16x4_t |
float16_t |
4 |
float32x2_t |
float32_t |
2 |
float64x1_t |
float64_t |
1 |
- 注意が必要なのは、Arm v7では、
double
の演算はサポートされていません。そのため、Arm v7(もしくはRaspberry Pi 3 + 32bit Raspbianのような32bit OS on Arm v8)では、float64x2_t
、float64x1_t
は利用できません。 - また、
float16x8_t
、float16x4_t
はNEON
のFP16
拡張であり、Armv7では、別途別フラグを確認する必要があります。(詳しくは後日解説) - また、
poly8x16_t
、poly16x8_t
、poly64x2_t
という「多項式型」と言うのも存在します。- が、有効な利用方法及び原理を筆者が理解していないため、今回のアドベントカレンダーでは全面的に割愛します
- Githubで検索しても、引っかかるのはGCCのテストコードが何千ファイルと同じものが引っかかるだけで、具体的に利用している人をGithub上で見たことはありません。
- 多分
CRC
やSHA1
などを計算するのに必要っぽいのだけれど、不明です。
組み込みSIMD命令について
- サンプルコードでは
vaddq_u8
という命令を使いました。
uint8x16_t d = vaddq_u8(s0, s1); // ここで足し算
- 各命令は接頭辞の
v
で始まり、v<命令><bit幅>_<データ型>
というフォーマットで表されます。- 末尾のデータ型は、引数によって変わり、以下の14種類があります
- 符号なし整数:
u8
、u16
、u32
、u64
- 符号あり整数:
s8
、s16
、s32
、s64
- 浮動小数点型:
f16
、f32
、f64
- 多項式型:
p8
、p16
、p64
- 今回は
add
命令で、128bit分のデータを利用しました。この場合は末尾にq
が付きます。- 64bit幅分の同様の命令は
vadd_u8
です - 基本的に64bit幅でも128bit幅でも、演算の内容は同じです
-
vadd
に対してvaddq
の様に64bit/128bit幅でサイズが違うだけの命令の対が多数存在します。
- 64bit幅分の同様の命令は
- 以上の規則、みたいなものを頭の片隅に入れて本アドベントカレンダーを読んで頂ければより理解が深まるのではないかと思います。
ロードとストアについて
-
vld1q_u8
がメモリから読んでレジスタに格納するロード命令 -
vst1q_u8
がレジスタからメモリに格納するストア命令
uint8x16_t s0 = vld1q_u8(src0);
uint8x16_t s1 = vld1q_u8(src1);
uint8x16_t d = vaddq_u8(s0, s1); // ここで足し算
vst1q_u8(dst, d);
-
load
とstore
に関しては後日解説します - ベクトルの値から直接
print
するには、一度メモリに格納する必要があるため、そうしています。
おわりに
- サンプルコードを示してSIMD演算を行いました
- 明日も手島の担当で、
add
命令の解説から順番に始めます。