「Python 3.11」からその成果が得られ始めたPython高速化プロジェクトとはPython最新情報キャッチアップ

Python 3.11は従来のバージョンよりも高速なものになっている。どのようにして高速化が実現されたのか、その手法を幾つか調べてみよう。

» 2022年11月01日 05時00分 公開
[かわさきしんじDeep Insider編集部]

この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。

「Python最新情報キャッチアップ」のインデックス

連載目次

 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

    // …… 省略 ……
}


Python 3.10のceval関数の抜粋

 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のceval関数の抜粋

 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のコールスタックの消費も減ることになる。

ほんとうに高速化できているのかを確認する

 ドキュメントによれば、再帰を利用してフィボナッチ数を求める関数や階乗を求める関数であれば1.7倍程度の速度向上が見込めるということだ。そこで実際にPython 3.10とPython 3.11でフィボナッチ数を求める関数を記述/実行して、その実行速度を調べてみた。

import time

def fib(n):
    if n == 0:
        return 1
    if n == 1:
        return 1
    return fib(n-1) + fib(n-2)

diffs = []
for _ in range(10):
    start = time.time()
    result = fib(40)
    end = time.time()
    print(result)
    diff = end - start
    diffs.append(diff)

print(f'{sum(diffs) / len(diffs):.3f}')

フィボナッチ数を求める関数とその実行時間を計測するコード

 ここでは再帰を使ってフィボナッチ数を求める関数を記述して、その第40項を求めるのにかかる時間を計ってみた。for文を見ると分かる通り、これを10回行い、その平均を最後に表示している。Python 3.10とPython 3.11では以下のような速度差が見られた。

Python 3.10 Python 3.11 速度差
34.573秒 19.302秒 1.79倍
Python 3.11はPython 3.10よりも1.79倍高速にフィボナッチ数を求められる

 おおよそドキュメント通りの差が確認できた。

 このようにフレームオブジェクトの遅延作成と関数呼び出しのインライン化を組み合わせることで確かにPython 3.11では関数呼び出しについてはかなりの高速化が実現されているようだ。

 なお、関数呼び出しのインライン化についてはpython.jpの「Python 3.11の新機能(その3)関数呼び出しのインライン化」がとても参考になるのでぜひ参照されたい。

特殊化適応的インタプリター

 特殊化適応的インタプリター(Specializing Adaptive Interpreter)とは簡単にいえば「多様性を持つバイトコードを特定の型に適合したバイトコードに特殊化」する機能を持ったインタプリターとなる。

 例えば加算演算子「+」について考えてみよう。

def add(a, b):
    return a + b

加算または結合を行う関数

 上に示したadd関数は2つの値を受け取り、それを加算または結合した結果を返送する。Pythonは静的型付け言語ではないので、同じ型であれば、引数に数値(整数、浮動小数点数値)やリスト、文字列などを渡しても例外は発生しない。

 これはPythonのよいところでもあるが、加算演算子はそのオペランドの型を比較して加算または結合が行えるかの判断を内部で行う必要がある(その結果、加算または結合をできないときにはTypeError例外が発生する)。

 しかし、ループの中で同じ型のデータを大量に処理するといった場合には、型のチェックは不要であり、そうした処理を省略すればそれなりの処理の高速化が行えるだろう。例えば、上記のadd関数について考えてみよう。disモジュールのdis関数を使って、この関数がどんなバイトコードにコンパイルされるかを確認してみる。

import dis

def add(a, b):
    return a + b

dis.dis(add)

add関数のバイトコードを表示

 これをPythonのREPL環境で実行すると次のようになる。

>>> dis.dis(add)
  1           0 RESUME                   0

  2           2 LOAD_FAST                0 (a)
              4 LOAD_FAST                1 (b)
              6 BINARY_OP                0 (+)
             10 RETURN_VALUE


