[cuSOLVER]LAPACKライブラリを使ったアプリケーションのGPU高速化

はじめに

プロメテックの阿部です。HPCエンジニアとして、プログラムのGPU高速化の業務を担当しています。このコラムは、

  • LAPACKライブラリを使ったアプリケーションのGPU化の方法を、LU分解を使って連立方程式を解く例を通して知る。
  • アプリケーション性能のCPUとGPUの比較を行い、GPUを数値計算に用いる利点と課題を知る。

を目的としています。今回のコラムは、アプリケーションのGPU化により、どのくらい性能向上するか知りたい方や、LAPACKライブラリを使ったアプリケーションを高速化したい方(そのためのGPU化の方法を知りたい方)に参考になればと思います。今回の記事の執筆にあたり、PGIコンパイラの記事を参考にしました。今回は、NVIDIA HPC SDKを用いてCPUとGPUの実行時間の比較を行いました。

今回評価したCPUとGPU

今回評価したCPUとGPUを表1に示します。

製品名 理論演算性能
(倍精度浮動小数点演算)
Clock rate
[GHz]
Memory
[GiB]
core CUDA version
CPU Intel(R) Xeon(R) W-2123 CPU 460.8 GFLOPS 3.60 64 4
GPU NVIDIA Quadro GV100 (Volta) 7.4 TFLOPS 1.627 32 CUDA Cores: 5,120
Tensor Cores: 640
11.7
表1 今回評価したCPUとGPU。

今回評価したアプリケーションの概要

今回は、LU分解(A=LU)を使って連立方程式(Ax=b)を解くプログラムを評価しました。私は、大学院生の時に量子多体問題の研究を行い、角運動量射影法により量子系の回転エネルギーを計算する際にLU分解を使いました。プログラムでは、LAPACKライブラリとしてDgetrfとDgetrs(以下、2つ合わせて“Dgetrf(s)’’などと略す)を使いました。また、それらのLAPACKライブラリを使ったプログラムを、cuSolverライブラリを使ってGPU化しました。Dgetrf(s)に対応するcuSolverライブラリは、cuSolverDnDgetrf(s)です。

LAPACK cuSolver
LU分解 Dgetrf cuSolverDnDgetrf
連立方程式 Dgetrs cuSolverDnDgetrs
表2 性能評価に用いたLAPACKとcuSolverライブラリの対応関係。

 

アプリケーション性能のCPUとGPUの比較

まず、実行時間の測定結果を先にお見せします。プログラムをGPU化する具体的な方法や性能の詳細は後で説明します。

図1 CPUとGPU上で、LU分解を使って連立方程式を解く時間。行列の次元が2048、4096、8192のそれぞれの場合に対して、LAPACK (Dgetrf(s))(青)はDgetr(s)の実行時間、cuSolver (cuSolverDnDgetrf(s))(赤)はcuSolverDnDgetrf(s)の実行時間を表す。エラーバーは、標準偏差による測定誤差を表す。

CPUとGPU上でのLU分解を使って連立方程式を解く時間を図1に示しました。行列の次元が大きくなると、CPUと比べてGPU上での実行時間は大きく減少し、行列の次元が8192の場合には9.3倍性能が向上しました。GPU化に伴うoverheadを考慮した結果の詳細については後ほどお見せします。

LAPACKライブラリ(LU分解)を使ったアプリケーションをGPU用に書き換える方法

LAPACKライブラリを使ったアプリケーションをGPU用に書き換える方法についてご説明します。今回、新たにLU分解を使って連立方程式を解くプログラムを作成しました。ソースコードは以下から取得できます。

ライブラリ ソースコード名
LAPACK dgetrs_lapack.f90
cuSolver dgetrs_cusolver.f90

以下は、CPU上でLAPACKライブラリを使って解くプログラムのソースコードの一部を抜粋したものです。(左の数字は行番号です。)

(前略)
    23	   integer, parameter :: m = 5      ! row number of matrix A
    24	   integer, parameter :: n = m      ! column number of matrix A
    25	   integer, parameter :: nrhs = 2   ! number of vector b
    26	   integer, parameter :: lda = m
    27	   integer, parameter :: ldb = n

    29	   integer :: i, j, k, min_mn
    30	   real(8) :: a(lda, n)       ! m x m matrix A
    31	   real(8) :: b(ldb, nrhs)    ! n x nrhs RHS (set of vectors b)

    43	   ! LAPACK
    44	   integer, allocatable :: ipiv(:)
    45	   integer :: info_f, info_s
