バッチシステムの使い方

バッチ処理とは

限られた計算資源を複数人で使いたいことがある。昔は計算機が非常に高価であり、使いたい時に対話的に使うことができなかった(そうするとアイドルタイムができてもったいないため)。そこで、あらかじめ計算機にやらせたいことをファイルに記述しておき、実行を予約して使われていた。これをバッチ処理、やらせたいことをバッチジョブと呼ぶ。現代においても、スパコンや計算クラスタなどでバッチ処理が行われている。以下では、研究室クラスタでバッチ処理をしてみよう。ここでバッチ処理に慣れておき、将来的にはスパコンを利用することを見据える。

ジョブスケジューラ

バッチ処理とは、やらせたいことをあらかじめ記述しておき、その実行を依頼する仕組みである。その処理の単位を「ジョブ」と呼ぶ。ジョブの実行をリクエストされた際、空いている計算資源を調べ、どのジョブをどのような順番で実行するのかを決めるのがジョブスケジューラだ。世の中には多数のジョブスケジューラがあるが、研究室クラスタで採用しているのはOpenPBSと呼ばれるスケジューラである。これはPBS(Portable Batch System)のオープン版だ。スパコンなどではそのプロ版であるPBS Proなどが使われている。

バッチシステム

バッチシステムを利用するには、自分のやりたいことをバッチジョブの形にしなければならない。そのためには、

という手続きをする必要がある。

ジョブスクリプト

バッチシステムは、ジョブスクリプトと呼ばれるスクリプトを記述し、それをジョブスケジューラに登録することで実行を予約する。ジョブスケジューラはジョブスクリプトを見て、このジョブがどのような計算資源をどの程度占有したいかを調べ、どのタイミングでどの計算資源に割り当てるかを決める。ジョブを計算資源に割り当てて実行することを「ジョブのディスパッチ」と呼ぶ。

大勢が使うシステムでは、優先度をつけて「多く使った人は優先度を下げる。まだあまり使っていない人は優先度を上げる」といった処理をする。これを「フェアシェア」と呼ぶ。しかし、研究室クラスタではいわゆる「FIFO」で運用されている。FIFOとは「First-in First-out」の略で、要するに「早い者勝ち」のシステムである。使う際は節度を守って利用すること。

ジョブスクリプトは、シェルスクリプトにジョブスケジューラ用の情報を付加したものだ。例えば以下のようなフォーマットだ。

#!/bin/bash
#PBS -l nodes=2:ppn=20

cd $PBS_O_WORKDIR

mpirun -np 40 ./a.out

ジョブスクリプトはシェルスクリプトなので、#以降はコメントである。しかし、いくつか「意味のあるコメント」がある。

最初の行の#!/bin/bashは、Shebang(シバン、シェバン)等と呼ばれ、このスクリプトをどのシェル(インタプリタ)で実行するかを指定するものだ。これはジョブスクリプトだけでなく、シェルスクリプト全般で使われる。

次の#PBSから始まる行が、ジョブスクリプトとしての情報を持つコメントである。それぞれ、

という意味だ。

次に、cd $PBS_O_WORKDIRでカレントディレクトリを移動している。

ハンズオン

まずは研究室サーバにログインせよ。次に、githubというディレクトリに移動せよ(なければmkdirで作成せよ)。

cd github

次に、サンプルプログラムをcloneする。

git clone https://github.com/kaityo256/batch_sample.git
cd batch_sample

基本的な操作

まずは、簡単なジョブスクリプトを見てみよう。ディレクトリhostnameに移動せよ。

cd hostname

ここに、MPIを使った並列プログラムのサンプルtest.cppがある。

#include <mpi.h>
#include <cstdio>
#include <unistd.h>

int main(int argc, char**argv){
  MPI_Init(&argc, &argv);
  int rank, procs;
  MPI_Comm_rank(MPI_COMM_WORLD, &rank);
  MPI_Comm_size(MPI_COMM_WORLD, &procs);
  char hostname[256];
  gethostname(hostname, sizeof(hostname));
  printf("%02d / %02d at %s\n",rank, procs, hostname);
  sleep(10);
  MPI_Finalize();
}

このプログラムは、全プロセス数と、自分のID(ランク)、そして実行されたホストの名前を表示し、最後に10秒待つプログラムだ。まずはこれをビルドしよう。並列プログラムはmpic++でコンパイルする。

