Morikatron Engineer Blog

モリカトロン開発者ブログ

Python のプログラムを並列処理で高速化する

こんにちは、モリカトロンでプログラマおじさんをやってる岡島です。

Python でプログラムを書いていると高速に実行したくなることが多々あると思います。 でも、「とにかく実行速度を最速に!」みたいな人は最初から Python なんて使わないですよね。

ということでプログラムの最適化にあんまり興味のない人が、出来るだけ手間をかけずそこそこ効果が得られる方法を紹介していこうと思います。

並列処理で高速に処理する

何もしなかったら Python はCPUコアひとつを使って処理していくだけです。 これは勿体ないので、たくさんの CPU コアで処理を並列に走らせ、短時間で処理が終わるようにしてみましょう。

今回は、細かいことを抜きにして MultiProcessing も MultiThreading も区別なく「並列処理」と呼ぶようにします。

Python 標準の並列処理モジュール

Python は並列/並行処理を実現するために以下のようなモジュールを提供しています。

concurrent.futures はザックリと説明すると threading 及び multiprocessing をより扱いやすくした ようなモジュールです。
大量の処理を効率よく並列に処理させたい場合、threading や multiprocessing を使う場合よりも concurrent.futures の方が同じことを短いコード量で実現できますので、特に理由がない限り concurrent.futures を使うのが良いんじゃないかと思います。

処理を並列実行してみる

それでは concurrent.futures を使ってプログラムを並列実行してみましょう。

速度比較

オライリーの「ハイパフォーマンス Python」の2章で使われているジュリア集合の計算プログラム (calc_pure_python) を繰り返し実行するプログラムで比較実験をしました。

手元のデスクトップPC (CPU: Intel Core-i7 8700) で上記を実行した場合、以下のような結果となりました。

時間
SingleThread 24271.074ms
MultiThread 29613.817ms
MultiProcess 4633.611ms

マルチプロセスで処理する場合はかなり高速になりますが、マルチスレッドだと逆に遅いという結果になります。

マルチスレッドでの処理がなぜ遅いのか?

Python には GIL という仕組みがあって、複数のスレッドが同時に動作出来ないようになっています。 そのため Python におけるマルチスレッド処理は基本的に複数の CPU コアを効率よく使って計算をすることが出来ません。

一方、マルチプロセスの場合はプロセスそれぞれに GIL が存在する … ようするに個々のプログラムが独立して動きます。 そのため、複数の CPU コアで複数のプロセスの処理を同時に実行することが出来ます。

ザックリした説明になりますが、以下の図のようになると思ってください。 f:id:morika-okajima:20200130160052p:plain

GIL についてもっと詳しく

docs.python.org

もう少し詳しく知りたい方は、以下も参照ください。

Understanding the Python GIL (dabeaz) qiita.com

マルチスレッドに価値はないのか?

シンプルなプログラムで実験するとマルチスレッドによる並列処理は遅くて価値が無いように見えますが、実行する処理次第ではシングルスレッドよりも高速に処理されます。

Python の GIL は Python の計算処理が正しく実行されるのを保証するための仕組みですので、ファイルを読み込みや GPU 側での計算などの待ち時間までロックすることはありません。 そういう処理を含んでいる場合は GIL が解放され、以下の図のように効率的に処理を並列実行することが出来ます。 f:id:morika-okajima:20200130163114p:plain

GIL が解放される処理の例

  • スリープ
  • ファイルアクセス
  • ネットワーク通信の送受信
  • GPU コンピューティングの結果待ち
  • subprocess モジュールで別プログラムを実行
  • print などの出力

実測して確認

前述のコードの一部に sleep を挿入してみます。

def calculate_z_serial_purepython(maxiter, zs, cs):
    """ジュリア漸化式を用いてoutput リストを計算する"""
    output = [0] * len(zs)
    for i in range(len(zs)):
        n = 0
        z = zs[i]
        c = cs[i]
## --> ここから
        if (i % 100) == 0:
            # 100 回に一回の頻度で 100us のスリープ=処理待ちを挿入
            time.sleep(0.0001)
## <-- ここまで
        while abs(z) < 2 and n < maxiter:
            z = z * z + c
            n += 1
        output[i] = n
    return output

サンプルコード全体はこちら

時間
SingleThread 154708.914ms
MultiThread 33806.534ms
MultiProcess 16723.904ms

今度はシングルスレッドよりマルチスレッドの方が高速になりました。 例えば大量の画像ファイルに対して何か処理をするような場合なら、ファイルの読み込みをマルチスレッドで処理するメリットは十分に出てくるでしょう。

マルチプロセスのデメリット

また、マルチプロセスによる並列処理にはデメリットもあります。

プロセスの生成はスレッドよりもオーバヘッドが大きい

PC 上のプログラムは「プロセス」という単位で区切られており、マルチプロセスはそのプロセスをたくさん作ることで並列処理する仕組みです。
そして、個々のプロセスはメモリ空間を共有しない…内部データを共有しません。新しいプロセスを生成するたびに必要なデータを複製してあげる必要があります。
※ 当然「プロセスをあたらしく作る」という処理も発生します。

f:id:morika-okajima:20200130170508p:plain
PC 上のプログラムはプロセスという単位で管理される

それに対し、マルチスレッドは同じプロセス内にスレッド(本体とは別のプログラムの流れ)を作るだけなので、短時間で作れ、メモリ消費も少なくて済みます。
ひとつの処理が短時間で終わる場合には、マルチプロセスよりマルチスレッドの方が適しているでしょう。

速度比較

「オーバーヘッドが大きい」と言ってもピンとこないので、実際に動かして確認しましょう。

時間
MultiThread 3885.588ms
MultiProcess 31397.583ms

プロセス生成のオーバーヘッドは、この実験結果だと1回あたり 0.3ms 程度でした。

子プロセスに渡すデータに Pickle 化できないオブジェクトがあってはいけない

プロセスを生成する際、システムが必要なオブジェクトを Pickle 化 (Serialization) します。データをコピーするのにPickle 化が必須だからです。 もし、Pickle 化できないオブジェクトが含まれていると ProcessPoolExecutor.submit() 内でエラー停止してしまいますので、マルチプロセス化できません。

docs.python.org

場合によってはマルチプロセスで処理するためにプログラムを修正する作業が発生する場合があったり、使っているモジュールの都合で Pickle 化できない場合もあります。
そういう場合に諦めてマルチスレッドで処理するケースも出てくるでしょう。

”諦めてマルチスレッドで処理” が低コストなのが concurrent.futures の魅力の一つです。

結局、どうすればいいのか?

並列実行する処理によってケースバイケースだと思います。

出来るだけ手間をかけず にやるなら、とりあえず concurrent.futures の ThreadPoolExecutor を使うようにして、それで満足できない場合には ProcessPoolExecutor に切り替えてみるというのが良いかもしれません。