(中略)
   134	   min_mn = min(m, n)        ! mとnの小さい方をmin_mnに代入
   135	   allocate (ipiv(min_mn))   ! allocateにより、配列ipivの次元をここで定義

   139	   ! LU factorization
   140	   call Dgetrf(m, n, a, lda, ipiv, info_f)
   141	   ! solve simultaneous equation
   142	   ! 'N': non-transpose of the matrix A
   143	   call Dgetrs('N', n, nrhs, a, lda, ipiv, b, ldb, info_s)
(後略)

140行目でLAPACKライブラリのDgetrfにより行列AはLU分解され、143行目でDgetrsにより連立方程式Ax=bの解xを計算しています。変数m(=n)は行列Aの次元、nrhsはベクトルbの個数です。変数ipivはピボット行列を表す一次元の配列です。以下は、行列Aが5×5行列の場合のコンパイルと実行結果の例です。

(CPU上でのコンパイル・実行結果)
[abe@gv100-ws ~]$ nvfortran -fast -lblas -llapack dgetrs_lapack.f90 -o dgetrs_lapack
[abe@gv100-ws ~]$ ./dgetrs_lapack
 Dgetrs (LAPACK)
 Time measurement accuracy : .10000E-05
 lda,ldb            5            5
 A=LU
 generate b
 Matrix A
    1.00      1.12      0.90      0.62      0.31
    1.12      2.25      2.18      1.59      0.93
    0.90      2.18      3.19      2.73      1.78
    0.62      1.59      2.73      3.44      2.57
    0.31      0.93      1.78      2.57      3.00
 RHS
    0.41      0.51
    0.69      0.79
    0.91      1.01
    1.05      1.15
    1.10      1.20
 time:    5.0900000000000001E-004 sec
 LU factorization
    1.12      2.25      2.18      1.59      0.93
    0.89     -0.89     -1.05     -0.80     -0.53
    0.55     -0.38      1.12      2.25      1.86
    0.80     -0.42      0.89     -0.89     -0.85
    0.28     -0.35      0.72     -0.25      1.00
 Solution
 0.271E+00   0.396E+00
 0.203E-01  -0.120E-01
 0.365E-01   0.455E-01
-0.393E-01  -0.516E-01
 0.344E+00   0.380E+00
 det(A):    1.000000000000001     

このプログラムでは、はじめに適当な上三角行列Uと下三角行列Lを用意し、A=LUによって正則行列Aを計算しました。行列Aをdet(A)=1を満たすように用意したため、行列の次元が大きくなっても連立方程式の解が比較的安定して得られました。

このLAPACKを使って連立方程式を解くプログラムを、OpenACCのディレクティブを挿入することによりGPU化しました。この作業時間は2時間ほどかかりました。今回は、cuSolverを使うことで比較的容易にGPU上で実行することができました。CPU上でLAPACKを使って連立方程式を解く方法と、GPU上でcuSolverを使って連立方程式を解く方法の大まかな流れを図2で比較しました。

図2 CPU上でLAPACKを使うプログラムのGPU化。
cuSolverライブラリのcuSolverDnDgetrf(s)は、LAPACKライブラリのDgetr(s)に対応します。cuSolverを使うためには、GPUの作業領域の初期化をする必要があります。以下はGPU化したプログラムの一部です。

(前略)
    22	   use cuBlas_v2 ! cudaでLAPACKを使う場合に追加
    23	   use cuSolverDn ! cudaで密行列に対するLAPACKライブラリを使う場合に追加

    45	   ! cuSolver
    46	   type(cuSolverDnHandle) :: h   ! h: handle
    47	   integer :: istat, istat_f, istat_s
    48	   integer, allocatable :: devIpiv(:)
    49	   integer :: devInfo_d
    50	   real(8), allocatable :: workspace_d(:)
    51	   integer :: Lwork
