RISC-VのVector Extensionをざっくりと理解する
こちらから仕様書のPDFをダウンロードできます。
SIMDとベクター演算の違い
SIMDはレジスタのbit長が固定されていて、そのサイズに合わせた命令が定義されています。後からより大きなサイズのSIMDレジスタが追加されたときには、それを使用する命令も同時に追加しなくてはなりません。その新しい大きなサイズのSIMDレジスタを使おうとすると新しいコンパイラで再コンパイルすることが必要になりますし、従来のものと共存するにはコードを振り分ける必要があります。
それに対してベクター演算ではレジスタの幅がシステムによって異なることが織り込まれた仕様になっています。つまり同じ命令列でベクターレジスタのbit長が異なるシステム間でバイナリ互換にできるようになっています。
RISC-V Vector Extention v1.0
v1.0の仕様書を見てざっくりと自分の言葉で整理すると以下のようになります。
Vector register 32本。v0 - v31。そのbit長はシステムごとに異なる固定長を持つ。
bit長はVLEN。VLENはシステムごとの定数。
7つの制御レジスタが追加。
- vstart, vxsat, vxrm, vscr が読み書き可能。
- vl, vtype, vlenb がリードオンリー。
vtypeレジスタとvlレジスタへの設定はvset{i}vl{i}
命令を使用する。
vlenbレジスタはVLEN/8, つまりベクターレジスタのバイト長を保持する。
vstartレジスタはtrapが発生したときにどこまで実行していたかをハードウェアによって書き込まれる。これによってtrapからの復帰するときに再開するべきところがわかるようになっている。
vxrmはRounding Mode(丸め)の設定。
vxsatは固定小数命令で飽和が生じたことが記録される。
v0はマスクに使われる。現在はv0だけがマスクレジスタとして使用可能だが、将来への拡張のためアセンブラ表記としてはv0に限定していない。
組み込み向けCPUでのベクター拡張
Zve*: Vector Extensions for Embedded Processors
回路規模を小さくおさえたい組み込み向けCPUでのベクター拡張。
最小のVLENが32, 64。
Zve32x, Zve64x: 整数のみサポート
Zve32f, Zve64f: 整数とfp32のみサポート
Zve64d: 整数、fp32, fp64をサポート
これは実行効率を上げるというよりもコードの互換性のためにあるのかもしれません。一度に処理できる個数は少ないけれどもベクターレジスタを使うようにビルドされたライブラリをそのまま使用することができます。
ArmのSVE(Scalable Vector Extension)はなんか仰々しくていかにもスーパーコンピュータ向けという感じですが、RISC-VのVector Extensionはもっとずっとカジュアルなので、数年したらFPUと同じくらいの扱いでさまざまなプロセッサに普通に搭載されるのではないでしょうか。
Arm のScalable Vector Extensionとの比較
こちらの記事が興味深いです。
SVEではFPUとVector registersが共用。
RISC-VではFPUとVector registersは別々。
SVEは命令数が多くて複雑。理解するのが難しい。
RISC-Vはシンプル。ベクター演算の入門に向いている。
Intel のAVXはArmの10倍複雑だそうです。
仕様書に載っているサンプルプログラム
SAXPY: Single-Precision A·X Plus Y
void
saxpy(size_t n, const float a, const float *x, float *y)
{
size_t i;
for (i=0; i<n; i++)
y[i] = a * x[i] + y[i];
}
# register arguments:
# a0 n
# fa0 a
# a1 x
# a2 y
saxpy:
vsetvli a4, a0, e32, m8, ta, ma // a4 <- 一度に処理できる個数
vle32.v v0, (a1) // Load x by the size of vector register
sub a0, a0, a4 // n -= a4
slli a4, a4, 2 // a4 <- a4 * sizeof(float)
add a1, a1, a4 // x += a4
vle32.v v8, (a2) // Load y by the size of vector register
vfmacc.vf v8, fa0, v0 // y += a * x
vse32.v v8, (a2) // Store y by the size of vector register
add a2, a2, a4 // y += a4
bnez a0, saxpy
ret
vectorレジスタのbit長によって一度に処理できる個数が変わりますが、このコードの自体はそのまま使えますね。これがSIMDとの決定的な違いです。
また、memcpyもベクターレジスタを使って一気に行うことができます。
# void *memcpy(void* dest, const void* src, size_t n)
# a0=dest, a1=src, a2=n
#
memcpy:
mv a3, a0 # Copy destination
loop:
vsetvli t0, a2, e8, m8, ta, ma
vle8.v v0, (a1)
add a1, a1, t0
sub a2, a2, t0
vse8.v v0, (a3)
add a3, a3, t0
bnez a2, loop
ret
ベクター命令以外を一段インデント下げています
clang 16でコンパイルして生成されたコード
$ clang-16 --version
clang version 16.0.0 (https://github.com/llvm/llvm-project.git 434575c026c81319b393f64047025b54e69e24c2)
Target: aarch64-unknown-linux-gnu
Thread model: posix
InstalledDir: /usr/local/bin
$ clang-16 -c -march=rv64gcv -target riscv64 -O2 saxpy.c
$ llvm-objdump -dr saxpy.o
この記事に書いてあった通り、clang-16ではベクタレジスタを持っているアーキテクチャ向けには-O2
の最適化でもうベクタレジスタを使うようにauto vectorizeされます。
注目するべきなのは一番最後のブロック。
saxpy.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <.text>:
0: 01 00 nop
0000000000000000: R_RISCV_ALIGN *ABS*+0x2
0000000000000002 <saxpy>:
2: 01 c1 beqz a0, 0x2 <saxpy>
0000000000000002: R_RISCV_RVC_BRANCH .LBB0_8
4: 73 23 20 c2 csrr t1, vlenb
8: 93 56 13 00 srli a3, t1, 1
c: 21 47 li a4, 8
e: 63 60 d7 00 bltu a4, a3, 0xe <saxpy+0xc>
000000000000000e: R_RISCV_BRANCH .LBB0_3
12: a1 46 li a3, 8
0000000000000014 <.LBB0_3>:
14: 63 60 d5 00 bltu a0, a3, 0x14 <.LBB0_3>
0000000000000014: R_RISCV_BRANCH .LBB0_5
18: 93 16 25 00 slli a3, a0, 2
1c: 33 07 d6 00 add a4, a2, a3
20: ae 96 add a3, a3, a1
22: b3 36 d6 00 sltu a3, a2, a3
26: 33 b7 e5 00 sltu a4, a1, a4
2a: f9 8e and a3, a3, a4
2c: 81 c2 beqz a3, 0x2c <.LBB0_3+0x18>
000000000000002c: R_RISCV_RVC_BRANCH .LBB0_9
000000000000002e <.LBB0_5>:
2e: 81 48 li a7, 0
0000000000000030 <.LBB0_6>:
30: 33 05 15 41 sub a0, a0, a7
34: 8a 08 slli a7, a7, 2
36: 46 96 add a2, a2, a7
38: c6 95 add a1, a1, a7
000000000000003a <.LBB0_7>:
3a: 07 a0 05 00 flw ft0, 0(a1)
3e: 87 20 06 00 flw ft1, 0(a2)
42: 43 70 05 08 fmadd.s ft0, fa0, ft0, ft1
46: 27 20 06 00 fsw ft0, 0(a2)
4a: 7d 15 addi a0, a0, -1
4c: 11 06 addi a2, a2, 4
4e: 91 05 addi a1, a1, 4
50: 01 e1 bnez a0, 0x50 <.LBB0_7+0x16>
0000000000000050: R_RISCV_RVC_BRANCH .LBB0_7
0000000000000052 <.LBB0_8>:
52: 82 80 ret
0000000000000054 <.LBB0_9>:
54: 93 52 13 00 srli t0, t1, 1
58: 93 86 f2 ff addi a3, t0, -1
5c: 33 78 d5 00 and a6, a0, a3
60: b3 08 05 41 sub a7, a0, a6
64: d7 76 00 0d vsetvli a3, zero, e32, m1, ta, ma
68: 57 54 05 5e vfmv.v.f v8, fa0
6c: 93 13 13 00 slli t2, t1, 1
70: 46 8e mv t3, a7
72: b2 86 mv a3, a2
74: 2e 87 mv a4, a1
0000000000000076 <.LBB0_10>:
76: 87 64 87 02 vl1re32.v v9, (a4)
7a: b3 07 67 00 add a5, a4, t1
7e: 07 e5 87 02 vl1re32.v v10, (a5)
82: 87 e5 86 02 vl1re32.v v11, (a3)
86: b3 87 66 00 add a5, a3, t1
8a: 07 e6 87 02 vl1re32.v v12, (a5)
8e: d7 15 94 b2 vfmacc.vv v11, v8, v9
92: 57 16 a4 b2 vfmacc.vv v12, v8, v10
96: a7 85 86 02 vs1r.v v11, (a3)
9a: 27 86 87 02 vs1r.v v12, (a5)
9e: 1e 97 add a4, a4, t2
a0: 33 0e 5e 40 sub t3, t3, t0
a4: 9e 96 add a3, a3, t2
a6: 63 10 0e 00 bnez t3, 0xa6 <.LBB0_10+0x30>
00000000000000a6: R_RISCV_BRANCH .LBB0_10
aa: 63 10 08 00 bnez a6, 0xaa <.LBB0_10+0x34>
00000000000000aa: R_RISCV_BRANCH .LBB0_6
ae: 01 a0 j 0xae <.LBB0_10+0x38>
00000000000000ae: R_RISCV_RVC_JUMP .LBB0_8
.LBB0_10
のところがベクターレジスタを使って一気に演算しているところです。
1回のループでベクターレジスタ2個分の演算をするようになっていますね。
余談
他のCPUでは関数内の相対ブランチはコンパイル時に飛び先が確定しているのが普通ですが、RISC-Vではそれの解決もリンカーの仕事になっていますね。これは以前のkernel/VM探検隊でmoldリンカーを作ったRuiさんが話していた、「RISC-Vはリンク時にコードセクションのサイズが変わることがある」ためなのでしょうね。
関連
Discussion