写 Python 的人常被教育「函数调用有开销,热点路径要内联」。可这个开销到底多大、由什么构成?本文用 CPython 3.13.14 实测,把一次函数调用拆到底。
一、结论先行
一次空函数调用实测 97.8 ns ------减去空 for 循环体本身的 47.0 ns,调用机制本身约占 51 ns 。这个数字决定了后面所有的取舍:日常业务代码里,一次调用成本可以忽略;百万次级热点循环里,它值得算账。
二、一次调用的字节码:CALL 背后发生了什么
调用方的字节码简单得惊人:
objectivec
LOAD_GLOBAL 1 (callee + NULL)
LOAD_CONST 1 (41)
CALL 1
RETURN_VALUE
但 CALL 指令在解释器内部要做一串事:创建栈帧对象、绑定参数到局部变量 、压入调用栈、执行函数体、弹出栈帧。3.11 起帧对象改为惰性 materialize(RETURN_VALUE 后不再物化),调用才便宜下来。
实测空调用 97.8 ns 里,主要就是这套帧生命周期。
三、参数形式:*args 最贵,默认参数反而便宜
同一函数体、不同参数签名,1M 次调用实测:
| 签名 | ns/次 | 相对 f0() |
|---|---|---|
f0() |
141.2 | 基准 |
f1(a) |
148.5 | +5% |
f3(a, b, c) |
154.2 | +9% |
fd(a, b=1, c=2) |
156.2 | +11% |
fs(a, *args) |
178.9 | +27% |
fk(a, **kw) |
162.7 | +15% |
规律很直白:*args 要把变长参数打包成 tuple,**kw 要建 dict,都比位置参数贵 。默认参数几乎免费------因为默认值在函数定义时 求值一次存进 __defaults__,调用时直接取引用,不是每次重算。
这也解释了经典陷阱:def f(x=[]) 的可变默认参数之所以危险,正是因为那个 list 只创建了一次,跨调用共享。
四、递归的账:fib(30) 的 200 ms
fib(30) 实测 200 ms,产生约 269 万次调用------摊到每次调用约 74 ns(含 fib 内部的两次加法与比较)。纯调用机制占比可见一斑。
递归深度 2000 层无压力,默认 recursionlimit 是 10 万------限制存在的意义是保护 C 栈,不是 Python 层。
五、绑定方法:c.m() 比 C.m(c) 快 40%
同一个方法两种调法:
scss
c.m() 120.4 ns/次
C.m(c) 202.8 ns/次
c.m() 走的是 descriptor 协议预绑定的 method 对象,少一次「把 self 显式塞进参数表」;C.m(c) 是普通函数调用加一次参数绑定。代码里热点路径的 getattr(obj, name)(...) 动态分发,付的就是这条链路。
六、内联的收益上限:48 ns
同一段 1 + 2,函数调用 139.0 ns vs 内联 91.1 ns,差 48 ns。
这就是内联优化的天花板量级。Python 没有 C 意义上的内联展开(3.13 的实验性 JIT 另论),手动内联省的是这 48 ns。百万次循环省 48 ms------值不值,看你的循环在干什么。
七、实战守则
- 热点循环里消灭属性查找和方法调用 :循环内
self.x、obj.method()先提取到局部变量,省的是每次的 dict 查找加绑定 - 拒绝
*args/**kw进热点签名:固定参数位置,比变长快 12-27% - 可观测性用 frame 对象 :
inspect.currentframe()、f_back、f_locals是现成的调试入口,f_back 链条就是调用栈------但别在生产代码里留着 - 默认参数定义时求值 :
def f(ts=time.time())拿到的是导入时刻的时间戳,要「每次求值」请用None哨兵
一句话总结:Python 的函数调用不贵,但也不免费------51 ns 的帧创建成本,乘以百万次循环就是 50 ms。知道账在哪,才知道哪里该省。