(中略)
   143	   cpu0 = second()   ! 時間の計測
   144	
   145	   ! prior to calling any other library function
   146	   istat = cuSolverDnCreate(h)

   150	   cpu1 = second()   ! 時間の計測
   151	
   152	   !$acc data copy(a, b) copyout(devIpiv, devInfo_d)
   153	
   154	      !$acc host_data use_device(a)
   155	         ! buffer size
   156	         istat = cuSolverDnDgetrf_bufferSize(h, m, n, a, lda, Lwork)
   157	      !$acc end host_data

   161	      allocate (workspace_d(Lwork))   ! executed on CPU
   162	      !$acc enter data create(workspace_d)
   163	
   164	         !$acc host_data use_device(a, b, workspace_d, devIpiv, devInfo_d)
   165	
   166	   cpu2 = second()   ! 時間の計測
   167	
   168	            ! LU factorization
   169	            istat_f = cuSolverDnDgetrf(h, m, n, a, lda, workspace_d, devIpiv, devInfo_d)
   170	            ! solve simultaneous equation
   171	            ! 'CUBLAS_OP_N': non-transpose of the matrix A
   172	            istat_s = cuSolverDnDgetrs(h, CUBLAS_OP_N, n, nrhs, a, lda, devIpiv, b, ldb, devInfo_d)
   173	
   174	   cpu3 = second()   ! 時間の計測
   175	
   176	         !$acc end host_data
   177	
   178	      !$acc exit data delete(workspace_d)
   179	
   180	   !$acc end data
   181	
   182	   ! destroy handle
   183	   istat = cuSolverDnDestroy(h)

   187	   cpu4 = second() ! 時間の計測
(後略)

