2023年11月: Python3.12で新たにサポートされたsub-interpretersの紹介

門脇(@satoru_kadowaki)です。 11月の「Python Monthly Topics」は、Python 3.12の新機能であるsub-interpretersについて紹介します。

2023年10月2日に「 Python 3.12.0 」がリリースされました。 今回も気になる新機能が多く、本記事で紹介するsub-interpretersもPythonで並列処理を行うための新機能です。

Python 3.12の新機能については以下のリンクを参照してください。

https://docs.python.org/3/whatsnew/3.12.html

本記事ではPythonにおける並列実行のこれまでと、sub-interpretersが現状どのように使用できるかについて説明します。

なお、執筆にあたり先日開催された PyCon APAC 2023 においてsub-interpretersに関するトークを行ったAnthony Shaw氏の資料やブログ記事なども参考にさせていただいてまとめています。 興味のある方は以下のリンクについても参照してみてください。

Pythonの実行環境を取り巻くコンポーネント

最初に、Pythonプログラムの並列実行モデルを理解する際に知っておきたいコンポーネントについて簡単に説明します。

GIL

GIL とは「Global Interpreter Lock」のことで、Pythonのオブジェクトを複数のスレッドが同時に操作することを防ぐための仕組みです。 GILによってPythonコードの実行はコア数に関係なく1度に1つのスレッドのみが実行されることを保証しています。 Pythonオブジェクトに対する安全なアクセスを保証するためのものですが、並列処理の観点では、たとえ複数のコアがあったとしても「ある命令に対して実行できるのは1つのコアに制限されてしまう」ということになり、パフォーマンスに大きな影響を与えてきました。

インタープリター、サブインタープリター

Pythonインタープリター とは、Pythonコードを読み込み実行するプログラムのことです。 Pythonの実装にはいくつかの種類 [1] がありますが、本記事におけるインタープリターとは CPython のことを指しており、先述のGILについてもCPythonにおける概念です。 [2]

サブインタープリターはPythonインタープリター内で動作する小さなインタープリターのインスタンスのことです。 メインのインタープリターとは別に独立して実行でき、サブインタープリターごとにリソースを割り当てることが可能であるため、複数使用することで並列処理(Parallel Processing)、あるいは並行処理(Concurrent Processing)が可能になります。GIL制限がある状況では、とりわけマルチスレッド処理におけるパフォーマンス向上が期待されます。

なお、並列処理や並行処理、あるいはマルチプロセスやマルチスレッドの違いなどに関する解説は本記事では省略します。 これらの違いを詳しく知りたい方はWikipediaやIT技術系サイトなどをご確認ください。 並列、並行処理については Pythonエンジニア育成推進協会監修 Python実践レシピ|技術評論社 にも説明があります。ぜひご参考いただければ幸いです。

sub-interpretersとは?

sub-interpretersとは、先述の説明のとおり、メインのインタープリターとは別に独立したサブインタープリターを生成できるようになった機能のことです。 ここからはPython 3.12で実装されたsub-interpretersについて説明します。 最初にsub-interpretersに関連のあるPEP(Python Enhancement Proposal)について説明します。

PEP 554、PEP 684

2017年にPythonにサブインタープリターを導入するための提案がPEP 554で行われました。 PEP 554の本文については以下を参照してください。

PEP 554 - Multiple Interpreters in the Stdlib

PEP 554を簡単に説明すると、「サブインタープリター間でのデータ共有や通信を行うための管理APIの提供」が提案されています。 しかしながら、これを実現するための改修はかなり大きく、この提案が実装されるのはPython 3.13以降の予定とされています。

また、PEP 554の関連としてPython 3.12ではPEP 684が導入されました。 PEP 684では、GILをプロセスではなくインタープリターごとに分離する実装が行われており、マルチコア並列処理を容易にするための準備が進んだと言えます。

PEP 684の本文については以下を参照してください。 [3]

PEP 684 – A Per-Interpreter GIL

PEP 684が実装されたことによる違いをイメージ図を使用して具体的に見ていきます。 [4]

Python 3.11までのアーキテクチャでは下図のように実行プロセスに対してGIL制限がかかっていました。