mpic++ test.cpp

すると、a.outが作成されるので実行してみよう。

./a.out

ランク、プロセス数、そしてホスト名が表示されたはずだ。次に、並列実行してみよう。並列実行するためにはmpirunを用いる。

mpirun -np 4 ./a.out

mpirun-npの後に、何プロセスで実行するかを指定する。ここでは4プロセスで実行している。

さて、このプログラムを、バッチジョブとして実行してみよう。ジョブスクリプトは二つ用意してある。まずは、1ノードを占有実行するtest.shだ。

#!/bin/bash
#PBS -l nodes=1:ppn=20

cd $PBS_O_WORKDIR

mpirun -np 20 ./a.out

このジョブスクリプトを実行するには、qsubコマンドを用いる。

qsub test.sh

qstatコマンドによりジョブの状態を見ることができる。

$ qstat
Job id            Name             User              Time Use S Queue
----------------  ---------------- ----------------  -------- - -----
1254.hostname     test.sh          watanabe          00:00:00 R workq
1255.hostname     test.sh          watanabe          00:00:00 R workq
1256.hostname     test.sh          watanabe                 0 Q workq
1257.hostname     test.sh          watanabe                 0 Q workq

ジョブにはジョブIDが振られる。また、誰が投げたか、実行中(R)か、実行待ち(Q)かなどがわかる。

プログラムの標準出力と標準エラーは、ファイルに落とされる。この時、標準出力は「投入したシェルスクリプト名.oジョブID」、標準エラー出力は「投入したシェルスクリプト名.oジョブID」という名前で保存される。

例えばtest.shというジョブスクリプトを投入し、Job idが1254であった場合、標準出力はtest.sh.o1254に、標準エラー出力はtest.sh.e1254に保存される。

さて、自分の標準出力を見てみよ。実行のタイミングにより、hostnameが異なるはずである。

次に、2ノード占有ジョブを実行してみよう。

#!/bin/bash
#PBS -l nodes=2:ppn=20

cd $PBS_O_WORKDIR

mpirun -np 40 ./a.out

nodes=2で2ノードを使うことを宣言し、mpirun -np 40で40プロセス実行することを宣言している。これは2ノードを占有するため、2ノードしかない計算資源では一度に一つしかジョブが走らない。

qsub test2.sh

実行が終わったら、標準出力を見てみよ。40プロセスのうち、20プロセスずつ別のホストで実行されたのがわかるはずだ。

バルクジョブ

次に、「バルクジョブ」と呼ばれるジョブを実行する。リポジトリのpiというディレクトリに移動せよ。

cd ..
cd pi

そこにcpsというディレクトリがあるはずだ。そこが空であることを確認せよ。

ls cps

実は、このディレクトリはGitのSubmoduleと呼ばれる仕組みで、別のリポジトリを取り込む場所として予約されているが、こちらで指示するまでは空ディレクトリになっている。

では、ここでSubmoduleを更新することで、そのリポジトリをcloneしよう。

git submodule update -i

またpiに移動し、cpsの中身がcloneされたことを確認せよ。

$ cd pi
$ ls cps
LICENSE  README.md  cps.cpp  makefile  task.sh

なお、最初にcloneする際に--recursiveオプションをつけておくと、サブモジュールも同時にcloneされるため、この工程が不要となる。

git clone --recursive https://github.com/kaityo256/batch_sample.git

Git Submoduleの詳細についてはここでは説明しない。気になった人は各自調べること。

さて、ディレクトリpiには、モンテカルロ法で円周率を計算するpi.pyがある。このスクリプトを実行すると入力待ちになるので、適当な数字を入力してみよう。その数字を乱数の種(シード)として円周率を計算する。

$ python3 pi.py
1
3.1420144

これを様々な種を与えて並列に計算し、あとで統計処理をすることを考えよう。例えばseed00.datには0を、seed01.datには1などと、異なるシードをファイルに保存しておき、

python3 pi.py < seed00.dat > result00.dat
python3 pi.py < seed01.dat > result01.dat
python3 pi.py < seed02.dat > result02.dat
python3 pi.py < seed03.dat > result03.dat
python3 pi.py < seed04.dat > result04.dat
...

