Bytecode và cách CPython thực thi code: dis, code object, specializing interpreter
“Python là ngôn ngữ thông dịch” - câu này chỉ đúng một nửa. CPython biên dịch mã nguồn thành bytecode, sau đó một vòng lặp C khổng lồ (ceval.c) thông dịch bytecode đó. Đọc được bytecode giúp bạn hiểu chính xác Python làm gì với từng dòng code - rất hữu ích khi tối ưu hiệu năng và khi viết code đa luồng.
Trong bài này, bạn sẽ học:
- Hành trình từ mã nguồn tới bytecode và vai trò của
__pycache__ - Code object chứa gì và vì sao biến cục bộ được xác định lúc biên dịch
- Đọc bytecode bằng module
disvà hiểu máy ảo ngăn xếp - Vì sao biến cục bộ nhanh hơn biến toàn cục
- Constant folding và vì sao
x += 1không nguyên tử - Specializing adaptive interpreter (3.11+) và những mẹo tối ưu đã lỗi thời
- Frame object - cơ sở của traceback, debugger và logging
1. Từ mã nguồn tới kết quả
Phần tiêu đề “1. Từ mã nguồn tới kết quả”source.py ──► tokenize ──► parse (AST) ──► compile ──► code object (bytecode) │ ▼ máy ảo ngăn xếp (ceval loop)- Bytecode được cache trong thư mục
__pycache__/*.pycđể lần import sau không phải biên dịch lại. - Script chạy trực tiếp (
python main.py) thì không được cache, chỉ các module được import mới có.
2. Code object
Phần tiêu đề “2. Code object”Mỗi hàm có một thuộc tính __code__ chứa kết quả biên dịch:
def add(a, b): c = a + b return c
co = add.__code__print(co.co_varnames) # ('a', 'b', 'c') tên biến cục bộprint(co.co_consts) # (None,) các hằng sốprint(co.co_argcount) # 2print(co.co_stacksize) # 2 độ sâu ngăn xếp tối đa cần dùngprint(co.co_code) # b'...' bytecode thôĐiều thú vị: danh sách biến cục bộ được xác định lúc biên dịch. Đó là lý do lỗi UnboundLocalError xảy ra (xem bài này): chỉ cần có phép gán x = ... ở bất kỳ đâu trong hàm là x được coi là biến cục bộ ở toàn bộ hàm.
3. Đọc bytecode bằng dis
Phần tiêu đề “3. Đọc bytecode bằng dis”import disdis.dis(add) 2 RESUME 0
3 LOAD_FAST_LOAD_FAST 1 (a, b) BINARY_OP 0 (+) STORE_FAST 2 (c)
4 LOAD_FAST 2 (c) RETURN_VALUE(Kết quả trên CPython 3.13. Tên lệnh thay đổi giữa các phiên bản - ví dụ 3.14 dùng LOAD_FAST_BORROW - nhưng ý tưởng giữ nguyên.)
CPython là máy ảo ngăn xếp (stack machine):
LOAD_FAST_LOAD_FAST (a, b) stack: [a, b]BINARY_OP (+) stack: [a+b] lấy 2 phần tử, đẩy kết quảSTORE_FAST c stack: [] c = a+bLOAD_FAST c stack: [c]RETURN_VALUE trả về đỉnh stackCột bên trái là số dòng trong mã nguồn - cho biết mỗi dòng Python sinh ra những lệnh nào.
4. Biến cục bộ, toàn cục và built-in
Phần tiêu đề “4. Biến cục bộ, toàn cục và built-in”Có ba nhóm lệnh truy cập biến:
| Lệnh | Dùng cho | Cách tìm |
|---|---|---|
LOAD_FAST |
biến cục bộ | đọc mảng theo chỉ số - cực nhanh |
LOAD_GLOBAL |
biến toàn cục, built-in | tra dict globals(), rồi dict builtins |
LOAD_ATTR |
obj.attr |
tra theo MRO, descriptor, __dict__… |
LOAD_DEREF |
biến của closure | qua “cell” object |
Biến cục bộ không nằm trong dict mà nằm trong một mảng cố định của frame, truy cập bằng chỉ số tính sẵn lúc biên dịch. Vì vậy code đặt trong hàm thường chạy nhanh hơn code ở cấp module (nơi mọi biến đều là global). Mẹo đơn giản: luôn đặt code chính trong hàm main().
5. Constant folding: tính trước lúc biên dịch
Phần tiêu đề “5. Constant folding: tính trước lúc biên dịch”import disdis.dis("x = 24 * 60 * 60")# LOAD_CONST (86400) <- đã được tính sẵn!# STORE_NAME xTrình biên dịch tự tính các biểu thức hằng. Vì vậy bạn cứ thoải mái viết TIMEOUT = 24 * 60 * 60 cho dễ đọc - không tốn gì lúc chạy. Tương tự, x in [1, 2, 3] được đổi thành x in (1, 2, 3) và x in {1, 2, 3} thành frozenset hằng.
6. x += 1 KHÔNG phải một thao tác nguyên tử
Phần tiêu đề “6. x += 1 KHÔNG phải một thao tác nguyên tử”import dis
counter = 0def inc(): global counter counter += 1
dis.dis(inc)LOAD_GLOBAL counter (1) đọc giá trịLOAD_CONST 1BINARY_OP 13 (+=) (2) tínhSTORE_GLOBAL counter (3) ghi lạiMột dòng Python thành nhiều lệnh bytecode. Interpreter có thể chuyển sang thread khác giữa bất kỳ hai lệnh nào. Nếu hai thread cùng đọc counter = 5 rồi cùng ghi 6, một lần tăng bị mất. GIL không bảo vệ bạn khỏi chuyện này - xem chi tiết ở bài GIL và Threading.
7. Specializing adaptive interpreter (Python 3.11+)
Phần tiêu đề “7. Specializing adaptive interpreter (Python 3.11+)”Từ 3.11 (PEP 659), interpreter quan sát kiểu dữ liệu thực tế lúc chạy và thay lệnh tổng quát bằng lệnh chuyên biệt nhanh hơn. Đây là lý do chính khiến Python 3.11 nhanh hơn 3.10 khoảng 25%.
import dis
def add(a, b): c = a + b return c
for _ in range(100): add(1, 2) # "làm nóng" với int
dis.dis(add, adaptive=True)# BINARY_OP_ADD_INT (+) <- chuyên biệt cho int
for _ in range(100): add("a", "b") # đổi sang str
dis.dis(add, adaptive=True)# BINARY_OP_ADD_UNICODE (+) <- tự chuyên biệt lạiTương tự có LOAD_ATTR_INSTANCE_VALUE, LOAD_GLOBAL_MODULE, CALL_PY_EXACT_ARGS… Mỗi lệnh chuyên biệt kiểm tra nhanh một điều kiện (“vẫn là int chứ?”), nếu sai thì quay về bản tổng quát.
Hệ quả thực tế: code có kiểu dữ liệu ổn định (một biến luôn là int, một thuộc tính luôn cùng kiểu) chạy nhanh hơn code “đa hình” lung tung.
8. Các mẹo tối ưu cũ có còn đúng?
Phần tiêu đề “8. Các mẹo tối ưu cũ có còn đúng?”Nhiều mẹo tối ưu cổ điển đã lỗi thời nhờ specializing interpreter. Ví dụ “cache phương thức vào biến cục bộ”:
import timeit
print(timeit.timeit("""out = []for i in range(1000): out.append(i)""", number=5000)) # 0.122s
print(timeit.timeit("""out = []; ap = out.appendfor i in range(1000): ap(i)""", number=5000)) # 0.134s <- còn CHẬM hơn!
print(timeit.timeit("[i for i in range(1000)]", number=5000)) # 0.094sTrên CPython 3.13, mẹo ap = out.append không còn giúp gì. Bài học:
- Đừng tối ưu theo “mẹo” đọc trên mạng - hãy đo bằng
timeittrên đúng phiên bản Python bạn dùng. - Comprehension, hàm built-in (
sum,map,sorted,str.join) vẫn là lựa chọn nhanh vì vòng lặp chạy trong C. - Thay đổi thuật toán/cấu trúc dữ liệu (list → set) luôn đem lại nhiều hơn micro-optimization.
Xem thêm bài Đo và tối ưu hiệu năng.
9. Tự biên dịch và thực thi code
Phần tiêu đề “9. Tự biên dịch và thực thi code”code = compile("a + b", "<expr>", "eval") # chế độ: 'exec', 'eval', 'single'print(eval(code, {"a": 1, "b": 2})) # 3
import asttree = ast.parse("x = 1 + 2")print(ast.dump(tree, indent=2)) # xem cây cú phápast là nền tảng của các công cụ như linter (Ruff, Pylint), formatter, và ast.literal_eval - cách an toàn để parse chuỗi chứa literal Python (khác với eval, không thực thi code tuỳ ý):
import astprint(ast.literal_eval("{'a': [1, 2, (3, 4)]}")) # {'a': [1, 2, (3, 4)]}# ast.literal_eval("__import__('os').system('rm -rf /')") -> ValueError10. Frame object: ngữ cảnh thực thi của mỗi lời gọi hàm
Phần tiêu đề “10. Frame object: ngữ cảnh thực thi của mỗi lời gọi hàm”Mỗi lần một hàm được gọi, CPython tạo một frame chứa: code object đang chạy, mảng biến cục bộ, ngăn xếp giá trị, vị trí lệnh hiện tại và con trỏ tới frame của hàm gọi nó (f_back). Chuỗi các frame chính là call stack.
import sys
def outer(): x = 42 inner()
def inner(): caller = sys._getframe(1) # frame của hàm đã gọi inner() print(caller.f_code.co_name, caller.f_lineno, caller.f_locals)
outer() # outer 5 {'x': 42}Frame là nền tảng của rất nhiều công cụ bạn dùng hằng ngày:
- Traceback: khi có exception, Python ghi lại chuỗi frame (
e.__traceback__.tb_frame) để in ra “File …, line …, in …”. logging: định dạng%(funcName)svà%(lineno)dđược lấy bằng cách đi ngược frame tới nơi gọilogger.info().- Debugger (
pdb, debugger của IDE) và profiler (sys.setprofile,sys.monitoringtừ 3.12) đọc frame để biết chương trình đang ở đâu. - pytest đọc frame và AST để in chi tiết giá trị trong câu
assertbị sai.
Một hàm nhỏ tự viết để in vị trí gọi:
import inspect
def where(): f = inspect.currentframe().f_back return f"{f.f_code.co_filename}:{f.f_lineno} trong {f.f_code.co_name}()"
def work(): print(where()) # .../main.py:8 trong work()
work()Từ Python 3.11, frame được tạo “lười”: CPython dùng một cấu trúc nội bộ nhẹ hơn cho mỗi lời gọi và chỉ tạo object frame đầy đủ khi có ai đó yêu cầu (traceback, sys._getframe). Đây là một phần lý do lời gọi hàm trong 3.11+ rẻ hơn đáng kể.
Bài tập
Phần tiêu đề “Bài tập”- Dùng
dis.disso sánh bytecode của[x * 2 for x in data]vàlist(map(lambda x: x * 2, data)). Đo thời gian bằngtimeitvà giải thích. - Viết hàm có một biến global được đọc trong vòng lặp 10 triệu lần, đo thời gian; sau đó chuyển thành biến cục bộ và đo lại trên phiên bản Python bạn đang dùng.
- Dùng
ast.parsevàast.walkviết công cụ nhỏ liệt kê mọi lời gọiprint(...)trong một file Python kèm số dòng.
Kết luận
Phần tiêu đề “Kết luận”Bạn đã đi qua những kiến thức cốt lõi của bài này:
- CPython biên dịch code thành bytecode (cache ở
__pycache__) rồi chạy trên máy ảo ngăn xếp. dis.dis()cho bạn thấy chính xác mỗi dòng làm gì.- Biến cục bộ (
LOAD_FAST) nhanh hơn global → đặt code trong hàm. - Biểu thức hằng được tính lúc biên dịch.
- Một dòng Python = nhiều lệnh bytecode → không nguyên tử khi đa luồng.
- Từ 3.11, interpreter tự chuyên biệt hoá theo kiểu dữ liệu; nhiều mẹo tối ưu cũ không còn giá trị.
Bài tiếp theo: GIL và Threading.