Python 性能:原生 NumPy 循环、回调和转换成本
一项受控的纯 CPU 数字测试跟踪了 CPython 循环、NumPy 算术、Python 回调包装器和编译后的 Numba 循环中的相同值。
一项受控的纯 CPU 数字测试跟踪了 CPython 循环、NumPy 算术、Python 回调包装器和编译后的 Numba 循环中的相同值。准备好输入的 NumPy 具有很大的领先优势,而从 Python 列表转换到 Python 列表再转换回来则将该优势缩小到几乎持平。本集解释了执行边界以及如何测量应用程序所需的结果。
本视频涵盖的内容
- CPython 将源代码编译成字节码。重复的解释器/对象工作和类型化的原生数字循环是不同的执行安排。
- 数字 NumPy 算术可以将类型化的批次交给编译操作;算术循环仍然存在,并且视图可以具有不连续的布局。
- 测量转换和所需的输出表示。在此运行中,将 Python 列表转换为数组再转换回来,消除了准备好数组的巨大领先优势。
- 小工作负载可能偏爱普通 Python,因为固定的数组调用成本很重要。八值列表推导式赢得了这次测量的比较。
- np.vectorize 是一个方便的包装器,它为连续的元素调用 Python;仅批处理接口并不能建立原生数字执行。
- 编译的 Python 语法循环也可以很快。当它们对调用者很重要时,包括冷编译/设置成本。
- 原生执行、CPU SIMD、多线程和 GPU 后端是独立的思想。可用的 CPU 特性不会跟踪特定运行中使用的指令。
翻译的文字记录
译自英文原版旁白。可用音频和字幕由 YouTube 控制。
为什么相同的 CPU 可以更快完成?
0:00 你认为 Python 需要一种新语言才能变快。 循环移动后,相同的 CPU 运行我们的批处理更快了。 工作去哪儿了? 加速何时消失? 哪个捷径让 Python 回来了? 这里是 The Daily Diff,幕后揭秘。 密切关注那个捷径。 CPython 是标准的 Python 实现。
0:17 它将你的源代码编译成字节码,然后在 解释器中运行这些指令。NumPy 是一个数值库,其数组包含带有 声明数据类型的值。 它的内置算术可以将整个批处理交给编译操作。 取一百万个浮点值,将每个值平方, 然后加上原始值。 每个版本都得到相同的值并生成匹配的答案。 这些值足够小,以避免舍入差异影响结果。
0:40 在这台机器上,列表推导式大约耗时三十三毫秒。 NumPy 耗时不到一毫秒。 两者都在 CPU 上运行。 这对于这个工作负载来说是有用的证据,但对于一门 编程语言来说,却是一个糟糕的讣告。从普通循环开始。
Python 循环重复了哪些工作?
0:53 Python 获取下一个值,执行算术运算,并追加答案。 然后它再次执行此操作。 乘法运算成本很低。 安排下一次乘法运算,以及处理其周围的 Python 对象, 每次都会增加工作量。 这是测试函数的实际反汇编。 迭代、乘法和加法以字节码指令的形式出现。 Python 版本和解释器做出不同的选择。
1:14 这些计时属于屏幕上显示的 Python 3.10 版本。 Python 列表存储对象的引用,因此不同类型的事物可以 共享一个列表。 我们的算术任务始终使用数字。 它为通用安排付费,同时请求重复服务。 想象一下点一百万次相同的咖啡,每次都填写一张新表单 来获取每杯咖啡。 咖啡机没问题。
1:36 文书工作已经发展出了它自己的职业。
NumPy 将循环移到哪里了?
1:38 NumPy 改变了契约。 此数组包含具有一种声明类型的固定大小浮点值。 我们的数组是连续的,因此其值在内存中紧密排列。 视图可以有其他布局。 算术操作知道它正在接收哪种数据。 该调用将批处理交给原生操作。 循环仍然会访问编译代码中的值。 它可以在整个工作中重用对类型的了解。
2:00 Python 仍然是交接工作的人。 Itamar Turner Trauring 在 Python Speed 中解释了这种区别。 批处理接口、原生实现和处理器向量指令是 独立的事物。函数名称提供的证据很少。
快速意味着 GPU 还是许多 CPU 内核?
2:13 这个结果在 CPU 上使用了 NumPy。 CuPy 等 GPU 后端为数组和操作选择了不同的归宿。 购买 GPU 会让这个普通的 Python 循环继续在其已经运行的地方运行。 CPU 向量指令可以同时处理多个值。 NumPy 拥有选择支持内核的机制。 我们的运行时报告了可用功能,但并未跟踪此表达式的 指令。将所有测量到的增益归因于向量指令会 夸大证据。
2:40 GPU 购物车可以保持关闭状态。 性能修复同时取消购买,这让人深感满足。 不幸的是,基准测试仍在继续。
惊人的加速何时消失?
2:49 八个值时,列表推导式获胜。 调用数组机制的成本超过了微小的任务。 较大的就绪数组后来居上。 我们使用了重复试验和预热过程。 较新的 Python 版本可以改变交叉点。 然后改变应用程序的需求。 从 Python 列表开始,以一个新的 Python 列表结束。 NumPy 必须将输入转换为数组,并将结果转换回 Python
3:11 对象。如果你的应用程序需要这些步骤,它们就应该包含在成本中。 对于一百万个值,这个完整路径大约需要二十八毫秒。 列表推导式也大约需要二十八毫秒。 我们巨大的领先优势变成了几乎持平。 快速的部分是真实的。 优势取决于让数据保持其数值形式。
基准测试中包含哪些边界?
3:28 跟踪数据。 如果下一个操作接受数组,请保留数组。 如果你在列表和数组之间反复转换, 请测量这些转换。 在昂贵的移交之前停止会为错误的 问题提供准确的数字。将此数组相乘会创建临时结果。 添加原始值会创建输出。 原生执行仍然需要内存并移动数据。
3:47 某些操作接受输出缓冲区,当你的代码可以 安全地重用它时,可以减少分配。 一行漂亮的代码可以订购几个大型缓冲区。 Python Speed 也指出了这个问题。 分配器对你的审美观 remarkably indifferent。
编译后的 Python 循环也能很快吗?
4:00 你还可以保留循环语法并更改其执行。 Numba 可以将受支持的数值 Python 函数编译成本机代码。 这是我们测试中带有编译器装饰器的循环。 它仍然说 for。 昂贵的解释器路径已经改变。 编译后,这个循环在准备好数组的情况下耗时不到半毫秒。 在此次运行中,编译本身大约耗时一秒。 该设置成本已从重复计时中排除,
4:22 因此一个微小的一次性任务需要不同的比较。 Numba 自己的指南乐于展示显式循环。 在找到 热路径之前开始语言迁移是 避免打开分析器的一种复杂方式。 首先找出耗时的地方。 网络等待、对象解析和批量算术需要不同的修复方法。 当计算和数据适合时,原生数组操作会有所帮助。
4:43 测量你的调用者实际需要的结果。 现在说说那个捷径。 你有一个 Python 函数,并希望它接受一个数组。 名为 vectorize 的包装器听起来像是缺失的性能按钮。 它的文档中有一个重要的句子正在等待。
np.vectorize 实际执行了什么?
4:56 NumPy 表示其 vectorize 包装器主要用于方便。 它的实现本质上是一个 for 循环。 包装器接受批处理,但会继续为 连续的元素调用你的 Python 函数。接口变短了。 执行边界仍然很忙。 观察相同的值通过 Python 回调返回。 使用准备好的数组,此版本大约需要八十七毫秒。 它比我们的列表推导式慢。
5:18 一个有用的广播包装器可以保留你试图删除的 精确的每个元素的工作。 检查批处理接口后面的实现,将兼容数据保持在其 有用的形式,并在比较中包含转换。 从调用者的角度看,原生循环、回调循环和编译循环可能看起来相似。 vectorize 这个名字做出了你的秒表从未签署的承诺。 当你需要方便时,保留它。 为了性能,请检查正在运行的循环。
你的代码应该改变什么?
5:41 我的结论是,对于批量测量到的数值热路径,SHIP IT。 当普通循环已经运行良好时,请保留它们。 检查工作发生在哪里以及下一个调用者需要什么。 订阅,点击铃铛,并在评论中告诉我你是否会 以不同的方式盖章。这就是今天的差异。 我是 Axrisi 的 Niko。 负责任地合并。
来源
- Universal functions (ufunc) basics — NumPy 2.2NumPy
- The N-dimensional array — NumPy 2.2NumPy
- numpy.vectorize — convenience and performance notesNumPy
- CPU/SIMD optimizations — NumPy 2.2NumPy
- dis — Disassembler for Python bytecodePython Software Foundation
- CPython 3.10.12 list representationCPython contributors
- The limits of Python vectorization as a performance techniqueItamar Turner-Trauring / Python⇒Speed
- Performance TipsNumba
- Basics of CuPyCuPy