Python 3.11までのアーキテクチャ

一方、Python 3.12で導入されたsub-interpretersは、Pythonプロセス内の各インタープリターにGILがかかることで並列処理が実行できるように変更されました。

Python 3.12 sub-interpreters導入後のアーキテクチャ

この2つの違いにより、Python 3.11以前は1つのプロセス内で同時に使用できるコア数が1つだったのに対し、Python 3.12からはサブインタープリターごとにコアを使用したマルチスレッドの処理が可能になりました。

Try it out

ここまで、Python言語の制限やアーキテクチャの変更内容について説明を行ってきました。 以降ではsub-interpretersについて、具体的なPythonコードでみていきます。

残念なことにPython 3.12におけるサブインタープリターの実装はCPythonコア部分のみにとどまっており、API提供は開発レベルのものとなっています。 以降のサンプルスクリプトもPython 3.13の実装が進むことで変更され、動作しなくなることも予想されます。 執筆時点(2023年11月中旬)での状態として読んでいただければ幸いです。

執筆時点のPythonバージョンは以下を使用しております。

$ python3.12 --version
Python 3.12.0

サンプルスクリプト

以下のスクリプトでは、引数で指定された数値範囲に含まれる素数の数を表示する関数 calc_primes() を作成しました。 以降はこの関数を例に説明していきます。

数値範囲の素数をカウントするスクリプト - prime_number.py
def calc_primes(num_start, num_end):
    prime_count = 0
    for num in range(num_start, num_end):
        # 試し割法を使って素数を計算
        for i in range(2, int(num**0.5) + 1):
            if num % i == 0:  # 割り切れる数値がある場合は素数ではない
                break
        else:  # ループが最後まで到達したら素数と判定
            prime_count += 1

    print(f"Prime Count [{num_start:,}-{num_end:,}]: {prime_count}")

まずは calc_primes() 関数を呼び出して動作確認を行うためのスクリプトを以下のように作成しました。 ranges リストで指定された数値範囲のタプルを繰り返し実行して結果を表示します。 指定する数値は計算コストによる速度の違いを明確にするために、数値範囲を一定にせず範囲の異なる値を指定しています。 最終行では処理開始時間と終了時間から処理時間も出力しています。

逐次処理による実行サンプルスクリプト - example1.py
import time
from prime_number import calc_primes


start_time = time.time()  # 処理時間計算用

ranges = [  # 素数を計算する開始値と終了値
    (1_000_000, 1_600_000),
    (2_000_000, 2_900_000),
    (3_000_000, 3_300_000),
]
for num_start, num_end in ranges:
    calc_primes(num_start, num_end)

end_time = time.time()  # 終了時間
print(f"Processing time: {end_time - start_time} seconds")

このスクリプトを実行した結果は以下のようになりました。 ranges で指定した数値範囲の計算が順番に実行され、およそ9.5秒の処理時間がかかっています。

$ python3.12 example1.py
Prime Count [1,000,000-1,600,000]: 42629
Prime Count [2,000,000-2,900,000]: 61176
Prime Count [3,000,000-3,300,000]: 20084
Processing time: 9.447153806686401 seconds

スクリプトの並列化

calc_primes() 関数をこれまでのやり方で並列化する場合、threadingモジュールや multiprocessingモジュールを使用する方法があります。 ここではthreadingモジュールを使用して以下のようにしてみました。

threadingモジュールを使用した並列化の例 - example2.py
import threading
import time
from prime_number import calc_primes


threads = []
start_time = time.time()  # 処理時間計算用

ranges = [  # 素数を計算する開始値と終了値
    (1_000_000, 1_600_000),
    (2_000_000, 2_900_000),
    (3_000_000, 3_300_000),
]

# ループ内でそれぞれの計算をスレッドで計算開始
for num_start, num_end in ranges:
    thread = threading.Thread(target=calc_primes, args=(num_start, num_end))
    thread.start()
    threads.append(thread)

# すべてのスレッドが完了するのを待つ
for th in threads:
    th.join()

end_time = time.time()
print(f"Processing time: {end_time - start_time} seconds")

