😽

Python3.13におけるGILオプション化に伴うマルチスレッド、マルチプロセスの使い分け

2024/12/10に公開

やること

GIL除去とマルチスレッド、マルチプロセスの性能を検証する

背景

前回書いたこちらの記事の続きです。
https://zenn.dev/headwaters/articles/c0e54e9ccf55ee

この記事を書いた時点では知らなかったんですが、Python3.13からGILを無効か有効か選べるようになったんですね。というわけで、マルチスレッド、マルチプロセスとの使い分けはどうなるのかという観点で検証したいと思います。

GILとは?

Global Interpreter Lockと呼ばれている「複数のスレッドが同時にPythonコードを実行することを防ぐ」機能のことです。デフォルトのPythonではマルチスレッドを用いた並列処理ができないように制限されていますが、PEP703でGILを除去可能にする提言がなされました。
https://www.python.jp/pages/no-gil/index.html
これまではどうしても計算処理を早くしたいといった理由で並列処理をしたい場合はmultiprocessingを用いる必要があったのですが、今回それに加えてGIL除去という選択肢が1つ増えたとも言えます。
GIL除去は言い換えればマルチスレッドの有効化であり、I/Oバウンドなタスク(ファイルの読み書き、ネットワーク通信など)では差が生じないと予想されたため、今回の検証ではマルチスレッドによる影響が大きそうなCPUバウンドタスク(計算量の多い数値処理など)をターゲットとして検証しました。

実行環境の準備

まず、以下の公式ページからWindows installer (64-bit)をダウンロードします。
https://www.python.org/downloads/release/python-3131/

このとき、注意点としては「Customize Installation」を選択した後、「Download free-threaded binaries」にチェックを入れてインストールするようにしてください。

その後、インストールしたフォルダにあるpython.exeとpython3.13t.exeでそれぞれ仮想環境を作成しておきます。
また、今回の検証では以下スペックのWindowsPCを用いています。
・CPU:12th Gen Intel(R) Core(TM) i7-1265U 2.70 GHz
・実装RAM:16.0 GB (15.8 GB 使用可能)
・システムの種類:64 ビット オペレーティング システム、x64 ベース プロセッサ

前提

はじめに、前回の記事で用いたスクリプトを使ってデフォルトのPythonではマルチスレッドが使えないことを確認しておきます。
<multithreadを用いない場合>

2^10000000の計算が完了しました, 実行時間: 0.0382秒
3^10000000の計算が完了しました, 実行時間: 2.9727秒
5^10000000の計算が完了しました, 実行時間: 4.9781秒
7^10000000の計算が完了しました, 実行時間: 6.8437秒
総実行時間: 14.8353秒

<multithreadを用いた場合>

2^10000000の計算が完了しました, 実行時間: 0.0375秒
3^10000000の計算が完了しました, 実行時間: 2.8863秒
5^10000000の計算が完了しました, 実行時間: 4.7408秒
7^10000000の計算が完了しました, 実行時間: 6.5670秒
マルチスレッド版の総実行時間: 14.2351秒

デフォルトのPythonではGILが有効なので、multithreadの使用の有無にかかわらず実行時間はほとんど変わらないことがわかります。前回の記事でmultiprocessingを用いるとトータルの計算時間が約半分になったのとは対照的です。

マルチスレッドとマルチプロセスの比較

それではマルチスレッドとマルチプロセスを比較していきます。ここでは実行時間に加えてCPU使用率、メモリ使用量を見てみましょう。マルチスレッド版のスクリプトを参考に示します。

multithread.py
import threading
import time
import psutil  # リソース使用量の計測に使用

def compute_power(number):
    process = psutil.Process()  # 現在のプロセスを取得
    start_time = time.time()  # 開始時間を記録
    result = number ** 10000000
    end_time = time.time()  # 終了時間を記録
    duration = end_time - start_time

    # CPU使用率とメモリ使用量を取得
    cpu_usage = process.cpu_percent(interval=0.1)
    memory_usage = process.memory_info().rss / (1024 * 1024)  # メモリ使用量(MB)

    print(f'{number}^10000000の計算が完了しました, 実行時間: {duration:.4f}秒, CPU使用率: {cpu_usage:.2f}%, メモリ使用量: {memory_usage:.2f} MB')

