AMD系インスタンスにNvidiA HPC SDKを入れて色々やってみる話③【OpenACC編】

はじめに

 あけましておめでとうございます!今年もよろしくお願いします。
年末年始いかがお過ごしでしたでしょうか。前回の記事の続きが気になっておちおち寝てもいられなかった多いのではないでしょうか。えっ、そんなことないですか?

 さて、今回の記事ではFortranのコードをOpenACCを使って高速化する方法について紹介します。OpenACCとは、既存のコードに極力変更を加えずに並列処理への最適化を行うことを目的としたオープンソースのことです。FortranやCのコードで、「この部分並列化したいな~」と思うブロック部を後に述べるディレクティブで修飾してあげることにより、CUDA等に比べ簡単にCPUやGPUで並列処理を行うことができます。もしあなたがCUDAやHIPを使用した経験があるのなら、その簡単さにびっくりされると思います(カーネルを弄ることはほぼ不可能ですが、少なくとも限定的局面ではパフォーマンスも申し分ないと言えるレベルまで来つつあると筆者は認識しています)。

 今回はFortranのコードをOpenACCで並列処理に対応させてみましょう。なお、検証ではAMD GPUを使用したインスタンス(amd1dl)を用いています。そして、大変残念ながらOpenACCをHIP対応させることが筆者にはできず、GPUレベルの並列化には至ることができませんでした(Nvdia GPUを使ったインスタンスではGPUドリブン確認しました)。いつかリベンジしたいと思います。今回はCPUレベルの並列化で勘弁願います・・・。ただし、弊社はこれまでTensorFlow(1.x, 2.x), PyTorch1.6をAMD GPUでも駆動させてきた実績があり、関連分野の技術力自体は、国内であれ国外であれトップクラスです( 2021年1月上旬現在 )。

前提

この記事は下記の条件を満たした方を対象としております。

  • HGAでインスタンスを作成済であること(インスタンスタイプはamd1dl, amd2dl, amd3dlのいずれか)
  • インスタンスにログイン可能であること(方法, VSCodeでの接続も推奨します)
  • 前回の記事等でFortranの概要を学んでいること
  • 何かしらのプログラミング言語使用経験あるのが望ましいです

OpenACCの概要

そもそも処理の並列化とは

 OpenACCの使い方を説明する前にそもそも処理の並列化とはどんなことを行うのか考えてみましょう。
今次のようなFortranで実装されたループ文を考えてみます:

do j = 0, 99
  do i = 0, 99
    A(j, i) = 0
  end do
end do

 これは、宣言済みの二次元配列A(i, j)を0で初期化する二重ループです。(並列化処理への最適化に全く対応していない、今時化石のような)コンパイラでこれをコンパイルする場合、実行時プロセッサ上シングルプロセスとして逐次的に処理されていくことになります。よって計算量のオーダは100×100=10000となります。

 一方、もし上記のループ文が下図のような構造のプロセッサで実行されたらどうなるでしょう。下図は紫の四角を一つのコアとするマルチコアプロセッサ(CPUであれGPUであれ)を表しています。コアは横にi個、縦にj個配置されているものとします。なお、説明に用いるプロセッサは、1物理コアで1つのプロセス(ジョブ)を処理するものであり、スレッド等の概念は適用できない仕様であるものとします。

超簡易かつご都合主義なマルチコアプロセッサ

 まるで上記のコード例に合わせて作られたような、ご都合主義の権化のようなプロセッサですね。そして、本当に恐ろしいのは、一見1万コアというのは非現実的な仮定に見えますが、GPUであればこれくらいのコア数は既に達成され製品化されていることです。

 話が少し逸れましたね。結局のところ先ほどの二重ループ文をi×j個のコア数からなるプロセッサのコア上に展開した場合、A(j, i) = 0という処理を対応したコアで一斉にやってしまうことが可能になります(下図)。

 したがって、計算量オーダを考えた場合、となります。驚きましたか?あらためて言いますが、計算量オーダは1になります。


コア上に展開して並列処理

 並列処理は、上記の例で示すように、逐次処理を上手く分割してコア(スケールやライブラリによってスレッドやブロック、グリッドなど呼称が変わります)にアサインすることで、計算にかかる時間を劇的に短縮させることが可能になります。後で例を示しますがCPUのみの並列処理を行う場合でも、コードにちょちょいと数行挿入して、コンパイルの構文を少し変えるだけで、およそ100万分の1程度の処理高速化が可能になります。次節で具体的な使い方を見てみましょう。

