Raspberry Pi スパコン (9) 通信オーバーヘッド


2023年 03月 30日

円周率を利用したMPIの評価は全回で終わりにする予定であったが、通信オーバーヘッドを調べるのが簡単にできるので、もう少し利用することにした。

通信オーバーヘッドとは

通信時間がどのくらい掛かっているか、それもノード数が多数になるとメッセージが入り乱れて飛ぶようになり、通信が負担になることはないだろうか。
円周率では、各ノードで円の内外判定を延々と続け、その結果求まった円周率の近似値をマスターに集めて、マスターはその平均値を、円周率の近似値としていた。(下図の左側の状態)
このままだと、各ワーカーからマスターに送るメッセージは1回ということになり、あまり問題がない。

しかし、プログラムによっては、ワーカーで計算した値を頻繁に集め、さらにワーカー間でもメッセージ交換が必要になることがある。
そのような状況を、円周率の計算を複数回に分割して実施し、分割してできた塊の計算が終わるごとにワーカーがマスターに送ることにするとどうなるであろうか。円周率の計算のための、円の内外判定の回数(計算量)は同じにしておくと、たぶんその部分の計算時間は同じであろう。しかし、複数回に分けてマスターに送るので、MPIの通信回数は分割数に応じて増加する。
MPIの通信オーバーヘッドが非常に少なければ分割しても全体の計算時間に差はないはずである。もし、オーバーヘッドが大きければ、全体の計算量が同じでも、通信のオーバーヘッドの分の遅れが出る。それも、非常に大きな遅れが出るかもしれない。

プログラムの修正

near_pi_gather.pyを少し変形して、オーバーヘッドを調べられるプログラムにしよう。

引数として、1ノードでの試行回数を指定することにする。全体の試行数は、MPIのsize倍になる。

何回に分割するかは、プログラム中に[1,2,5,10,20,50,100,200,500,1000]というリストを与えることで分割回数を変えることにした。(ちょっと横着だが)

$ mpiexec -H RP0,RP1,RP2,RP3 python3 near_pi_gather_ov.py 8000000

以下のプログラムを用意した。説明が必要そうな変更はしていないので、説明は省略する。

# -*- coding: utf-8 -*-

from mpi4py import MPI
import socket
import random
import sys, time

name = socket.gethostname()
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()

def near_pi(n):
	inside = 0

	for i in range(n):
    	x = random.random()
    	y = random.random()
    	if x*x+y*y < 1.0:
       	inside += 1
    
	return 4.0 * inside / n

def print_pi_from( r, pv ):
	print( f"rank {r:2d}   円周率:{pv:.10f}" )

def mpi_near_pi(n,m):
	start_time = time.perf_counter()
    
	sz = n//m
	pi_avesum = 0.0

	for j in range(m):
    	pival = near_pi(sz)
    	# print_pi_from(rank,pival)
    	values = comm.allgather(pival)
    	# print( "rank {:2d}  values {}".format(rank,values) )
   	 
    	if rank==0:
        	pi_avesum += sum(values)/size
        	# print( "総数 {:8d}   円周率:{:.10f}".format(size*sz*(j+1),pi_avesum/(j+1) ) )
    
	if rank==0:
    	print( "{:5}分割	円周率:{:.10f}	{:12.3f}秒".
           	format(m,pi_avesum/m, time.perf_counter() - start_time) )

if __name__ == '__main__':
	args = sys.argv
	n = int(args[1])
    
	if rank==0:
    	print( f"総数 {size*n:10d} = {size} * {n}" )
   	 
	for m in [1,2,5,10,20,50,100,200,500,1000]:
    	mpi_near_pi(n,m)
   	 
	MPI.Finalize()

1ノードでの全試行数をn、分割数をm として試す関数が mpi_near_pi(n,m) であり、この関数の最後で、結果を表示する。

とりあえず実行すると、こんな感じになった。

$ mpiexec -hostfile host32 python3 near_pi_gather_ov.py 1000000
総数   32000000 = 32 * 1000000
    1分割	円周率:3.1422670000       9.170秒
    2分割	円周率:3.1417440000       8.220秒
    5分割	円周率:3.1418595000       8.787秒
   10分割	円周率:3.1418603750      10.083秒
   20分割	円周率:3.1415906250      12.403秒
   50分割	円周率:3.1416473750      21.225秒
  100分割	円周率:3.1412976250      34.077秒
  200分割	円周率:3.1416820000      66.294秒
  500分割	円周率:3.1420510000     165.598秒
 1000分割	円周率:3.1415998750     329.557秒

8台のRaspberry Pi のそれぞれに4ノードで、全体で32ノードで並列実行した場合である。最後の引数が、各ノードでの試行回数であり、1000000(百万)回を指定した。

分割数は徐々に増やしているが、試行回数は同じで、ノード単位では100万回、全体では3200万回になっている。

1から5分割くらいまでは8〜9秒くらいで大差ない。

しかし、分割数が増えるに従って遅くなり、1000分割だと330秒ほどにになる。計算量は同じはずなので、増加分は頻繁にMPIで通信(allgather)のオーバーヘッドということになる。

330秒のうち、10秒は円周率の試行のための時間だが、残りの320秒はオーバーヘッドと考えられそうだ。MPI通信を1000回実施しているので、1回のオーバーヘッドは320ミリ秒と考えられそうだ。

同じことを100分割で計算すると、オーバーヘッド分は約25秒なので、1回のオーバーヘッドは250ミリ秒となる。分割数が少なくなると、通信頻度が下がり、1回あたりののオーバーヘッドも多少下がったと思われる。

さて、せっかく色々な条件でMPIの通信オーバーヘッド(といってもallgatherだけだが)を調べられるので、次回は条件を変えながら実験してみよう。