「Python 3.11」からその成果が得られ始めたPython高速化プロジェクトとは:Python最新情報キャッチアップ
Python 3.11は従来のバージョンよりも高速なものになっている。どのようにして高速化が実現されたのか、その手法を幾つか調べてみよう。
2022年10月24日にPython 3.11がリリースされた。主要な新機能や変更点については「Pythonが平均1.22倍高速化、メジャー安定版『Python 3.11』の機能向上とは」を参照したいただきたい。
CPython高速化プロジェクト(Faster CPython project)
「What’s New In Python 3.11」によれば、Python 3.11は前バージョンであるPython 3.10と比べて10〜60%高速になっているという。以下はpyperformanceパッケージを使って筆者のローカル環境(Mac mini 2018/16GB/Intel Core i3)でベンチマークを測った結果を示す。
わずかにPython 3.10よりも遅くなっているものもあるが(といっても有意に遅いかは微妙)、ほとんどの項目で高速化が果たされている。この高速化に大きく貢献しているのが「Faster CPython」と呼ばれるプロジェクトだ。
この計画では4回のリリースのたびに1.5倍の高速化を行い、最終的に5倍の高速化を目指している。Python 3.11では、その成果が実際に組み込まれるようになった。このプロジェクトが主に注力しているのは以下の2つ。
- スタートアップの高速化
- ランタイムの高速化
本稿では後者のランタイムの高速化についてまとめよう。ランタイムの高速化についてはPython 3.11では次のようなことが行われている。
- フレームオブジェクトの遅延作成
- Pythonで記述されている関数の呼び出しをバイトコード評価関数内にインライン化
- 特殊化適応的インタプリターによるバイトコードのキャッシュ
このうち、最初の2つはPythonプログラム実行中にPythonで記述された関数が呼び出される際の処理の高速化やメモリ使用の効率化に役立っている。最後の特殊化適応的インタプリターは、さまざまな型を扱えるように生成されたバイトコードを、特定の型のオブジェクトに適応するバイトコードに変更することで処理の高速化を狙うものといえる。
フレームオブジェクトの遅延作成
ここでいうフレームオブジェクトとは、関数を呼び出すごとに作成され、その関数の実行情報を格納するオブジェクトのことだ。関数内のローカル変数や評価スタック、コードオブジェクト(実行するコードを表すオブジェクト)などは一般にフレームオブジェクトに保持される。
Python 3.10まではこのフレームオブジェクトが関数呼び出しのたびに作成され、CPythonを実装しているC言語側のヒープに確保されるようになっていた。しかし、メモリ確保は高価な処理であるとともに、ヒープのさまざまな箇所にフレームオブジェクトを分散させることになる(これは参照の局所性を阻害する)。
そこでPython 3.11では、Pythonで記述された関数を呼び出す場合、C言語側のヒープにフレームオブジェクトを確保するのをそれが必要になるまで遅延するようになった(高速化のためにCで書かれているような関数についてはこの対象外だ)。その代わりに関数の実行情報は、スレッドごとに用意されているスタックに(連続的に)確保する。この情報は_PyInterpreterFrame型のオブジェクトとして管理される(一方、ヒープに確保される従来のフレームオブジェクトはPyFrameObject型である)。
inspectモジュールのcurrentframe関数が呼び出されるなどして、従来のフレームオブジェクト(PyFrameObject型)が必要になると初めてそれが作成され、_PyInterpreterFrameオブジェクト内にそれへのリンクが作成される(恐らくは同時にPyFrameObjectオブジェクトを介して実行情報にアクセスできるような処理が行われるはずだ)。
このことには以下のようなメリットがある。
- スタックを使ったメモリの確保と解放はスタックポインタの操作で行えるのでヒープのメモリ確保よりも高速
- スタックに実行情報を連続的に確保することで、参照の局所性が保たれCPUのキャッシュに一連のデータを保持できる
Pythonのジェネレーターなど、関数呼び出しの連鎖とは別に独立して存在するものについては従来と同様にヒープにフレームオブジェクトが始めから存在するが、ドキュメントによれば、全体としてはこの処理により3〜7%の高速化が行われているようだ。
以下はPython 3.11で関数やメソッドなどの呼び出しを処理するコードだ。
TARGET(CALL) {
// …… 省略 ……
// 関数呼び出しをインライン化できるかどうかをチェック
if (Py_TYPE(function) == &PyFunction_Type && tstate->interp->eval_frame == NULL) {
int code_flags = ((PyCodeObject*)PyFunction_GET_CODE(function))->co_flags;
PyObject *locals = code_flags & CO_OPTIMIZED ? NULL : PyFunction_GET_GLOBALS(function);
STACK_SHRINK(total_args);
_PyInterpreterFrame *new_frame = _PyEvalFramePushAndInit(
tstate, (PyFunctionObject *)function, locals,
stack_pointer, positional_args, call_shape.kwnames
);
call_shape.kwnames = NULL;
// …… 省略 ……
frame->prev_instr = next_instr - 1;
new_frame->previous = frame;
cframe.current_frame = frame = new_frame;
CALL_STAT_INC(inlined_py_calls);
goto start_frame;
}
/* 通常のPython関数でない場合 */
// …… 省略 ……
DISPATCH();
}
if文では関数がPythonで記述され、以下で述べるインライン化が可能かどうかをチェックしている。そうであれば、その下のコードが実行される。そのときには上で述べたように_PyInterpreterFrame型のオブジェクトが作成されていることが分かるはずだ。
そうでない場合の処理については省略したが、Python 3.10では全ての関数(Pythonで書かれている関数、Cで書かれている関数など)の呼び出しで行われていたのと同様な処理が行われるようになっているようだ。
詳細については「The Frame Stack」が参考になる。
そして、コメントにある「関数呼び出しをインライン化できるかどうかをチェック」が次の話題だ。
Pythonで記述された関数の呼び出しのインライン化
「What's New In Python 3.11」では「During a Python function call, Python will call an evaluating C function to interpret that function's code」とある。日本語にすれば「Python関数の呼び出し時には、Pythonはその関数のコードを解釈(して実行)するためにCの評価関数(ceval関数と一般には呼ばれているようだ)を呼び出す」ということだ(Python 3.10までのお話)。
ということは、Python関数がまた別のPython関数を呼び出すと、Cの評価関数の中からさらにCの評価関数が呼び出されるということでもある。これはCのコールスタックが深くなることを意味していて、関数の呼び出しが深くネストすれば、Cのコールスタックも深くネストすることを意味する。
Python関数が再帰関数でどれだけ再帰呼び出しがどれだけ深くなるかが分からなければ、Cのコールスタックを保護するためにもどこかの時点で制限を掛ける必要がある(上限はsysモジュールのgetrecursionlimit/setrecursionlimit関数で取得/設定ができる)。
そこでPython 3.11では、Python関数からPython関数が呼び出されたときには、ceval関数の中で新たにフレームを作成し、そのフレームの中で呼び出した関数のコードを実行するようにジャンプを行うようになった。この新たに作成するフレームというのは、もうお分かりだと思うが上で述べた_PyInterpreterFrame型のオブジェクトのことだ。
ceval関数の違いを確認する
では、Python 3.10とPython 3.11とでceval関数がどう変わったかを見てみよう。
以下はPython 3.10のceval関数を抜粋したものだ。
for (;;) {
// …… 省略 ……
switch (opcode) {
// …… 省略 ……
case TARGET(CALL_METHOD): { // メソッド呼び出し
// …… 省略 ……
res = call_function(tstate, &trace_info, &sp, oparg, NULL);
// …… 省略 ……
DISPATCH();
}
case TARGET(CALL_FUNCTION): { // 関数呼び出し
// …… 省略 ……
res = call_function(tstate, &trace_info, &sp, oparg, NULL);
// …… 省略 ……
DISPATCH();
}
// …… 省略 ……
} // switch
// …… 省略 ……
}
for文による無限ループの中でswitch文を使って現在のバイトコード(TARGETマクロの引数となっている「CALL_METHOD」や「CALL_FUNCTION」)によって処理を分岐させている。上のコードはメソッドと関数の呼び出し部分だけを抜き出したものだ(なお、多くの場合はバイトコードの処理が終わった後、for文の先頭に制御が戻るのではなく、DISPATCHマクロによってfor文の先頭とswitch文の間にあるpredispatchラベルへと制御が移るコードになっているようだ)。
そして、CALL_METHODとCALL_FUNCTIONの処理では共に以下に示すcall_function関数が呼び出されている。
/* Issue #29227: Inline call_function() into _PyEval_EvalFrameDefault()
to reduce the stack consumption. */
Py_LOCAL_INLINE(PyObject *) _Py_HOT_FUNCTION
call_function(……)
{
// …… 省略 ……
if (trace_info->cframe.use_tracing) {
x = trace_call_function(tstate, trace_info, func, stack, nargs, kwnames);
}
else {
x = PyObject_Vectorcall(func, stack, nargs | PY_VECTORCALL_ARGUMENTS_OFFSET, kwnames);
}
assert((x != NULL) ^ (_PyErr_Occurred(tstate) != NULL));
// …… 省略 ……
return x;
}
いきなり先頭のコメントに「Inline call_function() into _PyEval_EvalFrameDefault() to reduce the stack consumption」とある。日本語にすると「スタック消費を抑えるために、call_function関数は_PyEval_EvalFrameDefault関数にインライン化する」とある。Faster CPythonプロジェクトによってこれが行われた結果がPython 3.11のceval関数だ。その成果は上でも示したが、以下に再掲する(分かりやすくなるようにswitch文とTARGETマクロの組み合わせで記述する。case文がないように見えるが、これはTARGETマクロを展開することでそれと同様なコードになるようになっている)。
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(……)
{
// …… 省略 ……
dispatch_opcode:
switch (opcode) {
// …… 省略 ……
TARGET(CALL) {
// …… 省略 ……
call_function:
// …… 省略 ……
// 呼び出しをインライン化できるかどうかのチェック
if (Py_TYPE(function) == &PyFunction_Type && tstate->interp->eval_frame == NULL) {
// …… 省略 ……
// _PyInterpreterFrameを使ってスレッドごとに用意されている
// スタック上にフレームを確保して、そのフレームで関数の
// コードを実行する
// …… 省略 ……
_PyInterpreterFrame *new_frame = _PyEvalFramePushAndInit(
tstate, (PyFunctionObject *)function, locals,
stack_pointer, positional_args, call_shape.kwnames
);
// …… 省略 ……
_PyFrame_SetStackPointer(frame, stack_pointer);
JUMPBY(INLINE_CACHE_ENTRIES_CALL);
frame->prev_instr = next_instr - 1;
new_frame->previous = frame;
cframe.current_frame = frame = new_frame;
CALL_STAT_INC(inlined_py_calls);
goto start_frame;
}
// インライン化(上のコードを実行)できなかったら以下を実行する
PyObject *res;
if (cframe.use_tracing) { // トレースを使用している場合
res = trace_call_function(
tstate, function, stack_pointer-total_args,
positional_args, call_shape.kwnames);
}
else {
res = PyObject_Vectorcall(
function, stack_pointer-total_args,
positional_args | PY_VECTORCALL_ARGUMENTS_OFFSET,
call_shape.kwnames);
}
// …… 省略 ……
DISPATCH();
}
// …… 省略 ……
} // switch
Python 3.11ではバイトコードの構成もPython 3.10とはかなり変わっていてCALL_METHODやCALL_FUNCTIONがなくなって基本的にはCALLに一本化されるようになっている(CALLバイトコードを実行する前には、呼び出し可能オブジェクトの種類に応じて呼び出しのセットアップを行うPRECALL系統のバイトコードが実行されるようだ)。ただし、全てがCALLバイトコードに置き換わったわけではなく、似た処理を行うCALL系統のバイトコードもある。
そういうわけで、TARGET(CALL)節の内容と先ほどのPython 3.10のコードをおおざっぱに見比べるとTARGET(CALL)節にはcall_functionラベルと共に、Python 3.10のcall_function関数+アルファの内容が記述されているといえる。もちろんアルファの部分が関数を呼び出すのではなく、それと同じ処理をceval関数の内部でインラインに行う処理だ。
これを行っているのが「呼び出しをインライン化できるかどうかのチェック」というコメントをいれたif文の条件が真であるときに実行されるコードである。このときには、上で述べたように_PyInterpreterFrame型の(Python 3.11で導入された新しい)フレームを作成し、JUMPBYマクロでその次に実行するインストラクション(バイトコード)を調整し、その後、呼び出し先の関数が使用するフレームの調整をした上で、switch文の前にあるstart_frameラベルへとジャンプをして、次のバイトコードの読み込みと処理が実行されるようになっている。
インライン化できなかったときには「インライン化(上のコードを実行)できなかったら以下を実行する」というコメント以下が実行される。ここではトレースを使用するかどうかによってtrace_call_function関数かPyObject_Vectorcall関数のどちらかが呼ばれ、その後はスタックのクリーンアップなどを行った後、DISPATCHマクロによりswitch文の直前にあるdispatch_opcodeラベルに分岐すると考えられる。
Pythonを使っているのであれば、その関数はPythonで書くのがほとんどだろう。よって、上のTARGET(CALL)節ではif文の条件が真となり、_PyInterpreterFrame型の(新しい)フレームオブジェクトを使って関数のコードが実行される。これによりceval関数呼び出しとそれに関連してCのコールスタックの消費も減ることになる。
ほんとうに高速化できているのかを確認する
Copyright© Digital Advantage Corp. All Rights Reserved.