码农高天
Python加法性能低下的核心原因并非算术运算本身,而是复杂的动态类型检查以及运算结果超出小整数池后强制触发的对象包装与堆内存分配。
AI-assisted summary of 【python】看似简单的加法,背后究竟有多少代码需要运行?看了才知道! — original source. Check the original for full context.
Source published:
Key ideas
- Python 虚拟机执行简单的加法指令 `BINARY_ADD` 时,外围的栈指针移动与引用计数维护所耗费的时间,就已普遍超过 C 语言底层的原生硬件加法。 (0:00)
- CPython 将数值实现为无限精度的大数对象(`PyLongObject`),采用多 `digit` 变长结构且每个 `digit` 仅存 15 或 30 位以防止原生运算溢出。 (5:00)
- 加法计算结果必须封装为完整的 Python 对象,一旦超出小整数池缓存范围,就会调用 `_PyLong_New` 触发堆内存 `malloc` 与潜在的 page fault,构成严重的性能黑洞。 (8:34)
Detailed notes
## Background 该视频由 YouTube 频道“码农高天”于 2023 年 9 月 27 日发布。视频简介指出,许多 Python 开发者未曾意识到简洁语法背后所执行的庞杂逻辑,本期内容旨在通过一次基础加法的底层实现,带大家深入理解 Python 运行缓慢的根本原因。
## TL;DR 视频深入剖析了 Python 执行最基础的加法运算时在 CPython 虚拟机底层的完整调用链路。从基于栈的字节码指令 `BINARY_ADD` 出发,首先需要经历出栈、字符串快速路径检测以及 `PyNumber_Add` 的动态类型派发与槽位检查。接着,解析了底层针对大整数设计的 `PyLongObject` 变长结构体及其防溢出的 15 位 digit 存储方案,并指出加法完成后调用 `PyLong_FromLong` 包装对象才是最大开销——一旦超出小整数池缓存范围,就必须频繁调用 `_PyLong_New` 进行堆内存分配甚至触发系统调用,这也是 Python 执行效率落后于 C 等原生语言的核心原因。
## Atmosphere & style 视频风格硬核严谨且条理清晰,充满了一线开发者深入源码底层的硬核极客质感。作者以幽默从容的口吻抽丝剥茧,适时穿插生动的比喻与自嘲,将晦涩繁琐的 CPython 源码解析讲解得通俗透彻。整体观看体验犹如一场层层拨开黑盒迷雾的探秘之旅,既能直观感受到 Python 语法极致灵活性带来的底层性能沉重负担,又充满了探寻系统设计细节的智力快感。
## Bulletin - [00:00](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=0s) Python 虚拟机执行简单的加法指令 `BINARY_ADD` 时,外围的栈指针移动与引用计数维护所耗费的时间,就已普遍超过 C 语言底层的原生硬件加法。 - [05:00](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=300s) CPython 将数值实现为无限精度的大数对象(`PyLongObject`),采用多 `digit` 变长结构且每个 `digit` 仅存 15 或 30 位以防止原生运算溢出。 - [08:34](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=514s) 加法计算结果必须封装为完整的 Python 对象,一旦超出小整数池缓存范围,就会调用 `_PyLong_New` 触发堆内存 `malloc` 与潜在的 page fault,构成严重的性能黑洞。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=0s) Python 虚拟机的加法操作由基于栈的 `BINARY_ADD` 指令触发,必须通过栈指针移动弹出两个操作数。 - [00:45](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=45s) 解释器在加法执行前会专门为字符串拼接设置 fast path,非字符串则转入通用的 `PyNumber_Add`。 - [01:40](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=100s) 即便是字节码层面的 de-reference 与栈指针调整,其执行耗时相比于 C 原生加法也是只多不少。 - [02:14](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=134s) `binary_op1` 是动态派发核心,因为动态语言不限制两操作数类型,必须做大量的安全检测与类型检查。 - [03:00](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=180s) 数值运算依赖对象内部的 `tp_as_number` 函数指针表,根据 `NB_SLOT` 动态查找并支持运算符重载实现。 - [04:00](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=240s) 当右操作数是左操作数的同类型或子类且均有实现时,解释器优先调用右操作数的槽位方法进行相加。 - [05:00](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=300s) Python 整型在底层使用可变长度结构体表示的大数(`longobject.c`),支持任意精度的数学计算。 - [05:50](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=350s) `PyLongObject` 使用 15 位或 30 位的 `digit` 数组,预留高位以确保两数在单 `digit` 相加时绝不溢出。 - [06:50](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=410s) `ob_size` 字段兼具多重职责:其符号代表数值正负,绝对值代表实际占用的 `digit` 元素数量。 - [07:40](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=460s) 两数相加若在单 `digit` 范围内,会通过 `medium_value` 提取物理数值并在 C 语言层完成快速运算。 - [08:34](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=514s) 计算完成的原生整数必须经由 `PyLong_FromLong` 打包回 Python 对象,这是加法链路的核心耗时点。 - [09:00](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=540s) 命中范围的小整数通过全局小整数池直接复用单例,免去重复申请内存的开销。 - [10:00](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=600s) 超出小整数池时需调用 `_PyLong_New` 申请堆内存,受碎片整理、内核交互及 page fault 影响,性能不可控。 - [11:40](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=700s) Python 加法性能损耗本质源于两点:动态类型与重载机制带来的层层派发检查,以及必须打包对象引发的堆内存分配。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=0s) 字节码层面的加法:从栈操作到 `PyNumber_Add`
很多人只知道 Python 运行速度比较慢,却完全没有意识到 Python 极其简洁的语法背后究竟执行了多么庞杂的代码。在 Python 虚拟机中,加法运算最终会被编译并转换成一条字节码指令 `BINARY_ADD`。Python 的虚拟机是基于栈(stack-based)架构的,因此做加法的第一步,必须先从栈里面把两个待相加的对象取出来,分别对应一次 `POP` 和一次 `TOP` 操作。这两个操作本身并不算太费劲,它们在源码中本质上只是宏(macro),做的事情仅仅是移动了一下栈指针。
紧接着代码会进入一段名为 fast path(快速路径)的分支:它会专门检测相加的两个对象是不是字符串(string)。因为在 Python 中字符串拼接操作实在是太频繁了,解释器专门为字符串相加设立了一个快速处理的独立 case。如果不是字符串,真正比较耗时的部分就会交由 `PyNumber_Add` 来处理。而在执行完加法之后,后面的代码还要进行栈指针的设置、两次解引用(de-reference)操作以及字节码分发(dispatch)。在有 GIL(全局解释器锁)保护的情况下,de-reference 只是去修改引用计数(reference counter),速度相对还算快。
```mermaid flowchart TD accTitle: Python BINARY_ADD 字节码执行流程 accDescr: 展示从栈中取出操作数、判断是否为字符串快速路径,再到进入 PyNumber_Add 及后续栈维护与解引用的流程。
subgraph StackOps ["栈准备阶段"] A["从栈顶获取操作数<br/>(POP & TOP 宏移动栈指针)"] end
subgraph FastPathCheck ["快速路径判断"] B{"是否均为 String?"} C["String 专用快速拼接路径"] D["调用 PyNumber_Add(v, w)"] end
subgraph Cleanup ["收尾与分发"] E["设置栈顶 (TOP)"] F["执行两次 De-reference<br/>(调整引用计数)"] G["DISPATCH 下一条字节码"] end
A --> B B -- "是" --> C B -- "否" --> D D --> E E --> F F --> G ```
虽然这一整段执行逻辑中耗时最核心的肯定是 `PyNumber_Add`,但即便是外围那几行看起来“还好”的栈指针移动与解引用代码,其耗费的时间相比于在 C 语言中直接做一次原生硬件加法,就已经是有过之而无不及了。码农高天在此强调:“也就是这几行代码啊,这几行代码什么 de-reference 啊,什么 pop top 呀,它消耗的时间比真正在 C 里面做一次加法只多不少,要看这个内容是在 register 里面还是在 cache 里面还是在更远的地方。”
- Python 虚拟机的架构是基于栈(stack-based)运行的。 - 栈操作通过宏执行,耗时较少,只修改栈顶指针。 - 字符串相加作为高频场景享有专门的 fast path 优化分支。 - 两个对象的解引用在拥有 GIL 的前提下主要开销是操作引用计数器。
---
[02:14](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=134s) 动态类型的分发陷阱:`binary_op1` 与加法重载机制
进入 `PyNumber_Add` 函数后,其核心逻辑其实直接调用了 `binary_op1`。在 `binary_op1` 中,传入的参数主要包括两个操作数对象 `v` 与 `w`,以及一个代表槽位操作的 `NB_SLOT`(在当前场景下代表加法槽位)。代码之所以在此处开始变得晦涩难懂,根源在于 Python 作为动态语言的灵活性:两个对象在做加法时并不要求类型相同。虽然当前演示的是简单的整数加法,但实际运行中左边和右边可能一个是 float、一个是 string,甚至可能是用户自定义的 class,因此在执行二元运算时必须做大量的安全检测与类型检查。
为了完成算术分发,Python 对象内部存在一个名为 `tp_as_number` 的指针,它指向了一整套用于数学计算的函数指针集合(包含加、减、乘、除等一系列函数)。代码中的 `NB_BINOP` 操作就是根据传入的 `NB_SLOT`(加法),在类型的函数表中查找对应的加法函数指针。由于 Python 支持运算符重载,如果用户在自定义类中实现了 `__add__`,重载后的函数指针就会被传递到这里。
在确定了调用逻辑后,解释器需要用复杂的优先级规则决定以何种顺序让两个对象参与计算。优先级最高的场景是:当右操作数 `w` 的类型是左操作数 `v` 的同类型或子类型(subtype),并且两者都实现了加法函数指针时,系统会优先选用右操作数 `w` 的槽位方法对二者进行相加。当执行最基础的 `1 + 1` 表达式时走的正是这种分支;如果涉及到自定义类与原生数字相加,则会进入其他对应的回退逻辑。码农高天在准备深入挖掘具体的函数指针时幽默地说道:“这就是为什么你应该关注我,而不是自己硬读 CPython 源码的原因,因为我可以帮你节省很多时间。”
- `binary_op1` 是二元运算动态派发的核心处理函数。 - `tp_as_number` 是 CPython 对象模型中指向数值方法表(包含加减乘除)的结构体指针。 - 动态语言的加法需要同时提取左操作数和右操作数的运算函数指针,做双向的兼容性检查。 - 当两操作数类型一致或存在子类关系且都提供加法实现时,优先采用 `slot_w` 的逻辑来驱动计算。
---
[05:00](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=300s) Python 整数的底层大数表示与 `long_add`
在 Python 内部,所有整型实现都对应 CPython 中的 `long`,相关的底层实现集中存放在源码文件 `longobject.c` 中。前面找到的数值函数集合里的加法指针,对应的具体 C 函数正是 `long_add`。打开 `long_add` 会发现其复杂度远超预期,里面充斥着对 `size` 以及 `medium_value` 的处理。为了看懂这段代码,必须先理解 Python 的整数数据结构。与 C 语言直接使用 CPU 寄存器位宽(如 32 位、64 位)的整型不同,Python 的整数支持无限精度(big int),在 C 层面上采用的是一个可变长度的结构体(struct)。
该结构体尾部的 `ob_digit` 数组定义虽然写成了 `ob_digit[1]`,但那只是 C 语言中为了方便获取内存偏移地址的“柔性数组”技巧。在实际调用 `malloc` 申请内存时,它会按需动态申请更大的空间。由于底层算法的实现限制,Python 要求每个 `digit` 保存的有效二进制位数必须是 5 的倍数。当采用 16 位整型作为 `digit` 的存储类型时,实际上只保存最多 15 个 bit 的数值;如果采用 32 位类型,则保存 30 个 bit 的数值。码农高天解释这种设计的绝妙之处在于:“15 个 bit 的整数有个什么好处呢?就是它在两个数做加法的时候它是不会 overflow 的。”
此外,Python 所有变长对象都包含 `ob_size` 字段。在列表或字典中,`ob_size` 代表元素的数量;但在 `PyLongObject` 中,`ob_size` 具有三重含义: 1. 若 `ob_size` 为正,代表该数是一个正数; 2. 若 `ob_size` 为负,代表该数是一个负数; 3. 若 `ob_size` 为 0,代表数值为 0; 4. `ob_size` 的绝对值则明确记录了该整数到底占用了多少个 `digit`。
在 `long_add` 中,程序首先分别获取操作数 A 和 B 的 `ob_size` 并取绝对值。如果两者的绝对值大小都在单 `digit` 范围内(即 15 个 bit 以内),说明它们属于能够快速计算的“小数”。此时通过宏 `medium_value` 直接提取该 `digit` 的物理数值,并结合符号位的正负将其还原为 C 语言层面的原生整型,随后直接在 C 语言层面完成加法。由于保证了数值绝对不会溢出,这一步计算非常安全。然而,将两数相加并得出原生整型值后,整个函数并没有真正结束,后续还需要将其包装回 Python 对象。
```mermaid flowchart LR accTitle: PyLongObject 内存布局示意图 accDescr: 展示 PyLongObject 的变长结构,包括对象头部元数据与可变长度的 digit 数组。
subgraph Header ["对象基础信息 (PyVarObject)"] H1["引用计数: ob_refcnt"] H2["类型指针: ob_type"] H3["变长大小: ob_size<br/>(符号表示正负,绝对值表示 digit 数量)"] end
subgraph Digits ["可变长数据区 (连续内存)"] D1["ob_digit[0]<br/>(低位数据, 如15位)"] D2["ob_digit[1]<br/>(高位数据)"] D3["..."] end
Header --- Digits ```
---
[08:34](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=514s) 性能黑洞:`PyLong_FromLong`、小整数池与内存分配
当在 C 语言层面计算完原生加法后,必须调用 `PyLong_FromLong` 把 C 的原生整数重新打包成 Python 的 `PyLongObject`,而这里才是整个运算中最为耗时的地方。进入 `PyLong_FromLong` 函数,解释器首先会调用 `IS_SMALL_INT` 进行检测。Python 在启动时预先分配好了一批极其常用的小整数对象(范围大约在负几到正两百多之间),这就是小整数池。当计算结果命中该范围时,系统会调用 `get_small_int` 直接返回那个已存在的全局单例对象,并仅仅递增其引用计数,这属于典型的“以空间换时间”优化策略。
一旦加法得到的结果超出了小整数池的范围,解释器就必须老老实实创建新的堆对象。对于一个单 `digit` 能够表示的中等大小整数,代码会调用 `_PyLong_New` 申请新的内存,再把运算后的数值写进 `digit[0]` 中。只要看到 `new`,就意味着性能要大打折扣。查看 `_PyLong_New` 的实现会印证之前的内存布局:其申请的内存大小并非单纯一个结构体的大小,而是结构体自身大小加上额外 `size` 个 `digit` 的总字节数。
码农高天直言,内存申请才是性能波动的关键来源:“我们也都知道只要涉及到了 malloc 这个东西就没谱了,他要检验我这个 process 已经申请的内存有没有地方可能会分配,如果没有地方还要再管操作系统的 kernel 拿,或者有的话也可能触发 page fault,所以这个部分实际上是一个非常耗时的点。”在 `malloc` 分配好内存空间并完成字段初始化之后,`PyNumber_Add` 历经重重调用,才终于把一个全新的 `PyLongObject` 作为计算结果推回给虚拟机作为新的栈顶数据。
纵观整个加法流程,Python 之所以在底层付出如此巨大的代价,主要原因可以概括为两点: 1. **灵活的动态类型与重载机制**:在执行一个看似极简的加法之前,必须进行大量的类型兼容性检查、槽位探测与运算符重载判定。 2. **对象包装与堆内存分配的开销**:加法计算的结果不能留在原生硬件寄存器中,必须打包返回一个全新的 Python 对象;一旦逃逸出小整数池,就会触发昂贵的堆内存 `malloc`、页面置换甚至系统调用。
- 小整数池范围内的数值使用 `get_small_int` 直接获取单例并递增引用计数,运行速度较快。 - 超出小整数范围的对象需要通过 `_PyLong_New` 动态分配堆内存。 - 动态调用 `malloc` 可能面临进程内内存碎片整理、向操作系统内核申请空间或触发 page fault,是主要的性能损耗点。 - 函数调用链路:`BINARY_ADD` $\to$ `PyNumber_Add` $\to$ `binary_op1` $\to$ `long_add` $\to$ `PyLong_FromLong` $\to$ `_PyLong_New`。
## Notable quotes > [08:34](https://www.youtube.com/watch?v=mJPQXuqnlaE&t=514s) 只要涉及到了 malloc 这个东西就没谱了,他要检验我这个 process 已经申请的内存有没有地方可能会分配,如果没有地方还要再管操作系统的 kernel 拿,或者有的话也可能触发 page fault,所以这个部分实际上是一个非常耗时的点。 — 码农高天
## Mentioned Python, CPython, C 语言, 码农高天, BINARY_ADD, PyNumber_Add, binary_op1, long_add, PyLong_FromLong, _PyLong_New, PyLongObject, GIL
## Video description 这期视频聊一下加法。很多用python的人其实是完全没有意识到python简洁的语法背后究竟有多少代码在执行的。这次带大家从加法体验一下,为什么python会这么慢。
[Source](https://www.youtube.com/watch?v=mJPQXuqnlaE)