Fortranのコンパイルオプション(fortran77/90で有効)
デバッグオプション
上記のオプションをつけて実行すると詳しいエラーメッセージが表示されて計算が止まる。
最適化オプション
計算のちょっとしたコツ
- Fortranの場合2次元以上の配列を用意したとき、実際に用意されるメモリの順番は内側が先である。例えば
real(8) :: a(3,3)
という3×3の2次元配列を定義すると、確保される実際のメモリの順番は
a(1,1)
a(2,1)
a(3,1)
a(1,2)
a(2,2)
a(3,2)
a(1,3)
a(2,3)
a(3,3)
となる(c/c++やjavaなど他のプログラム言語は全て逆に外側が先になるらしい)。do文を用いて配列を操作するようなことがあった場合、メモリの順番に従って操作するようにしたほうが速い。例えば
do i=1,3
do j=1,3
a(i,j)=i+j
enddo
enddo
と
do j=1,3
do i=1,3
a(i,j)=i+j
enddo
enddo
は行なっている演算は同じであるが、後者はメモリの順番に従って操作しているので前者よりも速くなる。
- データの書き出し・読み取りは案外、数値計算のボトルネックになっていることが多いので、ここを改善すると驚くほど計算が速くなったりする。例えば配列データの書き出しは
open(10,file='hoge.dat')
do j=1,3
do i=1,3
write(10,*) a(i,j)
enddo
enddo
close(10)
のようにデータを1つずつ書き出すのではなく
open(10,file='hoge.dat')
write(10,*) ((a(i,j),i=1,3),j=1,3)
close(10)
のようにまとめて書き出せるのであれば書き出すと速い。
- データファイルには人間が読める形式(ascii:アスキー)と、PCは読めるが(厳しい修行に耐え抜いた人間以外の)人間には読めない形式(binary:バイナリ)があり、人間が読む必要がないのであれば(例:別のプログラムへデータを引き渡すだけ)、バイナリ形式でデータを書き出すと、書き出す時間・ファイルサイズともに驚くほど小さくなる(読み込みもバイナリの方が圧倒的に速い)。ちなみにAVS/Expressはバイナリ形式のデータファイルを読むことができる。ある程度修行を積めばgnuplotにもバイナリ形式のデータファイルを読み込ませることができるらしい。バイナリ形式でデータを書き出す例
open(10,file='hoge',form='binary')
write(10) ((a(i,j),i=1,3),j=1,3)
close(10)
とすれば良い。拡張子は付けないか、付けるとすればdatとは別の拡張子を付けた方が良い。
- 計算機は有限桁の数値しか扱えないので加減演算の間で桁落ちが生じることがある。例えば(a+b)+cとa+(b+c)は必ずしも等しくない。桁落ちが深刻になるのはaとbが近い値でa-bの演算をするときである。問題となる2例を示す。1つ目は分散
(1/N) Σ (x - <x>)^2
を計算する際に
(1/N) (Σ x^2 - N <x>^2)
とすると、大きな桁落ちが生じる可能性がある。この桁落ちを回避するには前者の方法で計算するか、メモリに問題がある場合には<x>の予想値x0を設定し
(1/N) Σ (x - x0)^2 - (x0 - <x>)^2
とすればよい。もう1つの例は2次方程式で公式
(- b + (b^2 - 4 a c)^(1/2)) / (2 a), (- b - (b^2 - 4 a c)^(1/2)) / (2 a)
を使って解を求める際、4 a cの絶対値が小さいときである。この場合b > 0であれば前者に、b < 0であれば後者に大きな桁落ちが生じる。これを回避するには、桁落ちが生じる可能性のある方に分子の有理化を行って
(- 2 c) / (b + (b^2 - 4 a c)^(1/2)), (- 2 c) / (b - (b^2 - 4 a c)^(1/2))
とすれば良い
ライブラリの使用
ライブラリとしてMath Kernel Library (MKL)をインストールしている。ここでは線形代数計算(Lapack)に対してMKLを使用する例を紹介する。バージョンが少し古いがMKLの日本語マニュアルのリンクをここに貼っておく。マニュアルに従ってプログラム内でLapackのサブルーチンをコールし、コンパイルする際にMKLへリンクを指定することでLapackのルーチンを使うことができる。具体的には
ifort -L /opt/intel/compilers_and_libraries/linux/mkl/lib/intel64/ -mkl hoge.f90
のようにコンパイルオプションを指定するだけでLapackのルーチンを使うことができる。MKLには乱数生成やフーリエ変換もサポートしている(詳しい使い方はマニュアルを参照)。
並列化マニュアル
並列計算を用いる方法として、自動並列(OpenMP)と手動並列(OpenMPI)がある。それぞれの長所と短所は以下のとおり。
|
長所 |
短所 |
自動並列(OpenMP) |
プログラムちょっと書き換えてコンパイルオプションを付け加えれば簡単に並列化できる。並列効率が良くなるよう自動で調整してくれる |
PC間をまたがった並列計算(ノード間並列)ができず、1台のPCの複数のコアを用いた並列計算(ノード内並列)のみ可能。全て自動なので、細かい並列処理が難しい。 |
手動並列(OpenMPI) |
ノード内並列・ノード間並列の両方が可能である。並列の仕方について細かく指定できるので、OpenMPでは並列化できなかった部分を並列化できたりもする。メモリを分散させるための並列計算が可能である。 |
並列化の仕組みとMPI言語を知る必要があり、プログラムを組むのに大きな労力がかかる。並列効率を意識してプログラムを書く必要があり、下手なプログラムを書くと全く速くならなかったりする。 |
OpenMP並列
ここではよく使う2つの例を挙げておく。
- do文の並列化
完全に独立なdo文は簡単に並列化できる
real(8),dimension(1:100) :: a,b,c
省略
!$OMP PARALLEL DO
do i=1,100
a(i)=b(i)*c(i)
enddo
!$OMP END PARALLEL DO
のような文章を書く。!$OMP PARALLEL DOから!$OMP END PARALLEL DOの間にあるdo文が並列化される。例えば4コア並列をさせた場合1<i<25は1番目、26<i<50は2番目、51<i<75は3番目、76<i<100は4番目のコアが計算を行い、最後!$OMP END PARALLEL DOにおいてそれぞれのaのデータが統合される。
real(8),dimension(1:100,1:100) :: a,b,c
省略
!$OMP PARALLEL DO
do j=1,100
do i=1,100
a(i,j)=b(i,j)*c(i,j)
enddo
enddo
!$OMP END PARALLEL DO
のようにdo文が入れ子になっている場合は外側のdo文に対して(この場合はj)、並列処理が行われる。
注意したいのが、独立でないdo文を並列化するとおかしな結果が出ることである。例えば
real(8) :: a(1:100),b(1:99)
省略
!$OMP PARALLEL DO
do i=1,99
a(i+1)=a(i)+b(i)
enddo
!$OMP END PARALLEL DO
と書くと、a(i+1)の計算に1つ前のiで計算した結果a(i)を用いているが、例えば4コア並列のときのa(26)はa(25)の結果を用いており、a(26)を計算するのは2番目のコア、a(25)は1番目のコア、と別のコアが計算しているのでa(26)は正しい結果が得られない(a(26)が正しくなければ以降は全て正しくない)。
別の例として、有名な和の計算
real(8) :: s,a(1:100)
省略
a=0.d0
!$OMP PARALLEL DO
do i=1,100
s=s+a(i)
enddo
!$OMP END PARALLEL DO
と書くと、異なるiの領域のa(i)の和をそれぞれのコアが独立に計算することになり(sに対するメモリ領域はそれぞれのコアが独立に用意し、共有できない)、最後異なるコアの異なるsを統合することができず、おかしな結果が得られる。この場合には下の配列式の例を用いるか、ATOMICを用いて
real(8) :: s,a(1:100)
省略
a=0.d0
!$OMP PARALLEL DO
do i=1,100
!$OMP ATOMIC
s=s+a(i)
enddo
!$OMP END PARALLEL DO
とすればよい(!$OMP ATOMICを指定した次の行に対して、メモリの変更を共通で行う。速度、安定性において配列式の使用が推奨される。配列式で書けない場合のみATOMICを用いればよい)。
- 配列式の並列化
配列式も簡単に並列化できる。例えば
real(8),dimension(1:100) :: a,b,c
省略
!$OMP PARALLEL WORKSHARE
a(1:100)=b(1:100)+c(1:100)
!$OMP END PARALLEL WORKSHARE
のように!$OMP PARALLEL WORKSHAREから!$OMP END PARALLEL WORKSHAREの間の配列式が並列化される。配列関数も並列化できて、例えば
real(8) :: s,a(1:100)
省略
!$OMP PARALLEL WORKSHARE
s=sum(a(1:100))
!$OMP END PARALLEL WORKSHARE
と書くと、それぞれのコアでaの異なる範囲の和を計算し、!$OMP END PARALLEL WORKSHAREにおいて計算された和の和をとることでaの和が得られる。
OpenMPを用いたプログラムをコンパイルするにはコンパイルオプション-openmpを用いる。
ifort -openmp hoge.f90
また、実際に並列計算をさせるにはOMP_NUM_THREADSという環境変数にコア数を与えなければいけない。ジョブスクリプトの例としては
#!/bin/bash
#PBS -q batch
#PBS -e error.log
#PBS -o out.log
#PBS -l nodes=1:ppn=4
#PBS -l walltime=00:10:00:00
cd $PBS_O_WORKDIR
export OMP_NUM_THREADS=4
./out.exe
となり、4行目のppnと7行目の環境変数の部分に並列コア数を指定する。また$PBS_NUM_PPNを用いて
#!/bin/bash
#PBS -q batch
#PBS -e error.log
#PBS -o out.log
#PBS -l nodes=1:ppn=4
#PBS -l walltime=00:10:00:00
cd $PBS_O_WORKDIR
export OMP_NUM_THREADS=$PBS_NUM_PPN
./out.exe
としてもよい。
OpenMPI並列
ここではよく使う部分だけ記述する。詳しくは理研のマニュアルにて。
MPI並列ではすべてのコアが、同一のプログラムを実行することになる。最も簡単なOpenMPIプログラムの例を示す。
program main
implicit none
include '/opt/openmpi/include/mpif.h'
integer :: ierr,nprocs,myrank
!
call mpi_init(ierr)
call mpi_comm_size(mpi_comm_world,nprocs,ierr)
call mpi_comm_rank(mpi_comm_world,myrank,ierr)
!
write(*,*) myrank,nprocs
!
call mpi_finalize(ierr)
stop
end program main
-
3行目においてMPI並列に必要な関数群を定義しているファイルを読み込んでいる。兎にも角にもこれを読み込まないとMPI並列が出来ないので注意。別のシステムでMPI並列をする場合、mpif.hのファイルの場所がシステムによって異なるので正しい場所に書きなおす必要がある。
-
4行目で定義した整数の使い道だが、
-
ierr:エラーメッセージ格納するための整数。
-
nprocs:全MPI並列数を格納するための整数。
-
myrank:全MPI並列数のうち、自分が何番目なのかを格納するための整数。
-
6行目でMPI並列が始まることを宣言する。ここから12行目のmpi_finalizeまでが並列化されることになる。
-
7行目のMPI組み込み関数mpi_comm_sizeによって、全並列数がnprocsに格納される。mpi_comm_worldはMPIコミュニケーターと呼ばれる変数で、呪文のようなものだと思っても構わない。
-
8行目のMPI組み込み関数mpi_comm_rankによって、全並列数のうち、自分自身が何番目なのかという番号がmyrankに格納される。つまりmyrankは計算するコアごとに異なる値が割り振られ、並列計算を行うにあたって最も重要な値である。0≤myrank≤nprocs-1である。
-
12行目のmpi_finalizeにおいてMPI並列の終了を宣言する。すべてのコアの計算が終了した時点で次の行へと進むことになる。もしこの1行がなければ、一番最初に計算を終了したコアが13行目のstopを実行してしまい、他のコアが計算途中なのにもかかわらずプラグラムが終了してしまうことになる。したがってこの1行は非常に重要である。
10行目でmyrankとnprocsを標準出力に書き出している。このプログラムを4並列で実行すると
0 4
1 4
2 4
3 4
となる(表示される行の順番はバラバラかもしれない)。これは4つのコアが独立に上記のプログラムを実行し、それぞれのコアが10行目に来た時点でmyrankとnprocsを出力した結果である。MPI並列計算をさせるために書かれたプログラムをコンパイルするにはifortの代わりにmpif90(FORTRAN77で書いているのであればmpif77を用いる)を用いる。MPI並列計算をさせるためのジョブスクリプトの例として
#!/bin/bash
#PBS -q batch
#PBS -e error.log
#PBS -o out.log
#PBS -l nodes=4:ppn=1
#PBS -l walltime=00:10:00:00
cd $PBS_O_WORKDIR
export OMP_NUM_THREADS=1
mpirun ./out.exe
となる。実行にはmpirunを用いること。ここでnodesとppnの違いに注意。ppnはノード内でどれだけ並列させるかというのを指定し、nodesはノード間でどれだけ並列させるかというのを指定する。例えばnodes=1:ppn=4とすると、mauiは現時点で最も速いノードを探し出し、そのノードに対して並列数4のノード内並列計算をさせる。逆にnodes=4:ppn=1とすると1並列ずつノードにジョブを割り振りながらノードの速度を随時測定する。コアの割り振りが決まった時点で、すべてのコアが同一ノードに割り振られていればノード内並列になる(つまりnodes=1:ppn=4としたときとほぼ同じになる)し、複数のノードに割り振られていれば、ノード間並列となる。全並列数=nodes×ppnとなり、例えばnodes=2:ppn=4とするとノード内4並列を各2ノードで行うような8並列の計算となる(nodes=2に対してmauiが同じノードに計算を割り当てれば、ノード内8並列になる)。ノード間並列ではLANケーブルを介したデータ通信を行うため、データ通信が多ければ多いほど並列効率は悪くなる。
以下に具体的なプログラム例を示す。
完全に独立なdo文の並列化
いわゆるバカパラと呼ばれる並列計算で、つまり並列化しなくても、ジョブを個別に複数投入すれば代用できる類の並列化である。例えば異なるパラメーターで全く同じ計算をさせたい場合に
program main
implicit none
include '/opt/openmpi/include/mpif.h'
integer :: ierr,nprocs,myrank
!
integer :: it
real(8) :: t
他の変数を定義
!
call mpi_init(ierr)
call mpi_comm_size(mpi_comm_world,nprocs,ierr)
call mpi_comm_rank(mpi_comm_world,myrank,ierr)
!
do it=myrank,100,nprocs
t=dble(it)/100.d0
計算
enddo
!
call mpi_finalize(ierr)
stop
end program main
これはパラメータ0≤t≤1においてtを0.01刻みに動かして計算させる際に、パラメータの動かし方を並列化させたという最も簡単な並列化である。この並列計算において注意したい点は、101通りのtの計算が各コアごとに均等に割り振られるため、特にノード間並列をさせた場合に速いコアは先に終わってずっと待っており、遅いコアがずっと計算することになる。これを回避するために次のような改良が考えられる。
program main
implicit none
include '/opt/openmpi/include/mpif.h'
integer :: ierr,nprocs,myrank
character(len=20) :: crank
!
integer :: it
real(8) :: t
他の変数を定義
!
call mpi_init(ierr)
call mpi_comm_size(mpi_comm_world,nprocs,ierr)
call mpi_comm_rank(mpi_comm_world,myrank,ierr)
!
it=myrank
if(myrank==0) then
open(10,file='next.dat',form='formatted')
write(10,'(i5)') nprocs
close(10)
endif
write(crank,*) myrank
call system('sleep '//crank)
!
do
t=dble(it)/100.d0
計算
open(10,file='next.dat',form='formatted',status='old')
read(10,'(i5)') it
close(10)
if(it>100) exit
open(10,file='next.dat',form='formatted')
write(10,'(i5)') it+1
close(10)
enddo
!
call mpi_finalize(ierr)
stop
end program main
これは各tにおいてdo文の計算が終わったコアから次のtの計算を順番に行っていくためのプログラムである。ここでnext.datというファイルを作成しているが、ここには次に計算すべきitの値を書き出しておく。do文の一番最後まで来たコアは、next.datを読み込んで次のitの値を知り、next.datにit+1を上書きするという寸法である。非線形のシステムはnfsのファイル共有を随時更新するような設定になっているので問題ないが、nfsのファイル共有にタイムラグをもたせているようなシステムの場合、この方法は使えない(next.datの更新が遅れてすでに終わっているitの計算を再度別のコアが行う可能性があるため)。next.datへの書き込みおよび読み込みに対して複数のコアが集中するのを防ぐためにdo文の直前で(myrank)秒だけ計算を止め、コアごとに計算のタイミングを少しずらしている。
Monte Carlo計算などでアンサンブルを稼ぎたい場合に、各アンサンブルの計算を並列化させるという並列計算が考えられる。この場合は平均値等を計算するために、最後にそれぞれのコアで計算された値を足し合わせる必要がある。前の例を一歩進めて書き直せば
program main
implicit none
include '/opt/openmpi/include/mpif.h'
integer :: ierr,nprocs,myrank
character(len=20) :: crank
!
integer :: it
real(8) :: t,s,st
他の変数を定義
!
call mpi_init(ierr)
call mpi_comm_size(mpi_comm_world,nprocs,ierr)
call mpi_comm_rank(mpi_comm_world,myrank,ierr)
!
it=myrank
if(myrank==0) then
open(10,file='next.dat',form='formatted')
write(10,'(i5)') nprocs
close(10)
endif
write(crank,*) myrank
call system('sleep '//crank)
!
s=0.d0
do
計算
s=s+st
!
open(10,file='next.dat',form='formatted',status='old')
read(10,'(i5)') it
close(10)
if(it>100) exit
open(10,file='next.dat',form='formatted')
write(10,'(i5)') it+1
close(10)
enddo
mpi_allreduce(mpi_in_place,s,1,mpi_double_precision,mpi_sum,mpi_comm_world,ierr)
!
if(myrank==0) then
open(11,file='average.dat',form='formatted')
write(11,'(e26.16e3)') s/101.d0
close(11)
endif
!
call mpi_finalize(ierr)
stop
end program main
これは101個のアンサンブルを並列化して計算させ、アンサンブル毎の値stを各コアで足し合わせる(=s)。最後にコア毎のsをすべて足し合わせる必要があるが、ここでデータ通信を行う。そのためのコマンドがmpi_allreduceであり、これは
mpi_allreduce(通信したい変数,結果を格納する変数,通信したいデータ数,データの型,通信する際に行う演算,mpiコミュニケータ,エラーメッセージ)
の形で用いる。通信したい変数と結果を格納する変数を同じにしたい場合、通信したい変数の部分をmpi_in_placeとする。こうすると各ノードのsの和を計算して各ノードのsに格納する。また通信するデータは配列であっても良い。その場合は通信したい配列の一番最初のアドレスを指定し、通信したいデータ数を指定する。データの型としてmpi_integer,mpi_double_precision,mpi_double_complexなどがある。また上の例ではデータ通信の際にそれぞれのコアのデータを足し合わせるように指定したが、その場合にはmpi_sumとなる。その他にもmpi_productのように掛けたりする演算など色々指定できる。また、配列を指定した場合は配列の要素ごとに和をとったり積をとったりする。詳しくはマニュアルを参照。
基本的に通信を行うためのコマンドを指定すると、すべてのコアがそのコマンドの部分に来た時点で(つまり速いコアはそこで待っている)実行されるが、意図的にコアの同期(コアの足並みをそろえる)を行いたい場合には
mpi_barrier(mpi_comm_world,ierr)
とする。
独立ではないdo文の並列化:差分法
差分法であれば、普段ならOpenMPで全く問題ないが、乱数生成等との兼ね合いや、メモリの節約等でどうしてもOpenMPIを使いたい場合もあるので載せておく。また、差分法はMPI並列コードの格好の練習にもなる。今、2次元格子Nx X Nyを考え、Nyを並列化することを考える。また並列数をMとしたときにNy/Mは割り切れるとしておく。
program main
implicit none
include '/opt/openmpi/include/mpif.h'
integer :: ierr,nprocs,myrank
integer :: istatus(mpi_status_size)
integer :: kup,kdown
!
integer,parameter :: nx=100,ny=100
integer,parameter :: nxm=nx-1,nym=ny-1
integer :: ix,iy
integer :: nyi,nyf,nyr
real(8),allocatable :: f(:,:),fd(:,:)
!
call mpi_init(ierr)
call mpi_comm_size(mpi_comm_world,nprocs,ierr)
call mpi_comm_rank(mpi_comm_world,myrank,ierr)
!
kup=myrank+1
kdown=myrank-1
if(myrank==0) kdown=mpi_proc_null
if(myrank==nprocs-1) kup=mpi_proc_null
!
nyr=ny/nprocs
nyi=myrank*nyr
nyf=(myrank+1)*nyr-1
!
allocate(f(-1:nx,nyi-1:nyf+1),fd(0:nxm,nyi:nyf))
!
do iy=nyi,nyf
do ix=0,nxm
f(ix,iy)=fに値を代入
enddo
enddo
!
do iy=nyi,nyf
f(-1,iy)=0.d0
f(nx,iy)=0.d0
enddo
if(myrank==0) then
do ix=0,nxm
f(ix,-1)=0.d0
enddo
else if(myrank==nprocs-1) then
do ix=0,nxm
f(ix,ny)=0.d0
enddo
endif
!
call mpi_sendrecv(f(0,nyf),nx,mpi_double_precision,kup,1,f(0,nyi-1),nx,mpi_double,precision,kdown,1,mpi_comm_world,istatus,ierr)
call mpi_sendrecv(f(0,nyi),nx,mpi_double_precision,kdown,1,f(0,nyf+1),nx,mpi_double,precision,kup,1,mpi_comm_world,istatus,ierr)
!
do iy=nyi,nyf
do ix=0,nxm
fd(ix,iy)=f(ix+1,iy)+f(ix-1,iy)+f(ix,iy+1)+f(ix,iy-1)-4.d0*f(ix,iy)
enddo
enddo
!
call mpi_finalize(ierr)
stop
end program main
解説はそのうちに