プログラムのGPU化に伴う主な変更点は、

  • モジュールcuBlas_v2とcuSolverDnの追加
  • cuSolverを使うための変数h、devIpiv、devInfo_d、workspace_dとLworkの用意(それぞれハンドル、pivot行列、デバイス情報、作業領域とその大きさ)
  • 行列Aとベクトルbのデータをホスト(CPU)側からデバイス(GPU)側へ送るため、OpenACCのdata構文を挿入
  • LAPACKライブラリのDgetrf(s)をcuSolverのcuSolverDnDgetrf(s)に書き換え(LAPACKとcuSolverでインターフェイスが異なる部分があるので注意。
  • です。以下は、行列Aが5×5行列の場合のプログラムのコンパイルとプロファイリングツールnvprofを用いた実行結果です。

    (GPU上でのコンパイル・実行結果)
    [abe@gv100-ws ~]$ module load nvhpc/22.5
    [abe@gv100-ws ~]$ nvfortran -acc -fast -target=gpu -Mcudalib=cublas,cusolver dgetrs_cusolver.f90 -o dgetrs_cusolver
    [abe@gv100-ws ~]$ nvprof ./dgetrs_cusolver
    abe@gv100-ws lapack_cusolver_gpu]$ nvprof ./dgetrs_cusolver
     Dgetrs (cuSolver)
     Time measurement accuracy : .10000E-05
     lda,ldb            5            5
     A=LU
     generate b
     Matrix A
        1.00      1.12      0.90      0.62      0.31
        1.12      2.25      2.18      1.59      0.93
        0.90      2.18      3.19      2.73      1.78
        0.62      1.59      2.73      3.44      2.57
        0.31      0.93      1.78      2.57      3.00
     RHS
        0.41      0.51
        0.69      0.79
        0.91      1.01
        1.05      1.15
        1.10      1.20
    ==78999== NVPROF is profiling process 78999, command: ./dgetrs_cusolver
     0-1 time: 
       0.7571530000000000      sec
     1-2 time: 
       1.6590000000000001E-002 sec
     2-3 time: 
       1.2500000000000000E-004 sec
     3-4 time: 
       1.2519999999999999E-003 sec
     0-4 time: 
       0.7751199999999999      sec
     LU factorization
        1.12      2.25      2.18      1.59      0.93
        0.89     -0.89     -1.05     -0.80     -0.53
        0.55     -0.38      1.12      2.25      1.86
        0.80     -0.42      0.89     -0.89     -0.85
        0.28     -0.35      0.72     -0.25      1.00
     Solution
     0.271E+00   0.396E+00
     0.203E-01  -0.120E-01
     0.365E-01   0.455E-01
    -0.393E-01  -0.516E-01
     0.344E+00   0.380E+00
     det(A):    1.000000000000001     
    ==78999== Profiling application: ./dgetrs_cusolver
    ==78999== Profiling result:
                Type  Time(%)      Time     Calls       Avg       Min       Max  Name
     GPU activities:   19.80%  14.335us         1  14.335us  14.335us  14.335us  void trsm_ln_kernel<double, unsigned int=32, unsigned int=32, unsigned int=4, bool=1>(int, int, double const *, int, double*, int, double, double const *, int, int*)
                       19.67%  14.240us         1  14.240us  14.240us  14.240us  void trsm_ln_up_kernel<double, unsigned int=32, unsigned int=32, unsigned int=4, bool=0>(int, int, double const *, int, double*, int, double, double const *, int, int*)
                       17.33%  12.544us         1  12.544us  12.544us  12.544us  void getf2_cta_32x32<double, double>(int, int, int, int, double*, int, int*, int*)
                        8.36%  6.0480us         4  1.5120us  1.3120us  2.0480us  [CUDA memcpy DtoH]
                        7.03%  5.0880us         1  5.0880us  5.0880us  5.0880us  void laswp_kernel2<double, bool=0, int>(int, double*, unsigned long, int, int, int const *, int)
                        5.57%  4.0320us         1  4.0320us  4.0320us  4.0320us  getrf2_set_info(int, int, int, int*)
                        5.08%  3.6800us         1  3.6800us  3.6800us  3.6800us  dtrsv_init(int*)
                        4.51%  3.2640us         1  3.2640us  3.2640us  3.2640us  getrf2_reset_info(int*)
                        4.24%  3.0720us         1  3.0720us  3.0720us  3.0720us  copy_info_kernel(int, int*)
                        4.24%  3.0720us         1  3.0720us  3.0720us  3.0720us  dtrsv_init_up(int*, int)
                        4.16%  3.0080us         2  1.5040us  1.1840us  1.8240us  [CUDA memcpy HtoD]
          API calls:   85.70%  543.46ms        18  30.192ms     538ns  294.22ms  cudaFree
                       11.48%  72.801ms         3  24.267ms  2.9160us  72.795ms  cudaStreamCreateWithFlags
                        2.37%  15.004ms         1  15.004ms  15.004ms  15.004ms  cuMemHostAlloc
     OpenACC (excl):   98.80%  15.145ms         1  15.145ms  15.145ms  15.145ms  acc_enter_data@dgetrs_cusolver.f90:152
    (1%未満のものは省略)
    

    今回のアプリケーションで行列の次元が小さい場合は、cudaFree等のGPU作業領域初期化処理のoverheadが顕著になり、かえって実行時間が増えてしまいました。

    アプリケーション性能のCPUとGPUの比較の詳細

    最後に、GPU化に伴うoverheadを含めた測定結果の詳細をお見せします。


    図3 CPUとGPU上で、LU分解を使って連立方程式を解く時間。LAPACK (Dgetrf(s))(青)はDgetr(s)(GPU化する前のプログラムの140行目から143行目まで)の実行時間。cuSolver (cuSolverDnCreate)(グレー)はGPU化した後のプログラムの143行目から150行目、(データ転送やallocate)(橙)は150行目から166行目、(cuSolverDnDgetrf(s))(赤)は166行目から174行目、(データ転送やcuSolverDnDestroy)(緑)は174行目から187行目の実行時間。

    図3は、図1の結果にcuSolverを使うためのoverheadを含めたものです。実行時間の測定をそれぞれ5回ずつ行い、平均値と標準偏差を計算しました。GPU化した後のプログラムの実行時間については、全体だけでなく重要な部分を分けて測定しました。行列Aの次元を大きくするとCPUに対するGPUの性能が向上し、行列の次元が8192の場合には1.6倍性能が向上しました。また、GPUはCPUと比べて、実行時間の測定誤差が小さく、行列の次元が8192の場合には10倍安定していることもわかりました。GPUの作業領域の初期化で必要なcuSolverDnCreateは約0.52秒かかり、この影響は無視できませんが、この時間は行列Aの次元にはあまり依りませんでした。cuSolverDnCreateは一度のみ呼び出せばよいことや、データ転送のコストは減らす余地があることも考慮すると、LAPACKライブラリを使ったアプリケーションのGPU高速化は、アプリケーションによって変わるものと捉えるのがフェアだと思いました。

    まとめ

    今回は、Lapackライブラリ(LU分解)を使ったアプリケーションのGPU高速化をしてみました。そのプログラムを用いて、アプリケーションの実行時間の測定を行い、アプリケーション性能のCPUとGPUの比較を行いました。計算量が大きくなるとCPUに対するGPUの性能が向上し、1.6倍の性能向上ができました。GPUを用いることにより、LU分解を使って連立方程式を解く時間は短縮できる一方、行列の次元が小さい場合はGPUメモリ管理コスト等によるoverheadが無視できない課題があることもわかり、今後検討していきたいと思います。