AMD系インスタンスでJuliaを始めよう②【GPU編】

はじめに

 みなさんこんにJulia!(2021年の標準挨拶です)

 前回の記事ではインスタンス内にJuliaを導入し、主としてJupyterLabを用いて動作確認を行いました。そこでは、基本的な演算処理やグラフの描画、応用として最適化問題を解いてみましたね。その際数式表現の簡素やPythonやMatlabと同程度にグラフ描画がしやすいこと、数学でよく使う記号や定数が予約語として入っておりかつ呼び出しやすいと、感じられたのではないでしょうか。実際、筆者が頭の中で渋谷の女子高生100人にインタビューしたところ、なんと1億人が「Juliaはとりわけその数式表記が美しい」と回答していました。特に驚くに値しないでしょう。

 今回は、JuliaをGPUで動かしてみましょう。それもAMD製のGPUです。弊社のインスタンスタイプで言えば、amd1dlにおいて、JuliaをGPUで動かせるようにします。弊社のサービスについては弊社公式サイトからご確認いただけます(宣伝完了)。それではやってみましょう!

前提

この記事は下記の条件を満たした方を対象としております(特にインスタンスにJuliaをセットアップしていることを前提としております)。

  • HGAでインスタンスを作成済であること(推奨インスタスタイプはamd系、筆者はamd1dl使用)
  • インスタンスにログイン可能であること(方法, VSCodeでの接続も推奨します)
  • 動作確認はJupyter Labを使って行いました(参考記事
  • インスタンス内にJulia環境がセットアップされていること(前回記事参考)

Julia on AMD GPU環境の構築

 AMD製のGPUでJuliaを動かすための環境構築は簡単です。前回までに導入したJulia環境にAMDGPU.jlという専用のパッケージを導入するだけです!公式のGitHubを見ると、2021/01/26現在Dynamic Parallelism(
GPUで実行するプログラムから直接、他のカーネルの実行を依頼することができるという機能、CPUとGPU間の通信を劇的に減らすことができます。)に非対応であるもののCUDA分を概ねカバーしていると言えそうです。こういうののコントリビュータは本当にすごいですよね。尊敬します。

パッケージの導入

 話が飛びました。それではJuliaにAMDGPUパッケージを導入してみましょう。ターミナルで

julia

と入力しREPLに切り替えて]と入力します:

]

すると下図のようなpkg>という、パッケージマネージメント用のモードに切り替わるのでそこで

add AMDGPU

と入力し、JupyterのカーネルでJuliaを実行するためのパッケージが導入できます。

AMDGPUパッケージの導入

Jupyter Labの立ち上げ

 パッケージが導入できたら実際にnotebookサーバを立ち上げてJuliaを動かしてみましょう。

 まず、ローカルPCのブラウザからJupyterLabのnotebookにアクセスするため、以下のコマンドでnotebookサーバを立ち上げます:

jupyter lab
notebookサーバ立上げ

 サーバを立ち上げたローカルPC(インスタンスに接続かけているPC)のブラウザからhttp://localhost:8888にアクセスします。

カーネル選択

 Notebook用のカーネル選択画面にJuliaが表示されていたらOKです!クリックしてNotebookを開きましょう。

Jupyter上でターミナルを立ち上げる

 また、今回GPUレベルでの駆動を確認をしたいので、今回はターミナルもJupyter上で開いてコードの実行とGPUの動作をリアルタイムに確認できるようにしましょう。まず、新規にターミナルを開きます(下図)。

ターミナルを開く

 ターミナルがタブとして開けたら、下図に示すようにターミナルタブをドラッグアンドドロップで右端に寄せてあげることで、画面をスプリットすることができます(VSCodeのタブ操作と同じ要領です)。

良い表現方法思いつかない・・・

GPUの動作をモニタリング

 インスタンスのGPUの使用状況の確認にはrocm-smiコマンドを使用するのが簡易です(下図)。

