Python 函数调用为什么会成为性能热点?从 CALL、Bound Method 到千万级循环优化实战
很多 Python 性能问题,并不是因为某一行代码“特别慢”,而是因为一件很便宜的小事,被做了几千万次。
比如这段代码:
def f(x):
return x + 1
for i in range(10_000_000):
f(i)
单独看:
f(1)
几乎快得让人没有优化欲望。
但当调用次数从 1 次变成:
10
1,000
1,000,000
10,000,000
问题的性质发生了变化。
如果把一次调用抽象成:
总耗时 ≈ 调用次数 × 单次调用成本
那么哪怕一次函数调用只需要非常短的时间,乘上几千万次以后,也足以成为程序的 CPU 热点。
这也是 Python 性能优化中非常重要的一条经验:
不要只盯着“某次操作贵不贵”,还要看“它到底执行了多少次”。
本文就从这个最简单的 f(x) 出发,深入理解 Python 函数调用到底付出了什么成本,并回答几个非常实际的问题:
inline 一个小函数一定更快吗?
普通函数、bound method、callable object 有什么区别?
为什么减少 Python 层循环,往往比调整几个语法细节有效得多?
profiler 显示几千万次小函数调用占据大量 CPU 时,
真正的工程优化顺序应该是什么?
本文重点讨论现代 CPython。需要注意,函数调用、字节码以及解释器优化都属于 CPython 实现层面的内容,不应该理解成 Python 语言规范永久不变的承诺。
一、先别优化:我们到底慢在哪里?
考虑:
def f(x):
return x + 1
total = 0
for i in range(10_000_000):
total += f(i)
很多初学者第一反应是:
x + 1是不是很慢?
其实未必。
真正的问题是:
f(i)
发生了 1000 万次。
我们可以先用 cProfile 看一下。
创建:
# demo.py
def f(x):
return x + 1
def main():
total = 0
for i in range(10_000_000):
total += f(i)
return total
main()
运行:
python -m cProfile -s cumulative demo.py
cProfile 会记录函数调用次数以及各函数累计执行时间,非常适合回答:
CPU 时间究竟花在哪些函数上?
Python 官方将 cProfile 和 profile 定义为 deterministic profiler,它们可以统计程序各部分执行了多少次、花费多少时间。对于绝大多数性能调查,cProfile 都是很好的第一站。(Python documentation)
你很可能看到类似概念上的结果:
ncalls function
10000000 f
1 main
这里最重要的信息甚至不是时间,而是:
10,000,000 calls
如果 f() 本身只有一行代码,那么大量 CPU 时间很可能并不是来自一个复杂算法,而是来自:
Python 层循环
+
函数调用
+
函数体执行
不断重复。
这就是所谓的 hot path(热路径)。
二、一次 Python 函数调用,到底发生了什么?
先看看:
def f(x):
return x + 1
然后:
import dis
dis.dis(f)
在现代 CPython 中,会看到类似:
RESUME
LOAD_FAST
LOAD_CONST
BINARY_OP
RETURN_VALUE
这说明 f() 本身非常简单。
但现在再看调用者:
def run():
return f(10)
执行:
dis.dis(run)
现代 CPython 的输出会出现类似:
LOAD_GLOBAL
LOAD_CONST
CALL
RETURN_VALUE
在 CPython 3.14 中,CALL(argc) 的抽象职责就是:从解释器栈中取得 callable、self 或 NULL 以及参数,然后执行 callable,并把返回值重新压回执行栈。(Python documentation)
因此:
f(10)
并不是简单等价于:
CPU: JMP 到另一段代码
它首先是 Python 对象系统中的一次 call operation。
可以粗略画成:
f(10)
│
↓
找到 callable
│
↓
准备参数
│
↓
确认调用协议
│
↓
建立函数执行状态
│
↓
切换到 f.__code__
│
↓
执行 x + 1
│
↓
RETURN_VALUE
│
↓
恢复调用者执行
每一步都已经被现代 CPython 高度优化,但它们依然不是零成本。
三、函数调用的成本到底来自哪里?
早期理解 Python 函数调用时,人们经常会简单地说:
每调用一次函数,就创建一个 frame。
这个方向没有错,但如果讨论现代 CPython,需要稍微精确一些。
Python 的函数执行需要 frame 保存执行状态,例如:
当前执行的 code object
局部变量
指令位置
执行栈状态
异常相关状态
调用关系
但从 Python 3.11 开始,CPython 对 frame 和 Python-to-Python function call 做了重要优化。
官方 Python 3.11 文档指出,CPython 简化了 frame 创建过程,并尽量复用 C 栈空间;更重要的是,当 Python 函数调用另一个 Python 函数时,解释器可以设置新的 frame 后直接跳到新的 Python 代码执行,而不是再次调用一层 C 语言解释器函数。官方将这项优化称为 Inlined Python function calls。(Python documentation)
这里有一个很容易产生误解的地方:
“Inlined Python function calls”并不等于编译器把
f()的函数体直接复制进调用者。
也就是说:
def f(x):
return x + 1
y = f(i)
并没有自动在源码语义上变成:
y = i + 1
它优化的是解释器内部进入另一个 Python frame 的机制。
因此现代 CPython 的函数调用已经比旧版本便宜很多,但仍然存在:
callable 分派
参数处理
frame 状态准备
局部变量初始化
解释器控制流切换
返回值处理
等开销。
一次看起来没有什么。
一千万次,就不能忽略了。
四、Vectorcall:CPython 已经在努力让调用更便宜
理解现代 Python 函数调用,还绕不开一个关键词:
Vectorcall
传统 C API 中的一种调用方式可以粗略想象成:
位置参数 → tuple
关键字参数 → dict
↓
调用函数
问题是,如果每次简单调用都为了传递参数额外构造 tuple、dict,会产生不必要的对象分配和转换。
因此 CPython 引入了 Vectorcall。
PEP 590 的目标,就是提供一种更高效的 CPython 调用协议。(Python Enhancement Proposals (PEPs))
它的核心思想可以粗略理解为:
args[0]
args[1]
args[2]
...
直接通过一段参数指针数组传递,而不是总要先包装成:
(args_tuple, kwargs_dict)
Python 当前的 C API 文档也明确指出,CPython 支持 tp_call 与 vectorcall 等调用机制,并且 PyObject_Vectorcall() 通常是效率更高的调用入口之一。(Python documentation)
所以当我们说:
Python 函数调用有成本。
不要理解成:
CPython 完全没有优化函数调用。
恰恰相反。
它已经做了很多事情来降低成本。
问题在于:
很低的固定成本
×
10,000,000
依然可能非常可观。
五、普通函数、Bound Method、Callable Object 有什么区别?
这是非常值得理解的一组对比。
先看普通函数:
def f(x):
return x + 1
f(10)
f 是一个普通 Python function object。
Bound Method
再看:
class Calculator:
def add_one(self, x):
return x + 1
calc = Calculator()
calc.add_one(10)
这里:
calc.add_one
不是单纯的:
Calculator.add_one
我们可以验证:
print(Calculator.add_one)
print(calc.add_one)
概念上会看到:
<function Calculator.add_one ...>
<bound method Calculator.add_one of <Calculator ...>>
继续:
method = calc.add_one
print(method.__self__)
print(method.__func__)
其中:
method.__self__
指向:
calc
而:
method.__func__
指向原始函数:
Calculator.add_one
Python 数据模型明确描述了这种关系:bound method 保存实例 __self__ 与原始函数 __func__,调用:
calc.add_one(10)
在语义上类似:
Calculator.add_one(calc, 10)
也就是自动把实例作为第一个参数传入。(Python documentation)
可以画成:
calc.add_one
│
↓
Bound Method
├── __self__ → calc
└── __func__ → Calculator.add_one
不过,千万不要因此直接得出:
bound method 每一次都一定要创建一个完整 method object,所以一定慢很多。
现代 CPython 对常见的:
obj.method(...)
调用路径有专门优化。
从当前 CALL 字节码模型甚至可以看到,调用栈显式考虑了:
callable
self 或 NULL
其他参数
这样的布局。(Python documentation)
Vectorcall 也专门考虑了 bound method 需要把 self 放到参数序列前面的情况,以减少额外分配。(Python documentation)
因此不要根据“理论上多一个对象”就猜性能。
测量才是答案。
六、Callable Object 又是什么?
Python 中不只有函数可以被调用。
例如:
class Increment:
def __call__(self, x):
return x + 1
inc = Increment()
print(inc(10))
为什么:
inc(10)
能够工作?
因为它实现了:
__call__()
Python 语言参考明确规定,调用表达式前面的对象必须是 callable;除了函数、方法和类之外,实现了 __call__() 的对象也可以被调用。(Python documentation)
因此:
inc(10)
在概念上类似:
inc.__call__(10)
Callable Object 非常适合保存状态:
class Scale:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
double = Scale(2)
print(double(10))
这是非常优雅的 API 设计。
但如果:
double(x)
位于一个执行几千万次的 CPU 热循环里,就应该用 profiler 和 benchmark 判断这种抽象是否值得保留在最内部路径。
注意这里的原则不是:
callable object 很慢,不要使用。
而应该是:
任何 Python 层抽象,如果进入千万级 hot path,都值得测量。
可维护性和性能不是敌人。
真正成熟的工程实践,是把抽象留在正确的层级。
七、手动 inline 一个小函数一定更快吗?
我们比较:
版本 A:函数调用
def f(x):
return x + 1
total = 0
for i in range(10_000_000):
total += f(i)
版本 B:手动 inline
total = 0
for i in range(10_000_000):
total += i + 1
第二种有可能更快。
原因很直接:
版本 A:
循环
→ 调 f
→ 执行 +
→ 返回
→ 下一轮
版本 B:
循环
→ 执行 +
→ 下一轮
手工 inline 消除了一部分 Python 函数调用边界。
但是:
inline 并不是“一看到小函数就必须做”的通用优化。
假设业务代码原本是:
def normalize_price(price):
return max(price, 0) * 1.05
很多地方都调用:
price = normalize_price(price)
如果为了所谓性能,把它复制到 17 个地方:
price = max(price, 0) * 1.05
你得到了什么?
可能只得到一点点速度,却失去了:
语义命名
复用能力
统一修改位置
单元测试边界
代码可读性
如果这个函数一天只执行 5000 次,这种优化几乎没有意义。
但如果 profiler 告诉你:
normalize_price
calls = 60,000,000
情况就不同了。
因此 inline 的正确判断公式不是:
函数很小
→ inline
而是:
函数很小
+
调用次数巨大
+
位于 CPU hot path
+
profile 明确证明收益可能存在
+
inline 不会严重破坏设计
↓
值得 benchmark
八、为什么“减少 Python 层循环”通常比微调语法有效?
这是整篇文章最重要的性能思维。
假设有:
data = range(10_000_000)
你写:
def process(x):
return x * 2 + 1
result = []
for x in data:
result.append(process(x))
这里每个元素需要经历:
Python for loop
↓
Python function call
↓
Python arithmetic
↓
Python list append
↓
下一元素
1000 万个元素,就是至少 1000 万轮解释器调度。
很多人这时候开始研究:
局部变量是不是比全局变量快?
append 绑定到局部变量会不会更快?
lambda 会不会比 def 快?
把 if 改成三元表达式会不会更快?
这些问题并非完全没有意义。
但它们可能都没有触及最大的问题:
你正在让 Python interpreter 管理 1000 万轮循环。
如果能把工作下沉到 C、C++、Rust、SIMD 或已经高度优化的底层实现中,优化量级可能完全不同。
例如数据科学中:
import numpy as np
x = np.arange(10_000_000)
result = x * 2 + 1
从 Python 源码看只有:
x * 2 + 1
真正的大规模元素处理主要由 NumPy 的底层实现完成,而不是让 Python 字节码逐个处理 1000 万个元素。
于是模型从:
Python
↓
处理第 1 个元素
↓
处理第 2 个元素
↓
处理第 3 个元素
↓
...
↓
处理第 10,000,000 个元素
变成:
Python
↓
发出一次“大任务”
↓
底层高性能循环
↓
一次性处理大量数据
两者最本质的差异不是:
哪一种 Python 语法更漂亮
而是:
谁在负责那个最内层循环?
这句话值得记住:
Python 性能优化的巨大机会,往往来自减少解释器需要参与的次数,而不是让每一次解释器操作快一点点。
九、一个经典反例:不要为了避免函数调用重写优秀算法
假设:
def distance(x1, y1, x2, y2):
return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5
你发现它执行很多次。
于是把函数 inline,获得 5%~10% 的收益。
当然很好。
但如果真正的问题是:
原算法比较了 N × N 个点
复杂度是:
O(N²)
而实际上可以通过:
空间索引
KD-Tree
分桶
剪枝
向量化
减少 90% 的计算,那么还在纠结函数调用,就是优化错层了。
因此优化优先级通常是:
算法
↓
数据结构
↓
减少计算量
↓
减少 Python 层循环
↓
批处理 / 向量化
↓
减少高频函数边界
↓
局部微优化
越靠前,潜在收益往往越大。
十、项目实战:Profiler 显示 5000 万次小函数调用,怎么办?
来看一个更真实的例子。
某数据处理服务:
def normalize(value):
return value * 1.08
def process(records):
result = []
for record in records:
value = normalize(record["price"])
result.append(value)
return result
运行 profiler 后发现:
normalize
50,000,000 calls
这时候我不会第一时间修改 normalize()。
我会先问一个更重要的问题:
为什么必须调用 5000 万次?
第一步:确认它真的是 CPU hotspot
运行:
python -m cProfile -s cumulative app.py
重点关注:
ncalls
tottime
cumtime
例如:
ncalls tottime function
50,000,000 ... normalize
1 ... process
如果函数调用次数极高,而且累计 CPU 时间显著,再进入优化。
如果真正耗时的是:
数据库
HTTP
磁盘
序列化
那优化这个函数可能毫无意义。
十一、第二步:检查能否把工作合并成批处理
原设计:
record 1 → normalize()
record 2 → normalize()
record 3 → normalize()
...
应该首先问:
能不能一次处理一批数据?
例如:
def normalize_batch(values):
return [value * 1.08 for value in values]
调用端:
values = [record["price"] for record in records]
result = normalize_batch(values)
这里函数调用已经从:
N 次 normalize()
变成:
1 次 normalize_batch()
虽然内部仍然存在 Python 循环,但至少消除了大量 Python 函数边界。
下一步如果数据规模真的巨大,还可以进一步考虑 NumPy:
import numpy as np
values = np.asarray(values)
result = values * 1.08
这就把最内部的大规模数值循环进一步下沉到了 Python 之外。
十二、第三步:如果不能批处理,再考虑 inline
有时候业务逻辑决定了不能轻易批处理。
例如:
for event in event_stream:
x = normalize(event.value)
if x > threshold:
handle(x)
此时如果:
normalize()
真的调用几千万次,并且函数只有:
def normalize(x):
return x * 1.08
那么可以测试:
for event in event_stream:
x = event.value * 1.08
if x > threshold:
handle(x)
注意关键词:
测试。
不是:
我觉得应该更快。
十三、第四步:测试普通函数、method 和 callable,而不是猜
可以建立一个简单 benchmark:
from timeit import repeat
def plain(x):
return x + 1
class Processor:
def method(self, x):
return x + 1
class CallableProcessor:
def __call__(self, x):
return x + 1
processor = Processor()
callable_processor = CallableProcessor()
def test_plain():
total = 0
for i in range(1_000_000):
total += plain(i)
return total
def test_method():
total = 0
for i in range(1_000_000):
total += processor.method(i)
return total
def test_callable():
total = 0
for i in range(1_000_000):
total += callable_processor(i)
return total
测试:
for func in (
test_plain,
test_method,
test_callable,
):
times = repeat(
func,
number=1,
repeat=7,
)
print(
func.__name__,
min(times),
)
不要直接照搬别人博客中的数字。
因为实际结果会受到:
Python 版本
CPython 优化
CPU
操作系统
代码上下文
对象类型
specialization 状态
参数形式
影响。
尤其从 Python 3.11 开始,CPython 已经显著优化 Python 函数调用与 frame 处理,并通过 specializing interpreter 优化许多稳定的常见执行模式。(Python documentation)
所以五年前的“Python 性能秘籍”,今天不一定仍然成立。
十四、第五步:检查参数设计有没有制造额外成本
比较:
f(x)
和:
f(x=x)
以及:
f(*args)
或者:
f(**kwargs)
它们并不一定拥有完全相同的调用路径。
特别是:
f(*args, **kwargs)
涉及参数展开与匹配。
当前 CPython bytecode 中甚至有专门处理这一类动态参数调用的:
CALL_FUNCTION_EX
而普通 positional call 通常可以走更直接的路径。(Python documentation)
所以在真正的千万级热路径里,如果 API 内部只是:
def execute(*args, **kwargs):
...
然后层层转发:
wrapper()
↓
dispatcher()
↓
handler()
↓
callback()
↓
execute()
很可能值得重新审视。
问题通常不是:
**kwargs很坏。
而是:
你为什么在最热的路径上建立了这么多 Python 抽象层?
十五、函数调用优化最危险的误区:删除所有抽象
性能意识过强以后,很容易走向另一个极端:
函数调用有成本
↓
函数越少越好
↓
所有逻辑全部写进一个巨大函数
结果:
def process_everything(...):
# 1800 行
...
确实“减少了函数调用”。
但也减少了:
可测试性
可维护性
可阅读性
模块化
复用能力
错误隔离能力
这通常不是优秀工程。
真正合理的设计是区分:
控制层
业务层
hot loop
外层代码完全可以保持漂亮的抽象:
processor.process(dataset)
而在真正执行数亿次操作的内部:
尽量减少 Python dispatch
批量处理
使用底层优化实现
避免无意义的对象创建
也就是说:
不是消灭抽象,而是不要让昂贵的抽象出现在最内层循环里。
十六、一个更成熟的优化版本
假设最初是:
def convert(x):
return x * 1.8 + 32
def process(values):
result = []
for value in values:
result.append(convert(value))
return result
Profiler 显示:
convert: 30,000,000 calls
第一阶段可以考虑:
def process(values):
result = []
for value in values:
result.append(value * 1.8 + 32)
return result
如果仍然不够快,而且数据天然适合数组:
import numpy as np
def process(values):
values = np.asarray(values)
return values * 1.8 + 32
优化路线实际上是:
版本 A
30,000,000 次
Python loop
+
Python function call
↓
版本 B
30,000,000 次
Python loop
↓
版本 C
少量 Python 调度
+
底层批量数值循环
这三种版本之间,真正重要的不是:
代码少了几行
而是:
Python interpreter 被要求参与多少次?
十七、怎样判断一个小函数值得优化?
我在实际项目里通常使用一个非常简单的判断框架:
调用次数
↑
│
值得关注 │ 优先优化
│
│
───────────────────┼────────────→ 单次成本
│
│
基本忽略 │ 单次很贵但次数少
│
例如:
函数 A:
1 次 × 2 秒
应该研究为什么一次需要 2 秒。
而:
函数 B:
50,000,000 次 × 极小成本
应该研究为什么需要调用 5000 万次。
这是完全不同的性能问题。
前者通常需要:
改善单次操作
后者通常更应该:
减少操作次数
十八、为什么 profiler 比“性能直觉”可靠?
程序员特别容易形成一些微优化传说:
这个语法比较快
那个写法比较慢
method 一定比 function 慢
lambda 一定比 def 慢
局部变量一定要缓存
list comprehension 永远最快
其中有些在特定 Python 版本、特定场景确实成立。
但 CPython 本身不断演进。
例如 Python 3.11 不仅大幅优化了 frame 与 Python-to-Python function call,还引入了 specializing adaptive interpreter,通过运行时的类型稳定性为常见操作寻找更快的执行路径。官方文档强调,开发者通常应该继续编写正常、Pythonic 的代码,而不是为了迎合解释器优化去扭曲程序结构。(Python documentation)
所以专业的 Python 性能优化流程应该是:
Measure
↓
Locate
↓
Understand
↓
Change
↓
Benchmark
↓
Measure Again
而不是:
Guess
↓
Rewrite Everything
十九、项目中的完整处理策略
假设 profiler 告诉我:
58,000,000 calls to transform()
并占据大量 CPU。
我的处理路线通常会是:
① 确认热点是真实业务负载,而不是测试假象
↓
② 检查算法:
为什么需要执行 5800 万次?
↓
③ 检查能否减少输入规模或重复计算
↓
④ 检查能否批处理
↓
⑤ 检查能否把循环下沉:
NumPy / Pandas / 数据库 / C Extension 等
↓
⑥ 检查是否存在过度抽象:
wrapper → dispatcher → callback → handler
↓
⑦ 对极小热点函数测试 inline
↓
⑧ 用 timeit / benchmark 验证收益
↓
⑨ 再跑 profiler
↓
⑩ 确保可读性、测试与正确性没有退化
这套顺序非常重要。
因为真正值得追求的不是:
“我让一个函数调用快了 8%。”
而是:
“我把 5800 万次 Python 调用减少到了 20 次批处理调用。”
两者不是同一个优化量级。
二十、最后建立一张 Python 调用性能心智地图
回到最初:
def f(x):
return x + 1
for i in range(10_000_000):
f(i)
现在我们可以把它重新理解成:
Python for loop
│
↓
读取 i
│
↓
找到 callable f
│
↓
准备调用参数
│
↓
CALL
│
↓
建立/切换函数执行 frame
│
↓
LOAD_FAST
│
↓
BINARY_OP
│
↓
RETURN_VALUE
│
↓
返回调用者
│
↓
下一轮循环
× 10,000,000
现代 CPython 已经通过:
Vectorcall
Frame 优化
Python-to-Python call 优化
Specializing Interpreter
Inline Cache
不断降低这些工作的成本。(Python Enhancement Proposals (PEPs))
但解释器再快,也改变不了一个最基本的乘法:
小成本 × 巨大次数 = 大成本
因此 Python 性能优化中真正应该建立的思维,不是看到:
f(x)
就害怕函数调用。
而是看到:
50,000,000 calls
以后立刻开始思考:
我真的需要跨越 Python 函数边界 5000 万次吗?
写在最后:真正高级的优化,往往不是“写得更聪明”
刚学习 Python 性能优化时,我们很容易沉迷于各种技巧:
这个操作码少一个
那个变量绑定快一点
这里换一个表达式
那里缓存一个 method
这些技巧并非没有价值。
但随着项目经验增加,你会发现,收益最大的优化经常来自更高层次的问题:
能不能少做一些工作?
能不能一次处理一批?
能不能避免重复计算?
能不能把最内层循环交给更适合做循环的底层实现?
能不能把 5000 万次函数调用变成 5000 次,甚至 5 次?
Python 的魅力从来不是让开发者和解释器比谁更擅长管理 CPU 指令,而是让开发者能够用足够高层的抽象,快速表达复杂问题。
真正优秀的 Python 最佳实践,也不是简单地追求:
每一行都最快
而是在:
算法
架构
可维护性
抽象
性能
开发效率
之间找到最合理的平衡。
如果 profiler 有一天告诉你:
某个只有一行代码的函数,占了程序 30% 的 CPU
不要急着笑它。
它可能什么都没有做错。
它只是被你叫了 几千万次。🙂
而那个时候,真正值得问的不是:
“这个函数还能不能再快 5%?”
而是:
“为什么我要调用它这么多次?”
这往往才是性能优化真正开始的地方。
延伸阅读关键词
继续深入这一主题,可以重点研究:
Python编程、Python教程、Python实战、Python最佳实践、CPython、Python函数调用、Vectorcall、PEP 590、Bound Method、Callable Object、Python性能优化、cProfile、timeit、Python字节码、CALL opcode、Specializing Interpreter。
Python 官方资料中,cProfile 文档适合继续学习性能定位;Python Data Model 可以深入理解 function、bound method 与 callable;CPython Call Protocol 与 PEP 590 则适合继续研究 Vectorcall 和底层调用协议。(Python documentation)
下一次看到一个“慢函数”时,不妨先打开 profiler。
性能优化最有价值的问题,通常不是“这一行怎样写得更快”,而是“这一行为什么需要执行这么多次”。

206

被折叠的 条评论
为什么被折叠?



