有个问题我被问过很多次:"Python 为什么慢?"
我以前的回答一直是:"因为它是解释型语言,运行时才翻译,不像 C 那样编译成机器码。"
这个答案没错,但很空洞。直到我第一次用 dis 模块把一段 Python 代码翻开来看,才意识到"解释型语言"这四个字背后到底藏着什么。
dis 是 Python 标准库里自带的字节码反汇编工具。Python 源代码在运行之前会先被编译成字节码,再交给 CPython 虚拟机执行——dis 能让你直接看这个字节码长什么样。
用法很简单:
import dis
def add(a, b):
return a + b
dis.dis(add)
输出(Python 3.12):
2 RESUME 0
3 LOAD_FAST 0 (a)
LOAD_FAST 1 (b)
BINARY_OP 0 (+)
RETURN_VALUE
每一行就是一条虚拟机指令。LOAD_FAST 是从局部变量加载值,BINARY_OP 是执行加法,RETURN_VALUE 是返回。
这段代码很简单,指令也很少。但事情在复杂一点的场景下就开始有意思了。
有一次我在优化一段数据处理代码,发现一个循环明显比预期慢,于是把两种写法拿出来对比。
第一种,每次循环都做属性访问:
import dis
def sum_with_attr(data):
result = 0
for x in data:
result += x
return result
dis.dis(sum_with_attr)
第二种,先把 append 这类方法缓存到局部变量:
def collect_items(data):
result = []
append = result.append # 缓存方法引用
for x in data:
append(x)
return result
dis.dis(collect_items)
我把 collect_items 和不缓存的版本对比了一下字节码数量,发现缓存版本每次循环少了 LOAD_FAST(加载 result)和 LOAD_METHOD(查找 append 方法)两条指令。循环跑一百万次,这个差距就很可观了。
实际测一下:
import timeit
data = list(range(100000))
def collect_no_cache():
result = []
for x in data:
result.append(x)
return result
def collect_with_cache():
result = []
append = result.append
for x in data:
append(x)
return result
t1 = timeit.timeit(collect_no_cache, number=100)
t2 = timeit.timeit(collect_with_cache, number=100)
print(f"不缓存: {t1:.3f}s")
print(f"缓存 append: {t2:.3f}s")
print(f"提速比: {t1/t2:.2f}x")
输出:
不缓存: 0.823s
缓存 append: 0.612s
提速比: 1.34x
快了 34%,只是因为少了一次属性查找。
用 dis 能看到的东西不只是循环优化。
有一次我看到有人说"全局变量比局部变量慢",我一直没搞清楚为什么,直到我用 dis 对比了两者的字节码:
import dis
G = 42
def use_global():
return G
def use_local():
x = 42
return x
print("=== use_global ===")
dis.dis(use_global)
print("=== use_local ===")
dis.dis(use_local)
输出:
=== use_global ===
2 RESUME 0
3 LOAD_GLOBAL 0 (G)
RETURN_VALUE
=== use_local ===
4 RESUME 0
5 LOAD_CONST 1 (42)
STORE_FAST 0 (x)
6 LOAD_FAST 0 (x)
RETURN_VALUE
LOAD_GLOBAL 和 LOAD_FAST 是不同的指令。全局变量需要在全局命名空间里做字典查找,而局部变量直接通过索引访问一个数组,快得多。
这就解释了为什么在热点函数里,把常用的全局变量或模块属性赋给局部变量是一个常见的优化手段:
import math
# 慢:每次循环都要 LOAD_GLOBAL math,再 LOAD_ATTR sin
def calc_slow(data):
return [math.sin(x) for x in data]
# 快:把 math.sin 缓存到局部变量
def calc_fast(data):
_sin = math.sin
return [_sin(x) for x in data]
dis 还有一个用法我特别喜欢:看列表推导式和 map 到底有什么区别。
import dis
def with_list_comp(data):
return [x * 2 for x in data]
def with_map(data):
return list(map(lambda x: x * 2, data))
print("=== list comprehension ===")
dis.dis(with_list_comp)
print("=== map + lambda ===")
dis.dis(with_map)
输出(精简版):
=== list comprehension ===
2 RESUME 0
GET_ITER
...(listcomp 内联到函数里)
=== map + lambda ===
5 RESUME 0
LOAD_GLOBAL 1 (list)
LOAD_GLOBAL 3 (map)
LOAD_CONST 1 (<code object <lambda>>)
MAKE_FUNCTION 0
LOAD_FAST 0 (data)
CALL 2
CALL 1
RETURN_VALUE
map + lambda 的字节码涉及到创建函数对象(MAKE_FUNCTION)和两次函数调用(CALL),开销比列表推导式更大。这就是为什么大多数场景下列表推导式比 map(lambda ...) 更快的底层原因。
当然,dis 不是银弹。
它给你看的是 CPython 字节码,不是机器码。字节码指令的数量不完全等于运行时间,因为每条指令的实际开销差异很大——CALL 比 LOAD_FAST 重得多。
而且 Python 版本不同,字节码格式也在变。Python 3.11 和 3.12 对解释器做了大幅度重构,很多指令被合并或优化,生成的字节码和老版本不一样。所以用 dis 看到的结果要结合实际 benchmark 来判断,不能光数指令数。
但即便如此,dis 作为一个"翻开引擎盖看看"的工具,依然非常值得用。
我用 dis 最大的收获不是某个具体的优化技巧,而是一种理解方式的转变:
写代码的时候,不再把"这行代码"当成一个原子操作,而是会多想一步:这背后大概会产生几条指令?有没有不必要的查找?有没有重复的调用?
这个意识养成之后,很多"直觉"就有了根据,而不是靠模糊的经验猜猜猜。
如果你对 Python 底层感兴趣,可以试着把自己常写的几种模式都用 dis.dis() 看一遍——有时候结果会让你很意外。
最后,别忘了关注「有为大青年」,我们下期见~