rocm-smi実行

 一方、このコマンドでわかるのわかるのはコマンド実行時点でのGPU情報なので、リアルタイム監視を可能にするためwatchをコマンドを併用します。watchコマンドは

watch [一定間隔で実行したいコマンド]

の書式でデフォルトでは2秒おきぬ引数に入れたコマンドの実行結果を出力してくれます。今回はよりリアルタイム性を求め、0.1秒間隔で実行するようにしましょう。間隔を秒単位で明示的に指定するため、–interbal [sec]オプションを使用しましょう。まとめると、次のようなコマンドをJupyter上のターミナル画面で実行しましょう:

watch --interbal 0.1 rocm-smi
# watch -n .1 rocm-smi でもokです
rocm-smi0.1秒間隔実行(右側)

 以上でJupyterのセルで実行したGPU駆動コードにおいて、その使用状況をリアルタイムにモニタリングする体制が整いました。実際にコードを動かしてみましょう。

(やっと)動作検証

 お待ちかねの動作検証です!今回はAMDGPUパッケージの公式のチュートリアルから引用させてもらったコードにいくつか改修を加えて使用します。全体としては以下のようなコードになります。これはN個(ハードコーディング)の一様分布に基づく乱数値からなるベクタa, b同士の足し算結果を出力するコードです。先にCPUのみ使った計算を行ってその結果を表示し、次にGPUにおける計算とその結果表示、そして(念のため)CPUとGPUでの計算結果に誤差はないか検証します。

## N個の一様分布に基づく乱数値(a, b)同士の足し算を行います
## コードはほとんどhttps://juliagpu.gitlab.io/AMDGPU.jl/quickstart/ からのパクリです
## ドキュメントの充実ぶりも好きですJulia
N = 64
a = rand(Float64, N)  # N個の乱数(0 <= n < 1)を生成(配列)
b = rand(Float64, N)
c_cpu = a + b  # c = a + b
println("乱数値同士の和(CPU): ", c_cpu, "\n")

## 以下では上記と同じ計算をGPUベースで行います。右側のrocm-smiからも目を離さないようにしましょう。
using AMDGPU  # パッケージのimport, 最初は時間かかります

a_d = ROCArray(a)  # GPU演算用の配列に変換
b_d = ROCArray(b)
c_d = similar(a_d)  # a+b演算後の結果を格納するGPU用配列を定義(a_dから)

## 以下の関数では配列の値をスレッド上に配置し、a(a_d)とb(b_d)の値を”一斉に”和演算する
function vadd!(c, a, b)  # 関数の定義はFortranに似てます
    i = workitemIdx().x  # 今回は横方向にのみ値をアサイン
    c[i] = a[i] + b[i]
    return
end

## マクロと呼ばれる箇所。記事内で解説します。
@roc groupsize=N vadd!(c_d, a_d, b_d)
wait(@roc groupsize=N vadd!(c_d, a_d, b_d))

c_gpu = Array(c_d)  # 通常の配列に戻す
println("乱数値同士の和(GPU): ", c_gpu, "\n")

println("err: ", c_gpu - c_cpu)

 筆者、未だにファイル作ってコマンドでそのファイルを実行する癖が抜けず、つい上記のようにまとめて書いちゃうときがあるんですよね。本当はJupyter的なプラクティスに合わせて、こまめにセルに分割して、マークダウンでノート書くなりした方が良いと思います(でもやらないんだなこれが・・・)。

 ひとまず、動作確認しましょう。セル内に上記を貼り付け、Ctrl + Enterキー押下で実行します。

この時注意してもらいたいのですが、初回実行時AMDGPUパッケージをimportする箇所に相当時間がかかります(下図)。GPU演算以前の話なのです。初回実行時のみ、このimport処理に数分程度かかるので、トイレを済ませておきましょう。一度カーネルに取り込んでしまえば、後は早いです。

初回時AMDGPU Pack.のプリコンは時間かかるよん

 パッケージがロードされたら、GPU演算を実行して、実行結果が表示されます。まとめると下図のような実行結果になるはずです。

