RISC-Vベクタ拡張(RVV)はRISC-Vの目玉機能として規格の策定が進められてきました.RVVは2021年にバージョン1.0として批准されました(公式の仕様については下記URL).
RVVを搭載するRISC-V CPUは,バージョン0.7のAllwinner D1が先駆けだったように思います.下記の論文によると,バージョン1.0のRVVについては,SiFive X280,Andres NX27V,Atrevido 220などで実装されているそうです(この論文自体もオープンソースのRVV実装を提案するものでした).
RISC-VではOSやプログラミング言語処理系向けに,標準的にサポートすべき拡張をプロファイルとして定義しています(下記URL).2023年版プロファイルでは,RVVを必須とする案が審議されている模様です.
RVVには批判もあります(たとえば下記のツイート).曰く,仕様が複雑で巨大であること,
スーパースカラに組込んだ時にハードがどうなるかの考慮が不足しているということだそうです.
このようなRVVを活用するには,2023年5月現在では目下Auto-vectorizationを開発中のようで,アセンブリコードを書くしかなさそうです.しかも調べてみたところ,2023年5月現在ではアセンブラasにRVVをアセンブルさせるとエラーになってしまって受け付けてくれなかったので,GCCインラインアセンブラで実装することにしてみました.macOSの場合ですが,コンパイラオプションには下記のようにRVVを含めてビルドするように指定します.
実行環境についても,これもmacOSの場合ですが下記記事のように spike と pkを用いてエミュレーション実行させることができました.
この記事では,RVVをGCCインラインアセンブラで記述する方法について説明したいと思います.
コード例
先にRISC-VのCコード例を示します.
#include <stdint.h>
#if ! ( defined(__riscv_vector) && defined(ASM_ROUTINE) )
int64_t sum(uint64_t n, int64_t *v)
{
int64_t result = 0;
int64_t *p = v;
for(uint64_t i = 0; i < n; i++) {
result += *p++;
}
return result;
}
#else // ( defined(__riscv_vector) && defined(ASM_ROUTINE) )
int64_t sum(uint64_t n, int64_t *v)
{
int64_t result = 0;
int64_t *p = v;
asm volatile(
"mv t1, %[n]\n\t"
"vsetvli t0, t1, e64, m8\n\t"
"vmv.v.x v8, x0\n\t"
"loop%=:\n\t"
"vsetvli t0, t1, e64, m8\n\t"
"vle64.v v0, %[p]\n\t"
"vredsum.vs v8, v0, v8\n\t"
"sub t1, t1, t0\n\t"
"slli t0, t0, 2\n\t"
"add %[v], %[v], t0\n\t"
"bnez t1, loop%=\n\t"
"vmv.x.s %[result], v8\n\t"
: [result] "=r" (result), [p] "=rm" (*p), [v] "=r" (p)
: [n] "r" (n)
: "t0", "t1"
);
return result;
}
#endif // ( defined(__riscv_vector) && defined(ASM_ROUTINE) )
コンパイルと実行の仕方は次のとおりです.
C版
riscv64-unknown-elf-gcc -march=rv64gv -mabi=lp64d -O2 sum.c -o sum_c
./sum_c
RVV版
riscv64-unknown-elf-gcc -march=rv64gv -mabi=lp64d -O2 -DASM_ROUTINE sum.c -o sum_asm
./sum_asm
コード解説
#if ! ( defined(__riscv_vector) && defined(ASM_ROUTINE) )
マクロ__riscv_vectorはGCCによって設定され,RVVをサポートしている時に真になります.これを用いて #ifdef 等で分岐することができます.ここでは.__riscv_vectorが真で,かつマクロASM_ROUTINEも真である時にRVV用のコードが発動するようにしています.
asm volatile(
...
: [result] "=r" (result), [p] "=rm" (*p), [v] "=r" (p)
: [n] "r" (n)
: "t0", "t1"
);
-
asm volatileはインラインアセンブリコードを指示します.volatileはコード最適化の対象にしないことを示しています. -
...部分にアセンブリコードを書きます. -
: [result]の行は出力として変化するレジスタと変数を指定しています.=rmとすることでメモリを参照することを示しています.*pとpを呼び分けているのは,苦肉の策です(どうまとめたらいいかがわからなかったので,より良い方法をご存知の方は教えてください). -
: [n]の行は入力として使用するレジスタと変数を指定しています. -
"t0"の行は,内部で使用するレジスタを指定しています.ベクタレジスタは指定しなくて良いようです.
asm volatile(
"mv t1, %[n]\n\t"
"vsetvli t0, t1, e64, m8\n\t"
"vmv.v.x v8, x0\n\t"
...
);
プロローグ部分です.
-
t1レジスタにnの値を格納して,カウンタとして機能させます. -
vmv.v.x命令により,ベクトルレジスタv8をx0すなわち0で初期化します.ベクトルレジスタv8に結果をアキュムレート(累算)していくことになります. - ベクトル命令を使用する時にはそれに先立ってコンフィギュレーションをしないと不正命令例外になるようです.それで仕方なく後述するのと同じ
vsetvli命令の初期化を入れています.これをもっとスマートにするにはどうしたらいいかがよくわかりません.もしより良い方法をご存知の方は教えてください.
asm volatile(
...
"loop%=:\n\t"
"vsetvli t0, t1, e64, m8\n\t"
"vle64.v v0, %[p]\n\t"
"vredsum.vs v8, v0, v8\n\t"
"sub t1, t1, t0\n\t"
"slli t0, t0, 2\n\t"
"add %[v], %[v], t0\n\t"
"bnez t1, loop%=\n\t"
...
);
ループ本体です.
- ラベルを指定する時には
%=を末尾につけます.こうすることで,コード展開によって同名のラベルが現れたときに区別することができます. -
vsetvli命令によってベクトルレジスタを初期化します.e64は1要素のサイズが64ビットであることを示します.m8はベクトルレジスタを8つまとめて同時に使うことで,より効率の良いコードにします.スカラレジスタt0には各イテレーション(反復)で一度に処理する要素数が格納されます. -
vle64.v v0, %[p]命令によって,ベクトルレジスタv0に*pの値をロードします. -
vredsum.vs v8, v0, v8とすることで,ベクトルレジスタv8の先頭要素にベクトルレジスタv0の値をアキュムレート(累算)していきます. -
sub t1, t1, t0命令によって,カウンタt1から,このイテレーション(反復)で一度に処理する要素数t0を減算します. -
slli t0, t0, 2とadd %[v], %[v], t0により,ポインタをインクリメントします. -
bnez t1, loop%=とすることで,カウンタt1の値が0より大きい時にループします.
asm volatile(
...
"vmv.x.s %[result], v8\n\t"
...
);
エピローグ部分です.
-
vmv.x.s命令は,今回のようにベクタレジスタの先頭要素にアキュムレート(累算)した値などが格納されている時に,その値をスカラレジスタに移動するときに用いる命令です.ここではスカラレジスタである変数resultにベクトルレジスタv8の先頭要素を格納します.
おわりに
RVVについての日本語ドキュメントはこちらが便利です.
RVVでアセンブリプログラミングするのは,元となるCコードがあれば,比較的,容易にできると思いました.少なくともソフトウェアを組む立場からは,RVVはとても良さそうに見えます.
でも前述のツイートのように,ロジックを組む側からすると,実装するのはとても大変なのでしょうね.