if __name__ == '__main__':
    numbers = [2, 3, 5, 7]

    threads = []

    total_start_time = time.time()  # 総開始時間を記録

    # 各数値に対してスレッドを作成してスタート
    for number in numbers:
        thread = threading.Thread(target=compute_power, args=(number,))
        thread.start()
        threads.append(thread)

    # 全てのスレッドが終了するのを待つ
    for thread in threads:
        thread.join()

    total_end_time = time.time()  # 総終了時間を記録
    total_duration = total_end_time - total_start_time
    print(f'マルチスレッド版の総実行時間: {total_duration:.4f}秒')

GILあり(python.exeを実行)

<multithreadを用いた場合>

2^10000000の計算が完了しました, 実行時間: 0.0387秒, CPU使用率: 52.50%, メモリ使用量: 19.53 MB
3^10000000の計算が完了しました, 実行時間: 2.9743秒, CPU使用率: 25.10%, メモリ使用量: 21.50 MB
5^10000000の計算が完了しました, 実行時間: 4.9255秒, CPU使用率: 1.40%, メモリ使用量: 25.14 MB
7^10000000の計算が完了しました, 実行時間: 6.8404秒, CPU使用率: 0.00%, メモリ使用量: 20.13 MB
マルチスレッド版の総実行時間: 14.8853秒

<multiprocessingを用いた場合>

2^10000000の計算が完了しました, 実行時間: 0.0546秒, CPU使用率: 0.00%, メモリ使用量: 18.78 MB
3^10000000の計算が完了しました, 実行時間: 3.2082秒, CPU使用率: 0.00%, メモリ使用量: 19.61 MB
5^10000000の計算が完了しました, 実行時間: 5.1579秒, CPU使用率: 0.00%, メモリ使用量: 20.58 MB
7^10000000の計算が完了しました, 実行時間: 7.2749秒, CPU使用率: 0.00%, メモリ使用量: 21.28 MB
マルチプロセス版の総実行時間: 7.6420秒

GILがロックされているためmultithreadは機能せず、multiprocessを使った場合では実行時間が短縮されています。

GIL除去(python3.13t.exeを実行)

さて、こちらではマルチスレッドとマルチプロセスはそれぞれどのような結果が得られたでしょうか。
<multithreadを用いた場合>

2^10000000の計算が完了しました, 実行時間: 0.0341秒, CPU使用率: 170.70%, メモリ使用量: 33.28 MB
3^10000000の計算が完了しました, 実行時間: 3.0851秒, CPU使用率: 93.20%, メモリ使用量: 155.96 MB
5^10000000の計算が完了しました, 実行時間: 4.9992秒, CPU使用率: 78.00%, メモリ使用量: 208.60 MB
7^10000000の計算が完了しました, 実行時間: 7.2869秒, CPU使用率: 0.00%, メモリ使用量: 233.39 MB
マルチスレッド版の総実行時間: 7.3967秒

<multiprocessingを用いた場合>

2^10000000の計算が完了しました, 実行時間: 0.0422秒, CPU使用率: 0.00%, メモリ使用量: 28.94 MB
3^10000000の計算が完了しました, 実行時間: 3.1350秒, CPU使用率: 0.00%, メモリ使用量: 66.71 MB
5^10000000の計算が完了しました, 実行時間: 5.1236秒, CPU使用率: 0.00%, メモリ使用量: 88.86 MB
7^10000000の計算が完了しました, 実行時間: 7.0858秒, CPU使用率: 0.00%, メモリ使用量: 103.80 MB
マルチプロセス版の総実行時間: 7.4145秒

実行時間はGILありでmultiprocessingを用いたときとほぼ変わりませんでしたが、GIL除去のPythonではメモリ使用量が増加しています。

どう使い分けるべきか

これらの結果からは、「GIL除去ではメモリ使用量が増加するので、GILありのmultiprocessingが良い」となりますが、それは違うと思っています。というのも、本来的にはmultithreadの方がメモリ効率が良いはずだからです。ではなぜこのような結果が得られたのかと言えば、スレッドごとにキャッシュや中間データが生成されたとか、実際の計算や処理とは直接関係ないメモリ操作が悪さをしたとか何らかマルチスレッドによる弊害があると思ってますが、よくわかりませんでした。なので、現状ではGILありのmultiprocessingとGIL除去によるmultithreadをどちらも試した方がよいというのが個人的な見解になります。

コメントなど

本音を言えば、「Python3.13からmultiprocessingの代わりにmultithreadを使いましょう」と言いたいところでしたが、そうは問屋がおろしませんでした。。たまたま今回のタスクがそうだったという可能性もあるので、引き続き検証していきたいところです。

ヘッドウォータース

Discussion