行結果

 結果を見ると、一様分布ベースで生成した乱数ベクタa+bの和をCPUとGPUで計算したとき、その処理結果に差がないことが確認できます。 この時、右側のrocm-smiの結果を見て、GPU%の結果が変動しているかどうか確認しましょう。N=64の時は早すぎて追うのが大変かもしれません。Nの値を大きくするなりして実行時間を稼ぎましょう。あるいは Ctrl + Enterキー連打マンに変身するのも良いでしょう!

コードの解説:マクロと同期

 このブログ記事執筆のため、筆者は先ほどのコードに要所要所コメント文入れたり、解説の上で必要な出力を出すよう改修を加えたりしていたのですが、以下の2行を解説しようとしてつまづきました:

@roc groupsize=N vadd!(c_d, a_d, b_d)
wait(@roc groupsize=N vadd!(c_d, a_d, b_d))

 なぜか?それは、筆者にもわからなかったからです。

まず、上記の筆者にはよくわからなかったコード群のうち、よりやばそうな方

@roc groupsize=N vadd!(c_d, a_d, b_d)

を見てみます。@rocはマクロと呼ばれる機能の一つです。マクロとは、OpenACCで言うディレクティブのように、適切な場所に書いたり、引数を渡して呼び出せば、ソースコードを生成・実行するような機能のことを指します。筆者は一時期C言語を勉強していたときがあるのですが(そして見事に挫折した)、Cではmain関数の前にdefine構文で定数の定義を行ったりしていました。あれがマクロです。Cにおけるマクロはコンパイル前にそれに先立って行う処理のことを指します。とにかく機能の塊というイメージをもっておくのも良いでしょう。

 @rocは関数を渡すとそれをGPUにいい感じに展開してくれる機能(マクロ)です。話を簡単にするため、Jupyterで上の2行を以下のように改修します:

@roc vadd!(c_d, a_d, b_d)
wait(@roc vadd!(c_d, a_d, b_d))

 この状態でもコードは動きます。要は、実際にGPU上で処理を行う際には@rocにその関数を渡せば良いという結論が得られるでしょう。

 なお、groupsize=Nワークグループという値を指定しており、CUDAにおいてはスレッドをひとまとめにしたもの(スレッドブロック)を指します。上記のコードを例にすれば、あるiに対して実行されるカーネル一つ一つをワークアイテム(CUDAでいうスレッド)と呼ぶのですが、これをグループ化したものです。正直CUDAやOpenCLでは、ここらあまり明示しなくても、とりあえず動いてはくれるので、groupsize=Nは省略するのがむしろ普通じゃないのかな?とさえ思うのですが、Julia on AMD GPUの何がどんなトラブル引き起こすか筆者自身よくわかってないので、当面省略せずに行く方針です。 その上、(超)並列処理においては、グルーピングという考えは構造を整理する上でとても有用なので、書きはしなくても概念自体は覚えておいた方が良い気がします。

 ここまで理解した上で、wait(@roc vadd!(c_d, a_d, b_d))を考えると、これは、公式の解説にもありますが、各ワークグループ(ないしワークアイテム)間の同期をとるために設けられています。並列処理において、(上記のコード例で言えば)例えばiで呼び出したカーネルとi+1で呼び出したカーネル処理の間の実行時間に差が出ると困ります。ここでは、問答無用で一定時間ホールドして、強引に同期を取るというアプローチをとっています。共産主義国家の経済政策みたい。

 以上のように、wait()は、ある意味お守りみたいなものですが、結構本当に大事なお守りだったりします。他に有効な手段がないのなら、なくしてはいけない。

 

いかがでしたでしょうか。
弊社クラウドGPUサーバのご利用をご検討中の方はhttps://gpu-advance.highreso.jpからお気軽にお申込みやお問い合わせください。
最初の3日間はお試し期間として利用無料です!