このブログは、株式会社フィックスターズのエンジニアが、あらゆるテーマについて自由に書いているブログです。
アルバイトの大友です。
TensorコアのWMMA APIを使っている人があまりいなかったため、6月中はインターンとして、7月からはアルバイトとしてその使い方や性能を調べていました。
この記事はその成果をまとめたものです。
Tensorコアを使うことでFP16のSIMD計算(f16x2)に比べ密行列積を5倍程度高速化できました。
NVIDIA Voltaアーキテクチャから採用されたTensorコアは2つの$4 \times 4$のFP16行列の積を1サイクルで計算し、その累積和をFP16/FP32で取ることができる計算ユニットです。
cuBLAS, cuDNNなどのライブラリではCUDA 9からTensorコアを利用できます。
CUDA 9ではWMMA (Warp Matrix Multiply Accumulate) と呼ばれるTensorコアを使用してGEMM計算を行うためのC++ APIが用意されています。
このAPIでは$16 \times 16$など決められた大きさの行列をfragmentと呼ばれる構造体にスレッドごとに分割し、1ワープ(32スレッド)が協調してその行列積を計算します。
行列$A,B,C \in R^{16 \times 16}$に対し$C \Leftarrow A \times B$を計算する流れは次のようになります。
$16 \times 16$の2行列$A,B$の積$C$を計算するCUDAのカーネル関数は次のように書けます。(CUDA 9.2.148)
__global__ void matmal_16x16(const half* const a_ptr,const half* const b_ptr,half* const c_ptr){
// A,B,Cのfragment
nvcuda::wmma::fragment<nvcuda::wmma::matrix_a, 16, 16, 16, half, nvcuda::wmma::col_major> a_frag;
nvcuda::wmma::fragment<nvcuda::wmma::matrix_b, 16, 16, 16, half, nvcuda::wmma::col_major> b_frag;
nvcuda::wmma::fragment<nvcuda::wmma::accumulator, 16, 16, 16, half> c_frag;
// Cのfragmentを0で初期化
nvcuda::wmma::fill_fragment(c_frag, __float2half(.0f));
// A,Bをメモリからfragmentに読み込み
nvcuda::wmma::load_matrix_sync(a_frag, a_ptr, 16);
nvcuda::wmma::load_matrix_sync(b_frag, b_ptr, 16);
// C ← A x B + C
nvcuda::wmma::mma_sync(c_frag, a_frag, b_frag, c_frag);
// Cのfragmentの中身をメモリに書き出し
nvcuda::wmma::store_matrix_sync(c_ptr, c_frag, 16, nvcuda::wmma::mem_col_major);
}
このAPIでは1ワープで$16 \times 16$の行列の積を計算するため、カーネル関数は次のように呼び出します。
constexpr unsigned int warpSize = 32;
matmal_16x16<<<1,warpSize>>>(dA, dB, dC);
各スレッドが保持するfragmentは
template<typename Use, int m, int n, int k, typename T, typename Layout=void> class fragment;
と定義されており、それぞれのテンプレート引数は次のような役割を担っています。
Use
: GEMM計算 $D \Leftarrow A \times B + C$ の$A, B, C, D$どの行列のfragmentか
nvcuda::wmma::matrix_a
nvcuda::wmma::matrix_b
nvcuda::wmma::accumulator
m
, n
, k
: Tensorコアで計算する行列積の行列の大きさm
, n
, k
)は (16, 16, 16), (32, 8, 16), (8, 32, 16)のいずれか
T
: fragmentの型
Layout
: 列優先か行優先か
nvcuda::wmma::col_major
nvcuda::wmma::row_major
x
: fragmentの要素配列num_elements
: fragmentの要素数void fill_fragment(fragment<...> &a, const T& v);
nvcuda::wmma::fragment a
の全要素にv
を代入
void load_matrix_sync(fragment<...> &a, const T* mptr, unsigned ldm);
void load_matrix_sync(fragment<...> &a, const T* mptr, unsigned ldm, layout_t layout);
fragmentをメモリから読み込む
a
: 読み込み先fragmentmptr
: 読み込み元ポインタldm
: 行列全体のLeading dimensionlayout
: 列優先の場合はnvcuda::wmma::mem_col_major
, 行優先の場合はnvcuda::wmma::mem_row_major
mptr
が128-bit境界である必要あり (Alignment制約)ldm
が16 bytesの倍数である必要あり (halfでは8, floatでは4) (Leading dimension制約)void store_matrix_sync(T* mptr, const fragment<...> &a, unsigned ldm, layout_t layout);
fragmentをメモリに書き出す
mptr
: 書き出し先ポインタa
: 書き出し元fragment (nvcuda::wmma::accumulator
のみ)ldm
: 書き出し先行列のLeading dimensionlayout
: 列優先の場合はnvcuda::wmma::mem_col_major
, 行優先の場合はnvcuda::wmma::mem_row_major
nvcuda::wmma::load_matrix_sync
と同様の制約と未定義動作あり
void mma_sync(fragment<...> &d, const fragment<...> &a, const fragment<...> &b, const fragment<...> &c, bool satf=false);
Tensorコアを用いたGEMM計算
d, a, b, c
: GEMM計算 $d \Leftarrow a \times b + c$ の各fragmentsatf
: fragmentの要素が+-Infinity, NaNとなった場合に有限値に修正するか否かWMMA APIでは決められた大きさの行列積しか計算できませんが、行列積を分解して考えることで任意の大きさの行列積を計算することができます。
行列$A,B$の積$C$を計算する流れは次のようになります。
上述したとおり、nvcuda::wmma::load_matrix_sync
関数とnvcuda::wmma::store_matrix_sync
関数にはメモリのAlignment制約とLeading dimension制約があり、
Globalメモリにある任意の大きさの行列のGEMM計算を行うにはこの制約に対応しなければなりません。
そこでSharedメモリを用いることで対応します。
nvcuda::wmma::load_matrix_sync
関数でfragmentに読み込みnvcuda::wmma::mma_sync
関数でGEMM計算nvcuda::wmma::store_matrix_sync
関数でSharedメモリに書き出しSharedメモリであればAlignment制約が満たされるわけではないので、必要ならば__align__([n byte])
で境界を指定しなければならない。
Tensorコアを使用した場合、使用しなかった場合に比べて$N \geq 512$で5倍程度高速化されました。
行列がどのようにfragmentとしてワープ内で保持されているのかをprintfですべて標準出力して調査しました。
行列$M \in \mathrm{half}^{16 \times 16}$を(m, n, k) = (16, 16, 16)
,nvcuda::wmma::col_major
なnvcuda::wmma::matrix_a
,nvcuda::wmma::matrix_b
それぞれのfragmentにloadする場合を考えます。
$M$を$M_{i,j} \in \mathrm{half}^{4 \times 4}$のブロックに分割し
\[
\left(\begin{matrix}
M_{0,0} & M_{0,1} & M_{0,2} & M_{0,3} \\
M_{1,0} & M_{1,1} & M_{1,2} & M_{1,3} \\
M_{2,0} & M_{2,1} & M_{2,2} & M_{2,3} \\
M_{3,0} & M_{3,1} & M_{3,2} & M_{3,3}
\end{matrix}\right)
\]
と表すとthreadIdx.x
$ = i$の
nvcuda::wmma::matrix_a
のfragmentは\[nvcuda::wmma::matrix_b
のfragmentは\[で表される行列の$i+1$行目となります。
となります。
行列$M \in \mathrm{half/float}^{16 \times 16}$は(m, n, k) = (16, 16, 16)
,nvcuda::wmma::col_major
なnvcuda::wmma::accumulator
のfragmentでは
$M$を$M_{i,j} \in \mathrm{half/float}^{4 \times 1}$のブロックに分割し
と表すとthreadIdx.x
$ = i$では
\[
\left(\begin{matrix}
M_{0,0} & M_{0,1} & M_{0,2} & M_{0,3} & M_{0,4} & M_{0,5} & M_{0,6} & M_{0,7} \\
M_{2,0} & M_{2,1} & M_{2,2} & M_{2,3} & M_{2,4} & M_{2,5} & M_{2,6} & M_{2,7} \\
M_{0,8} & M_{0,9} & M_{0,10} & M_{0,11} & M_{0,12} & M_{0,13} & M_{0,14} & M_{0,15} \\
M_{2,8} & M_{2,9} & M_{2,10} & M_{2,11} & M_{2,12} & M_{2,13} & M_{2,14} & M_{2,15} \\
M_{1,0} & M_{1,1} & M_{1,2} & M_{1,3} & M_{1,4} & M_{1,5} & M_{1,6} & M_{1,7} \\
M_{3,0} & M_{3,1} & M_{3,2} & M_{3,3} & M_{3,4} & M_{3,5} & M_{3,6} & M_{3,7} \\
M_{1,8} & M_{1,9} & M_{1,10} & M_{1,11} & M_{1,12} & M_{1,13} & M_{1,14} & M_{1,15} \\
M_{3,8} & M_{3,9} & M_{3,10} & M_{3,11} & M_{3,12} & M_{3,13} & M_{3,14} & M_{3,15}
\end{matrix}\right)
\]
で表される行列の$i+1$行目となります。
となります。
Globalメモリに行列$A,B$が行優先で置かれている場合、単純にfragmentに読み込む際に転置して読み込んでいるわけではありません。
行優先か列優先かでfragmentの中身が異なるため、nvcuda::wmma::mma_sync
関数に対応するPTX命令であるwmma.load
命令は
wmma.mma.sync.alayout.blayout.shape.dtype.ctype{.satfinite} d, a, b, c;
.alayout = {.row, .col};
.blayout = {.row, .col};
.shape = {.m16n16k16, .m8n32k16, .m32n8k16};
.ctype = {.f16, .f32};
.dtype = {.f16, .f32};
という構造となっており(3)、fragment a
,b
が行優先か列優先かを指定する必要があります。
$C,D$に関しては行優先か列優先かを指定する必要はなく、実際列優先か行優先かでfragmentに差は見られませんでした。
Warp内の各スレッドでのnvcuda::wmma::fragment
構造体の中身がわかったので、nvcuda::wmma::load_matrix_sync
関数を使わずに自前でfragmentを読み込んだ場合と速度を比較しました。
nvcuda::wmma::matrix_a
として読み込むだけのカーネルを実行関数 | 実行時間 |
load_matrix_sync 関数 |
91 us |
自作load関数 | 77670 us |
高速化を余り考えずに書いたと言え、自作load関数に比べてnvcuda::wmma::load_matrix_sync
関数が850倍程度高速という結果になりました。考察NVIDIA Visual Profilerで実行されたSASSコードを見たところ、nvcuda::wmma::load_matrix_sync
関数でも汎用的なメモリ読み込み命令であるLDG命令が使われているようでした。
読み込みアドレスの計算と実際の読み込み命令の実行順などが工夫されているのかもしれません。
TensorコアはWMMA APIを用いることで簡潔に利用することができました。
WMMA APIのload_matrix_sync
,store_matrix_sync
,mma_sync
関数はほとんど単純にPTXの命令に置き換えられるだけなためレイヤーは低く、使用の自由度は高いと考えられます。
性能面ではTensorコアを使用することでf16x2を使用した場合に比べFP16密行列積を高速に計算できることが確認できました。
吉藤さんにはインターン及びアルバイトでCUDAやコードの書き方について指導していただきました。
ありがとうございました。
本記事に含まれるあらゆる文章および画像は、クリエイティブ・コモンズ 表示-継承 4.0 国際(CC-BY-SA 4.0 International)ライセンス(帰属表示/attribution: Fixstars Corporation)の下で利用可能です。
keisuke.kimura in Livox Mid-360をROS1/ROS2で動かしてみた
Sorry for the delay in replying. I have done SLAM (FAST_LIO) with Livox MID360, but for various reasons I have not be...
Miya in ウエハースケールエンジン向けSimulated Annealingを複数タイルによる並列化で実装しました
作成されたプロファイラがとても良さそうです :) ぜひ詳細を書いていただきたいです!...
Deivaprakash in Livox Mid-360をROS1/ROS2で動かしてみた
Hey guys myself deiva from India currently i am working in this Livox MID360 and eager to knwo whether you have done the...
岩崎システム設計 岩崎 満 in Alveo U50で10G Ethernetを試してみる
仕事の都合で、検索を行い、御社サイトにたどりつきました。 内容は大変参考になりま...
Prabuddhi Wariyapperuma in Livox Mid-360をROS1/ROS2で動かしてみた
This issue was sorted....