Python3.11の新機能 Python 3.11の新機能(その2) 特殊化適応的インタープリタ

(その1) CPython高速化計画(その2) 特殊化適応的インタープリタ(その3) 関数呼び出しのインライン化(その4) 例外グループとTaskGroup(その5) PEP-646 可変長ジェネリックス(その6) PEP-673 Self型(その7) PEP-681 データクラス変換(その8) PEP-655 TypedDictの要素ごとに省略可・不可を指定する(その9) その他の型ヒント関連機能(その10) 正規表現 - アトミックグループとPossessive指定子(その11) 標準ライブラリ(その12) Python本体の機能追加

特殊化適応的インタープリタ(PEP 659: Specializing Adaptive Interpreter) は、 Python 3.11の新機能(その1) CPython高速化計画 で紹介した CPython 高速化計画 の一環として導入された新機能で、実行中にプログラムをより効率的な処理に書き換えて高速化する仕組みです。

バイトコード

Pythonはプログラムを実行するとき、ソースコードをコンパイルしてバイトコードと呼ばれる実行用のデータを生成します。例えば、次の関数 func_add()

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

は、次のようなバイトコードに変換されます(Python 3.10の場合)。関数のバイトコードは、dis.dis()関数 で出力できます。

>>> dis.dis(func_add)
  2           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 BINARY_ADD
              6 RETURN_VALUE

Pythonインタープリタは、生成されたバイトコードを順番に実行します。func_add() には BINARY_ADD というバイトコードがありますが、これは + 演算子で2つの変数 ab の足し算を行うバイトコードです。

かんたんな足し算とはいえ、BINARY_ADD ではかなり複雑な処理が行われます。おおざっぱに列挙すると

  1. a + ba が、整数などの + 演算子を使えるデータであることを確認する
  2. a オブジェクトの、+ 演算子の処理を行う関数を呼び出す
  3. a + bba と加算できる値(整数や浮動小数点数など)であることを確認する
  4. ab の値を取り出し、加算する
  5. 新しいオブジェクトを生成して結果を返す

などが行われます。実際の加算よりも、加算を行うためのチェックや前準備に多くの手間がかかっています。

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

ところで、func_add(a, b) のような関数を呼び出すとき、引数として指定する ab の値の型はそんなに頻繁に変わるものでしょうか? あるときは整数同士の加算を行い、別のところでは文字列の加算を行う……という使われ方をすることもありますが、多くの関数では、引数の値は異なっていても型は変わらない、という場合が多いでしょう。

そこで、Python3.11では、プログラムを実行中に実際に指定されたデータをもとに、そのデータに最適な処理を行うようにバイトコードを書き換えてしまいます。

例えば、func_add() は前述の通り次のようなバイトコードとしてコンパイルされています。

>>> dis.dis(func_add)
  2           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 BINARY_ADD
              6 RETURN_VALUE

そこで、func_add("abc", "def") のように引数として文字列を指定して呼び出すと、次のように BINARY_ADD の部分を文字列の加算を専門に行う BINARY_ADD_UNICODE に書き換えてしまいます。

>>> dis.dis(func_add)
  2           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 BINARY_ADD_UNICODE
              6 RETURN_VALUE

このようにプログラムを処理するデータにとって最適な処理に変更することを、特殊化(Specializing) といいます。

特殊化が行われるときには少しだけ処理が遅くなりますが、それ以降は文字列専用の効率的な加算処理が行われるようになり、全体としては大きく処理速度が向上することが期待できます。

また、バイトコードを BINARY_ADD_UNICODE に書き換えて文字列用に特殊化した後でも、func_add(10, 20) のように引数として数値を指定しても問題なく動作する仕組みになっています。文字列以外の引数を指定した呼び出しが何回も行われると、再び特殊化が行われ、今度は数値の加算に最適化されたバイトコードに書き換えられます。

次のバイトコードは、整数の加算に特殊化したバイトコードの例です。

>>> dis.dis(func_add)
  2           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 BINARY_ADD_INT
              6 RETURN_VALUE

このように、対象となるデータが変化するとそれに合わせて特殊化が行われることを 適合的(Adaptive) と呼びます。

Python 3.11のバイトコード

実際にPython 3.11でどのようにバイトコードが変化するのか確認してみましょう。

次の関数 func_add(a, b)

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

は、Python 3.11 では次のようなバイトコードに変換されます。dis.dis() で特殊化されたバイトコードを参照するときは、引数に show_caches=True, adaptive=True を指定します。

>>> dis.dis(func_add, show_caches=True, adaptive=True)
  1           0 RESUME                   0

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

まず、一度 func_add("aaa", "bbb") と、文字列を指定して呼び出してみましょう。

>>> func_add("aaa", "bbb")
'aaabbb'
>>> dis.dis(func_add, show_caches=True, adaptive=True)
  1           0 RESUME                   0

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

まだバイトコードは変化していません。一度しか使わない関数を特殊化しても時間の無駄になってしまいますから、一定回数の呼び出しが行われるまで、特殊化されないようになっています。Python 3.11rc1では、8回呼び出すと初めて特殊化が行われます。

あと七回呼び出してから、もう一度バイトコードを確認します。

>>> for i in range(7): func_add("aaa", "bbb")
'aaabbb'
...
>>> dis.dis(func_add, show_caches=True, adaptive=True)
  1           0 RESUME_QUICK             0

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