上記のスクリプトを実行した結果は以下のようになりました。 並列実行されたため、計算コストの低い数値範囲の結果が先に出力されていることがわかります。 処理時間は先ほどの逐次処理(シングルスレッド)で実行した場合とほぼ同じですが、スレッド化されたオーバーヘッドがあるためか10秒を超える少し遅い結果になりました。 (結果に大きな違いがないことから、CPUなど実行環境の違いによっては逐次処理とほぼ同じ結果になることもあり得ます)

$ python3.12 example2.py
Prime Count [3,000,000-3,300,000]: 20084
Prime Count [1,000,000-1,600,000]: 42629
Prime Count [2,000,000-2,900,000]: 61176
Processing time: 10.180454969406128 seconds

sub-interpretersモジュールの試用

それでは、続いてPython 3.12のsub-interpretersモジュールを試してみます。 先述の通り、Python 3.12では、まだ開発途中にあります。 したがって、隠しモジュールのような状態ですが import _xxsubinterpreters とすることでインポートできるようになっています。 サンプルスクリプトでは as interpreters として別名を設定しました。

各処理の説明については後述します。

sub-interpretersのサンプルスクリプト - example3.py
 1import time
 2import _xxsubinterpreters as interpreters
 3
 4start_time = time.time()  # 処理時間計算用
 5
 6# interpreters.run_string()に渡す関数を文字列として読み込む
 7with open("prime_number.py", "r") as f:
 8    # ファイルの内容を読み込む
 9    calc_prime_script = f.read()
10
11# 数値範囲のリスト
12num_ranges = [
13    "1_000_000, 1_600_000",
14    "2_000_000, 2_900_000",
15    "3_000_000, 3_300_000",
16]
17
18intp_id = interpreters.create()  # サブインタープリターの作成
19
20# サブインタープリターの実行
21for num_range in num_ranges:
22    interpreters.run_string(intp_id, calc_prime_script + f"\ncalc_primes({num_range})")
23
24interpreters.destroy(intp_id)  # サブインタープリターの削除
25
26end_time = time.time()
27print(f"Processing time: {end_time - start_time} seconds")

サンプルスクリプトの18行目以降からがsub-interpretersの作成、実行、終了の流れになりますが、これらの概要は下表のようになります。

操作

使用する関数

概要

作成

.create()

サブインタープリターを起動してIDを返す

実行

.run_string()

引数で指定された文字列をスクリプトとして実行

削除

.destroy()

サブインタープリターを削除して終了

なお、これらの関数は開発途中ということもあり、 calc_primes() 関数を外部ファイルからインポートしての実行や、一部のコードは動作しないこともあるようです。 したがって、 現時点では run_string() 関数に文字列(サンプルスクリプトの7行目〜)としてスクリプトを記載する必要があります。 サンプルスクリプトでは、prime_number.pyファイルを読み込み、文字列として使用しています。

サブインタープリターの作成(18行目)では create() 関数を実行することでサブインタープリターが起動され、IDが返されます。 起動されたサブインタープリターは run_string() 関数に取得したIDとPythonのコードを文字列で渡すことで、サブインターブリターでそのコードを実行します。 また、destroy()にIDを渡すとサブインターブリターを終了します。

上記のスクリプトを実行した結果は以下のようになりました。 実行時間は逐次処理とthreadingモジュールを使用したサンプルの中間くらいの結果となりましたが、処理順番については逐次処理と同じになっていることにも気がつきます。

処理が逐次処理になる理由について調べてみたところ、現時点の実装では、 .run_string() 関数を呼び出すとPythonのスレッドはブロッキング(一時停止)状態になるようです。 このため、複数の計算処理をそれぞれ別のスレッドで呼び出すにはthreadingモジュールを併用する必要があるようです。

$ python3.12 example3.py
Prime Count [1,000,000-1,600,000]: 42629
Prime Count [2,000,000-2,900,000]: 61176
Prime Count [3,000,000-3,300,000]: 20084
Processing time: 9.86731505393982 seconds

sub-interpretersとthreadingモジュールの併用

