Python 的函数调用到底慢在哪?拆开 PyFrameObject、字节码 CALL 与查找链
我之前写性能敏感的循环,习惯把函数调用尽量提出去、能内联就内联。但一直说不清「一次 Python 函数调用到底花在哪」。今天用 CPython 3.13.14 的 timeit 把一次空函数调用拆到纳秒级,结论和我直觉差得挺远。
本机:CPython 3.13.14 (64-bit),所有数字来自
timeit(各 500 万次取均值)。
一、一次调用背后,字节码干了什么
先看一段最朴素的函数调用编译成了什么:
def f(x):
return x
# 调用处相当于:
# LOAD_GLOBAL (f) ← 去全局命名空间找函数对象
# LOAD_FAST (x) ← 取参数
# CALL ← 关键指令
# STORE_FAST ← 接返回值
CALL 指令真正做的事比想象的重:
- 在堆上新建一个
PyFrameObject(帧对象),分配局部变量数组; - 按调用约定把实参绑定到帧的局部变量槽;
- 把执行流切到函数的字节码;
- 函数返回后销毁这个帧、把返回值压回调用栈。
第 1 步和第 4 步的「建帧 + 拆帧」,才是开销的大头,不是函数体本身(本例函数体是空的)。
二、本机实测(纳秒级)
| 操作 | 每次耗时 |
|---|---|
内联常量 1 |
12.4 ns |
读局部变量 LOAD_FAST |
15.9 ns |
内联加法 a + b |
26.4 ns |
空函数调用 f()(走 LOAD_GLOBAL) |
43.4 ns |
空函数调用 f()(局部化后) |
45.1 ns |
方法调用 obj.m() |
47.0 ns |
再把「调用」和「内联同一段函数体」放一起比:调用版本比内联版本额外多花约 17%。也就是说,一次空调用大约 45ns,其中真正属于「调用机制」的,就是建帧、拆帧加名字查找这几样。
三、成本拆成三块
1. 帧创建(PyFrameObject 堆分配 + 初始化)——大头。 CPython 3.11 起帧被拆成「解释器栈帧 + 更轻的 PyFrameObject」,比老版本轻了不少,但「每次调用都要 new 一个」这件事没变。
2. 名字查找链。 看实测:走 LOAD_GLOBAL(全局字典里查名字)比 LOAD_FAST(直接取局部槽)慢一截;方法调用 obj.m() 比普通函数调用再慢一点,因为它还要走描述器协议(instance → type → descriptor.__get__)。差异很小(45ns vs 47ns),但原理上多了一层查找。
3. 参数绑定 + 返回值搬运。 参数越多、对象越大,这部分越明显,但相对前两项仍是小头。
四、那它比 C 慢多少?
一次 Python 函数调用 ~45ns;一条原生加法机器指令 ~1ns 量级。所以一次调用大约比一条机器指令慢几十倍——但这不代表「Python 一定慢」。Py3.11 的 specializing adaptive interpreter、Py3.13 持续在压缩这个差距;而且绝大多数业务瓶颈根本不在「调用本身」,而在「调用次数 × 每次做的事」。
五、实用结论(排错清单)
- 热点循环不要逐元素回调:
for x in data: result.append(heavy(x))不如把循环移进heavy内部,或者改用map/ 列表推导 /numpy向量化,减少调用次数。 - 反复调用的全局名,先绑到局部:
local_f = globals_f后再循环用,省掉每次LOAD_GLOBAL的字典查找。 - 方法调用比函数调用略慢但差极小,别为这点差异去魔改代码,可读性优先。
- 优先用内置(
sum、sorted、推导式)替代手写循环 + 调用,内置走 C 路径,少建帧。
我的个人推断:真瓶颈几乎从不在「调用」这个动作,而在「调用次数 × 每次干的事」。上来先 cProfile 一把,别凭感觉猜——我见过把函数内联了半天,最后发现慢在里面的一个 re.compile 每次重建。

浙公网安备 33010602011771号