CPython基础加法机制曝光 动态类型检查与堆分配拖慢运算

【python】看似简单的加法,背后究竟有多少代码需要运行?看了才知道! — 码农高天

码农高天

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)

中文简报

CPython基础加法机制揭秘:动态类型检查与堆分配拖慢运算 Python加法性能低下的核心原因并非算术运算本身,而是复杂的动态类型检查以及运算结果超出小整数池后强制触发的对象包装与堆内存分配。 视频深入剖析了 Python 执行最基础的加法运算时在 CPython 虚拟机底层的完整调用链路。从基于栈的字节码指令 `BINARY_ADD` 出发,首先需要经历出栈、字符串快速路径检测以及 `PyNumber_Add` 的动态类型派发与槽位检查。接着,解析了底层针对大整数设计的 `PyLongObject` 变长结构体及其防溢出的 15 位 digit 存储方案,并指出加法完成后调用 `PyLong_FromLong` 包装对象才是最大开销——一旦超出小整数池缓存范围,就必须频繁调用 `_PyLong_New` 进行堆内存分配甚至触发系统调用,这也是 Python 执行效率落后于 C 等原生语言的核心原因。 Python 虚拟机执行简单的加法指令 `BINARY_ADD` 时,外围的栈指针移动与引用计数维护所耗费的时间,就已普遍超过 C 语言底层的原生硬件加法。 (0:00) CPython 将数值实现为无限精度的大数对象(`PyLongObject`),采用多 `digit` 变长结构且每个 `digit` 仅存 15 或 30 位以防止原生运算溢出。 (5:00) 加法计算结果必须封装为完整的 Python 对象,一旦超出小整数池缓存范围,就会调用 `_PyLong_New` 触发堆内存 `malloc` 与潜在的缺页异常(page fault),构成严重的性能损耗。 (8:34)

Reading summary

码农高天指出,Python 看似简单的加法操作在底层远非单条硬件指令,而是包含栈指针调整、动态类型分发、大数结构适配以及堆内存分配等极其繁复的执行链路。 虚拟机栈操作与快速路径检查 (0:00) 在 Python 虚拟机中,加法首先被编译为 BINARY_ADD 字节码。由于虚拟机基于栈架构运行,执行运算前必须先通过 POP 与 TOP 宏移动栈指针以取出两个操作数。随后解释器会进入 fast path 检测是否属于高频的字符串拼接场景,若非字符串则将运算交由 PyNumber_Add 处理。完成计算后,代码还需重设栈顶、对参与对象执行两次解引用以更新引用计数,并进行下一条字节码的分发。码农高天强调,仅这一外围过程中涉及的栈指针移动与解引用耗时,通常就已超过 C 语言在原生硬件寄存器中直接执行单次加法的时间。 动态类型系统下的二元运算分发 (2:14) 进入 PyNumber_Add 后,核心分发逻辑由 binary_op1 承载。由于 Python 具备动态类型且支持运算符重载,两端操作数可能为不同类型或自定义类,因此必须执行大量安全检查与类型匹配。解释器通过对象内部的 tp_as_number 结构体查找对应的数学函数指针(NB_BINOP)。当两操作数类型一致或右操作数为左操作数的子类且均实现了加法槽位时,解释器会优先采用右操作数的方法驱动计算,从而在运行时动态解析出底层具体的计算函数。 整数底层结构与防溢出设计 (5:00) Python 整数底层通过 longobject.c 中的 PyLongObject 实现,支持无限精度的大数表示。该结构体包含记录正负号及 digit 数量的 ob_size,以及采用柔性数组技巧存储数值的 ob_digit。为防止两数相加时溢出,底层对有效二进制位数做出限制,例如采用 16 位整型存储 digit 时仅存入最多 15 个 bit。在进入具体的 long_add 计算时,解释器先校验两操作数大小,若均处于单 digit 范围内,则借助宏 medium_value 提取物理数值并在 C 语言原生层级直接完成加法。 打包回传与堆内存申请的性能开销 (8:34) 原生加法计算完成后,数据必须通过 PyLong_FromLong 重新封装为 Python 对象。此时解释器会优先检测小整数池,若命中固定范围内的常用数字,则直接调用 get_small_int 复用全局单例并递增引用计数。一旦结果超出小整数池,代码就必须调用 _PyLong_New 通过 malloc 动态申请包含对象头和对应 digit 数量的连续堆内存。码农高天指出,堆内存分配不仅存在碎片开销,还可能向操作系统内核申请空间或触发缺页异常(page fault),这是导致整体执行效率降低的核心瓶颈。 Python 加法运算性能损耗的本质在于:解释器为了保障动态类型与运算符重载的灵活性,必须经历漫长的方法派发机制;同时由于计算结果必须封装为 Python 堆对象,一旦脱离小整数池便难以避免昂贵的内存申请与页面开销。

Source quotes

