タクトピクセル開発者ブログ

印刷・コンテンツ業界向けSaaS製品を開発・運営するタクトピクセル(株)の開発者ブログ

Python 3.14 のGILなし版で処理の高速化を試みる

大分時間が経ってしまいましたが、Python 3.14 にて、GILなし版が正式リリースされました。マルチスレッドにしても単純にはパフォーマンスがスケールしない、という時代が長く続きましたが手軽に高速化できるとして期待したいと思います。

その GIL という仕組みについては、何となくの間隔で制約を感じるだけで、具体的にはどのような挙動をするのかを確かめたことがありませんでした。そこで、これまでのGILありバージョンの挙動を調べながら、新しい free-threaded 版で処理速度がどう変化するのかを実験してまとめてみました。

GIL とは

GIL とは、Python で長年にわたって標準として機能してきた Global Interpreter Lock、日本語にすればグローバルインタプリタロックという仕組みです。CPython のインタプリタが、同時にひとつのスレッドにしか Python のバイトコードを実行させないようにする、大きな排他ロックのようなものです。スレッド自体は何本でも立ちます。ただ、実際にコードを走らせる権利はいつもひとつのスレッドしか持てない。だから CPU をひたすら使う処理をスレッドに分けても、24 コアのマシンで 1 コア分しか働かないことになります。

なぜこれが要かというと、CPython がオブジェクトの寿命を参照カウントで管理しているからです。すべてのオブジェクトが「いま何箇所から参照されているか」という数字を持っていて、参照が増えたり減ったりするたびにこのカウンタを書き換えます。複数のスレッドがロックなしにこれを同時に書き換えると、カウントは簡単に壊れます。まだ使われているオブジェクトが解放されたり、逆にいつまでも解放されなかったりする。インタプリタ全体をひとつのロックで守ってしまえば、この問題は無くなります。

その GIL を外そうという提案が PEP 703 で、3.13 で実験的に入り、PEP 779 によって 3.14 で公式サポートになったそうです。ただし、free-threaded ビルドがデフォルトになったわけではなく「公式にサポートされるが、依然としてオプション」という段階です。free-threaded ビルドを別に用意する必要があります。

画像処理や深層学習だと大きなデータで重たい処理を扱うことが多いので、メモリ共有の仕組みと並列化は詳しく知っておきたいところです。

実験方法

GIL を握りっぱなしにする、CPU バウンドな処理が要ります。素直に、試し割り法で素数を数える純 Python の関数にしました。NumPy などを使うと内部で GIL を解放してしまうので、測りたいものが測れません。

def count_primes(limit: int) -> int:
    """CPU バウンド、純 Python。GIL を握り続ける。"""
    count = 0
    for num in range(2, limit):
        is_prime = True
        for i in range(2, int(math.sqrt(num)) + 1):
            if num % i == 0:
                is_prime = False
                break
        if is_prime:
            count += 1
    return count

limit=150,000 のこの関数を 24 個のタスクとして、逐次実行・マルチスレッド・マルチプロセスの 3 通りで回します。5 回計測して中央値を採り、各系列の前にウォームアップを 1 回入れました。

比較する構成は 3 つ用意しました。標準ビルドと GIL 無効を並べるだけでは、差が出たときにそれが GIL を外した効果なのか、free-threaded ビルドであること自体の効果なのかが分かりません。そこで、free-threaded ビルドに PYTHON_GIL=1 を与えて GIL を戻した中間の構成を挟みました。

構成 説明 free-threaded ビルド GIL
標準+GIL 従来どおりのビルド 有効
FT+GIL FT ビルドだが GIL を戻す 有効
FT(GIL無効) FT ビルド本来の姿 無効

Python はいずれも 3.14.6、外部依存はありません。Anaconda なら環境を 2 つ作るだけで用意できます。

conda create -n mtdemo-py314-gil   -c conda-forge python=3.14 python-gil
conda create -n mtdemo-py314-nogil -c conda-forge python=3.14 python-freethreading

GIL が本当に外れているかどうかは sys._is_gil_enabled() で確認できます。False が返れば外れています。

計測に使ったマシンはこうです。

CPU : 13th Gen Intel(R) Core(TM) i7-13700KF
      物理コア 16 / 論理プロセッサ 24
OS  : Windows 11 Home

この「物理 16・論理 24」という数字は、あとで結果を読むときに効いてきます。差の 8 が SMT による論理コアなので、8 個の物理コアだけが 2 スレッドを走らせ、残りの 8 個は 1 スレッドしか走らせない構成だと分かります。前者が高性能な P コア、後者が高効率な E コアです。24 という数は、24 個の対等な実行資源を意味してはいません。

結果

いきなり結果です。

GIL有り無し比較とマルチスレッド・マルチプロセスの比較

 

構成 実行方式 中央値 (s) 速度比(自構成の逐次比)
標準+GIL 逐次実行 2.418 1.00x
標準+GIL マルチスレッド 2.416 1.00x
標準+GIL マルチプロセス 0.262 9.21x
FT+GIL 逐次実行 2.535 1.00x
FT+GIL マルチスレッド 2.555 0.99x
FT(GIL無効) 逐次実行 2.570 1.00x
FT(GIL無効) マルチスレッド 0.234 11.01x
FT(GIL無効) マルチプロセス 0.260 9.89x

標準ビルドのマルチスレッドは 1.00x です。24 コアのマシンで 24 本のスレッドを立てても、逐次実行と 1 ミリ秒も変わりません。そして同じコードを free-threaded ビルドで走らせると 0.234 秒、11.01 倍になりました。ソースコードは 1 行も変えていません。