といった計算を延々やりたい。これらのプログラムには全く依存関係がないから、同時に実行することができる。このようなジョブを自明並列(Trivial Parallelization)、別名「馬鹿パラ」と呼ぶ。

同じディレクトリにmakeseed.pyがあるので実行せよ。

python3 makeseed.py

すると、seed00.datからseed18.dat、そしてtask.shが作成されたはずだ。task.shには、並列実行したいタスクがずらずら記載されている。

このタスクを並列実行するために作った手抜きプログラムがCPS(Command Processor Scheduler)である。まずはビルドしよう。

cd cps
make

これでcpsというプログラムができたはずだ。これは、やりたいことが一行に一つ書かれたスクリプトを読み込んで、適当に並列実行するプログラムだ。

先ほどのディレクトリpiに戻ろう。

cd ..

ここに、cpsを使って並列実行をするジョブスクリプトpi.shがある。

#!/bin/bash
#PBS -l nodes=1:ppn=20

cd $PBS_O_WORKDIR
hostname
mpirun -np 20 ./cps/cps task.sh

さっそく投入してみよう。

qsub pi.sh

間違えてtask.shなどを投入しないように気をつけよう。実行が終わったら、標準出力を見てみよ。実行されたホスト名が表示されているはずだ。これはpi.shの中でhostnameを実行しているためだ。

cpsは、実行ログをcps.logというファイルに保存する。見てみよう。

$ cat cps.log
Number of tasks : 19
Number of processes : 20
Total execution time: 39.713 [s]
Elapsed time: 2.1 [s]
Parallel Efficiency : 0.995313

Task list:
Command : Elapsed time
python3 pi.py < seed00.dat > result00.dat : 2.098 [s]
python3 pi.py < seed01.dat > result01.dat : 2.087 [s]
python3 pi.py < seed02.dat > result02.dat : 2.098 [s]
python3 pi.py < seed03.dat > result03.dat : 2.086 [s]
python3 pi.py < seed04.dat > result04.dat : 2.089 [s]
python3 pi.py < seed05.dat > result05.dat : 2.087 [s]
python3 pi.py < seed06.dat > result06.dat : 2.098 [s]
python3 pi.py < seed07.dat > result07.dat : 2.077 [s]
python3 pi.py < seed08.dat > result08.dat : 2.098 [s]
python3 pi.py < seed09.dat > result09.dat : 2.086 [s]
python3 pi.py < seed10.dat > result10.dat : 2.1 [s]
python3 pi.py < seed11.dat > result11.dat : 2.086 [s]
python3 pi.py < seed12.dat > result12.dat : 2.097 [s]
python3 pi.py < seed13.dat > result13.dat : 2.065 [s]
python3 pi.py < seed14.dat > result14.dat : 2.092 [s]
python3 pi.py < seed15.dat > result15.dat : 2.086 [s]
python3 pi.py < seed16.dat > result16.dat : 2.096 [s]
python3 pi.py < seed17.dat > result17.dat : 2.088 [s]
python3 pi.py < seed18.dat > result18.dat : 2.099 [s]

全部で何個のタスクがあり、トータル何秒だったか、実際には何秒で実行できたか等が表示されている。

また、同じディレクトリにresult00.datからresult18.datまでファイルができているはずだ。一つ見てみよう。

$ cat result01.dat
3.1420144

result01.datは、乱数の種として「1」を入力した結果だ。先程Pythonで直接実行した時と同じ値が出ていることを確認せよ。乱数を使う計算では、「同じ乱数の種からは同じ結果が出る」という事実は覚えておきたい。Pythonのrandomモジュールは、デフォルトで乱数の種が固定されないため、実行される度に異なる結果を返す。これは「ランダムネス」という意味では良いが、数値計算においてはデバッグの障害となる。乱数を使うプログラムでは、乱数の種を指定しておく癖をつけておくこと。

さて、あとはこれらのファイルを集計するだけだ。Pythonを使うならこんなスクリプトになるだろう。

import glob
import numpy as np

data = []
for filename in glob.glob('result*.dat'):
  with open(filename) as f:
    a = f.readlines()[0]
    data.append(float(a))

average = np.average(data)
error = np.std(data)

print(f"{average} +- {error}")

実行してみよう。

$ python3 average.py
3.1416488 +- 0.0006905991052778619

上記の手続き、すなわち

というのは、非常に単純ながら効果的な手法なので覚えておくと良い。