[8:34] 码农高天: 只要涉及到了 malloc 这个东西就没谱了,他要检验我这个 process 已经申请的内存有没有地方可能会分配,如果没有地方还要再管操作系统的 kernel 拿,或者有的话也可能触发 page fault,所以这个部分实际上是一个非常耗时的点。

Original transcript

[0:00] 当你在python中做了一个简单的加法运算的时候你知道他要付出多少努力吗好 今天我们来讲一下Python加法背后的故事很多人知道Python是一个比较慢的语言今天我们就从一个很小很小的加法入手看一看一个加法在python里究竟要经过多少个步骤才能完成在python使用加法的时候 最后会转换成一个字节码叫做binary add 就是这个部分 我们直接从这个部分开始看 前面的部分我们就不管了那python的虚拟机是stack based 就是以站为基础的 所以说首先呢 他要把两个要加的东西从站里面取出来 分别用了一个pop和一个top这两个事情都不是非常费劲啊它只是一个macro 实际上就是动了一下战指针而已接下来这个部分叫fast path 它要检查一下两个相加的东西是不是string因为string相加在python里实在出现的太频繁了所以他单独给他列了一个case如果是两个string相加的话他有一个特殊的快速的处理那真正比较耗时的部分就在PiNumberAd里面但是我们先不管这个 我们先看后半部分后半部分的话就是要做一个de-reference 要做两个de-reference 然后要把top给设置好 就是站给设置好 然后再做一个dispatch 呃 de-reference这个部分呢 在有gill的情况下还是比较快的 它就是动一个那个reference counter所以在这一整块代码里相对来说耗时最长的肯定是PiNumpered剩下的部分还好但是我们所谓的还好就是这个操作和做一次加法在C里做一次加法的数量级是差不多的也就是这几行代码啊 这几行代码什么de-reference啊 什么poptop呀它消耗的时间比真正在C里面做一次加法只多不少要看这个内容是在register里面还是在cache里面还是在更远的地方好 那我们进去看一下PiNumberApp这里就是PiNo.i的代码其实非常简单它的核心代码在BinaryUp1里然后只要result是正常的就直接return result了那么这个BinaryUp1大家可能看着稍微有点费劲啊V跟W分别是我们两个要加起来的对象然后这个NB Slot相对来说复杂一些大家就可以理解为我要使用加法来把这两个对象做一个操作好 我们接着看Binary App 1好我们可以看到Binary UP1的代码已经越来越难懂了这是为什么呢 因为python是一个比较动态化的语言 它两个object做加法的时候 它们可以有不同的类型 对吧 我们现在的加法当然是两个都是整形 但是两个东西它可能有一个是flow的 甚至有一个是string或者有一个甚至是自定义的class 对不对 所以它在做这个binary operation的时候 它就会要进行很多的检查和检测那我们首先讲一下这个tps number啊这个tps number是一个指针这个指针指向了一组函数都是这个类型能不能被当作数来进行计算的函数这里面有加法减法成法除法这一代队那么下面这个部分 所谓NB是Number Binary Up这个部分就是找到相应的函数指针如果大家还记得的话OP Slot传进来的是加法这里就是找到这个类型做加法的函数指针大家可能知道在python里面add是可以重载的当你重载了你的自定义类型的add函数之后呢 这个函数指针会被传到这里来那我们之前说了这两个对象的类型可能不一样对不对那么他既拿了第一个类型的加法韩淑紫珍也拿了第二个类型的加法韩淑紫珍好 那我们接着看下面的部分啊下面这个部分就是用各种不同的顺序来让他们这两个对象进行加操作那么优先级最高的就是当W跟V几乎是同一个类型这里可以是subtype的情况下 并且 w 跟 v 都有加法的这个函数指针那么我们就用W的方法去对V跟W进行一个加把操作那当我们输入1+1的时候就是这种情况那当然如果我们用我们自定义的类型加1或者是1加上我们自定义的类型那就会是其他的情况好那现在问题来了 我们肯定要进这个slotw去找一找 但是slotw是一个函数指针 我们怎么知道它是什么呢这就是为什么你应该关注我 而不是自己 [5:00] 硬读Cpython圆满的原因因为我可以帮你节省很多时间在python里面整形叫做long 用的是long然后所有跟long有关的代码都被写在longobject.c里面这个long s number就是我们刚才提到的一组跟树有关的函数指针非常明显long add就是我们要找的函数好了 这个函数就在这里了 是不是比你想象的还要复杂一些啊 为什么会出现了一些size啊 什么叫medium value啊 对不对那么我们可以先忽视这个checkbinup 基本上就是简单的看一下这个A跟B这两个东西能不能做加法为了理解下面的代码 我们要简单的介绍一下 long也就是整形这个数据在python里面是如何进行存储的我们知道在python里面整形是可以存很大很大的数的 跟c里面不一样对吧那么它实现的方式就是一个big int它在C里对应的数据结构是一个可变长度的struct我们不要看这里是ob digit1 只是为了给它提供一个比较方便reference的地址实际上在maloc的时候它不只会要struck的这么多的内存它会根据自己要储存的数的大小来改变自己申请的内存那由于python内部一些算法实现的限制呢python要求它每一个digit里面保存的数的位数要是5的倍数所以说当这个digit是一个16位的type的时候这里面保存的就是一个最多15个bit的整数那如果它是32的话 它这里面就保存的是30位的整数那15个贝特的整数有个什么好处呢?就是它在两个数做加法的时候它是不会overflow的然后这里面它还有一个小的trick就是这个object的size那在python里面所有的可变长度的数据类型都有一个size你可以通过py size得到它的size对于比如说list来说或者是对dictionary来说它就是里面储存的数据的数量但是对于Long这个数据类型来说OB size如果是正的代表它保存的是一个正数如果是负的代表它保存的是一个负数如果是0代表它保存的是0同时这个OB size的绝对值代表了它到底有多少个digit大家可能听得云里雾里的啊听不懂也没有关系我只是给大家简单的介绍一下这个LUNK是怎么保存的我们好看刚才那个部分的代码为什么刚才要给大家介绍size呢 因为理解了size之后 你对这一行if的理解就变得清楚了很多他对A先求了一个size然后还做了一个求绝对值如果你刚才不知道Python的long是怎么表示的话你绝对想不通这个绝对值到底是个什么鬼我们刚才说了这个size有可能是负的只是代表这个long本身是一个负数那么对这两个object的set求一个绝对值本质上就是说这两个数是不是两个比较小的数所谓比较小的数也就是一个digit就可以表示的过来的15个bit以内的证数跟负数那如果能表示的话我就把他们两个直接在C的层面加到一起因为我能保证他们两个加在一起不会溢出啊不会溢出这个medium value其实大家可以看一下就是如何从一个pylongobject里面提取出这个东西的digit然后根据他的side是正还是负决定这个digit表示是一个正数还是一个负数把它变成一个c层面可以做加减乘逐的数如果A或者B非常大的话呢 这个case我们今天先不讲 它后面有它后面对应的代码然后你看终于return了 这期视频终于要结束了吗 还没有WHAT?!那实际上最耗时间的操作是在这里面是在这里面PiLong from long那PiLong from long是什么呢?它就是从一个C的整形产生一个python的整形也就是pylo我们进函数看一看这个函数里面是不是别有洞天呢 是不是比你想象的要复杂呢不要着急 我们一点一点说首先看 this small int以small n很简单 在python里面呢python首先把一些很常用的比较小的数都给做好了一个object当你要使用的时候直接用那个object就可以了这里相当于用空间换了一个时间所以在python里面操作相对来说比较小的数的时候是比较快的我记的是200多以内正负解渴那我们进去简单看一下get small int大家可以看getsmallint这个函数相对来说比较简单了对不对啊 [10:00] 需要拿一个这个gill然后它实际上返回的是一个已经建立好的python object然后只是增加了一个reference所以说如果你到了这条路径的话是比较快的那如果我们产生的数不是一个比较小的数是一个比较大的数呢首先前面的部分先给你算出来它绝对值和它的符号位然后如果你这个数是比较大 不是特别大 什么叫比较大呢 就是single digit ints 我们刚才说到的15个bit可以表示出它的绝对值的数那么就走这个部分建立一个新的pilog 然后把value复制给digit0 我们刚才看到digit0然后直接return但是我们知道当你看到new的时候你就要大事不妙 这块要慢了我们看一下PiLongnew这个函数并不复杂带大家来看一看函数主要是为了印证一下刚才那个说法就是当他在做maloc在申请内存的时候他申请的并不是pylonobject这个struct的size而是这个strap size还要加上size个digit这么大的一个size那python就是用这种c的小技巧啊来完成这种大数的实现我们也都知道只要涉及到了maloc这个东西就没谱了他要检验我这个process已经申请了那层有没有地方可能会分配如果没有地方还要在管操作系统的kernel拿或者有的话也可能触发page fault所以这个部分实际上是一个非常耗时的点那在mylock结束之后只要把它做一个initialization把它做一个初始化我们的工作就终于完成了那经过了茫茫多层的函数调用之后PiNumberAdd终于能返回一个Python的Long给这个Sung作为新的站顶数据了好 那我们总结一下啊Python的加法呢在实际的代码中需要费这么大劲才能实现我觉得主要有两点原因第一是python相对灵活的类型和重载的特性让他在做一个我们看似简单的加法的时候需要做非常多的类型检查呀 重载检查呀 这样的操作第二就是由于python中的加法需要返回一个新的python object在建立python object的时候确实会有非常多性能的损耗好 今天的视频就到这里 喜欢这期视频的朋友记得点一下关注 别忘了一键三连哦优优独播剧场——YoYo Television Series Exclusive