add関数のバイトコード

 左端の「1」「2」は関数の何行目のコードかを示す。1行目のRESUMEはこれが関数の始まりであることを意味している。2つあるLOAD_FASTは関数のローカル変数(パラメーター)の値への参照を評価スタックにプッシュするための命令だ。右端にパラメーター名が出ているのでどちらのパラメーターがプッシュされているかはすぐに分かるだろう。

 その次のBINARY_OP(2項演算)が上でプッシュされた2つの値を加算するためのバイトコードだ。右端に「(+)」とあるので2つの値を加算して、その結果を評価スタックにプッシュする。最後のRETURN_VALUEは呼び出し元に評価スタックの先頭にある値を戻す。

 このBINARY_OPを整数だけに特殊化することで、この関数がずっと整数値だけを取り扱うのであればそれなりの高速化が考えられる。特殊化が行われた後で、他の型の値(例えば、2つの文字列)を渡してもadd関数自体は正しく動作する(文字列を結合した結果が返される)。

 実際にBINARY_OPが特殊化されるところを確認してみよう。

for _ in range(8):
    result = add(1, 2)

dis.dis(add, adaptive=True, show_caches=True)

何度かadd関数に同じ型の値を渡し続けると特殊化が行われる

 今度はdis.dis関数に「adaptive=True」と「show_caches=True」という2つのキーワード引数を渡している点に注意。前者は特殊化されたバイトコードを、後者は特殊化に関連する何らかのデータをインラインにキャッシュする領域の内容を表示することを指示するものだ。

 これをPythonのREPL環境で実行すると次のようになる。

>>> dis.dis(add, adaptive=True, show_caches=True)
  1           0 RESUME_QUICK             0

  2           2 LOAD_FAST__LOAD_FAST     0 (a)
              4 LOAD_FAST                1 (b)
              6 BINARY_OP_ADD_INT        0 (+)
              8 CACHE                    0 (counter: 53)
             10 RETURN_VALUE


BINARY_OPがBINARY_OP_ADD_INTに特殊化された

 これはadd関数のバイトコードが整数型に特殊化されたことを意味している。このようにして、関数の実行時にバイトコードを動的に特殊化することで高速化を狙っている。

 「What's New In Python 3.11」ではこのような特殊化の対象としては、以下が挙げられている。

  • 2項演算
  • コンテナ型のオブジェクトのインデックス指定
  • コンテナ型のオブジェクトの特定インデックス位置への値の代入
  • Cで記述されている組み込み関数の呼び出しを、Cバージョンを直接呼び出すように特殊化
  • グローバル変数や組み込み関数のキャッシング
  • アクセスした属性のキャッシング
  • 呼び出されたメソッドのキャッシング
  • 属性への代入
  • シーケンスのアンパック

 最後に上で見たadd関数を例にほんとうに高速化ができているかを確認してみよう。

def add(a, b):
    return a + b

def call_add_10M_times():
    start = time.time()
    for _ in range(10000000):
        result = add(0, 1)

    end = time.time()
    diff = end - start
    return diff

diffs = []
for _ in range(10):
    diff = call_add_10M_times()
    diffs.append(diff)

print(f'{sum(diffs) / len(diffs):.3f}')

add関数が高速化したかの確認

 ここではadd関数を1000万回呼び出して、それにかかった時間を計測する関数を定義して、さらにその関数を10回呼び出してその平均値を計算するようにしている。

 結果は以下の通りだ。

Python 3.10 Python 3.11 速度差
0.975秒 0.627秒 1.56倍
実行結果

 ドキュメントでは2項演算については10%程度の速度向上が見込めるとあったが、それ以上の高速化が行われている。これは上で見たような関数呼び出しにまつわる高速化もあいまっての結果だと思われる。

 なお、特殊化適応的インタプリターについてもpython.jpの「Python 3.11の新機能(その2) 特殊化適応的インタープリタ」が参考になるはずだ。


 今回はFaster CPythonプロジェクトに注目した。次回は例外周りの新機能について見ていく予定だ。

「Python最新情報キャッチアップ」のインデックス

Python最新情報キャッチアップ

Copyright© Digital Advantage Corp. All Rights Reserved.

スポンサーからのお知らせPR

注目のテーマ

Microsoft & Windows最前線2025
AI for エンジニアリング
ローコード/ノーコード セントラル by @IT - ITエンジニアがビジネスの中心で活躍する組織へ
Cloud Native Central by @IT - スケーラブルな能力を組織に
システム開発ノウハウ 【発注ナビ】PR
あなたにおすすめの記事PR

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。