Bỏ qua để đến nội dung

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 dis và 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 += 1 khô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
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ó.

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) # 2
print(co.co_stacksize) # 2 độ sâu ngăn xếp tối đa cần dùng
print(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.

import dis
dis.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+b
LOAD_FAST c stack: [c]
RETURN_VALUE trả về đỉnh stack

Cộ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.

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 dis
dis.dis("x = 24 * 60 * 60")
# LOAD_CONST (86400) <- đã được tính sẵn!
# STORE_NAME x

Trì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)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 = 0
def inc():
global counter
counter += 1
dis.dis(inc)
LOAD_GLOBAL counter (1) đọc giá trị
LOAD_CONST 1
BINARY_OP 13 (+=) (2) tính
STORE_GLOBAL counter (3) ghi lại

Mộ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ại

Tươ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.

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.append
for 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.094s

Trê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 timeit trê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.

code = compile("a + b", "<expr>", "eval") # chế độ: 'exec', 'eval', 'single'
print(eval(code, {"a": 1, "b": 2})) # 3
import ast
tree = ast.parse("x = 1 + 2")
print(ast.dump(tree, indent=2)) # xem cây cú pháp

ast 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 ast
print(ast.literal_eval("{'a': [1, 2, (3, 4)]}")) # {'a': [1, 2, (3, 4)]}
# ast.literal_eval("__import__('os').system('rm -rf /')") -> ValueError

10. 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)s%(lineno)d được lấy bằng cách đi ngược frame tới nơi gọi logger.info().
  • Debugger (pdb, debugger của IDE) và profiler (sys.setprofile, sys.monitoring từ 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 assert bị 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ể.

  1. Dùng dis.dis so sánh bytecode của [x * 2 for x in data]list(map(lambda x: x * 2, data)). Đo thời gian bằng timeit và giải thích.
  2. 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.
  3. Dùng ast.parseast.walk viết công cụ nhỏ liệt kê mọi lời gọi print(...) trong một file Python kèm số dòng.

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.