Open6

RISC-V ベクトル拡張(Vector, RVV)メモ

だめぽだめぽ

正式な仕様

書籍

Web記事等

C言語へのバインディング

注意したい観点

私(@mod_poppo)は言語処理系に興味があるので、コンパイラーや言語設計の観点からベクトル拡張をどう扱えば良いか考えたい。

x86のSIMD事情はある程度知っているので、x86との比較も有益かもしれない。

ArmのSVEとの比較もできると良さそうだが、私はSVEにはそれほど詳しくない。

だめぽだめぽ

x86のSIMDではSIMDレジスターの幅が固定で要素数はそれに付随するもの、という感じだが、RVVでは要素数を中心に考えるっぽい。

実装定義のパラメーター

  • ELEN: 対応する最大の要素幅(ビット単位)。典型的には32、64や128(128ビット整数や四倍精度浮動小数点数を扱える場合)となりそう。ELEN≥8で、2の冪である必要がある。
  • VLEN: 一つのベクトルレジスターの幅(ビット単位)。2の冪で、2^16=65536以下であることが定められている。
  • VLEN≥ELENである必要がある。つまり、一つの要素は一つのレジスターに収まることとし、「複数のレジスターを使って一つの要素を表す」ような構成には対応しない。

状態

  • 32個のベクトルレジスターv0, …, v31
    • 各レジスターはVLENビットの状態を持つ。
  • 7個の制御レジスター:vstart, vxsat, vxrm, vcsr, vl, vtype, vlenb
    • vtypeはXLEN(典型的には32ビットか64ビット)ビット幅で、vill, vma, vta, vsew, vlmulのビットフィールドを持つ。
    • vsew: Vector selected element width。要素の幅を8ビット (e8)/16ビット (e16)/32ビット (e32)/64ビット (e64) の中から選択する。典型的には、一つのベクトルレジスターはVLEN/SEW個の要素に分割される。
    • vlmul: Vector Register Grouping。複数のレジスターを束ねて一つの大きなレジスターとして使ったり、一つのレジスターの一部だけを使ったりできる。LMUL=1 (m1), 2 (m2), 4 (m4), 8 (m8), 1/2 (mf2), 1/4 (mf4), 1/8 (mf8) などの値がサポートされる。
  • その他、ベクトルコンテクストに関するもの(状態の退避とかに使うやつ)
だめぽだめぽ

ループの構成

例えば、自明に並列化できるループがあったとする:

void float_array_add(size_t n, const float a[n], const float b[n], float result[restrict n])
{
    for (size_t i = 0; i < n; ++i) {
        result[i] = a[i] + b[i];
    }
}

これをベクトル化する時、x86のSSEやAVX、ArmのNEON等では次のような疑似コードになる:

    size_t i = 0;
    // ベクトルで処理できる部分
    for (; i + 4 <= n; i += 4) {
        float32x4_t aa = a[i..<i+4]; // load
        float32x4_t bb = b[i..<i+4]; // load
        float32x4_t cc = aa + bb; // 演算
        result[i..<i+4] = cc; // store
    }
    // 端数の処理
    for (; i < n; ++i) {
        float aa = a[i]; // load
        float bb = b[i]; // load
        float cc = aa + bb; // 演算
        result[i] = cc; // store
    }

このコードで、「本来処理したい要素数」(コード中の n)はRVVでは「application vector length」(AVL)と呼ばれる。AVLを vset{i}vl{i} 命令に渡すと「ループの現在の周回で処理すべき要素数」(vector length; VL)が得られる。VLは専用のレジスター(vl)に保管されるほか、汎用レジスターでも受け取ることができる(ループの次の周回までにAVLから減算するのに使う)。

疑似コードで書けば次のようになりそう:

    size_t i = 0;
    do {
        size_t vl = setvl(n - i); // n-iがAVLに相当
        float32xN_t aa = a[i..<i+vl]; // load
        float32xN_t bb = b[i..<i+vl]; // load
        float32xN_t cc = aa + bb; // 演算
        result[i..<i+vl] = cc; // store
        i += vl;
    } while (vl != 0);