OpenACCの使い方

 さて、並列処理の概要を理解したところで早速OpenACCの使い方を見てみましょう!既存の(少なくとも並列処理を想定して書いたわけではない)コードをOpenACCで並列処理化できるように改修を加える状況を考えてみましょう。例として先ほど使用した何の工夫も見られないコードをOpenACCで高速化してみましょう。実際に動かせるように少し改修を加えています。

program main
  implicit none
  integer(2) :: i, j
  integer :: a(0:99, 0:99)  ! 配列を宣言(癖で0からにしちゃった)
  
  ! 以下の処理を並列化したいよ~
 !具体的には、ここから
  do j = 0, 99
    do i = 0, 99
      A(j, i) = 0
      print *, A(j, i)
    end do
  end do
 !ここまでを並列化したいよ~

end program main

 Fortranの慣習に従ってメインルーチン名を付けたり、結局筆者の中ではおまじない扱いされてるimplicit noneを加えたり、2次元配列の宣言をしています(Fortranのデフォルトでは添え字1スタートですが癖で0からにしちゃいました)。また、コメントも添えて並列化したいという熱い思いも加えておきました。

 さて、上記コード例のループ文(do)の直前と直後(end do以下)のコメント文(「!具体的には、ここから」、「!ここまでを並列化したいよ~」)に着目してください。OpenACCのすごいところはこの2箇所のコメント箇所をディレクティブと呼ばれる構文で置き換えるだけで該当範囲を並列処理の対象とすることができることです。具体的には「 !具体的には、ここから」という箇所を!$acc kernels、 「!ここまでを並列化したいよ~」 という箇所を!$end acc kernelsという構文に置き換えるだけです。

 実際の実装例を下記に示します:

program main
  implicit none
  integer(2) :: i, j
  integer :: a(0:99, 0:99)  ! 配列を宣言(癖で0からにしちゃった)
  
  ! 以下の処理を並列化したいよ~
  !$acc kernels
  do j = 0, 99
    do i = 0, 99
      A(j, i) = 0
      print *, A(j, i)
    end do
  end do
  !$acc end kernels

end program main

これをarray-openacc-test.f90というファイル名で保存し、下記のコマンドでコンパイルをかけてみましょう。

nvfortran -acc -O2 -Minfo=accel  array-openacc-test.f90 -o array-test_withOpenACC

 実行したらarray-test_withOpenACCという実行ファイルができていることが確認できると思います。以下のコマンドでコードを実行することができます。

./array-test_withOpenACC

実行したら、0が1万個縦に出力されるという迷惑極まりない結果になったと思います。なお、OpenACCを使わないでコンパイルする場合には下記のコマンドを実行します:

nvfortran array-openacc-test.f90 -o array-test_withoutOpenACC

## array-test_withoutOpenACCという実行ファイルが生成されるので下記で実行:
./array-test_withoutOpenACC 

 このコードには処理時間計測用の実装をしていないので、OpenACCの効果を実感するには不十分です。次節で処理時間計測用のコードを実装しますので今しばらくお待ちくださいね。

 OpenACCを使う!と言っても、コードの並列化したい箇所にちょいちょいとディレクティブと呼ばれる構文を挿入し、コンパイルコマンドを少し修正する程度でしたね。CUDAやHIPの使用経験がある方は驚かれたのではないでしょうか。カーネルを呼び出す処理は上記のディレクティブ挿入だけで十分なのです。特に、簡単な処理を並列化する場合にもカーネル処理用のファイルを書いてそれを呼び出すプラクティス(グッドプラクティスだと思います)を遵守していた方は、悔しさのあまり地団太を踏んでいる特に驚かれていることでしょう。

 実はOpenACCのディレクティブには上記の他にもう一個、並列演算をさせるプロセッサのメモリの方に対象のデータをコピーするという極めて重要な処理があるのですが、今回は説明の簡易化と、CPUレベルの並列化を想定しているためこの処理は不要どころかパフォーマンスの悪化を招くと判断し、導入しておりません。今回、解説を見送りましたが、例えば並列処理をGPUで行うとした場合、CPU側のメモリからVRAMにデータを転送してから、並列演算を行うので、この処理は必要になります。そして、一般に(メイン)メモリからVRAM(GPUメモリ)間のデータのやり取りには時間がかかる(これは分散メモリ型のマルチプロセッサにも当てはまります)ので 、メモリ間のデータのやり取りを極力少なくする必要があります。このため、データコピーを行うディレクティブを使用する際には、この点を念頭に入れるようにしてください。