では、threadingモジュールを併用した場合はどうなるのでしょうか? 試しに前述のthreadingモジュールを使用した例をもとに下記のようなコードを書いてみました。 前述のコードとおよそ同じですが、threading.Thread() の引数に interpreters.run_string を渡してサブインタープリターIDと、実行するスクリプトを渡しています。

sub-interpretesとthreadingの併用
import time
import threading

import _xxsubinterpreters as interpreters

start_time = time.time()  # 処理時間計算用

## interpreters.run_string()に渡す関数を文字列として読み込む
with open("prime_number.py", "r") as f:
    # ファイルの内容を読み込む
    calc_prime_script = f.read()

# 数値範囲のリスト
num_ranges = [
    "1_000_000, 1_600_000",
    "2_000_000, 2_900_000",
    "3_000_000, 3_300_000",
]

# サブインタープリターのIDとスレッドを格納するリスト
intp_ids = []
threads = []

# 各サブインタープリターの作成とスレッドの開始
for num_range in num_ranges:
    intp_id = interpreters.create()
    intp_ids.append(intp_id)

    thread = threading.Thread(
        target=interpreters.run_string,
        args=(intp_id, calc_prime_script + f"\ncalc_primes({num_range})"),
    )
    thread.start()
    threads.append(thread)

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

# すべてのサブインタープリターを破棄
for intp_id in intp_ids:
    interpreters.destroy(intp_id)

end_time = time.time()
print(f"Processing time: {end_time - start_time} seconds")

上記コードの実行イメージ図は以下のようになります。 interpretes.create() が繰り返し回数分実行され、起動したサブインタープリターごとに interpreters.run_stirng を実行しています。

sub-interpretesとthreadingモジュールの実行イメージ

実行結果は以下のようになりました。 なんとこれまでの実行時間のおよそ半分で処理が完了しており、開発途中とは言えsub-interpretersに変わった際のパフォーマンスの違いを体験できた気がします。 (Linuxのtopコマンドで実際にコアが3つ使用されていることも確認できました!実行結果の後にtopコマンドの様子も掲載しております。) なんだか未来が見えてきたようでワクワクします!

$ python3.12 example4.py
Prime Count [3,000,000-3,300,000]: 20084
Prime Count [1,000,000-1,600,000]: 42629
Prime Count [2,000,000-2,900,000]: 61176
Processing time: 5.321488618850708 seconds
topコマンドによる確認で複数コアが使用されている様子

sub-interpretersとその他のモジュールの違い

繰り返しになりますが、Pythonにおけるsub-interpretersの実装はPython 3.12時点では開発レベルのものとなっています。 その全貌が見えるまでにはもう少し時間がかかりそうですが、sub-interpretersによってPythonの並列化における手法が1つ増えることになりました。 実行する処理内容の違いによってインスタンス起動時間も変わるため、threadingモジュールやmultiprocessingモジュールと比較しながら評価していくと良さそうです。

これまでPythonで使用されてきた並列・並行処理を実装するモジュール群との違いについて、PyCon APAC 2023でAnthony Shaw氏がまとめてくださっています。 内容を抜粋+αしたものを下表にまとめましたので、参考にしてみてください。

モジュール名

実行種別

インスタンス起動時間

用途

threading

並列

IOバウンドで複数コアを必要とする処理

asyncio

並行

最小

IOバウンドで複数コアを必要とする処理

concurrent.futures

並列・並行

小〜大

CPU、IOバウンドで複数コアを必要とする処理

multiprocessing

並列

CPU、IOバウンドで複数コアを必要とする処理

sub-interpretes

並列

CPU、IOバウンドで複数コアを必要とする処理

まとめ

PythonをGIL制限から解放する開発は着々と進んでいるようです。 最近では PEP 703 でCPythonでGILをオプションにして、インタープリターごとにGILと協調して動作させる提案が承認されました。 PEP 554も含めたこれらのsub-interpretersによる変更は、Pythonにおけるマルチスレッディングとマルチプロセッシングをより効率的に行える選択肢となるに違いありません。

Python 3.13のリリースは来年の予定ですが、3.12でのsub-interpretesのサポートは今後の実行パフォーマンスに大きな影響を与えるものと思われます。 高速化に興味のある方は、ぜひ今後の動向に注目してみてください。