ここで特殊化が行われました。これまで、汎用的なバイトコード BINARY_OP に変わって、文字列用のバイトコード BINARY_OP_ADD_UNICODE に特殊化されています。

次に、引数のデータ型を変更して、文字列ではなく数値を指定してみましょう。

>>> func_add(1, 2)
3
>>> dis.dis(func_add, show_caches=True, adaptive=True)
  1           0 RESUME_QUICK             0

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

数値を指定しても正しい結果が返ります。バイトコードは変化がなく、文字列に特殊化したままです。

もう一度、数値を指定してみましょう。

>>> func_add(1, 2)
3
>>> dis.dis(func_add, show_caches=True, adaptive=True)
  1           0 RESUME_QUICK             0

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

同じく正しい結果が返りますが、バイトコードは文字列に特殊化したままです。

ところで、バイトコード BINARY_OP_ADD_UNICODE の次の行に、

              8 CACHE                    0 (counter: 51)

書かれており、counter: 53 から counter: 52 -> counter: 51 と徐々に減少しているのにお気づきでしょうか?確認として、もう一度呼び出してみまししょう。

>>> func_add(1, 2)
3
>>> dis.dis(func_add, show_caches=True, adaptive=True)
  1           0 RESUME_QUICK             0

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

やはり呼び出すたびに減少しています。

呼び出しを繰り返して、counter の値が 0 になったらどうなるのでしょう?

>>> for i in range(50): func_add(1, 2)
>>> dis.dis(func_add, show_caches=True, adaptive=True)
  1           0 RESUME_QUICK             0

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

バイトコードが変化し、BINARY_OP_ADD_UNICODEBINARY_OP_ADAPTIVE に変化しました。BINARY_OP_ADAPTIVE はバイトコードの特殊化を行っているときに指定されるバイトコードで、必要な統計情報の収集などを行います。ただし、現在のPython3.11 rc1では特に何もせず、一定回数の呼び出しが行われるのを待ち合わせているだけのようです。

ここでさらにもう一度呼び出すとどうなるでしょう?

>>> for i in range(50): func_add(1, 2)
>>> dis.dis(func_add, show_caches=True, adaptive=True)
  1           0 RESUME_QUICK             0

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

やはりバイトコードは変わりませんが、counter の値が16ずつ減少しています。この値が 0 になるまで繰り返してみましょう。

>>> func_add(1, 2)
>>> ...
>>> dis.dis(func_add, show_caches=True, adaptive=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

counter の値が 0 以下になると再び特殊化が行われ、BINARY_OP_ADAPTIVE が整数演算に最適化したバイトコード BINARY_OP_ADD_INT に変化しました。

このように、関数の実際の使われ方に応じて、最適な処理に変更される仕組みになっています。

キャッシュ

特殊化には、これまで見てきた counter の値のように、実行時の統計データなどが必要になる場合があります。こういったデータは キャッシュ と呼ばれ、プログラム格納領域のバイトコードと一緒に書き込まれています。このようにバイトコードとキャッシュが同じ領域を利用することで、不要なメモリ確保やポインタ参照を減らし、より高速に動作するように工夫されています。

ベンチマーク

いろいろな処理を、Python3.11 rc1とPython3.10で実行してみましょう。

実行環境は以下のとおりです。

  • MacBook Pro(16 inch, 2019)
  • 2.4 GHz 8コアIntel Core i9
  • 64 GB 2667 MHz DDR4
  • macOS Monterey 12.3
  • Python 3.10.6 (v3.10.6:9c7b4bd164, Aug 1 2022, 17:13:48) [Clang 13.0.0 (clang-1300.0.29.30)] on darwin
  • Python 3.11.0rc1 (v3.11.0rc1:41cb07120b, Aug 5 2022, 11:44:46) [Clang 13.0.0 (clang-1300.0.29.30)] on darwin

加算

def test_add(a):
    for i in range(10000000):
        a + i
3.10 3.11
370ms 323ms 1.15倍

ビルトイン関数の参照

def test_load_global():
    for i in range(10000000):
        abs
3.10 3.11
288ms 199ms 1.41倍

タプルのアンパック

def test_unpack():
    a = (1, 2)
    for i in range(10000000):
        x, y = a
3.10 3.11
236ms 217ms 1.08倍

タプルのインデックス

def test_subscribe():
    a = (0, 1, 2)
    for i in range(10000000):
        a[0]
3.10 3.11
310ms 216ms 1.44倍

インスタンスの属性参照

class C:
    def __init__(self):
        self.attr = 1

def test_loadattr():
    c = C()

    for i in range(10000000):
        c.attr
3.10 3.11
335ms 200ms 1.68倍

Python 3.11では数多くのチューニングが行われているため、高速化がすべてバイトコードの特殊化によるものではありません。しかし、かなりの部分は特殊化による成果で、大きな効果が出ています。

現在、バイトコードの特殊化処理はまだ基本的なフレームワークが導入されたばかりで、本格的な最適化はまだまだこれからのようです。これからチューニングが進めば、より大きな性能向上効果が望めるでしょう。

Amazon.co.jpアソシエイト: Python関連書籍
Copyright © 2001-2023 python.jp Privacy Policy python_japan
Amazon.co.jpアソシエイト
Amazonで他のPython書籍を検索