CPython 3.13 のバイトコードを実験で読み解く
はじめに
本ドキュメントはPythonのバイトコードについて調査と実験したものをまとめます。
バイトコードはPython プログラムの CPython インタプリタの内部表現です。
バイトコードの知見を得ることで、パフォーマンス上の問題の解析や、書き方の良し悪しを根拠をもって説明できます。たとえば、単なる条件分岐で済む箇所を例外で制御しているコードをレビューする際、感覚的に「直して」ではなく、次のように言えます
「Python の例外処理は“例外が発生しない限り”はゼロコスト設計ですが、例外が発生すると ExceptionTable の参照→スタック深さの調整→(必要なら)lasti を積む→ハンドラへ分岐→RERAISE 等の経路を辿るため、通常フローより高コストです。ここはロジック分岐にすると処理が軽く、読みやすさも上がります。」
また、実際、バイトコードを活用したプログラムには以下のようなものがあります。
-
pytype
- 最終対応が 3.12の歴史のある型チェッカー
-
python-goto
- PythonでGOTO構文を実現するライブラリ
なお、本ドキュメントは後述の条件で実験して、記載したものです。
環境が異なれば動作しませんし、記載内容について実験ミス、ドキュメントの誤読による誤った結論が記載されていることがあります。
簡単なバイトコードの生成と確認
Pythonのスクリプトが、どのようにバイトコードを作って動作するかの大まかな流れは以下のようになります。
- Parser が構文解析を行い、AST(抽象構文木)を生成します。
- その後 Compiler が AST から実行順序を示す論理構造である 制御フローグラフ(CFG) を作成します。
- Assembler が CFG を、逐次実行可能な命令列である バイトコードに変換します。
CPython ではコンパイルの起点は モジュール(ファイル) です。関数・クラス・内包表記などのブロックは、それぞれ独立した実行単位としてまとめられます。
設定や実行方法によっては、コンパイル済みバイトコードは .pyc としてキャッシュされる場合があります。次回、同じモジュールを再インポートする際は再コンパイルが省略されます。
重要な注意として、バイトコードは他のPython実装間での互換性はなく、CPython のリリース間でも安定性は保証されません。
コマンドラインからバイトコードを確認する方法
ここでは簡単にバイトコードの内容を閲覧する方法について説明します。
以下のようなファイルが存在するとします。
/lib/demo_mod.py
def add(x, y):
return x + y
このモジュールのバイトコードを確認するには以下のdisコマンドを実行します。
python -m dis lib/demo_mod.py
0 RESUME 0
1 LOAD_CONST 0 (<code object add at 0x10de36170, file "lib/demo_mod.py", line 1>)
MAKE_FUNCTION
STORE_NAME 0 (add)
RETURN_CONST 1 (None)
Disassembly of <code object add at 0x10de36170, file "lib/demo_mod.py", line 1>:
1 RESUME 0
2 LOAD_FAST_LOAD_FAST 1 (x, y)
BINARY_OP 0 (+)
RETURN_VALUE
本来、バイトコードは命令の順序付き列です。CPython(Wordcode)では、**各命令は原則 2 バイト(1 バイトのオペコード+1 バイトのオペランド)**で表されます。ただし場合により追加の語が付くため、2 バイト固定ではありません。
上の出力は、そのバイトコードを人間向けに整形した表示です。
オペコード(opcode)は「どの命令を実行するか」を示す1バイトの識別子です。
以下にその一覧があります。
それぞれのオペコードの説明については以下のページで確認できます。
オペランド(operand / oparg)はその命令に渡す1バイトの数値引数で、意味は命令ごとに異なります。
上記の例では人間向けに整形した表示では3列存在します。
1列目はバイトコードに対応するソースコードの行数です。
2列目はオペコードになっており、これを人間に見やすい文字として表示しています。
オペコードの一覧は以下に存在します。
3列目はオペランドの値と、それをどう解釈したかが書かれています。
今回の出力結果はモジュール本体と add 関数本体の 2 つに分かれています。
モジュール本体
RESUME 0
この命令は、内部トレース、デバッグ、および最適化チェックを実行します。
オペランドの0は「ジェネレータ、コルーチン、非同期ジェネレータではない関数の開始」を表しています。
フレームの開始を表します
LOAD_CONST 0
co_constsと呼ばれる定数の一覧からオペランドが指すインデックスの値を取得します。
今回のケースではaddの実行単位であるコードオブジェクトになります。これをスタックに積みます。
MAKE_FUNCTION
直前のコードオブジェクトから関数オブジェクトを作ります。
これにより、コードオブジェクトに実行環境を結び付けて呼び出せるようになります。
STORE_NAME 0
スタックのトップの値を名前表 co_names[0]
に対応する名前に紐づけます。
今回の場合は直前に作成した関数オブジェクトをグローバル名 add に紐づけます。
RETURN_CONST 1
co_consts[1]
(今回の場合はNone)を呼び出し元に返します。
モジュールの評価結果として None を即座に返す(.py を実行/インポートしたときの戻り値)
add関数本体
RESUME 0
フレームの開始を表します
LOAD_FAST_LOAD_FAST 1
ローカル変数の一覧であるco_varnamesから2つの参照を取得してスタックに積みます。
オペランドの1について上位4bitと下位4bitに分割して、それをco_varnamesのインデックスとします。
今回の場合, 1>>4でco_varnames[0]
(今回はx)と1 & 0xfでco_varnames[1]
(今回はy)をスタックに積みます。
BINARY_OP 0(+)
スタック中の2つの値をポップしてオペランドで与えた演算を行い、その結果をスタックします。
Python3.13ではオペランドの種類は以下の通りです。
オペランド | 名称 | 演算子 |
---|---|---|
0 | NB_ADD | + |
1 | NB_AND | & |
2 | NB_FLOOR_DIVIDE | // |
3 | NB_LSHIFT | << |
4 | NB_MATRIX_MULTIPLY | @ |
5 | NB_MULTIPLY | * |
6 | NB_REMAINDER | % |
7 | NB_OR | | |
8 | NB_POWER | ** |
9 | NB_RSHIFT | >> |
10 | NB_SUBTRACT | - |
11 | NB_TRUE_DIVIDE | / |
12 | NB_XOR | ^ |
13 | NB_INPLACE_ADD | += |
14 | NB_INPLACE_AND | &= |
15 | NB_INPLACE_FLOOR_DIVIDE | //= |
16 | NB_INPLACE_LSHIFT | <<= |
17 | NB_INPLACE_MATRIX_MULTIPLY | @= |
18 | NB_INPLACE_MULTIPLY | *= |
19 | NB_INPLACE_REMAINDER | %= |
20 | NB_INPLACE_OR | |= |
21 | NB_INPLACE_POWER | **= |
22 | NB_INPLACE_RSHIFT | >>= |
23 | NB_INPLACE_SUBTRACT | -= |
24 | NB_INPLACE_TRUE_DIVIDE | /= |
25 | NB_INPLACE_XOR | ^= |
上記の内容はopcodeモジュールの_nb_opsで確認できます。
BINARY_OPのオペランドの種類を確認する例
for i, (enum_name, symbol) in enumerate(_nb_ops):
print(f"{i:2d}: {enum_name:>24s} {symbol}")
RETURN_VALUE
呼び出し元の関数に直近にスタックされた値を返します。
Pythonのコード上でバイトコードを確認する方法
モジュールのバイトコードを確認する方法
compile組み込み関数でPythonのソースコードをコンパイルしてコードオブジェクトが取得できます。
このコードオブジェクトからdisモジュールのdis関数でバイトコードの解析結果の出力が可能です。
import dis, pathlib
path = pathlib.Path("lib/demo_mod.py")
src = path.read_text(encoding="utf-8")
code = compile(src, str(path), "exec") # ← モジュール本体のコードオブジェクト
print(type(code)) # <class 'code'>
print(code.co_code) # 生のバイトコード
dis.dis(code)
もし、随時コンパイルするのでなく.pycのキャッシュを利用したい場合はspec_from_file_locationを使用したModuleSpecからバイトコードを確認する方法もあります。
import importlib.util, dis
spec = importlib.util.spec_from_file_location("m", "lib/demo_mod.py")
code = spec.loader.get_code("m") # ← モジュール本体の code object
print(type(spec)) # <class '_frozen_importlib.ModuleSpec'>
print(type(code)) # <class 'code'>
print(code.co_code) # 生のバイトコード
dis.dis(code)
関数やクラスのバイトコードを確認する方法
クラスや関数からdis関数を使用してバイトコードを取得することも可能です。
単純な関数の例
disに関数を指定するだけで関数本体のコードオブジェクトからバイトコードを取得します。
関数の例
import dis
from lib.demo_mod import add
# 生のバイトコード
print(add.__code__.co_code)
# 関数addのバイトコード
dis.dis(add)
出力結果
b'\x95\x00X\x01-\x00\x00\x00$\x00'
1 RESUME 0
2 LOAD_FAST_LOAD_FAST 1 (x, y)
BINARY_OP 0 (+)
RETURN_VALUE
クラスの例
dis関数にクラスを指定することでクラス中のメソッドのバイトコードの解析結果が表示されます。
クラスの例
import dis
import types
class X:
def __init__(self):
self.x = 1
def inc(self):
self.x += 1
# 各メソッドの生のバイトコードを取得する
_have_code = (types.MethodType, types.FunctionType, types.CodeType,
classmethod, staticmethod, type)
items = sorted(X.__dict__.items())
for name, x1 in items:
if isinstance(x1, _have_code):
print(name, x1.__code__.co_code)
# バイトコードを人間が見れるようにする
dis.dis(X)
出力結果
__init__ b'\x95\x00S\x01U\x00l\x00\x00\x00\x00\x00\x00\x00\x00\x00g\x00'
inc b'\x95\x00U\x00=\x01R\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00S\x01-\r\x00\x00s\x02l\x00\x00\x00\x00\x00\x00\x00\x00\x00g\x00'
Disassembly of __init__:
5 RESUME 0
6 LOAD_CONST 1 (1)
LOAD_FAST 0 (self)
STORE_ATTR 0 (x)
RETURN_CONST 0 (None)
Disassembly of inc:
7 RESUME 0
8 LOAD_FAST 0 (self)
COPY 1
LOAD_ATTR 0 (x)
LOAD_CONST 1 (1)
BINARY_OP 13 (+=)
SWAP 2
STORE_ATTR 0 (x)
RETURN_CONST 0 (None)
バイトコードの解析情報の詳細を使用する方法
dis関数はバイトコードを解析してオペコードやオペランドを出力するには便利です。
しかし、オペコードやオペランドをプログラム中に使用したいケースには使用できません。
解析結果のオペコードやオペランドをプログラム中で使用したい場合はdisモジュールのBytecodeクラスを使用します。
Bytecodeクラスに関数を与えた場合、Instructionのシーケンスが取得できるので、それを活用できます。
サンプル
from lib.demo_mod import add
import dis
for ins in dis.Bytecode(add):
print(f"offset: {ins.offset}")
print(f" opcode: {ins.opname}({ins.opcode})")
print(f" baseopcode: {ins.baseopname}({ins.baseopcode})")
print(f" arg: {ins.argval}({ins.arg})")
実はバイトコードは実行中に最適化のために書き変わるケースがあります。
オリジナルのオペコードが必要な場合はbaseopcode, baseopnameを使用して、適応後のオペコードが必要な場合はopnameとopcodeを使用します。
詳細については後述します。
生のバイトコードを解析する方法
コードオブジェクトのco_codeから生のバイトコードが取得できます。
ここでは、その生データを直接解析する方法を説明します。
サンプル
import struct
import dis
def dump_code(f):
code = f.__code__.co_code
opcode, oparg = struct.unpack_from('BB', code, 2)
pos = 0
extended_arg = 0
extended_arg_offset = None
while pos < len(code):
offset = pos
if extended_arg_offset is not None:
offset = extended_arg_offset
opcode = struct.unpack_from('B', code, pos)[0]
pos += 1
if opcode in dis.hasarg:
# 引数あり
oparg = extended_arg | struct.unpack_from('B', code, pos)[0]
pos += 1
if opcode == dis.EXTENDED_ARG:
extended_arg = oparg << 8
extended_arg_offset = offset
continue
else:
# 引数がないケース
pos += 1
oparg = None
extended_arg = 0
extended_arg_offset = None
print(f" offset:{offset} opcode: {dis.opname[opcode]}({opcode}) args: {oparg}")
EXTENDED_ARGという1バイトに収まらないオペランドをもつオペコードに対応するための命令のため、ややこしい実装になっていますが、基本的にはオペコードとオペランドの組み合わせを取得するだけになります。
add関数に対してdump_codeを実行した結果は以下の通りとなります。
>>from lib.demo_mod import add
>>dump_code(add)
offset:0 opcode: RESUME(149) args: 0
offset:2 opcode: LOAD_FAST_LOAD_FAST(88) args: 1
offset:4 opcode: BINARY_OP(45) args: 0
offset:6 opcode: CACHE(0) args: 0
offset:7 opcode: CACHE(0) args: 0
offset:8 opcode: RETURN_VALUE(36) args: None
dis関数の結果と違い、CACHEという命令が増えています。
これは実行中に最適化のために使用されるキャッシュの情報です。詳細については後述します。
適応的スペシャライザ
バイトコードは実行中に最適化されて書き変わるケースがあります。
詳細については以下に記載があります。
PEP 659 – Specializing Adaptive Interpreter
たとえば以下の関数があります。
def add(x, y):
return x + y
この関数のバイトコードが以下のようになります。
5 RESUME 0
6 LOAD_FAST_LOAD_FAST 1 (x, y)
BINARY_OP 0 (+)
RETURN_VALUE
add関数は引数が文字型のときも、数値型の場合も動作します。
しかし、多くの場合、この関数は同じ型で実行されるケースが多いはずです。
つまり整数が頻繁に渡される場合はBINARY_OPの代わりにBINARY_OP_ADD_INTを変更した方がパフォーマンスは良くなります。
5 RESUME 0
6 LOAD_FAST_LOAD_FAST 1 (x, y)
BINARY_OP_ADD_INT 0 (+)
RETURN_VALUE
仮に文字列が渡される場合はBINARY_OPの代わりにBINARY_OP_ADD_UNICODEに変更します。
5 RESUME 0
6 LOAD_FAST_LOAD_FAST 1 (x, y)
BINARY_OP_ADD_UNICODE 0 (+)
RETURN_VALUE
特殊化適応的インタープリタでは、このように実行状況にあわせて、バイトコードを変更します。
動作例
実際に、どのように特殊化が適応されるか観察してみます。
特殊化されたバイトコードを解析する場合は、dis関数のadaptiveとshow_cachesをTrueとして実行します。
dis.dis(add, adaptive=True, show_caches=True)
以下が実際に特殊化が適応される例になります。
サンプルコード
import dis
def add(x, y):
return x + y
print("未実行----------------")
dis.dis(add, adaptive=True, show_caches=True)
print("数値型 1回実行----------------")
add(1, 1)
dis.dis(add, adaptive=True, show_caches=True)
print("数値型 2回実行----------------")
add(1, 2)
dis.dis(add, adaptive=True, show_caches=True)
print("数値型 3回実行----------------")
add(1, 3)
dis.dis(add, adaptive=True, show_caches=True)
print("文字型 1回実行----------------")
add("1", "1")
dis.dis(add, adaptive=True, show_caches=True)
print("文字型 大量に実行----------------")
for i in range(200): add("1", "x")
dis.dis(add, adaptive=True, show_caches=True)
未実行の場合にバイトコードを見た場合、以下のようになります。
3 RESUME 0
4 LOAD_FAST_LOAD_FAST 1 (x, y)
BINARY_OP 0 (+)
CACHE 0 (counter: 17)
RETURN_VALUE
この状態ではBINARY_OPのままです。
直後にCACHEがありますが、ここにBINARY_OPの使用状況を確認して特殊化が行われます。
CACHEの内容はオペコードごとに異なり、BINARY_OPの場合は16 ビットのバックオフ付きカウンタ 1 語のみとなります。
16ビットのカウンタは、12ビットの符号なし “value” と 4ビットの “backoff” フィールドから構成されます。
今回の場合はcounterが17なので以下のvalueは1、backoffは1となります。
参考
>>> n = 17
>>> n >> 4
1
>>> n & 0xF
1
これは、「あと1回“汎用実行”してプロファイルを溜めたら、次回に特殊化を試みる」 という段階です。
では、この状態で整数を引数にaddを実行したのちのバイトコードの解析結果は以下のようになります。
数値型 1回実行----------------
3 RESUME_CHECK 0
4 LOAD_FAST_LOAD_FAST 1 (x, y)
BINARY_OP 0 (+)
CACHE 0 (counter: 1)
RETURN_VALUE
BINARY_OPのままで特殊化は行われておらず、counterは1であるため、valueは0、backoffは1となります。
特殊化は 次にその命令に到達したとき に試みられます。
2回目の整数引数のaddを実行したのちのバイトコードの解析結果は以下のようになります。
数値型 2回実行----------------
3 RESUME_CHECK 0
4 LOAD_FAST_LOAD_FAST 1 (x, y)
BINARY_OP_ADD_INT 0 (+)
CACHE 0 (counter: 832)
RETURN_VALUE
BINARY_OPがBINARY_OP_ADD_INTと特殊化されました。
counter: 832 は value=52, backoff=0 を意味します。特殊化後はガード失敗(型が合わない実行)が起こるたびに value が減り、0 になると再特殊化やデオプトを試みます。ガード成功(想定型)では減りません。
すでに整数で特殊化されている状態で3回目の整数引数のaddを呼ぶと以下のようになります。
数値型 3回実行----------------
3 RESUME_CHECK 0
4 LOAD_FAST_LOAD_FAST 1 (x, y)
BINARY_OP_ADD_INT 0 (+)
CACHE 0 (counter: 832)
RETURN_VALUE
このケースではガードが成功したためcounterの数値は変化しません。
この状態で文字型の引数でaddを呼ぶと以下のようになります。
文字型 1回実行----------------
3 RESUME_CHECK 0
4 LOAD_FAST_LOAD_FAST 1 (x, y)
BINARY_OP_ADD_INT 0 (+)
CACHE 0 (counter: 816)
RETURN_VALUE
BINARY_OP_ADD_INTのままですが、ガードが失敗したためcounterのvalueが52->51に減っています。
この状況でさらに大量に文字型の引数でaddを呼ぶと以下のようになります。
文字型 大量に実行----------------
3 RESUME_CHECK 0
4 LOAD_FAST_LOAD_FAST 1 (x, y)
BINARY_OP_ADD_UNICODE 0 (+)
CACHE 0 (counter: 832)
RETURN_VALUE
BINARY_OP_ADD_INT->BINARY_OP_ADD_UNICODEに変更されていることが確認できます。
特殊化のタイミングで別の型を指定した場合
数値型一回目で特殊化のタイミングが来たのち、文字型を指定した場合の結果は以下のとおりです。
サンプルコード
import dis
def add(x, y):
return x + y
print("未実行----------------")
dis.dis(add, adaptive=True, show_caches=True)
print("数値型 1回実行----------------")
add(1, 1)
dis.dis(add, adaptive=True, show_caches=True)
print("文字型 1回実行----------------")
add("1", "1")
dis.dis(add, adaptive=True, show_caches=True)
結果
文字型 1回実行----------------
3 RESUME_CHECK 0
4 LOAD_FAST_LOAD_FAST 1 (x, y)
BINARY_OP_ADD_UNICODE 0 (+)
CACHE 0 (counter: 832)
RETURN_VALUE
BINARY_OP_ADD_UNICODEになっていることが確認できます。
つまり特殊化のタイミングのときに、その時の型で最適のものが選ばれていることがわかります。
特殊化の対象のオペコード
dis._specializationsで汎用命令と、その特殊命令の対応が取得できます。
特殊命令のオペコードについてはdis._specialized_opmapで取得できます。
また、使用するキャッシュの型も汎用命令の種類によって構造体が変わります。
Python3.13の場合は以下のようになります。
汎用命令 | 特殊型 | キャッシュの型 |
---|---|---|
RESUME | RESUME_CHECK(207) | キャッシュは付与されない |
TO_BOOL | TO_BOOL_ALWAYS_TRUE(214) TO_BOOL_BOOL(215) TO_BOOL_INT(216) TO_BOOL_LIST(217) TO_BOOL_NONE(218) TO_BOOL_STR(219) |
_PyToBoolCache |
BINARY_OP | BINARY_OP_MULTIPLY_INT(154) BINARY_OP_ADD_INT(151) BINARY_OP_SUBTRACT_INT(156) BINARY_OP_MULTIPLY_FLOAT(153) BINARY_OP_ADD_FLOAT(150) BINARY_OP_SUBTRACT_FLOAT(155) BINARY_OP_ADD_UNICODE(152) BINARY_OP_INPLACE_ADD_UNICODE(3) |
_PyBinaryOpCache |
BINARY_SUBSCR | BINARY_SUBSCR_DICT(157) BINARY_SUBSCR_GETITEM(158) BINARY_SUBSCR_LIST_INT(159) BINARY_SUBSCR_STR_INT(160) BINARY_SUBSCR_TUPLE_INT(161) |
|
STORE_SUBSCR | STORE_SUBSCR_DICT(212) STORE_SUBSCR_LIST_INT(213) |
_PyStoreSubscrCache |
SEND | SEND_GEN(208) | _PySendCache |
UNPACK_SEQUENCE | UNPACK_SEQUENCE_TWO_TUPLE(222) UNPACK_SEQUENCE_TUPLE(221) UNPACK_SEQUENCE_LIST(220) |
_PyUnpackSequenceCache |
STORE_ATTR | STORE_ATTR_INSTANCE_VALUE(209) STORE_ATTR_SLOT(210) STORE_ATTR_WITH_HINT(211) |
_PyAttrCache |
LOAD_GLOBAL | LOAD_GLOBAL_MODULE(204) LOAD_GLOBAL_BUILTIN(203) |
_PyLoadGlobalCache |
LOAD_SUPER_ATTR | LOAD_SUPER_ATTR_ATTR(205) LOAD_SUPER_ATTR_METHOD(206) |
_PySuperAttrCache |
LOAD_ATTR | LOAD_ATTR_INSTANCE_VALUE(193) LOAD_ATTR_MODULE(197) LOAD_ATTR_WITH_HINT(202) LOAD_ATTR_SLOT(201) LOAD_ATTR_CLASS(191) LOAD_ATTR_PROPERTY(200) LOAD_ATTR_GETATTRIBUTE_OVERRIDDEN(192) LOAD_ATTR_METHOD_WITH_VALUES(196) LOAD_ATTR_METHOD_NO_DICT(195) LOAD_ATTR_METHOD_LAZY_DICT(194) LOAD_ATTR_NONDESCRIPTOR_WITH_VALUES(199) LOAD_ATTR_NONDESCRIPTOR_NO_DICT(198) |
_PyLoadMethodCache |
COMPARE_OP | COMPARE_OP_FLOAT(182) COMPARE_OP_INT(183) COMPARE_OP_STR(184) |
_PyCompareOpCache |
CONTAINS_OP | CONTAINS_OP_SET(186) CONTAINS_OP_DICT(185) |
_PyContainsOpCache |
FOR_ITER | FOR_ITER_LIST(188) FOR_ITER_TUPLE(190) FOR_ITER_RANGE(189) FOR_ITER_GEN(187) |
_PyForIterCache |
CALL | CALL_BOUND_METHOD_EXACT_ARGS(163) CALL_PY_EXACT_ARGS(177) CALL_TYPE_1(181) CALL_STR_1(179) CALL_TUPLE_1(180) CALL_BUILTIN_CLASS(165) CALL_BUILTIN_O(168) CALL_BUILTIN_FAST(166) CALL_BUILTIN_FAST_WITH_KEYWORDS(167) CALL_LEN(170) CALL_ISINSTANCE(169) CALL_LIST_APPEND(171) CALL_METHOD_DESCRIPTOR_O(175) CALL_METHOD_DESCRIPTOR_FAST_WITH_KEYWORDS(173) CALL_METHOD_DESCRIPTOR_NOARGS(174) CALL_METHOD_DESCRIPTOR_FAST(172) CALL_ALLOC_AND_ENTER_INIT(162) CALL_PY_GENERAL(178) CALL_BOUND_METHOD_GENERAL(164) CALL_NON_PY_GENERAL(176) |
_PyCallCache |
特殊化されたバイトコードの解析情報の詳細を使用する方法
disモジュールのByteCodeクラスを使用して特殊化されたコードを取得するサンプルは以下のとおりです。
Bytecodeのadaptive=True, show_caches=Trueをすることで、cache_infoが取得可能になります。
サンプル
import dis
def add(x, y):
return x + y
# 特殊化するために複数回呼び出す
for i in range(200): add(1, 2)
for ins in dis.Bytecode(add, adaptive=True, show_caches=True):
print(f"offset: {ins.offset}")
print(f" opcode: {ins.opname}({ins.opcode})")
print(f" baseopcode: {ins.baseopname}({ins.baseopcode})")
print(f" arg: {ins.argval}({ins.arg})")
if ins.cache_info: # ここがインラインキャッシュ(構造化済み)
print(" cache_info:", ins.cache_info)
print(" cache range:", ins.cache_offset, "→", ins.end_offset)
出力結果
offset: 0
opcode: RESUME_CHECK(207)
baseopcode: RESUME(149)
arg: 0(0)
offset: 2
opcode: LOAD_FAST_LOAD_FAST(88)
baseopcode: LOAD_FAST_LOAD_FAST(88)
arg: ('x', 'y')(1)
offset: 4
opcode: BINARY_OP_ADD_INT(151)
baseopcode: BINARY_OP(45)
arg: 0(0)
cache_info: [('counter', 1, b'@\x03')]
cache range: 6 → 6
offset: 8
opcode: RETURN_VALUE(36)
baseopcode: RETURN_VALUE(36)
arg: None(None)
特殊化された生のバイトコードを解析する方法
特殊化される前のバイトコードはコードオブジェクト.__code__.co_code
に格納されていますが、特殊化されたものは非公開のコードオブジェクト.__code__._co_code_adaptive
に格納されています。
前述したdump_code()についてはオペコードがCACHE時の挙動について変更する必要もあります。
サンプルコード
import struct
import dis
# 特殊化のオペコードとその名前を構築
dict_specialized_codes = {v: k for k, v in dis._specialized_opmap.items()}
# 特殊化された命令はdis.opnameにないので自分で対応表をつくる
def get_op_name(opcode):
name = dict_specialized_codes.get(opcode)
if not name:
name = dis.opname[opcode]
return name
def dump_adaptive_code(f):
# 特殊化の場合は_co_code_adaptiveにバイトコードがある
code = f.__code__._co_code_adaptive
original_code = f.__code__.co_code
opcode, oparg = struct.unpack_from('BB', code, 2)
pos = 0
extended_arg = 0
extended_arg_offset = None
while pos < len(code):
offset = pos
if extended_arg_offset is not None:
offset = extended_arg_offset
opcode = struct.unpack_from('B', code, pos)[0]
pos += 1
original_op = original_code[offset]
if original_op == 0:
# オリジナルのOpCodeがCACHEDの場合、キャッシュの内容をそのままとる
args = struct.unpack_from('B', code, pos)[0]
print(" cache:", hex(opcode), hex(args), (opcode+(args<<8)))
pos += 1
continue
if original_op in dis.hasarg:
# 引数あり
oparg = extended_arg | struct.unpack_from('B', code, pos)[0]
pos += 1
if opcode == dis.EXTENDED_ARG:
extended_arg = oparg << 8
extended_arg_offset = offset
continue
else:
# 引数がないケース
pos += 1
oparg = None
extended_arg = 0
extended_arg_offset = None
print(f" offset:{offset} opecode: {get_op_name(opcode)}({opcode}) args: {oparg}")
def add(x, y):
return x + y
print('適応前')
dump_adaptive_code(add)
# 特殊化するために複数回呼び出す
for i in range(200): add(1, 2)
print('適応後')
dump_adaptive_code(add)
出力結果
適応前
offset:0 opcode: RESUME(149) args: 0
offset:2 opcode: LOAD_FAST_LOAD_FAST(88) args: 1
offset:4 opcode: BINARY_OP(45) args: 0
cache: 0x11 0x0 17
offset:8 opcode: RETURN_VALUE(36) args: None
適応後
offset:0 opcode: RESUME_CHECK(207) args: 0
offset:2 opcode: LOAD_FAST_LOAD_FAST(88) args: 1
offset:4 opcode: BINARY_OP_ADD_INT(151) args: 0
cache: 0x40 0x3 832
offset:8 opcode: RETURN_VALUE(36) args: None
バイトコードと.pycファイル
PYTHONDONTWRITEBYTECODE環境変数が未設定や0の場合、モジュールをインポートした際にモジュールのコンパイルが行われて、__pycache__
フォルダに.pycファイルが作成されます。
.pycファイル中にはコンパイル後のバイトコードが格納されており、.pycファイルが存在するファイルを再度インポートする場合は、コンパイルなしでインポートが可能になります。
.pycファイルを作る例:
%export PYTHONDONTWRITEBYTECODE=0
%python
>>> import lib.demo_mod
実行後のフォルダ構成は以下のようになります。
lib
├── __pycache__
│ └── demo_mod.cpython-313.pyc
└── demo_mod.py
importを行う代わりに以下のコマンドでもコンパイルしてキャッシュの作成がされます。
python -m py_compile lib/demo_mod.py
なお、pipenvなどを使用している場合、PYTHONDONTWRITEBYTECODE=1となっているので、キャッシュができない場合はこの環境変数をみなおしてみてください。
.pycファイルの内容確認
lib/demo_mod.py
のコンパイルしたコードオブジェクトの内容と.pycファイルの関係を検証するコードは以下の通りです。
import pathlib, struct, datetime, os, marshal
import importlib.util
def dump_byte(byte):
line = '00000000: '
for i, b in enumerate(byte):
line += f'{b:02x} '
if (i+1) % 16 == 0:
print(line)
line = f"{(i+1):08x}: "
print(line)
with open('lib/__pycache__/demo_mod.cpython-313.pyc', 'rb') as f:
cache_bytes = f.read()
print('demo_mod.cpython-313.pyc ---------------------')
dump_byte(cache_bytes)
# 00000000: f3 0d 0d 0a
print('pycのMagic number ------------')
dump_byte(cache_bytes[0:4])
print('importlib.util.MAGIC_NUMBER ------------')
dump_byte(importlib.util.MAGIC_NUMBER) # b'\xf3\r\r\n'
print('pycのBitField ------------')
dump_byte(cache_bytes[4:8])
st = os.stat("lib/demo_mod.py")
print('pyc中のTimestamp -------')
ts = struct.unpack("<I", cache_bytes[8:12])[0]
dump_byte(cache_bytes[8:12])
print('time stamp', datetime.datetime.fromtimestamp(ts))
print(f"実際のファイルのタイムスタンプ {datetime.datetime.fromtimestamp(st.st_mtime)}")
print('pyc中のFileSize -------')
dump_byte(cache_bytes[12:16])
size = struct.unpack("<I", cache_bytes[12:16])[0]
print("pyc中のFileSize", size)
print(f"実際のファイルのファイルサイズ {st.st_size}")
print('code objectのダンプ---------------------')
# ファイルをコンパイルしてコードオブジェクトのダンプを行う
path = pathlib.Path("lib/demo_mod.py")
src = path.read_text(encoding="utf-8")
code = compile(src, str(path), "exec") # ← モジュール本体のコードオブジェクト
dump_byte(marshal.dumps(code))
print("バイトコード.....")
dump_byte(code.co_code)
.pycの全体の内容は以下の通りになります。
00000000: f3 0d 0d 0a 00 00 00 00 1a 36 b9 68 21 00 00 00
00000010: e3 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00
00000020: 00 00 00 00 00 f3 0a 00 00 00 95 00 53 00 1a 00
00000030: 72 00 67 01 29 02 63 02 00 00 00 00 00 00 00 00
00000040: 00 00 00 02 00 00 00 03 00 00 00 f3 0a 00 00 00
00000050: 95 00 58 01 2d 00 00 00 24 00 29 01 4e a9 00 29
00000060: 02 da 01 78 da 01 79 73 02 00 00 00 20 20 da 0f
00000070: 6c 69 62 2f 64 65 6d 6f 5f 6d 6f 64 2e 70 79 da
00000080: 03 61 64 64 72 07 00 00 00 01 00 00 00 73 09 00
00000090: 00 00 80 00 d8 0b 0c 89 35 80 4c f3 00 00 00 00
000000a0: 4e 29 01 72 07 00 00 00 72 03 00 00 00 72 08 00
000000b0: 00 00 72 06 00 00 00 da 08 3c 6d 6f 64 75 6c 65
000000c0: 3e 72 09 00 00 00 01 00 00 00 73 0a 00 00 00 f0
000000d0: 03 01 01 01 f3 02 01 01 11 72 08 00 00 00
00000000〜0000000Fについてはヘッダになっています。
0-4バイトはPythonのバージョンを表すMAGIC_NUMBERになっています。
この値はimportlib.util.MAGIC_NUMBER
と一致します。
この例では「f3 0d 0d 0a」となります。
5-8バイトはFlags(Bitfield)となります。これが「00 00 00 00」の場合、タイムスタンプベースのpycとなります。
9-12バイトは元ファイルのタイムスタンプになります。
今回の場合、「1a 36 b9 68」となり「2025-09-04 15:47:54」を表します。
13-15バイトは元ファイルのサイズになります。
今回は「21 00 00 00」となり33バイトを表します。
残りのデータはコンパイル結果のコードオブジェクトをmarshal.dumps関数でダンプした内容と一致します。
以下がコードオブジェクトのダンプ内容で、.pycに格納されていることが確認できます。
00000000: e3 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00
00000010: 00 00 00 00 00 f3 0a 00 00 00 95 00 53 00 1a 00
00000020: 72 00 67 01 29 02 63 02 00 00 00 00 00 00 00 00
00000030: 00 00 00 02 00 00 00 03 00 00 00 f3 0a 00 00 00
00000040: 95 00 58 01 2d 00 00 00 24 00 29 01 4e a9 00 29
00000050: 02 da 01 78 da 01 79 73 02 00 00 00 20 20 da 0f
00000060: 6c 69 62 2f 64 65 6d 6f 5f 6d 6f 64 2e 70 79 da
00000070: 03 61 64 64 72 07 00 00 00 01 00 00 00 73 09 00
00000080: 00 00 80 00 d8 0b 0c 89 35 80 4c f3 00 00 00 00
00000090: 4e 29 01 72 07 00 00 00 72 03 00 00 00 72 08 00
000000a0: 00 00 72 06 00 00 00 da 08 3c 6d 6f 64 75 6c 65
000000b0: 3e 72 09 00 00 00 01 00 00 00 73 0a 00 00 00 f0
000000c0: 03 01 01 01 f3 02 01 01 11 72 08 00 00 00
このなかの001Aから始まる「95 00 53 00 1a 00 72 00 67 01」がバイトコードにあたります。
この.pycファイルのフォーマットについては以下のブログが参考になります。
What Is the __pycache__ Folder in Python?
様々なコードのバイトコードの例
分岐のバイトコード
if-elif-elseでの分岐
if-elif-else
のバイトコードを検証します。
今回はjump命令を見やすくするため、show_offsetsでオフセットを付与します。
ソースコード
import dis
def test_branch(a):
if a == 0:
return "EMPTY"
elif a == 1:
return "ONE"
else:
return "OTHER"
dis.dis(test_branch, show_offsets=True)
出力結果
3 0 RESUME 0
4 2 LOAD_FAST 0 (a)
4 LOAD_CONST 1 (0)
6 COMPARE_OP 88 (bool(==))
10 POP_JUMP_IF_FALSE 1 (to L1)
5 14 RETURN_CONST 2 ('EMPTY')
6 L1: 16 LOAD_FAST 0 (a)
18 LOAD_CONST 3 (1)
20 COMPARE_OP 88 (bool(==))
24 POP_JUMP_IF_FALSE 1 (to L2)
7 28 RETURN_CONST 4 ('ONE')
9 L2: 30 RETURN_CONST 5 ('OTHER')
COMPARE_OPとPOP_JUMP_IF_FALSEを使用してif-elif-else
を構築します。
POP_JUMP_IF_FALSEはオペランドで指定したオフセット分余計にジャンプします。
10 POP_JUMP_IF_FALSE 1 (to L1)
5 14 RETURN_CONST 2 ('EMPTY')
6 L1: 16 LOAD_FAST 0 (a)
この例の場合、次のオフセットは14, オペランドは1 * 2byte(オペコードとオペランドのサイズ)であるため、ジャンプ先のオフセットは16になります。
L1とかL2のジャンプ先のラベルはバイトコードに記載があるわけでなく、上記の計算から自動でもとめて、出力時に表示しているだけです。
条件式での分岐
条件式のケースは以下のようになります。
import dis
def test_cond(a):
return "even" if a % 2 == 0 else "odd"
dis.dis(test_cond, show_offsets=True)
出力結果
3 0 RESUME 0
4 2 LOAD_FAST 0 (a)
4 LOAD_CONST 1 (2)
6 BINARY_OP 6 (%)
10 LOAD_CONST 2 (0)
12 COMPARE_OP 88 (bool(==))
16 POP_JUMP_IF_FALSE 2 (to L1)
20 LOAD_CONST 3 ('even')
22 RETURN_VALUE
L1: 24 LOAD_CONST 4 ('odd')
26 RETURN_VALUE
前述のif-elif-elseと同じくCOMPARE_OPとPOP_JUMP_IF_FALSEで実現されていることがわかります。
match文での分岐
match文ではパターンマッチングが行われます。
import dis
def test_match(msg):
match msg:
case {"type": "ping"}:
return "pong" # 分岐1: ping
case {"type": "sum", "args": [x, y]}:
return x + y # 分岐2: sum の二項
case [x, y, z]:
return (x, y, z) # 分岐3: 長さ3のシーケンス
case _ if isinstance(msg, int) and msg < 0:
return "neg" # 分岐4: 負の整数(ガード)
case _:
return "unknown" # 分岐5: それ以外
dis.dis(test_match, show_offsets=True)
バイトコードの出力
3 0 RESUME 0
4 2 LOAD_FAST 0 (msg)
5 4 COPY 1
6 MATCH_MAPPING
8 POP_JUMP_IF_FALSE 24 (to L3)
12 GET_LEN
14 LOAD_CONST 1 (1)
16 COMPARE_OP 172 (>=)
20 POP_JUMP_IF_FALSE 18 (to L3)
24 LOAD_CONST 10 (('type',))
26 MATCH_KEYS
28 COPY 1
30 POP_JUMP_IF_NONE 11 (to L1)
34 UNPACK_SEQUENCE 1
38 LOAD_CONST 2 ('ping')
40 COMPARE_OP 88 (bool(==))
44 POP_JUMP_IF_FALSE 5 (to L2)
48 POP_TOP
50 POP_TOP
52 POP_TOP
6 54 RETURN_CONST 3 ('pong')
5 L1: 56 POP_TOP
L2: 58 POP_TOP
L3: 60 POP_TOP
7 62 COPY 1
64 MATCH_MAPPING
66 POP_JUMP_IF_FALSE 39 (to L5)
70 GET_LEN
72 LOAD_CONST 4 (2)
74 COMPARE_OP 172 (>=)
78 POP_JUMP_IF_FALSE 33 (to L5)
82 LOAD_CONST 11 (('type', 'args'))
84 MATCH_KEYS
86 COPY 1
88 POP_JUMP_IF_NONE 26 (to L4)
92 UNPACK_SEQUENCE 2
96 LOAD_CONST 5 ('sum')
98 COMPARE_OP 88 (bool(==))
102 POP_JUMP_IF_FALSE 19 (to L4)
106 MATCH_SEQUENCE
108 POP_JUMP_IF_FALSE 16 (to L4)
112 GET_LEN
114 LOAD_CONST 4 (2)
116 COMPARE_OP 72 (==)
120 POP_JUMP_IF_FALSE 10 (to L4)
124 UNPACK_SEQUENCE 2
128 STORE_FAST_STORE_FAST 18 (x, y)
130 POP_TOP
132 POP_TOP
134 POP_TOP
8 136 LOAD_FAST_LOAD_FAST 18 (x, y)
138 BINARY_OP 0 (+)
142 RETURN_VALUE
7 L4: 144 POP_TOP
146 POP_TOP
L5: 148 POP_TOP
9 150 COPY 1
152 MATCH_SEQUENCE
154 POP_JUMP_IF_FALSE 15 (to L6)
158 GET_LEN
160 LOAD_CONST 6 (3)
162 COMPARE_OP 72 (==)
166 POP_JUMP_IF_FALSE 9 (to L6)
170 UNPACK_SEQUENCE 3
174 STORE_FAST_STORE_FAST 18 (x, y)
176 STORE_FAST 3 (z)
178 POP_TOP
10 180 LOAD_FAST_LOAD_FAST 18 (x, y)
182 LOAD_FAST 3 (z)
184 BUILD_TUPLE 3
186 RETURN_VALUE
9 L6: 188 POP_TOP
11 190 POP_TOP
192 LOAD_GLOBAL 1 (isinstance + NULL)
202 LOAD_FAST 0 (msg)
204 LOAD_GLOBAL 2 (int)
214 CALL 2
222 TO_BOOL
230 POP_JUMP_IF_FALSE 7 (to L7)
234 LOAD_FAST 0 (msg)
236 LOAD_CONST 7 (0)
238 COMPARE_OP 18 (bool(<))
242 POP_JUMP_IF_FALSE 1 (to L7)
12 246 RETURN_CONST 8 ('neg')
13 L7: 248 NOP
14 250 RETURN_CONST 9 ('unknown')
ifなどの条件分岐に比べて、複雑なバイトコードが作成されましたが1つづつ確認します。
case {"type": "ping"}:
は以下のバイトコードに変換されます。
5 4 COPY 1
6 MATCH_MAPPING
8 POP_JUMP_IF_FALSE 24 (to L3)
12 GET_LEN
14 LOAD_CONST 1 (1)
16 COMPARE_OP 172 (>=)
20 POP_JUMP_IF_FALSE 18 (to L3)
24 LOAD_CONST 10 (('type',))
26 MATCH_KEYS
28 COPY 1
30 POP_JUMP_IF_NONE 11 (to L1)
34 UNPACK_SEQUENCE 1
38 LOAD_CONST 2 ('ping')
40 COMPARE_OP 88 (bool(==))
44 POP_JUMP_IF_FALSE 5 (to L2)
48 POP_TOP
50 POP_TOP
52 POP_TOP
... caseの中の処理
5 L1: 56 POP_TOP
L2: 58 POP_TOP
L3: 60 POP_TOP
- MATCH_MAPPINGはSTACK[-1] が辞書か判断します。辞書でなければL3へジャンプして次の判定をします
- GET_LEN〜COMPARE_OPで辞書が空ではないか判断します。空の場合はL3へジャンプして次の判定をします。
- LOAD_CONST〜MATCH_KEYS、指定したキーが全てあるならば、そのキーの値をスタックにプッシュします。ない場合はL1へジャンプして次の判定をします
- UNPACK_SEQUENCE〜COMPARE_OPで指定キーの値が一致するかを確認して一致しなければ、L1へジャンプします。もし一致する場合は分岐1の処理を行います。
case {"type": "sum", "args": [x, y]}:
は以下のバイトコードに変換されます。
7 62 COPY 1
64 MATCH_MAPPING
66 POP_JUMP_IF_FALSE 39 (to L5)
70 GET_LEN
72 LOAD_CONST 4 (2)
74 COMPARE_OP 172 (>=)
78 POP_JUMP_IF_FALSE 33 (to L5)
82 LOAD_CONST 11 (('type', 'args'))
84 MATCH_KEYS
86 COPY 1
88 POP_JUMP_IF_NONE 26 (to L4)
92 UNPACK_SEQUENCE 2
96 LOAD_CONST 5 ('sum')
98 COMPARE_OP 88 (bool(==))
102 POP_JUMP_IF_FALSE 19 (to L4)
106 MATCH_SEQUENCE
108 POP_JUMP_IF_FALSE 16 (to L4)
112 GET_LEN
114 LOAD_CONST 4 (2)
116 COMPARE_OP 72 (==)
120 POP_JUMP_IF_FALSE 10 (to L4)
124 UNPACK_SEQUENCE 2
128 STORE_FAST_STORE_FAST 18 (x, y)
130 POP_TOP
132 POP_TOP
134 POP_TOP
... 分岐2の処理
7 L4: 144 POP_TOP
146 POP_TOP
L5: 148 POP_TOP
- MATCH_KEYS使用した辞書のキーから値を取る処理までは分岐1とほぼ同じです。
- MATCH_SEQUENCEを使用してargsがシーケンスであることを確認しています。その後、argsの長さを確認していることがわかります。
case [x, y, z]
は以下のバイトコードに変換されます。
9 150 COPY 1
152 MATCH_SEQUENCE
154 POP_JUMP_IF_FALSE 15 (to L6)
158 GET_LEN
160 LOAD_CONST 6 (3)
162 COMPARE_OP 72 (==)
166 POP_JUMP_IF_FALSE 9 (to L6)
170 UNPACK_SEQUENCE 3
174 STORE_FAST_STORE_FAST 18 (x, y)
176 STORE_FAST 3 (z)
178 POP_TOP
... 条件3の処理
9 L6: 188 POP_TOP
- MATCH_SEQUENCEを使用してシーケンスであること、その長さを確認しています。
case _ if isinstance(msg, int) and msg < 0:
は以下のようなバイトコードに変換されます。
11 190 POP_TOP
192 LOAD_GLOBAL 1 (isinstance + NULL)
202 LOAD_FAST 0 (msg)
204 LOAD_GLOBAL 2 (int)
214 CALL 2
222 TO_BOOL
230 POP_JUMP_IF_FALSE 7 (to L7)
234 LOAD_FAST 0 (msg)
236 LOAD_CONST 7 (0)
238 COMPARE_OP 18 (bool(<))
242 POP_JUMP_IF_FALSE 1 (to L7)
12 246 RETURN_CONST 8 ('neg')
- 192〜230がisinstanceを呼び出しfalseであれば、POP_JUMP_IF_FALSEで次の判定に移ります
- 234〜242がandでつづく
msg < 0
にあたります。これも条件を満たさない場合はPOP_JUMP_IF_FALSEで次の判定に移ります
ループのバイトコード
for inループのケース
for x in xxx
のバイトコードを検証します。
ソースコード
import dis
def test_for(items):
for item in items:
if item == "NG":
break
if item == "PASS":
continue
print(item.lower())
print("exit loop")
test_for(["AB", "CD", "PASS", "NG", "E"])
dis.dis(test_for, show_offsets=True)
出力結果
3 0 RESUME 0
4 2 LOAD_FAST 0 (items)
4 GET_ITER
L1: 6 FOR_ITER 44 (to L4)
10 STORE_FAST 1 (item)
5 12 LOAD_FAST 1 (item)
14 LOAD_CONST 1 ('NG')
16 COMPARE_OP 88 (bool(==))
20 POP_JUMP_IF_FALSE 2 (to L2)
6 24 POP_TOP
26 JUMP_FORWARD 37 (to L5)
7 L2: 28 LOAD_FAST 1 (item)
30 LOAD_CONST 2 ('PASS')
32 COMPARE_OP 88 (bool(==))
36 POP_JUMP_IF_FALSE 2 (to L3)
8 40 JUMP_BACKWARD 19 (to L1)
9 L3: 44 LOAD_GLOBAL 1 (print + NULL)
54 LOAD_FAST 1 (item)
56 LOAD_ATTR 3 (lower + NULL|self)
76 CALL 0
84 CALL 1
92 POP_TOP
94 JUMP_BACKWARD 46 (to L1)
4 L4: 98 END_FOR
100 POP_TOP
10 L5: 102 LOAD_GLOBAL 1 (print + NULL)
112 LOAD_CONST 3 ('exit loop')
114 CALL 1
122 POP_TOP
124 RETURN_CONST 0 (None)
基本的な流れはGET_ITER→FOR_ITER→繰り返し処理→JUMP_BACKWARD→ループ終了時にEND_FORとなります。
GET_ITERはスタックの最上位のコンテナについてイテレータに変換して、それをスタックに積みます。
FOR_ITERはスタックの最上位のイテレータに対して__next__()メソッドを呼び出します。新しい値が生成されたらスタックに積みます。もしイテレータが終端に達した場合はオペコードの分だけジャンプします。今回のケースではL4のラベルまでジャンプになります。
JUMP_BACKWARDはオペコードに指定した分だけ戻ります。今回のケースだとL1のラベルまでジャンプします。
L1: 6 FOR_ITER 44 (to L4)
...
94 JUMP_BACKWARD 46 (to L1)
4 L4: 98 END_FOR
今回の例だとJUMP_BACKWARDの次のオフセットは98、これに対して46 * 2バイト分戻ります。
すなわちジャンプ先のオフセットは6となり、そのラベルはL1になります。
END_FORはループの終了時にクリーンアップするために使用されます。
POP_TOPと同じ挙動をしてスタックの先頭の項目を削除します。
breakについては以下のコードで実現されています。
6 24 POP_TOP
26 JUMP_FORWARD 37 (to L5)
7 L2: 28 LOAD_FAST 1 (item)
...
10 L5: 102 LOAD_GLOBAL 1 (print + NULL)
112 LOAD_CONST 3 ('exit loop')
JUMP_FORWARDはオペランドで指定したぶんだけ増加させてジャンプします。
今回の例ではJUMP_FORWARDの次のオフセットは28, 増分は37 * 2バイト = 74
。
すなわち、オフセット102のL5の箇所まで飛びます。
continueについては以下のコードで実現されています。
L1: 6 FOR_ITER 44 (to L4)
...
8 40 JUMP_BACKWARD 19 (to L1)
9 L3: 44 LOAD_GLOBAL 1 (print + NULL)
JUMP_BACKWARDでL1に戻るようなジャンプをします。
今回のケースではJUMP_BACKWARDの次のオフセットは44、減分は19 * 2バイト = 38
となります。
すなわち、オフセット 48 - 38 = 6
へジャンプします。
for i in rangeでのループのケース
for i in range(len(x))
のようにインデックスでループする例を見てみます。
import dis
def test_for_range(items):
for i in range(len(items)):
item = items[i]
print(item)
dis.dis(test_for_range, show_offsets=True)
出力結果
3 0 RESUME 0
4 2 LOAD_GLOBAL 1 (range + NULL)
12 LOAD_GLOBAL 3 (len + NULL)
22 LOAD_FAST 0 (items)
24 CALL 1
32 CALL 1
40 GET_ITER
L1: 42 FOR_ITER 18 (to L2)
46 STORE_FAST 1 (i)
5 48 LOAD_FAST_LOAD_FAST 1 (items, i)
50 BINARY_SUBSCR
54 STORE_FAST 2 (item)
6 56 LOAD_GLOBAL 5 (print + NULL)
66 LOAD_FAST 2 (item)
68 CALL 1
76 POP_TOP
78 JUMP_BACKWARD 20 (to L1)
4 L2: 82 END_FOR
84 POP_TOP
86 RETURN_CONST 0 (None)
前述のfor~inのケースと違い明らかに処理が増えています。
GET_ITERを実行する前にrangeとlenの呼び出しが増えています。
また、48 LOAD_FAST_LOAD_FAST, BINARY_SUBSCR, STORE_FASTによる添え字からデータの取得の処理が増えています。
while文でのループのケース
while文でループした場合にどのようなバイトコードが作成されるかを確認してみます。
import dis
def test_while(items):
i = 0
while i < len(items):
item = items[i]
print(item)
i += 1
print("end")
dis.dis(test_while, show_offsets=True)
出力結果
3 0 RESUME 0
4 2 LOAD_CONST 1 (0)
4 STORE_FAST 1 (i)
5 6 LOAD_FAST 1 (i)
8 LOAD_GLOBAL 1 (len + NULL)
18 LOAD_FAST 0 (items)
20 CALL 1
28 COMPARE_OP 18 (bool(<))
32 POP_JUMP_IF_FALSE 37 (to L2)
6 L1: 36 LOAD_FAST_LOAD_FAST 1 (items, i)
38 BINARY_SUBSCR
42 STORE_FAST 2 (item)
7 44 LOAD_GLOBAL 3 (print + NULL)
54 LOAD_FAST 2 (item)
56 CALL 1
64 POP_TOP
8 66 LOAD_FAST 1 (i)
68 LOAD_CONST 2 (1)
70 BINARY_OP 13 (+=)
74 STORE_FAST 1 (i)
5 76 LOAD_FAST 1 (i)
78 LOAD_GLOBAL 1 (len + NULL)
88 LOAD_FAST 0 (items)
90 CALL 1
98 COMPARE_OP 18 (bool(<))
102 POP_JUMP_IF_FALSE 2 (to L2)
106 JUMP_BACKWARD 37 (to L1)
9 L2: 110 LOAD_GLOBAL 3 (print + NULL)
120 LOAD_CONST 3 ('end')
122 CALL 1
130 POP_TOP
132 RETURN_CONST 0 (None)
while文ではCOMPARE_OPとPOP_JUMP_IF_FALSEとJUMP_BACKWARDでループを実現していることがわかります。
また、Pythonのコード的にはループの条件は5行目だけで書かれていますが、バイトコードとしてはループ前とループ中の2箇所で生成されていることが確認できます。
enumerateを使用したループ
enumerateを使用してindexと値を同時に取るようなループを試してみます。
import dis
def test_for_enumerate(items):
for i, item in enumerate(items):
print(i, item)
print('end')
dis.dis(test_for_enumerate, show_offsets=True)
出力結果
3 0 RESUME 0
4 2 LOAD_GLOBAL 1 (enumerate + NULL)
12 LOAD_FAST 0 (items)
14 CALL 1
22 GET_ITER
L1: 24 FOR_ITER 16 (to L2)
28 UNPACK_SEQUENCE 2
32 STORE_FAST_STORE_FAST 18 (i, item)
5 34 LOAD_GLOBAL 3 (print + NULL)
44 LOAD_FAST_LOAD_FAST 18 (i, item)
46 CALL 2
54 POP_TOP
56 JUMP_BACKWARD 18 (to L1)
4 L2: 60 END_FOR
62 POP_TOP
6 64 LOAD_GLOBAL 3 (print + NULL)
74 LOAD_CONST 1 ('end')
76 CALL 1
84 POP_TOP
86 RETURN_CONST 0 (None)
GET_ITERの前にCALLでenumerate関数を呼び出しています。
FOR_ITERの直後にUNPACK_SEQUENCEスタックから複数個の値を取得してiとitemとして束縛してます。
例外処理のバイトコード
ここでは基本的な例外処理を含むコードがどのようなバイトコードになるかを確認します。
サンプルコード
import dis
def test_try_catch(f):
try:
print('try...')
except Exception as e:
print('except', e)
finally:
print('finally....')
# offsetを表示する
dis.dis(test_try_catch, show_offsets=True)
出力結果
3 0 RESUME 0
4 2 NOP
5 L1: 4 LOAD_GLOBAL 1 (print + NULL)
14 LOAD_CONST 1 ('try...')
16 CALL 1
24 POP_TOP
9 L2: 26 LOAD_GLOBAL 1 (print + NULL)
36 LOAD_CONST 3 ('finally....')
38 CALL 1
46 POP_TOP
48 RETURN_CONST 0 (None)
-- L3: 50 PUSH_EXC_INFO
6 52 LOAD_GLOBAL 2 (Exception)
62 CHECK_EXC_MATCH
64 POP_JUMP_IF_FALSE 22 (to L7)
68 STORE_FAST 1 (e)
7 L4: 70 LOAD_GLOBAL 1 (print + NULL)
80 LOAD_CONST 2 ('except')
82 LOAD_FAST 1 (e)
84 CALL 2
92 POP_TOP
L5: 94 POP_EXCEPT
96 LOAD_CONST 0 (None)
98 STORE_FAST 1 (e)
100 DELETE_FAST 1 (e)
102 JUMP_BACKWARD_NO_INTERRUPT 39 (to L2)
-- L6: 104 LOAD_CONST 0 (None)
106 STORE_FAST 1 (e)
108 DELETE_FAST 1 (e)
110 RERAISE 1
6 L7: 112 RERAISE 0
-- L8: 114 COPY 3
116 POP_EXCEPT
118 RERAISE 1
L9: 120 PUSH_EXC_INFO
9 122 LOAD_GLOBAL 1 (print + NULL)
132 LOAD_CONST 3 ('finally....')
134 CALL 1
142 POP_TOP
144 RERAISE 0
-- L10: 146 COPY 3
148 POP_EXCEPT
150 RERAISE 1
ExceptionTable:
L1 to L2 -> L3 [0]
L3 to L4 -> L8 [1] lasti
L4 to L5 -> L6 [1] lasti
L5 to L6 -> L9 [0]
L6 to L8 -> L8 [1] lasti
L8 to L9 -> L9 [0]
L9 to L10 -> L10 [1] lasti
ExceptionTable
try/except/finallyを含む関数はコードオブジェクトのco_exceptiontableに、ExceptionTableを構築します。
このテーブルは「開始オフセット(含む)と 終了オフセット(含まない)の範囲で例外が起きたら どのハンドラ(ターゲット)へ飛ぶか」に加えて、スタック深さと push-lastiフラグも記録します。
スタック深(角括弧の数値)はハンドラへ入る直前に、値スタックからいくつポップして整えるかを指示します。
例外が発生した時点でのスタック形状は命令ごとに異なるため、ハンドラ開始時に想定の深さにそろえる必要があります。
push-lasti(lasti と表示されるフラグ)はそのエントリに入るとき、例外が発生した命令オフセット(“lasti”)を値スタックに積むかどうかを指定します。
後続で RERAISE 1 を使うと、この lasti を取り出して “元の発生位置にひも付けた再送出” を行えます(トレースバックの位置保持)。
RERAISE 0 は lasti を使わず 現在位置のまま再送出します。
開始オフセット(含む) | 終了オフセット(含まない) | ターゲット | スタックの深さ | push-lastiフラグ | 説明 |
---|---|---|---|---|---|
L1 | L2 | L3 | 0 | try区で例外が発生した場合、except区に飛ぶ | |
L3 | L4 | L8 | 1 | lasti | exceptの型が異なる場合の例外処理 |
L4 | L5 | L6 | 1 | lasti | except 本体の実行中の例外処理 |
L5 | L6 | L9 | 0 | except の後始末コードの途中の例外処理 | |
L6 | L8 | L8 | 1 | lasti | except 節の中や直後の後始末でさらに例外が発生した場合 |
L8 | L9 | L9 | 0 | 例外処理進行中の場合。(finallyを必ず通すため) | |
L9 | L10 | L10 | 1 | lasti | 例外後のfinally区のコードでエラーが出た時の例外処理 |
co_exceptiontableを解析する。
co_exceptiontableのバイナリ形式から前述のようなExceptionTableを構築しています。
Format of the exception tableに、その詳細が記載されています。
co_exceptiontableに格納されているデータは以下で構成されます。
start (up to 30 bits)
size (up to 30 bits)
target (up to 30 bits)
depth (up to ~8 bits)
lasti (1 bit)
これを元に前述のようなExceptionTableを構築してます。
具体的に解析を行う場合は、Lib/dis.pyの_parse_exception_tableを参考にするのが望ましいでしょう。
サンプルコード
# https://github.com/python/cpython/blob/3.13/Lib/dis.py#L685
def _parse_exception_table(code):
iterator = iter(code.co_exceptiontable)
entries = []
try:
while True:
start = _parse_varint(iterator)*2
length = _parse_varint(iterator)*2
end = start + length
target = _parse_varint(iterator)*2
dl = _parse_varint(iterator)
depth = dl >> 1
lasti = bool(dl&1)
yield start, end, target, depth, lasti
except StopIteration:
return entries
def _parse_varint(iterator):
b = next(iterator)
val = b & 63
while b&64:
val <<= 6
b = next(iterator)
val |= b&63
return val
for start, end, target, depth, lasti in _parse_exception_table(test_try_catch.__code__):
print(f"start:{start} end:{end} target:{target} depth:{depth} lasti:{lasti}")
出力結果
start:4 end:26 target:50 depth:0 lasti:False
start:50 end:70 target:114 depth:1 lasti:True
start:70 end:94 target:104 depth:1 lasti:True
start:94 end:104 target:120 depth:0 lasti:False
start:104 end:114 target:114 depth:1 lasti:True
start:114 end:120 target:120 depth:0 lasti:False
start:120 end:146 target:146 depth:1 lasti:True
例外が発生しない通常経路(try → finally)
下のとおり、try 本体(L1)を実行し、続いて finally 本体(L2)を実行して return None します。例外がなければ ExceptionTable は参照されません(ゼロコスト)。
3 0 RESUME 0
4 2 NOP
5 L1: 4 LOAD_GLOBAL 1 (print + NULL)
14 LOAD_CONST 1 ('try...')
16 CALL 1
24 POP_TOP
9 L2: 26 LOAD_GLOBAL 1 (print + NULL)
36 LOAD_CONST 3 ('finally....')
38 CALL 1
46 POP_TOP
48 RETURN_CONST 0 (None)
except Exception as e: への遷移と処理
L1〜L2 の範囲(オフセット 4 以上 26 未満)で例外が発生すると、ExceptionTable に従い L3 に入ります。以降は except の型判定・束縛・本体・後始末です。
-- L3: 50 PUSH_EXC_INFO
6 52 LOAD_GLOBAL 2 (Exception)
62 CHECK_EXC_MATCH
64 POP_JUMP_IF_FALSE 22 (to L7)
68 STORE_FAST 1 (e)
...
6 L7: 112 RERAISE 0
-
PUSH_EXC_INFOを実行します。「
STACK[-1]
を一時ポップ → 現在の例外(current exception)をプッシュ → さきほどの値を戻す」。これにより、直後の判定が期待する並び(例外本体と型)になります。 -
CHECK_EXC_MATCHを
STACK[-2](例外)
がSTACK[-1](Exception型)
にマッチするか確認します。STACK[-1](Exception型)
をポップしたのちに、マッチの結果の真偽値をスタックに積みます。 - POP_JUMP_IF_FALSEで指定したラベルL7(112)を使用してスタックの真偽値をチェックしてFALSEであれば、L7にジャンプします。
- 一致した場合はSTORE_FASTで例外インスタンスを e に束縛します。
続く L4〜L5 は except 本体とクリーンアップです。
7 L4: 70 LOAD_GLOBAL 1 (print + NULL)
80 LOAD_CONST 2 ('except')
82 LOAD_FAST 1 (e)
84 CALL 2
92 POP_TOP
L5: 94 POP_EXCEPT
96 LOAD_CONST 0 (None)
98 STORE_FAST 1 (e)
100 DELETE_FAST 1 (e)
102 JUMP_BACKWARD_NO_INTERRUPT 39 (to L2)
- LOAD_GLOBAL〜POP_TOPがexcept中の命令となります。
- POP_EXCEPT例外状態を復します。
- LOAD_CONST〜DELETE_FASTを使用してe のクリーンアップします。
- JUMP_BACKWARD_NO_INTERRUPTを使用してfinally本体(L2)へ戻ります
例外経路での finally と再送出
-- L6: 104 LOAD_CONST 0 (None)
106 STORE_FAST 1 (e)
108 DELETE_FAST 1 (e)
110 RERAISE 1
6 L7: 112 RERAISE 0
-- L8: 114 COPY 3
116 POP_EXCEPT
118 RERAISE 1
L9: 120 PUSH_EXC_INFO
9 122 LOAD_GLOBAL 1 (print + NULL)
132 LOAD_CONST 3 ('finally....')
134 CALL 1
142 POP_TOP
144 RERAISE 0
-- L10: 146 COPY 3
148 POP_EXCEPT
150 RERAISE 1
- L6(104–110)
- L4〜L5 の範囲(オフセット 70 以上 94未満)で例外が発生すると、ExceptionTable に従い L6 に入ります。
- LOAD_CONST〜DELETE_FASTで束縛した e を必ずクリーンアップ
- RERAISE 1で例外をlasti(元の発生位置)を使って再送出する
- L7(112)
- except Exception にマッチしなかったケースの経路。
- RERAISE 0でその場の位置で例外を再送出。
- ExceptionTable に従って **L9(finally 実行経路)**へ回され、finally を実行してから外側へ伝播する
- L8(114–118)
- L3→L4(マッチ判定の途中)や L6→L8(後始末の途中)で再度例外が起きたときの巻き戻し経路
- COPY 3 ... 再送出に必要な例外情報を一時複製(この後の後始末で失われないようにする)
- POP_EXCEPT ... ハンドラの入退場状態を復元
- RERAISE 1で例外をlasti(元の発生位置)を使って再送出する
- L3→L4(マッチ判定の途中)や L6→L8(後始末の途中)で再度例外が起きたときの巻き戻し経路
- L9(120–144)
- 例外が進行中でも finally を必ず実行するための専用経路
- PUSH_EXC_INFO 現在の例外を値スタックに挿入
- finallyの中身の実行
- RERAISE 0でその場の位置で例外を再送出。
- L10(146–150)
- L9(例外時 finally)の実行中にさらに例外が起きたときの保険経路。
- COPY 3 ... 再送出に必要な例外情報を一時複製(この後の後始末で失われないようにする)
- POP_EXCEPT ... ハンドラの入退場状態を復元
- RERAISE 1で例外をlasti(元の発生位置)を使って再送出する
- L9(例外時 finally)の実行中にさらに例外が起きたときの保険経路。
内包式のバイトコード
map+lambdaの実装は内包式に置き換えることができます。双方のバイトコードでの比較を行います。
import dis
def test_map(items):
return list(map(lambda item: item * 10, items))
def test_comprehension(items):
return [item * 10 for item in items ]
print('-------- map')
dis.dis(test_map, show_offsets=True)
print('-------- 内包式')
dis.dis(test_comprehension, show_offsets=True)
mapでのバイトコード
-------- map
3 0 RESUME 0
4 2 LOAD_GLOBAL 1 (list + NULL)
12 LOAD_GLOBAL 3 (map + NULL)
22 LOAD_CONST 1 (<code object <lambda> at 0x103ea56f0, file "/work/techblog/python_perfomance/pipenv313/test_byte_code012e1.py", line 4>)
24 MAKE_FUNCTION
26 LOAD_FAST 0 (items)
28 CALL 2
36 CALL 1
44 RETURN_VALUE
Disassembly of <code object <lambda> at 0x103ea56f0, file "/work/techblog/python_perfomance/pipenv313/test_byte_code012e1.py", line 4>:
4 0 RESUME 0
2 LOAD_FAST 0 (item)
4 LOAD_CONST 1 (10)
6 BINARY_OP 5 (*)
10 RETURN_VALUE
- 22: LOAD_CONST は ラムダの「コードオブジェクト」を読み込む命令です。
- 24: MAKE_FUNCTION は、そのコードオブジェクトから 関数オブジェクトを1つ生成します(この時点でデフォルト引数やクロージャは付かない構成です)。本体(item * 10)のバイトコード自体は、すでにラムダ用コードオブジェクトの中にコンパイル済みであり、MAKE_FUNCTION が「中身を作る」わけではありません。
このフレームで見える関数呼び出し(CALL)は次の2回です。
- map(lambda, items) を呼ぶ
- その戻り値(イテレータ)を list(...) に渡す
加えて、実行時には list(...) が map のイテレータを走査するため、入力要素ごとにラムダ関数が1回ずつ呼び出されます(各呼び出しはラムダ側のフレームで RESUME … RETURN_VALUE が実行されます)
内包式でのバイトコード
-------- 内包式
6 0 RESUME 0
7 2 LOAD_FAST 0 (items)
4 GET_ITER
6 LOAD_FAST_AND_CLEAR 1 (item)
8 SWAP 2
L1: 10 BUILD_LIST 0
12 SWAP 2
14 GET_ITER
L2: 16 FOR_ITER 7 (to L3)
20 STORE_FAST_LOAD_FAST 17 (item, item)
22 LOAD_CONST 1 (10)
24 BINARY_OP 5 (*)
28 LIST_APPEND 2
30 JUMP_BACKWARD 9 (to L2)
L3: 34 END_FOR
36 POP_TOP
L4: 38 SWAP 2
40 STORE_FAST 1 (item)
42 RETURN_VALUE
-- L5: 44 SWAP 2
46 POP_TOP
7 48 SWAP 2
50 STORE_FAST 1 (item)
52 RERAISE 0
ExceptionTable:
L1 to L4 -> L5 [2]
内包式を使用した場合、ExceptionTableが作成されます。
これは内包式の本体区間で例外が出た場合にクリーンアップ処理を担当します。
たとえば、関数本体にcountという内包式の中で使用している変数があっても、それに影響を与えないようにする必要があります。
まず、オフセット2〜8ではイテレータ作成と変数退避を行います。
7 2 LOAD_FAST 0 (items)
4 GET_ITER
6 LOAD_FAST_AND_CLEAR 1 (item)
8 SWAP 2
- LOAD_FAST_AND_CLEARとSWAPで内包式の外側にあるitemという名前があれば退避しておきます。
L1の処理では結果リストを作りループを開始します。
L1: 10 BUILD_LIST 0
12 SWAP 2
14 GET_ITER
反復処理の本体は以下になります。
L2: 16 FOR_ITER 7 (to L3)
20 STORE_FAST_LOAD_FAST 17 (item, item)
22 LOAD_CONST 1 (10)
24 BINARY_OP 5 (*)
28 LIST_APPEND 2
30 JUMP_BACKWARD 9 (to L2)
通常のFOR_ITERを使用したループ処理とほぼ同じです。
ループ終了後に後始末を実施して、呼び出し元に値をかえします。
L3: 34 END_FOR
36 POP_TOP
L4: 38 SWAP 2
40 STORE_FAST 1 (item)
42 RETURN_VALUE
反復処理で例外が出た場合、以下の処理を実行して後始末をします。
-- L5: 44 SWAP 2
46 POP_TOP
7 48 SWAP 2
50 STORE_FAST 1 (item)
52 RERAISE 0
ジェネレーターのバイトコード
ジェネレータを使用する関数のバイトコードを確認してみます。
import dis
def inner_func1():
print("start....")
x = yield 1
yield x + 1
def outer_func():
yield from inner_func1()
print('----------- inner_func1')
dis.dis(inner_func1, show_offsets=True)
print('----------- outer_func')
dis.dis(outer_func, show_offsets=True)
print("test...")
it = outer_func()
print("next1...")
print(next(it))
print("next2...")
print(it.send(10))
yieldを使用した関数の場合
inner_func1のバイトコードは以下のようになります。
----------- inner_func1
3 0 RETURN_GENERATOR
2 POP_TOP
L1: 4 RESUME 0
4 6 LOAD_GLOBAL 1 (print + NULL)
16 LOAD_CONST 1 ('start....')
18 CALL 1
26 POP_TOP
5 28 LOAD_CONST 2 (1)
30 YIELD_VALUE 0
32 RESUME 5
34 STORE_FAST 0 (x)
6 36 LOAD_FAST 0 (x)
38 LOAD_CONST 2 (1)
40 BINARY_OP 0 (+)
44 YIELD_VALUE 0
46 RESUME 5
48 POP_TOP
50 RETURN_CONST 0 (None)
-- L2: 52 CALL_INTRINSIC_1 3 (INTRINSIC_STOPITERATION_ERROR)
54 RERAISE 1
ExceptionTable:
L1 to L2 -> L2 [0] lasti
yieldを使用した関数についてはExceptionTableが生成されます。
ExcepetionTableで保護された区間、RESUMEを実行する際はexcept-depth 1となります。
まず初回の再開ポイントまでのバイトコードを確認します。
3 0 RETURN_GENERATOR
2 POP_TOP
L1: 4 RESUME 0
- RETURN_GENERATORは現在のフレームからジェネレータ、コルーチン、または非同期ジェネレータを作成します。RETURN_GENERATORを持つ関数を実行した場合、関数の本体は即時実行されません。返したジェネレータに対してnext()やsend()をした場合に再開点から動きます。
- RESUME 0は、初回の再開点になります。
1回目のyieldのバイトコードを確認します。
5 28 LOAD_CONST 2 (1)
30 YIELD_VALUE 0
32 RESUME 5
34 STORE_FAST 0 (x)
-
YIELD_VALUE 0は
STACK[-1]
を呼び出し側へ返し(yield)、フレームを一時停止します。オペランド0は通常のYIELDであることを表します。 -
RESUME 5
- フレームが再開されたときの最初のマーカーを表します。
- 5の意味は「yield 直後」かつ「except-depth 1」という意味になります。
- 下位2bit 01: yield 直後
- 次bit except-depthのフラグ
-
STORE_FASTで送信値を受け取る
- ジェネレーターにたいしてsend()を実行した場合、
STACK[-1]
に値が格納されているのでそれを取り出してxに格納する - next()の場合はNoneになる
- ジェネレーターにたいしてsend()を実行した場合、
2回目のyieldのバイトコードを確認します。
6 36 LOAD_FAST 0 (x)
38 LOAD_CONST 2 (1)
40 BINARY_OP 0 (+)
44 YIELD_VALUE 0
46 RESUME 5
48 POP_TOP
- YIELD_VALUEとRESUME 5は1回目と同じです。
- 今回はsendで外部からのデータを受け取らないため、POP_TOPで
STACK[-1]
を無視します。
最後に例外処理について確認します。
-- L2: 52 CALL_INTRINSIC_1 3 (INTRINSIC_STOPITERATION_ERROR)
54 RERAISE 1
- L1〜L2 の範囲で例外が発生したら実行されます。
- CALL_INTRINSIC_1でINTRINSIC_STOPITERATION_ERRORを実行します。StopIterationからvalueを抽出してスタックに積みます
- RERAISE 1で例外をlasti(元の発生位置)を使って再送出する
yield fromを使用した関数の場合
outer_funcのバイトコードは以下のようになります。
----------- outer_func
8 0 RETURN_GENERATOR
2 POP_TOP
L1: 4 RESUME 0
9 6 LOAD_GLOBAL 1 (inner_func1 + NULL)
16 CALL 0
24 GET_YIELD_FROM_ITER
26 LOAD_CONST 0 (None)
L2: 28 SEND 3 (to L5)
L3: 32 YIELD_VALUE 1
L4: 34 RESUME 2
36 JUMP_BACKWARD_NO_INTERRUPT 5 (to L2)
L5: 38 END_SEND
40 POP_TOP
42 RETURN_CONST 0 (None)
L6: 44 CLEANUP_THROW
L7: 46 JUMP_BACKWARD_NO_INTERRUPT 5 (to L5)
-- L8: 48 CALL_INTRINSIC_1 3 (INTRINSIC_STOPITERATION_ERROR)
50 RERAISE 1
ExceptionTable:
L1 to L3 -> L8 [0] lasti
L3 to L4 -> L6 [2]
L4 to L7 -> L8 [0] lasti
オフセット6からのLOAD_GLOBAL、CALL、GET_YIELD_FROM_ITERで委譲先(inner_func1)のイテレータを作成します。
委譲先イテレータのループ処理は以下のように行います。
26 LOAD_CONST 0 (None)
L2: 28 SEND 3 (to L5)
L3: 32 YIELD_VALUE 1
L4: 34 RESUME 2
36 JUMP_BACKWARD_NO_INTERRUPT 5 (to L2)
...
-
SENDで委譲先に
STACK[-1]
送る(初回は0)- 委譲先が終了(StopIteration)したら、引数のラベル
L5 END_SEND
にジャンプする - 委譲先が値を返したら
YIELD_VALUE 1
へ
- 委譲先が終了(StopIteration)したら、引数のラベル
-
YIELD_VALUE 1は
STACK[-1]
を呼び出し側へ返し(yield)、フレームを一時停止します。オペランド1は通常のyield fromであることを表します。 - RESUME 2はyield fromの直後の再開ポイントであることを表します。
- JUMP_BACKWARD_NO_INTERRUPTでL2に戻ります。
ループが終了したら以下のようにクリーンアップをして呼び出し元に帰ります。
L5: 38 END_SEND
40 POP_TOP
42 RETURN_CONST 0 (None)
最後にExceptionTableを確認します。
開始オフセット(含む) | 終了オフセット(含まない) | ターゲット | スタックの深さ | push-lastiフラグ | 説明 |
---|---|---|---|---|---|
L1 | L3 | L8 | 0 | lasti | 委譲前に例外が発生した場合はL8へジャンプする |
L3 | L4 | L6 | 2 | 委譲先での例外が発生した場合はL6へジャンプする | |
L4 | L7 | L8 | 0 | lasti | RESUME 直後のループ復帰、END_SEND やクリーンアップ経路で例外が出たらL8へジャンプする |
以下が例外の先になります
L6: 44 CLEANUP_THROW
L7: 46 JUMP_BACKWARD_NO_INTERRUPT 5 (to L5)
-- L8: 48 CALL_INTRINSIC_1 3 (INTRINSIC_STOPITERATION_ERROR)
50 RERAISE 1
-
L6
- CLEANUP_THROWで委譲先の後始末を行う
- JUMP_BACKWARD_NO_INTERRUPTでL5に合流する
-
L8
- CALL_INTRINSIC_1でINTRINSIC_STOPITERATION_ERRORを実行します。StopIterationからvalueを抽出してスタックに積みます
- RERAISE 1で例外をlasti(元の発生位置)を使って再送出します。
非同期関数のバイトコード
async, awaitを使用した場合のバイトコードについて確認します。
import dis
import asyncio
async def test_async():
await asyncio.sleep(10)
return 10
dis.dis(test_async, show_offsets=True)
出力結果
4 0 RETURN_GENERATOR
2 POP_TOP
L1: 4 RESUME 0
5 6 LOAD_GLOBAL 0 (asyncio)
16 LOAD_ATTR 2 (sleep)
36 PUSH_NULL
38 LOAD_CONST 1 (10)
40 CALL 1
48 GET_AWAITABLE 0
50 LOAD_CONST 0 (None)
L2: 52 SEND 3 (to L5)
L3: 56 YIELD_VALUE 1
L4: 58 RESUME 3
60 JUMP_BACKWARD_NO_INTERRUPT 5 (to L2)
L5: 62 END_SEND
64 POP_TOP
6 66 RETURN_CONST 1 (10)
5 L6: 68 CLEANUP_THROW
L7: 70 JUMP_BACKWARD_NO_INTERRUPT 5 (to L5)
-- L8: 72 CALL_INTRINSIC_1 3 (INTRINSIC_STOPITERATION_ERROR)
74 RERAISE 1
ExceptionTable:
L1 to L3 -> L8 [0] lasti
L3 to L4 -> L6 [2]
L4 to L7 -> L8 [0] lasti
まず最初のバイトコードを確認します。非同期関数のバイトコードはジェネレータを使用する関数と同じであることがわかります。
4 0 RETURN_GENERATOR
2 POP_TOP
L1: 4 RESUME 0
- RETURN_GENERATORは現在のフレームからジェネレータ、コルーチン、または非同期ジェネレータを作成します。RETURN_GENERATORを持つ関数を実行した場合、関数の本体は即時実行されません。今回のケースではawaitやasyncio.runなどで実行する必要などがあります。
- RESUME 0は、再開点になります。
次にawait asyncio.sleep()
について確認します。
5 6 LOAD_GLOBAL 0 (asyncio)
16 LOAD_ATTR 2 (sleep)
36 PUSH_NULL
38 LOAD_CONST 1 (10)
40 CALL 1
48 GET_AWAITABLE 0
- LOAD_GLOBAL〜CALLで
asyncio.sleep(10)
を呼び出します。 - この返却値をGET_AWAITABLEでawait 可能(コルーチン/await 実装) であることを確認し、待機ループで使える形(イテレータ)に正規化します
待機のためのループは以下の通りです。yield-fromのバイトコードに類似していることが確認できます。
50 LOAD_CONST 0 (None)
L2: 52 SEND 3 (to L5)
L3: 56 YIELD_VALUE 1
L4: 58 RESUME 3
60 JUMP_BACKWARD_NO_INTERRUPT 5 (to L2)
-
SENDで委譲先に
STACK[-1]
送る(初回は0)- 委譲先が終了(StopIteration)したら、引数のラベル
L5 END_SEND
にジャンプする - 委譲先が値を返したら
YIELD_VALUE 1
へ
- 委譲先が終了(StopIteration)したら、引数のラベル
-
YIELD_VALUE 1は
STACK[-1]
を呼び出し側へ返し(yield)、フレームを一時停止します。オペランド1は通常のyield fromであることを表します。 - RESUME 3はawait直後の再開ポイントであることを表します。
- JUMP_BACKWARD_NO_INTERRUPTでL2に戻ります。
例外処理や、待機終了後のクリーンアップ処理もyield-fromに類似していることが確認できます。
大量の変数を宣言したケース
大量に変数や定数を持つ時にオペランドは1バイトで表現できないケースがあります。
この例を確認します.
import dis
# 引数を 257 個もつ関数を動的生成(a0, a1, ..., a256)
n = 257
args = ", ".join(f"a{i}" for i in range(n))
src = f"def f({args}):\n return a{n-1}\n"
ns = {}
exec(src, ns)
f = ns["f"]
dis.dis(f, show_offsets=True)
出力
1 0 RESUME 0
2 2 EXTENDED_ARG 1
4 LOAD_FAST_CHECK 256 (a256)
6 RETURN_VALUE
EXTENDED_ARGを使用することで直後のオペコードのオペランドを1バイトを超えて拡張できます。
今回はco_varnames[256]
にアクセスするために使用されています。
参考
- CPython Internals: Your Guide to the Python 3 Interpreter
- Python 3.11の新機能(その2) 特殊化適応的インタープリタ
- Adding or extending a family of adaptive instructions.
- 16-bit countdown counters using exponential backoff.
- PEP 3147 – PYC Repository Directories
- What Is the __pycache__ Folder in Python?
- PEP 552 – Deterministic pycs
- Description of exception handling
Discussion