pythonマルチスレッド、マルチプロセスをベンチマーキングして見た


pythonの並列処理について

pythonの並列処理について勉強しました、どうやらCPythonのGIL(Global Interpreter Lock)がボトルネックになる場合があります。David Beazley氏がこの問題について詳しく説明してくれました。今回はベンチマーキングを通して実際の動作を体験して見たいです。

再現したい問題

ざっくり:マルチスレッドまたはマルチプロセスで同じワークロード(ある量の計算)を分散させたら、シリアル(1スレッド)よりスピードが遅い

pythonのコードはデフォルトでシングルスレッドのpythonインタープリタープロセス内で実行されます。Threadingモジュールを使ってマルチスレッド実行の場合、スレッドスケジューリングする時GILを用います。GILを獲得できたスレッドのみが実行できます。スレッドが一定期間走ったら、GIL待ちの他のスレッドの存在をチェックし、あったら譲ります、なかったら続行します。python3.2以前の実装だと、GILの移譲にすれ違いが多く、シングルスレッドよりパーフォマンスが悪いことが目立ちます。python3.2以後GILが改良されました。また、複数cpuを並列して利用できるmultiprocessingモジュールもありますので、合わせてテストしてみたいです。

ベンチマークプラン

同じワークロードに対して
1. python2でシリアルで実行する
2. python2でマルチスレッドで実行する
3. python2でマルチプロセスで実行する
4. python3でシリアルで実行する
5. python3でマルチスレッドで実行する
6. python3でマルチプロセスで実行する

各プランでの実行時間を比較します。
環境:

開発環境 設定
Mac OS: Sierra
Processer 2.4 GHz Intel Core i5(4cores)
Memory 16 GB 1600 MHz DDR3
python2 2.7.10
python3 3.6.2

使用するモジュール:

from threading import Thread
from multiprocessing import Pool
from timeit import default_timer

準備に入ります

まずはワークロードを用意します。

def workload():
    """
    provide CPU pound workload
    """
    counter = 1000000
    while counter > 0:
        counter -= 1

次にテストしたい並列度を用意します

settings = [2 ** i for i in range(9)]
>>> [1, 2, 4, 8, 16, 32, 64, 128, 256]

同じワークロードをシリアルあるいは並列処理に振り分ける時、例えばsetting=16の時、シリアルプランでは連続16回workload()を実行し、総実行時間を指標に取ります。マルチプランでは16スレッドあるいは16プロセスに振り分け、振り分け開始からワーク完了までの実行時間を指標に取ります。
シリアル実行関数:

def serializing(n):
    """
    workloading one by one/serially
    """
    start = default_timer()

    for _ in range(n):
        workload()
    return (default_timer() - start)

マルチスレッド実行関数

def threading(n):
    """
    dispatch workload to n thread
    """
    start = default_timer()

    thread_pool = []
    for _ in range(n):
        t = Thread(target=workload, args=())
        t.start()
        thread_pool.append(t)
    for t in thread_pool:
        t.join()
    return (default_timer() - start)

マルチプロセス実行関数

def multiprocessing(n):
    """
    dispatch workload to n process
    """

    start = default_timer()

    process_pool = Pool(processes=n, )
    process_pool.apply(workload)
    process_pool.close()
    process_pool.join()
    return (default_timer() - start)

実行に入ります

python2、python3で3プランずつ実行し、実行時間(秒単位)を集めてチャートにして見ました。

python2のベンチマーキング結果

驚くことに(予想通りではありますが)、マルチスレッド実行する場合、実行時間がシリアル実行よりもずっと遅いとわかりました。マルチプロセス実行の場合うまくワークロードを分散できて、シリアル実行よりずっと早いとわかりました。

python3のベンチマーキング結果

python3(3.2以後)のGILが改善され、python2のようにマルチスレッド実行の方がシリアル実行より何倍も遅いという現象は解消されました。それでもシリアル実行より早いことはありません(困ったもの)。
マルチプロセス実行に至っては相変わらず優秀です。

python2とpython3の結果を比較してみた

シリアル実行比較

なんてこと、シリアル実行(普通にコードを実行)する場合、python3の方が逆に遅いです。それも安定してpython2より倍遅いです。プロダクションの場合どうしましょうか。

まとめ

今回のベンチマーキングをとしてわかったこと(上記設定のワークロードに対して)。

  • シリアル実行: python3がpython2より倍遅い
  • マルチスレッド実行: python2ではシリアル実行より数倍遅いですが、 python3ではシリアル実行に近い性能を出せます。
  • マルチプロセス実行: 全体的優秀 (ワークロードが少ない時にシリアル実行より遅い場合もありえます)

追記:今回のワークロードは計算しかないのでマルチスレッドを使うほどダメになりますが、IO処理が入った場合マルチスレッド実行の方が早いこともあります。