AIGC标识 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 指令真正做的事比想象的重:

  1. 在堆上新建一个 PyFrameObject(帧对象),分配局部变量数组;
  2. 按调用约定把实参绑定到帧的局部变量槽;
  3. 把执行流切到函数的字节码;
  4. 函数返回后销毁这个帧、把返回值压回调用栈。

第 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 持续在压缩这个差距;而且绝大多数业务瓶颈根本不在「调用本身」,而在「调用次数 × 每次做的事」。

五、实用结论(排错清单)

  1. 热点循环不要逐元素回调:for x in data: result.append(heavy(x)) 不如把循环移进 heavy 内部,或者改用 map / 列表推导 / numpy 向量化,减少调用次数。
  2. 反复调用的全局名,先绑到局部:local_f = globals_f 后再循环用,省掉每次 LOAD_GLOBAL 的字典查找。
  3. 方法调用比函数调用略慢但差极小,别为这点差异去魔改代码,可读性优先。
  4. 优先用内置(sum、sorted、推导式)替代手写循环 + 调用,内置走 C 路径,少建帧。

我的个人推断:真瓶颈几乎从不在「调用」这个动作,而在「调用次数 × 每次干的事」。上来先 cProfile 一把,别凭感觉猜——我见过把函数内联了半天,最后发现慢在里面的一个 re.compile 每次重建。

posted @ 2026-10-31 12:01  小小张说故事  阅读(6)  评论(0)    收藏  举报