- GILとは
- Pythonの世界から抜け出してみよう(PythonからC言語関数を呼び出す)
- C言語関数とPython関数を比較してみる(GIL有効)
- C言語関数とPython関数を比較してみる(GIL無効)
- (おまけ)トップレベルの並列化とローレベルな並列化を比較してみる
- 最後に
- We Are Hiring!
こちらはABEJAアドベントカレンダー2024 20日目の記事です。
プラットフォームアプリケショングループの平原です。 Python 3.13からGILを無効にすることができるようになりました。 ですが、GILがどのように影響するかを理解していないと、 GILを無効にすることで得られるメリットがわからないかもしれません。 GILが効く範囲がどこまでなのかを攻めてみたいと思います。
GILとは
Pythonの公式ドキュメントにはGILの説明として以下のように記載されています。
global interpreter lock
(グローバルインタプリタロック) CPython インタプリタが利用している、一度に Python の バイトコード を実行するスレッドは一つだけであることを保証する仕組みです。これにより (dict などの重要な組み込み型を含む) オブジェクトモデルが同時アクセスに対して暗黙的に安全になるので、 CPython の実装がシンプルになります。インタプリタ全体をロックすることで、マルチプロセッサマシンが生じる並列化のコストと引き換えに、インタプリタを簡単にマルチスレッド化できるようになります。
ただし、標準あるいは外部のいくつかの拡張モジュールは、圧縮やハッシュ計算などの計算の重い処理をするときに GIL を解除するように設計されています。また、I/O 処理をする場合 GIL は常に解除されます。
As of Python 3.13, the GIL can be disabled using the --disable-gil build configuration. After building Python with this option, code must be run with -X gil=0 or after setting the PYTHON_GIL=0 environment variable. This feature enables improved performance for multi-threaded applications and makes it easier to use multi-core CPUs efficiently. For more details, see PEP 703.
GILはPythonのバイトコードの実行スレッドが一つだけであることを保証する仕組みであるとありますが、 I/O処理などの例外があり、また、CPythonインタプリタが利用するものであることがわかります。 ということは、Pythonの制御の外で実行されるコードはGILの影響を受けないと考えることもできます。
Pythonの世界から抜け出してみよう(PythonからC言語関数を呼び出す)
Pythonの世界から抜け出すための、C言語関数を作ってみます。 計算量の多い計算として、 ライプニッツの公式を使って円周率を計算するプログラムを作成してみます。 後ほど、トップレベルの並列化と比較するために、 OpenMPを使ってローレベルの並列化を行うプログラムも作成します。
/* pi.c */ double calc_pi(int n) { double pi = 0.0; for (int i = 0; i < n; i++) { pi += 4.0 * (i % 2 == 0 ? 1 : -1) / (2 * i + 1); } return pi; } // OpenMPによる並列化バージョン double calc_pi_mp(int n) { double pi = 0.0; #pragma omp parallel for reduction(+:pi) for (int i = 0; i < n; i++) { pi += 4.0 * (i % 2 == 0 ? 1 : -1) / (2 * i + 1); } return pi; }
Pythonから参照できるようにするために、共有ライブラリとしてコンパイルします。 私の環境はMacなので、clangを使っています。
# OpenMPを使うためにlibompをインストール brew install libomp # -Lフラグの値はlibomp.dylibのあるディレクトリを指定 # OpenMPを使わない場合は-X, -f, -L, -lフラグは不要 clang -Xpreprocessor -fopenmp -L/opt/homebrew/Cellar/libomp/19.1.5/lib -lomp -O2 -shared pi.c -o pi.dylib
Pythonを使って、先ほど作成したC言語関数と、 Pythonで実装した関数を呼び出す準備を行います。 ctypesを使って先ほど生成した共有ライブラリを読み込みます。 また、Pythonによる実装も追加しています。 実行時間を計測するために、デコレーターを作っておきます。
# main.py import ctypes import time from threading import Thread # あとで使う libc = ctypes.CDLL('./pi.dylib') # 環境によって、引数と戻り値の型を適切に指定しないと動かない libc.calc_pi.argtypes = (ctypes.c_int,) libc.calc_pi.restype = ctypes.c_double libc.calc_pi_mp.argtypes = (ctypes.c_int,) libc.calc_pi_mp.restype = ctypes.c_double def log(func): def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) elapsed_time = time.time() - start print(f'[{func.__name__}] elapsed_time: {elapsed_time:.3f}, args: {args}, kwargs: {kwargs}, result: {result}') return result return wrapper @log def calc_pi_c(n: int) -> float: return libc.calc_pi(n) @log def calc_pi_mp(n: int) -> float: return libc.calc_pi_mp(n) @log def calc_pi(n: int) -> float: sum = 0.0 for i in range(n): sum += 4.0 * (1 - i % 2 * 2) / (2 * i + 1) return sum
試しに動かしてみましょう。 次のように追記してから実行できていれば成功です。 (浮動小数点数の都合により、計算の順序でも値が少し変わります。) 一旦この実行時間を基準とします。
calc_pi_c(1_000_000_000)
calc_pi_mp(1_000_000_000)
calc_pi(100_000_000) # 遅いので桁をひとつ減らして計算
$ python main.py [calc_pi_c] elapsed_time: 1.054, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_mp] elapsed_time: 0.179, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi] elapsed_time: 10.164, args: (100000000,), kwargs: {}, result: 3.141592643589326
関数 | 1スレッドでの実行時間(秒) |
---|---|
calc_pi_c | 1.054 |
calc_pi_mp | 0.179 |
calc_pi | 10.164 |
C言語関数とPython関数を比較してみる(GIL有効)
それでは、C言語関数とPython関数を複数スレッドで実行してみましょう。
私の環境には8コアのCPUがあるので、その半分の4スレッドで実行してみます。
並列化にはthreading
モジュールを使うことにします。
そのため、Pythonで実装した関数はGILによりうまく並列化できず、
C言語関数はGILが無効になっているため、1スレッドの時と変わらないことが期待されます。
次のコードを追記してから実行してみます。
# calc_pi_cを複数スレッドで実行(経過時間は最大値を使用) threads = [Thread(target=calc_pi_c, args=(1_000_000_000,)) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print() # calc_piを複数スレッドで実行(経過時間は最大値を使用) threads = [Thread(target=calc_pi, args=(100_000_000,)) for _ in range(4)] for t in threads: t.start() for t in threads: t.join()
$ python main.py [calc_pi_c] elapsed_time: 1.039, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 1.046, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 1.050, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 1.068, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi] elapsed_time: 41.008, args: (100000000,), kwargs: {}, result: 3.141592643589326 [calc_pi] elapsed_time: 41.605, args: (100000000,), kwargs: {}, result: 3.141592643589326 [calc_pi] elapsed_time: 41.781, args: (100000000,), kwargs: {}, result: 3.141592643589326 [calc_pi] elapsed_time: 41.752, args: (100000000,), kwargs: {}, result: 3.141592643589326
関数 | 4スレッドでの実行時間(秒) | 1スレッドでの実行時間(秒) |
---|---|---|
calc_pi_c | 1.068 | 1.054 |
calc_pi | 41.781 | 10.164 |
想定通り、C言語関数はGILが無効になっているため、 1スレッドの4スレッドの実行時間が変わらないことがわかります。 また、Pythonで実装した関数はGILにより、 4スレッドの実行時間が1スレッドの4倍になっていることがわかります。
C言語関数とPython関数を比較してみる(GIL無効)
せっかくなので、Python 3.13でGILを無効にすると、 期待通りにPythonの関数も並列実行されるのか確認してみます。 pyenvでGILを無効にするには、3.13.0tのように末尾にtがつくビルドを指定すると良いようです。 pyenvで3.13.0tに変更してから、先ほどと同じ手順で実行時間を計測してみます。
pyenv shell 3.13.0t
# 1スレッドでの実行時間を確認 $ python main.py [calc_pi_c] elapsed_time: 0.965, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_mp] elapsed_time: 0.194, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi] elapsed_time: 14.849, args: (100000000,), kwargs: {}, result: 3.141592643589326
# 4スレッドでの実行時間を確認 $ python main.py [calc_pi_c] elapsed_time: 1.021, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 1.023, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 1.024, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 1.030, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi] elapsed_time: 16.449, args: (100000000,), kwargs: {}, result: 3.141592643589326 [calc_pi] elapsed_time: 16.474, args: (100000000,), kwargs: {}, result: 3.141592643589326 [calc_pi] elapsed_time: 16.493, args: (100000000,), kwargs: {}, result: 3.141592643589326 [calc_pi] elapsed_time: 16.511, args: (100000000,), kwargs: {}, result: 3.141592643589326
関数 | 4スレッドでの実行時間(秒) | 1スレッドでの実行時間(秒) |
---|---|---|
calc_pi_c | 1.030 | 0.965 |
calc_pi | 16.511 | 14.849 |
GILを無効にしたことで、Pythonの関数の実行時間が改善しました。 4スレッドでの実行時間が1スレッドの10%程度の増加に抑えられています。 一方で、GILを無効化したことにより、1スレッドの実行時間が少し遅くなっています。 おそらく、インタープリタ全体のロックが外れたために、 オブジェクトの変更の安全性を確保するためのコストが増えたためだと思われます。
(おまけ)トップレベルの並列化とローレベルな並列化を比較してみる
通常、numpyなどの別言語で実装されたライブラリを使うことで、 GILの影響を受けずに実行することができます。 また、numpyでは勝手に並列化を行なってくれます。 そこで、スレッド化によるトップレベルの並列化と、 OpenMPによるローレベルの並列化でどの程度差が出るかを確認してみます。
次のコードを追記して実行してみます。 スレッドが1つの時は、ローレベルで並列化してくれている方が当然速いです。 なので、ここではCPUが十分に使われるようにスレッド数を溢れさせてみます。 私の環境はCPUが8コアなので、16スレッドで実行してみます。
# トップレベルの並列化(経過時間は最大値を使用) threads = [Thread(target=calc_pi_c, args=(1_000_000_000,)) for _ in range(16)] for t in threads: t.start() for t in threads: t.join() print() # トップレベルの並列化 + ローレベルの並列化(経過時間は最大値を使用) threads = [Thread(target=calc_pi_mp, args=(1_000_000_000,)) for _ in range(16)] for t in threads: t.start() for t in threads: t.join() print() # ローレベルの並列化(経過時間は合計を使用) for i in range(16): calc_pi_mp(1_000_000_000)
$ python main.py [calc_pi_c] elapsed_time: 2.473, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.496, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.516, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.520, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.526, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.528, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.499, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.532, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.533, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.527, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.518, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.510, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.460, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.480, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.487, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_c] elapsed_time: 2.511, args: (1000000000,), kwargs: {}, result: 3.1415926525880504 [calc_pi_mp] elapsed_time: 2.966, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 2.969, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.008, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.111, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.134, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.159, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.195, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.202, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.220, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.256, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.256, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.258, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.258, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.262, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.300, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 3.303, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.206, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.185, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.166, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.162, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.176, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.309, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.170, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.192, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.163, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.175, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.219, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.183, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.180, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.171, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.163, args: (1000000000,), kwargs: {}, result: 3.141592652589324 [calc_pi_mp] elapsed_time: 0.173, args: (1000000000,), kwargs: {}, result: 3.141592652589324
並列化方法 | 16スレッドでの実行時間(秒) |
---|---|
トップレベルの並列化 | 2.532 |
トップレベルの並列化 + ローレベルの並列化 | 3.303 |
ローレベルの並列化 | 2.993 |
トップレベルとローレベルの両方でスレッド化したものが、最も遅いことが読み取れます。 Pythonのスレッド数が16で、OpenMPのスレッド数が8(デフォルトでCPUコア数)のため、 合計128スレッドを管理していることになります。 そのため、スレッドの管理コストが上がり、実行時間が長くなってしまったと予測できます。
また、トップレベルの並列化のみの場合は、ローレベルの並列化のみの場合よりも速いことも読み取れます。 トップレベル並列化の方が16スレッドとスレッド数は多いですが、 OpenMPの8スレッドよりも速くなっているようです。 詳しくはわかりませんが、遅くなる原因としては、 OpenMPによるスレッドの分割・統合のコストや、 スレッド間での計算コストの偏りなどが考えられます。
最後に
Pythonで書かれていない部分の実行ではGILの影響を受けないことを確認できました。 また、GILを無効にすることで、ビュアなPythonコードも並列化できるようになりますが、 代わりに実行時間が少し遅くなってしまうことも確認できました。 以上のことから、このGIL無効化はビュアなPythonコードを並列化するための手段として、 捉えておくと良さそうです。
We Are Hiring!
ABEJAは、テクノロジーの社会実装に取り組んでいます。 技術はもちろん、技術をどのようにして社会やビジネスに組み込んでいくかを考えるのが好きな方は、下記採用ページからエントリーください! (新卒の方やインターンシップのエントリーもお待ちしております!)
特に下記ポジションの募集を強化しています!ぜひ御覧ください!