だめぽだめぽ

要素数の決定

一度に処理できる要素の数は、当然、ハードウェアが提供するベクトルレジスターの幅(VLEN)に依存する。レジスターの幅が大きい方が1命令でより多くの要素を処理できる。

そして、もちろん、処理に使う要素の幅(SEW; e8/e16/e32/e64)にも依存する。幅が大きい要素型(例:64ビット浮動小数点数)だと1つのレジスターに乗せられる要素数は少なくなるし、幅が小さい要素型(例:8ビット整数)だと1つのレジスターに乗せられる要素数は多くなる。

さらに、処理に必要なベクトルレジスターの個数(register pressureというやつ?)も考慮の対象となる。32個のレジスターをフルに使う複雑な処理であれば32個のレジスターを独立に扱える必要があるが、必要なレジスターの個数が少なければ、複数のレジスターを束ねて1つの巨大なベクトルとして扱えると良さそうである。これを制御するパラメーターがLMULで、「32個のレジスターを独立に使う(LMUL=1 (m1))」「レジスターを2本ずつまとめて16個のグループに分ける(LMUL=2 (m2))」「レジスターを4本ずつまとめて8個のグループに分ける(LMUL=4 (m4))」「レジスターを8本ずつまとめて4個のグループに分ける(LMUL=8 (m8))」などの選択ができる。

処理の中で複数の幅のデータ型を扱う場合、LMUL=1だと小さな幅のデータを格納したレジスター1本に対して大きな幅のデータを格納するのに2本のレジスターが必要になることがある。しかし、独立したレジスターが大量に必要な複雑な処理だと一つのベクトルにレジスターを2本も使いたくないかもしれない。そういう場合は、LMULを分数値(1/2 (mf2), 1/4 (mf4), 1/8 (mf8))にして、小さな幅のデータを1本のレジスターの一部だけを使って格納しておくと、大きな幅のデータを1本のレジスターに収められる。

だめぽだめぽ

x86 SIMDからの移植・WebAssembly SIMDからの変換(128ビットSIMDのRVV上での模倣)

x86の資産をRISC-Vへ移植したり、WebAssemblyの128ビットSIMDをRVVで動かしたいかもしれない。

一文字の「V」で表される拡張(標準的なベクトル拡張)はZvl128bを含意し、Zvl128b拡張はVLENが128以上であることを保証する。なので、「ベクトルレジスターの幅が128ビット以上ある」という条件は普通のV拡張を実装したプロセッサーでは満たされると考えて良い。

128ビットSIMDはデータ型の幅によって要素数が変わる(8ビット整数なら16要素(SEW=8, AVL=16)、64ビット浮動小数点数なら2要素(SEW=64, AVL=2))。RVVで128ビットSIMDをエミュレートしたかったら、扱うデータ型の幅が変わる度にvsetivli命令を発行することになるのだろうか。

自動ベクトル化の例ではループを回していたが、128ビットベクトルの1回の演算のために毎回ループをコード生成していては面白くない。指定されたAVLがそのままVLとして採用される保証があれば1個の演算を1個の命令にコンパイルできる。そして、AVL≤VLMAX(ただしVLMAX=LMUL×VLEN/SEW)であれば VL=AVL であることが保証されている(仕様書6.3)。VLEN≥128で、AVL×SEW=128なので、LMUL≥1とすればこの不等式は確実に成り立つ。

だめぽだめぽ

課題

私の現時点の理解で、RVVに対して感じている課題を並べておく。

  • レジスター(あるいはそのグループ)を表すデータ型のサイズがコンパイル時に決まらない。イメージとしては float[VLMAX] みたいな配列になるが、VLMAXがLMUL, VLEN, SEWに依存して決まるので、これは可変長配列である。ArmのSVEにも似たような問題があり、C言語では特殊なデータ型を用意していた。
  • SEWは「扱うデータ型」によって決まり、LMULの最適な値はregister pressureに依存して決まる(私の理解が正しければ)。ループの自動ベクトル化のようにコンパイラーがコード全体を見渡せる場合は良いが、一部の処理を関数に切り出したい場合などに困る気がする。