📑

Pythonでの並行・並列処理をお手軽にやる方法

2023/04/23に公開

はじめに

Pythonで科学技術計算を行う場合、pythonの標準的なメソッドやクラスではなく、numpyなどの数値計算用のクラスを用いることで計算を高速に行えることが一般的に知られている。ただ、こういったクラスを用いたとしても、3次元空間内の物理量の演算などどうしても多重forループを行わなければいけない状況に割と頻繁に出くわす。こういった状況でいかに高速な計算方法を実装するかというのが、プログラマの力の見せ所ではあるが、できるだけ頑張らず(実装に時間をかけずに)に簡単に高速に計算できるようにしたいというのも事実である。

この記事では並列化を含むpythonで効率的に数値計算を行うノウハウを整理しておいた。

Pythonでの高速化の代表的な手法

リスト内包表記

最もメジャーな方法の一つ。ネット上の記事を見ると実行時間は大体半分から1/3ぐらいになる模様。
特にlistオブジェクトに対してappendを繰り返すような処理の際に効果を発揮することが知られている。

https://qiita.com/intermezzo-fr/items/43f90e07e4cebe63aeb6

numpyなどの数値計算用ライブラリ

numpyはPythonの数値計算ライブラリ。pandasやscikit-learn, scipyのような多くの科学技術計算系のライブラリはnumpyがベースになっている。numpyの計算が速いのはCやC++で書かれたコードをpythonから呼び出しているため。
小規模な配列の場合はリスト内包表記等の方が早いケースもあるが、科学技術計算で扱う規模(数百要素以上)の配列の場合はほぼnumpyの方が早い。numpyに限らず何らかの数値計算や集計処理等を行いたい場合、初手は何らかのライブラリでできないかを調べること。実用面ではナイーブに実装してもみんなが使っているライブラリに敵うことはまずない。

https://tech-lab.sios.jp/archives/21127

numba

numbaはjust in time compilerのこと。Pythonは動的型付け言語なので、明示的に型を定義していなくても、インタプリタ側でよろしくやってくれる。これは便利な反面、処理のたびに型の判定が入るためどうしても演算は遅くなる(Pythonが遅い理由の一つ)。
numbaはjust in time compilerのライブラリで、ざっくりいうとスクリプト実行時にコンパイルを行うことで、型推論の判定等を減らすことができるというもの。コンパイル時間の方が方推論等より短い場合に処理は速くなる。numbaを使うにはメソッドのデコレータとして@jitを記述するだけで基本的にはOKだが、numbaで型推論がうまく行えるように実装を書き換える必要がある。個人的には、過去に試した分ではあまり効果がなかったのでそれ以降使っていない。

https://qiita.com/gyu-don/items/9d223b007ca620e95abc

並列処理

今時のマシンはマルチコアなので、各コアにforループで処理する変数を割り当てれば、処理時間が短縮できる。単純なforループの場合は並列化効率はほぼ100%にできるので、処理時間は1/コア数となる。例えば、AWSのインスタンスで処理を行えば、単純にループを回すのに対して約3%の時間で同じ処理が実現できることになる。

並列処理の実装としてはマルチプロセス (multiprocessing)、スレッディング、MPI (mpi4py) がある。単純なforループの場合はプロセス間通信は不要なので、マルチプロセスかスレッディングが基本的な選択肢になる。

お気楽マルチプロセス処理のスニペット

著者がよく使用しているコード例を参考までに載せておく。下記のparallelメソッドに並列処理したいメソッドとそのメソッドの引数を辞書のリストの形で渡してやるだけでよいので、既存のメソッドを基本的にはそのまま利用できる。num_procには最大プロセス数を指定する。

import time 
import multiprocessing as multi

def parallel(target, args):
    t0 = time.time()
    jobs = []
    proc = 0
    rest = len(args)
    for arg in args:
        if proc < num_proc:
            p = multi.Process(target=target, args=(arg,))
            jobs.append(p)
            p.start()
            proc += 1
            rest -= 1
            if proc == num_proc or rest == 0:
                print('%s process was working, and rest process is %s.'%(num_proc, rest))
                for job in jobs:
                    job.join()
                proc = 0
                jobs = []
    t1 = time.time()
    print('{:.2f}'.format(t1-t0))

def calc(args):
    print('x=%d y=%d z=%d xyz=%d'%(args['x'], args['y'], args['z'], args['x']*args['y']*args['z']))

if __name__ == '__main__':
    num_proc = 2
    args = [
        {'x':x, 'y':y, 'z':z}
        for x in range(5)
        for y in range(5)
        for z in range(5)
        ]
    parallel(calc,args)

上記の例では、x, y, zがそれぞれ0〜4の整数に対してその積を計算するメソッドを例示しているが、手元のマシン (Macbook Pro 2015 early)を使ってnum_proc=1と2で実行するとそれぞれ、10.34 sec、6.48 secとなり、60%程度の実行時間になった。

Discussion