逐次実行の行を縦に見ると、2.418、2.535、2.570 と少しずつ遅くなっています。標準ビルドに対して free-threaded は 1.06 倍の時間がかかっています。これは GIL を外したからではなく、free-threaded ビルドであること自体のコストと思われます。3.14 のリリースノートは単スレッド性能のペナルティを「プラットフォームと C コンパイラによるが、おおむね 5〜10%」と書いていて、手元の 6% はおおよそ推定通りです。

スレッド数に応じたスケールの効果

ワーカー数は 24 の約数に限りました。24 個のタスクを均等に配れるようにするためです。

スレッド数に応じたスケールの効果
ワーカー数 標準+GIL FT(GIL無効) FT(GIL無効) の並列化効率
1 1.01x 1.00x 100%
2 1.00x 1.95x 97%
3 1.00x 2.98x 99%
4 0.96x 4.01x 100%
6 0.97x 5.03x 84%
8 1.00x 6.50x 81%
12 1.00x 8.03x 67%
24 1.02x 11.29x 47%

もうひとつは、4 ワーカーまでは効率 100% できれいに伸びるのに、6 ワーカーで 84% に落ちることです。P コアが 8 個あるなら、8 ワーカーまでは 8 倍近く出てほしいところです。ここは理由を特定できていません。負荷のかかるコアが増えるとターボブースト時のクロックが下がること、Windows のスケジューラが 6 本目あたりから E コアや SMT の論理コアにスレッドを載せ始めること、そして ThreadPoolExecutor.map は 24 個のタスクをワーカーへ均等に配るので、遅いコアに載ったワーカー 1 本が全体の完了時刻を決めてしまうこと。どれも効いているはずですが、コアアフィニティを固定して切り分けてはいないので、推測のみです。

標準ビルドの 4 ワーカーが 0.96 倍と、逐次実行より遅くなっているのはGILが余計な仕事をしているためだと思われます。

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

ここまでの速度比は、すべてワーカーを起動し終えたあとの、定常状態の実行時間です。プールを作るところから計測区間に含めてみます。

構成 実行方式 プール起動 (s) 定常の速度比 起動込みの速度比
標準+GIL マルチプロセス 0.278 9.21x 5.44x
FT(GIL無効) マルチスレッド 0.065 11.01x 9.23x

プロセスプールの起動に 0.278 秒、スレッドプールは 0.065 秒。4 倍以上の差があります。Windows の spawn 方式では、ワーカーごとに Python インタプリタを新しく起動して __main__ を再 import するため、オーバーヘッドが存在しているのかなと想像できます。

マルチプロセスは生成にコストがかかるようです。短い処理だとマルチプロセスをしてもあまり早くならないので、デバッグのしにくさもあって、あまり使ってきませんでした。さらに、プロセスをまたぐと、メモリを共有できないのもデメリットです。

コードの書き方の違い

速度比の表には現れませんが、実際に書くコードは、マルチプロセスとマルチスレッドでかなり違います。

まずマルチプロセスの場合は、

import numpy as np
from concurrent.futures import ProcessPoolExecutor

def process_tile(tile: np.ndarray) -> np.ndarray:   # モジュール直下に置く必要がある
    return heavy_filter(tile)

def run(image: np.ndarray) -> np.ndarray:
    with ProcessPoolExecutor() as ex:
        return merge(ex.map(process_tile, split(image)))

if __name__ == "__main__":      # spawn 方式では必須
    run(load_image())

ex.map に渡したタイルは pickle で直列化されてワーカーへ送られ、結果もまた pickle されて戻ってきます。4K 画像を 24 分割すれば、往復で 2 回のコピーが走ります。関数はワーカー側で import し直せる必要があるので process_tile はモジュール直下に置きますし、ラムダやクロージャは pickle できないので渡せません。深層学習系のプログラムではpickleできないものもあるので、不便な印象です。

スレッドの場合は、

from concurrent.futures import ThreadPoolExecutor

def run(image: np.ndarray) -> np.ndarray:
    out = np.empty_like(image)
    with ThreadPoolExecutor() as ex:
        list(ex.map(lambda box: apply_into(image, out, box), boxes(image)))
    return out

入力画像も出力バッファも、ワーカーはそのまま参照します。コピーはありません。ラムダも渡せます(便利!)。

差がいちばん出るのは初期化です。学習済みモデルを読み込むような重い準備があると、プロセスではワーカーの数だけ読み直すことになります。

ProcessPoolExecutor(max_workers=8, initializer=load_model)   # 8 回読む
model = load_model()                                         # スレッドなら 1 回

残る課題

今回の計測は純粋なPython だけですので、実務の画像処理は NumPy と OpenCV と Pillow の上に乗っていて、これらの C 拡張が free-threaded ビルドでどこまで動き、どこまで速いのかは未検証です。C 拡張は free-threaded 対応を明示しないと、インポート時に GIL を復活させられる仕様だと理解しています。free-threaded ビルドで動かしているつもりが GIL は有効に戻っていた、という事故はあり得るので、実行時に sys._is_gil_enabled() を確かめる必要があるようです。

まとめ

まだまだ一般的ではない技術を本番環境で使うのは怖いですが、徐々に小さなスコープのアプリから試してみようと思います。高速化はキリが無いのですが、それ自体がサービスやアプリの強みにもなったりするので、持ちうるカードとして日々情報収集を続けていきたいと思います。

参考