OpenACC使用・不使用で処理時間を比較

 それでは、お待ちかねのOpenACCで並列処理化した場合としない場合とで処理時間の差を計測してみましょう。先ほどの解説に使ったコードに、処理時間を計測・表示する実装を加えた上で、OpenACC有無でそれぞれコンパイルし実行した結果の処理時間を見てみましょう。

処理時間計測・表示処理の実装

 先ほどのコードに処理時間計測・表示機能を追加したコードの実装を示します。

program main
  use, intrinsic :: iso_fortran_env  ! int64型を使うのに必要
  implicit none
  integer(2) :: i, j
  integer :: a(0:9999, 0:9999)  ! 配列を宣言(癖で0からにしちゃった)
  integer(int64) :: time_begin_c,time_end_c, CountPerSec  ! 時間計測用の変数群

  call system_clock(time_begin_c, CountPerSec)
  
  ! 以下の処理を並列化したいよ~
  !$acc kernels
  do j = 0, 9999
    do i = 0, 9999
      A(j, i) = 0
    end do
  end do
  !$acc end kernels

  call system_clock(time_end_c)
  print *, "Time: ", real(time_end_c - time_begin_c)/CountPerSec,"sec"
end program main

 基本的には、OpenACC概要の説明に使用したコードにいくつか挿入を加えた形です。変更分を見ていきましょう。まずuse, intrinsic :: iso_fortran_envの箇所は、これは時間計測用に使う変数の型にはint64型を使うことが推奨されているためです。コンパイラの種類にもよるのですが、タイムカウントの上限にひっかかって、処理時間が0リセット→負の値と化すことを防ぐため、 処理時間の計測に使う変数には大きめの整数を確保するようにしておきましょう。

 次に、call system_clock(time_begin_c, CountPerSec)の箇所ですが、これは計測対象の直前に入れることで、計測開始時のカウント数の初期化(0にします)と、1秒あたりのカウント数を取得します(今回は1 [counts/sec]とします)。

 最後に、計測対象の直後にcall system_clock(time_end_c)で計測対象が終わった時点でのカウント数が得られるので、計測対象の処理時間は(time_end_c - time_begin_c) / CountPerSecとして算出することができます。

実行結果比較

 それでは、次節で上記のコードをOpenACCを使わずコンパイル(通常のコンパイル)した場合と使用した場合のコンパイルを行い、それぞれの実行結果を比較してみましょう。 それぞれのコンパイルコマンド及び実行コマンドを示します。

## OpenACCを用いず並列化処理を意図しない標準的なコンパイルを行う場合
# コンパイル用コマンド
nvfortran array-openacc-test.f90 -o array-test_withoutOpenACC
# 実行コマンド
./array-test_withoutOpenACC

## OpenACCを用いて並列化処理を行うためのコンパイルを行う場合
# nvfortran -acc -O2 -Minfo=accel  array-openacc-test.f90 -o array-test_withOpenACC
# 実行コマンド
./array-test_withOpenACC

実行毎に異なりますが、処理結果の例を下図に示します。なお、上側がOpenACCを使用せずコンパイルを行った場合、下側がOpenACCを使用してコンパイルを行った場合の実行結果の処理時間をそれぞれ出力しています。

処理時間(上:OpenACC不使用、下:OpenACC使用)

OpenACCを使用した場合、およそ9.7*10^-4 ≒ 0.1*10^-6 [sec]という結果となり、100万オーダで処理時間が短縮されていることがわかります。ただし、これらの処理時間の結果は実行毎に少し変わるので、同じプロセッサ-メモリ構成を使用しても毎回この通りの値がきっちり出力される訳ではありません。筆者が用いたインスタンス(amd1dl)では最低でも10万オーダは短縮されているかな?という感じでした。

 わずか2文を挿入して、コンパイルコマンドに修正を加えただけでこの結果です! チューニング性はさておき、短時間で素早く並列処理化を成し遂げたいならOpenACCは第一選択肢になりえることでしょう。Nvidia社はOpenACCへの更なるコミットを行うことが予想されるので、今後とも目が離せない技術動向だと思います。PGIコンパイラを無料で使えるようにしてくれたり、Nvidia社さまさまですね。ついでに弊社のインスタンスもおすすめです!(公式ブログなのでとりあえず弊社サービスの宣伝文入れておきました)

[HGAoffcial]

[dot]

[/dot]

この記事をシェアする