この記事はD言語 Advent Calendarの18日目の記事です.
はじめに
私が所属している豊橋技術科学大学(TUT)には学生や高専生が自由に使えるクラスタ計算機が設置されています.
CPU20コアのノードが30台あり,スーパーコンピュータと比べれば小さいですが1,研究用途での利用で基本的には年間1000円でこれだけのCPUパワーはすごくありがたいです. (もちろん費用は研究室の研究費から出してもらっています)
情報メディア基盤センターや関係者の方々には毎回感謝して利用させていただいています.
実はこの記事を書いている裏でもクラスタ計算機で200コアくらいCPUを利用して研究のプログラムを回しています.
ところで,私の研究室では無線通信に関連したシミュレーションを研究として行う学生が多く,そのような学生はだいたいクラスタ計算機を利用しています.
しかし,研究室に入るまでクラスタ計算機を触ったことのある学生は僕も含めておらず,マルチスレッドプログラミングでさえやったことのある学生は数人しかいません.
MPIなんてもってのほかです.
それでも,今年の4月に研究室に入って初めてまともにC言語を書いた学生が,12月現在はクラスタ計算機を利用しています.
この記事ではそんな学生でも利用しているD言語用TUTクラスタ計算機ライブラリとその動作原理について紹介します.
TUTのクラスタ計算機について
ライブラリの紹介をする前に,本学にあるクラスタ計算機について説明します.
スペックは前述のとおり,計算用のノードは1ノード20CPUコアで30ノードあります.
また,プログラム等の開発のための開発ノード(ログインノード)が2台あります.
学生などのユーザーはこの開発ノードにsshで入り,クラスタ計算機のジョブスケジューラにジョブを登録します.
登録されたジョブは計算用ノードに空きがあれば順次実行されていきます.
クラスタ計算機の各ノードのOSはLinux(RHEL)でCPUもIntel Xeonなので,Linuxbrewなどで環境を構築すればD言語でもプログラムを書くことができます.
ジョブスケジューラへのジョブの登録方法
クラスタ計算機を利用するときに一番面倒なのがジョブの登録になります.
基本的にはジョブを登録するには次のようなスクリプトを記述して,開発ノード上でqsub
というコマンドで登録する必要があります2.
例えば以下のスクリプトをqsub
コマンドに与えると「1ノードで20CPUコア専有して./a.out
を実行する」ジョブがジョブスケジューラに登録されます.
#PBS -q wLrchq
#PBS -l nodes=1:ppn=20
cd $PBS_O_WORKDIR
./a.out
アレイジョブ
単一のジョブだけ投入するのであればまだこれでもいいのですが,複数のジョブを登録するときは少し面倒です.
こういうときはアレイジョブとしてジョブを登録します.
#PBS -q wLrchq
#PBS -l nodes=1:ppn=20
#PBS -t 1-10
cd $PBS_O_WORKDIR
./a.out ${PBS_ARRAYID}
これは1,2,…,10という10個のジョブをジョブスケジューラに登録します.
すべてのジョブは計算用ノードに割り当てられると./a.out
を実行しますが,そのとき環境変数にPBS_ARRAYID
として番号(今回は1から10の値)が設定されているので,この値を利用して各ジョブで処理する内容を変更することができます.
アレイジョブの問題点
一つの変数について処理を分割するだけであればアレイジョブでもいいのですが,たとえば複数の変数で分割したい場合はどうすればいいでしょうか.
つまり,以下のプログラムでは2重ループの中身が100回実行されますが,これを100個のジョブとして登録したい場合,アレイジョブだと少し面倒です.
import std.stdio;
void main()
{
// この二重ループを分割したい
foreach(i; 0 .. 10){
foreach(j; 0 .. 10) {
writefln("Hello, (%s, %s).", i, j);
}
}
}
それにアレイジョブがジョブごとに変えてくれるパラメータは整数です.
整数以外のパラメータを変えてジョブを回したいとき,この制約は面倒すぎます.
このような「qsub
でジョブを登録するのはちょっと面倒くさいなー」という思いから生まれたのがD言語用TUTクラスタ計算機ライブラリです.
D言語用TUTクラスタ計算機ライブラリ
まず最初に,ライブラリ名に”D言語用”とありますが,実際にはC言語やC++からも利用可能です. 実際に私の研究室ではC++からの利用者もいます.
ただ,基本的にはD言語からの利用を想定しているので,D言語のサンプルコードを交えて説明していきます.
また,豊橋技術科学大学のクラスタ計算機用ですが,少し変更を加えれば京都大学のスーパーコンピュータシステムやその他PBSジョブスケジューラを利用しているシステムでも利用できそうです.
ジョブとタスク
このライブラリでは「ジョブ」と「タスク」を明確に分けています.
ジョブは「クラスタ計算機のジョブスケジューラに与える処理の最小単位」であり,タスクは「実行したい処理の最小単位で,互いにアイソレートなもの」です.
ここでの「互いにアイソレート」とは,複数のタスクがあった場合に各タスクの実行結果はタスクの順序に依らず,各タスクは別のタスクに依存しないということを意味しています.
一方でジョブには依存関係があってもよく,実際にジョブスケジューラにジョブを登録するときにジョブ間の依存関係を指定することができます.
本ライブラリは,タスクのリストを用いてジョブ(特にアレイジョブ)やジョブスケジューラを抽象化しています.
例1: 複数ノードでのHello, world!
まずは一番簡単な例として,標準出力に文字列を出力する100個のタスクをジョブとしてジョブスケジューラに投入するコードを以下に示します.
クラスタ計算機の開発ノードでこのプログラムをdub -- --th:g=1
というコマンドで実行すると,CPUを1個ずつ専有する100個のジョブからなるアレイジョブがクラスタ計算機のジョブスケジューラに投入されます.
qsub
の実行ややジョブ投入のためのスクリプトを書く必要はありません.
投入されたジョブはジョブスケジューラによって計算ノードに割り振られ,実行されます.
/++ dub.json:
{
"name": "hello",
"dependencies": { "tuthpc": { "path": "path/to/tuthpc" } }
}
+/
import std.stdio;
import tuthpc.taskqueue;
void main()
{
foreach(i; iota(100).runAsTasks){
writefln("Hello, this is the %sth task.", i);
}
}
このように,コード上はforeach
のループが少し変わっているだけで,なんともまともなD言語のコードです.
特異なのは.runAsTasks
でしょう.
これがなければ本当に単なるHello, world!プログラムと同じです.
たったこれだけのコードで,ライブラリはジョブスケジューラへのジョブの投入から,投入されたジョブが実行されたときの各タスクの処理まですべてを制御してくれます.
例2: ループ変数が2つあるとき
もう少し難しい例を示しましょう.
例1ではループ変数が一つしかありませんでしたが,次はループ変数を二つにしてみます.
また,片方のループ変数はユーザー定義のクラスオブジェクトにしてみましょう.
さらに,アレイジョブを構成する各ジョブは1ノード(CPU20コア)を専有するようにしてみましょう.
このプログラムはdub
コマンドのみで投入できます.
/++ dub.json:
{
"name": "hello",
"dependencies": { "tuthpc": { "path": "path/to/tuthpc" } }
}
+/
import std.conv;
import std.stdio;
import thtphc.taskqueue;
class MyClass
{
string id;
}
void main()
{
JobEnvironment env;
env.ppn = 20; // 1ノード(20コア)専有
// ループするオブジェクトの生成
MyClass[] myobjects;
foreach(i; 0 .. 10)
myobject ~= new MyClass(i.to!string);
// ジョブを生成する
auto list = new MultiTaskList();
foreach(i; 0 .. 10)
foreach(obj; myobjects)
list.append!jobFunc(i, obj);
// ジョブスケジューラへ投入 or 実行
run(list, env);
}
void jobFunc(int jobId, MyClass obj)
{
// ジョブの実装
}
例1のHello, worldに比べればすこし煩雑になっていますが,それでもまだジョブスケジューラをあまり意識せずコードを書けると思います.
このライブラリを使えばアレイジョブでは実現できない二重ループのジョブ投入や,整数以外のパラメータでの投入が可能です.
そして,このコードに出てきたrun
関数こそがこのライブラリで最も重要な機能を果たしています.
run
がどのように動いているか,それを具体的に説明します.
tuthpc.taskqueue.run
の動作
run
関数の第一引数には「タスクのリスト」を与えます.
そして,このライブラリではタスクは関数オブジェクトとして表現されています.
そのため,run
関数には「関数オブジェクトのリスト」を与えることができます.
今回の記事で紹介した例ではMultiTaskList
というクラスになっていますが,デリゲートの配列(void delegate()[]
)でも大丈夫です.
C++でならstd::vector<std::function<void()>>
のようなものです.
開発ノード上でプログラムが実行されたとき,run
関数はジョブスケジューラにこのrun
関数に対応するジョブをアレイジョブとして投入します.
一方で,計算用ノードでプログラムが実行されたときrun
関数はタスクリストの中から自分が計算すべきもののみを実行します.
処理の流れをもう少し具体的に説明するために,run
関数を展開した疑似的なコードも示しつつ説明します.
// 1. プログラムが開発ノード上で実行される
// 4. 計算用ノードで実行される
void main()
{
JobEnvironment env;
MultiTaskList list = ...; // 2. and 5. タスクリストの生成
// 3. 開発ノードならrun関数はジョブの投入
if(thisHostIsDevelopmentNode)
{
// ジョブの投入用のスクリプトを作成
std.file.write("jobscript.sh", makeJobScript(list, env));
// qsubを起動してジョブを投入
std.process.execute(["qsub", "jobscript.sh"]);
}
else // 6. 計算用ノードなら計算する
{
// 環境変数から何番目のタスクを実行するか手に入れる
size_t arrayId = environment["PBS_ARRAYID"];
list[arrayId]();
}
}
このコードは次のような流れで実行されます.
- プログラムが開発ノード上で実行される
- プログラムはジョブとして投入したい「タスクリスト」を生成
run
関数でアレイジョブ投入- ジョブスケジューラにより計算用ノードで実行される
- 計算ノードで動く各プログラムは「タスクリスト」を生成
run
関数によりそのジョブが実行すべきタスクだけが実行される
手順の1と2は加えて説明することはありません.
手順3ではrun
関数の中でジョブを投入するためのスクリプトを生成し,qsub
コマンドに与えてジョブスケジューラにジョブを登録しています.
このとき,タスクリストのサイズからどれだけのサイズのアレイジョブを投入するか決定します.
また,このアレイジョブは自分自身を計算用ノードで実行するようなジョブになっています.
また手順6のrun
関数では環境変数のPBS_ARRAYID
をチェックすることでタスクリストの何番目の関数オブジェクトを実行すべきかを判断しています.
このようにすることで,複数パラメータがあっても,もしくは整数以外のパラメータであっても,「リストの何番目の要素か」という整数に対応が付けられるので,アレイジョブで登録できます.
以上がライブラリの大まかな処理の流れになります.
結構簡単でしょう?
注意!
ただ注意しないといけないことが,手順2で生成されるリストと手順5で生成されるリストが同じでないといけないということです.
たとえば,関数オブジェクトのリストを乱数により生成するとしましょう.
乱数系列が実行毎に異なると,開発ノードで実行されたときと計算ノードで実行されたときでリストの中身が異なるかもしれません.
たとえばリストの要素の順番が変わってしまうと,異なるPBS_ARRAYID
として実行されているのに同じ処理を実行してしまう可能性もあります.
そのため,手順2で生成されるリストと手順5で生成されるリストが同じである必要があり,どの計算用ノードでどのような順番でジョブがいつ実行されても手順5で生成されるリストはすべてのジョブで同じである必要があります.
ライブラリの他の機能:グループ化
クラスタ計算機は大学で共有の計算資源ですので,ある一人のユーザーが専有すべきではありません.
そのため,このライブラリには一定数以上のタスクをジョブとして投入するときに,タスクをグループ化して専有を防ぐようになっています.
たとえば,一つのCPUコアを専有するような600個のタスクをジョブとしてジョブスケジューラに投入すると,ジョブスケジューラは最大限までジョブを投入してくれるので,クラスタ計算機の全ノードの600CPUコアを独占してしまう可能性があります.
しかし,11個のタスクで一つのジョブを形成し,そのような55個のジョブを一つのアレイジョブとして投入すれば,各ノードは20個しかCPUコアを持ちませんので最大でも30ノードで330CPUコアしか専有しません.
このライブラリのグループ化機能はCPUコアを一つしか専有しないタスクが大量に投入要求されたときに,それらを11個や7個のジョブとしてまとめ,複数のジョブをアレイジョブとして投入する機能です.
具体的には40タスクまではグループ化は機能せず,41タスクから219タスクまでは7個のタスクで一つのジョブを形成します.
220タスク以上は11個のタスクで一つのジョブを形成します.
つまり,「タスクリスト」のサイズが600個だとすると,600/11 = 54 あまり 6なので,55個のジョブが一つのアレイジョブとして投入されます.
このときの55個のジョブはそれぞれCPUを11コア専有します(端数分だけリソースは無駄になりますが).
このようにしてある一人のユーザーがクラスタ計算機を専有することが起きないようにしています.
グループ化の制御
このグループ化の機能は前述のとおり自動的に動作しますが,制御することもできます.
今すべてのノードには誰もジョブが登録されておらず,30ノード空いているとします.
4つCPUを専有するタスクを1000個投入したいですが,実はタスクの自動的なグループ化は各タスクが一つしかCPUを専有しないときにしか動きません.
しかし,このまま1000個のジョブから構成されるアレイジョブを投入すると,確実に全ノードを専有して他のユーザーに迷惑をかけるでしょう.
このとき,全30ノードのうち10ノードでだけ動かしたいのであれば,次のように実行時引数を与えます.
dub -- --th:p=4 --th:g=5 --th:m=10
-
最初の引数
--th:p=4
は一つのタスクがCPUを4つ専有することをライブラリに伝えるために必要です. -
次の引数
--th:g=5
は5つのタスクでグループ化をライブラリに強制します. -
最後の引数
--th:m=10
は投入するジョブの数は最大でも10個に制限することをライブラリに伝えます.
一つ目と二つ目についてはわかりやすいと思います.
一つのタスクが4つのCPUを専有するので,1ノード専有するジョブを作るためには5つのタスクで一つのグループを形成すればいいのです.
しかし,最後の引数は一見おかしく思えます.
ジョブ数を10個に制限してしまったら,1000個のタスクうち50個しか実行されないのでしょうか?
実はグループ化は「同時並行に実行される複数タスクのグループ化」であり,実際は200個のタスクで一つのジョブが形成されます.
そして,各ジョブに割り当てられた200個のタスクは同時並行に5つ実行され,5つの実行しているタスクのうち一つが終わると,実行待ちのタスクが一つ実行されるようになっています.
このため,--th:p=4 --th:g=5 --th:m=10
は「20CPUを専有する10個のジョブを投入する」という意味になるのです.
おわりに
gitリポジトリで履歴を眺めていると,このライブラリも開発開始から2年経過していることに気が付きました.
2年の間にいろいろあり,ロードアベレージ2万事件では豊橋技術科学大学の皆様にご迷惑をおかけしたこともあり,すみませんでした.
これからもよろしくお願いいたします.