导读:在 PyCon 2024 大会上,部分技术专家展示了加速 Python 编程语言的多种方法,包括子解释器、永久对象、即时编译等。
有一句“古老”的 Python 格言:
“使用 Python,您需要在运行时付费”。
是的,Python 语言因其运行缓慢而“声名狼藉”,但它却是一种很好的入门语言,虽然速度不如其更复杂的同类编程语言。
好的消息是,上个月在匹兹堡举行的PyCon US 2024上的许多演讲都展示了研究人员如何推动该语言的前沿技术升级以及发展。
编译 Python 代码加快数学运算速度
Saksham Sharma 在 Tower Research Capital 担任量化研究技术总监,但是他使用 C++ 构建交易系统。
他在演讲时说,“我喜欢快速的代码”,他坦承表示希望将这种热情带到 Python 中。
Saksham Sharma 出席 2024 年 PyCon大会
Python 是一种解释型语言,但是 Python 本身的CPython 参考,实际上的实现是用 C 编写的。
解释器将源代码转换为由 1 和 0 组成的高效字节码。然后它直接执行源代码,并在读入所有对象和变量时根据这些对象和变量构建程序的内部状态,而不是像编译器那样提前编译成机器码。
Sharma 说:“所以,我们在这里经历了一系列的间接过程,所以事情可能会变得缓慢。”
对于 Python 来说,即使是将两个数字相加的简单指令,也会导致对 CPU 本身产生超过 500 条指令,不仅包括加法本身,还包括所有支持指令,例如将答案写入新对象。
一款新编译器 Cython是一个针对 Python 进行优化的静态编译器,它允许您用 C 编写代码,提前编译,然后在 Python 程序中使用结果。
Sharma 提示说:
“你可以构建内置于解释器中的外部库和实用程序,它们可以与解释器的内部状态进行交互,如果你在解释器上编写了一个函数,则可以配置为调用该函数。”
例如, Python 代码的两个变量相加:
#Code written by Saksham Sharma
def print_add(a, b)
c = a + b
print(c)
return c
可以这样为 Cython 呈现:
#Code written by Saksham Sharma
PyObject *__pyx_pf_14cython_binding_print_add(...) {
...
__Pyx_XDECREF(__pyx_r);
__Pyx_INCREF(__pyx_v_c);
__pyx_r = __pyx_v_c;
goto _pyx_L0;
...
}
代码侧开发需要输入更多内容,但编译器侧的工作量会减少。
Sharma 发现,在自己的机器上,此种额外操作使用 Python 大概需要 70 纳秒,但使用 Cython 只需要大概 14 纳秒。
“Cython 确实让速度更快了,因为解释器不再起作用。例如,每次解释器进行两个变量的相加时,它都必须检查每个变量的类型。但如果你已经知道类型是什么,为什么不取消这种检查呢?这就是程序员在代码中声明变量类型时所做的。
Sharma 说:“这样输入的代码可以快得多。”
Cython 可以加速内循环
使用静态类型进行缩放的 Python
正如 Sharma 演讲中所指出的那样,通过 Python 中的静态类型化可以获得很多优势。使用静态类型化,您可以定义变量的数据类型。它是字符串?整数?数组?解释器需要时间来弄清楚所有这些。
Anaconda 首席软件工程师Antonio Cuni介绍了SPy,这是 Python 的一个新子集,需要静态类型。其目的是提供 C 或 C++ 的速度,同时保留 Python 本身的用户友好感。
Cuni 解释说,Python 在执行指令之前必须做很多事情。与 Sharma 一样,Cuni 指出,“使用低级语言时,运行时通常要做的工作比较少。”
在执行逻辑之前,Python 解释器必须找到所有工具(如工具和库)来执行逻辑本身,这个中间阶段的工作会花费大量时间。
好消息是,很多工作可以在编译阶段提前完成。
使用 SPy,所有全局常量(例如类、模块和全局数据)都被冻结为“不可变的”,并且可以使用即时 (JIT) 编译器进行优化,得益于它们的类型。
目前,Cuni 正致力于实现 SPy,要么作为 CPython 的扩展,要么使用自己的 JIT 编译器。
此外,Cuni 表示,他还在研究可以在WebAssembly中运行的版本。
图编译与解释 (Antonio Cuni)
静态链接的 C 扩展
Meta 工程经理Loren Arthur在演讲中表示,用 C 重写处理繁重的函数可以大大提高性能。但是,您必须小心将它们加载到程序中的方式。
在他自己的演示中,导入 Python 的 AC 模块可以将样本文件中数据的处理时间从 4 秒缩短到近半秒(常规 Python 代码的处理时间)。
这听起来微不足道。但对于Meta 这样规模的处理来说,这是一笔不小的开销。将 Python 功能转换为更灵活的 C 代码,用于 90,000 个 Python 应用程序,仅凭构建速度的提高,Meta 工程师每周就能节约 5,000 小时。
没错,这太棒了。然后 Instagram 就构建了数千个 C 扩展来推动事情的发展。
但是后来这家社交媒体巨头又遭遇到了另一个问题。构建中包含的 C 扩展越多,导入时间开始变长。这很令人奇怪,因为大多数模块都很小,可能只有一个方法和一个字符串返回值。
在使用Callgrind时(它是Valgrind动态分析工具套件的一部分),Arthur 发现一个名为dlopen的 Python 函数打开共享对象时耗费了 92% 的时间。
他说道:“加载共享对象的代价很高,特别是当数量非常大的时候。”
Meta 以嵌入式 C 扩展的形式找到了答案,即对共享对象进行静态链接而不是动态链接。C 代码直接复制到可执行文件中,而不是调用共享对象。
永生的对象
全局解释器锁( GIL) 可以锁定多个进程同时执行 Python 代码,但在Azion Technologies 软件工程师团队负责人 Vinícius Gubiani Ferreira 看来,它并不是这个故事中的罪魁祸首。
相反地,GIL 的确是一位英雄,但由于存在时间太长而变成了“恶棍”。
Ferreira 的演讲中讨论了 PEP 683,旨在改善大型应用程序的内存消耗。最终的库被纳入10 月份发布的 Python v 3.12 中。
GIL 的设计初衷是防止竞争条件产生,但它后来也阻碍了 Python 进行真正的多核并行计算。目前,Python 正在努力使 GIL 功能成为可选项,但可能还需要几年时间才能将其稳定地融入语言运行时。
Ferreira 解释说,基本上Python 中的一切都是对象。包括变量、字典、函数、方法甚至实例:所有都是对象。最基本的形式是,对象由类型、变量和引用计数组成,引用计数用于统计指向此对象的数量。
所有 Python 对象都是可变的,即使是那些标记为不可变的对象(例如字符串)。
而且在 Python 中,引用计数会经常发生变化,这实际上是有问题的,因为每次更新都意味着缓存会重新生成,基本上无效。它使分布式程序变得复杂。它会导致数据竞争,更改可能会相互覆盖,如果结果等于零,那么 Boom!垃圾收集器会立即删除该对象。
如果应用程序的扩展越大,这些问题就愈加严重。
答案很简单:创建一个引用计数为永不可变状态,即通过将引用计数设置为无法更改的特定高数字(Ferreira 指出,你可以让程序增加到该数字,这需要几天时间)。运行时将单独管理这些超级特殊对象,并负责关闭它们。
一个更好的特性是:这些特性还绕过了 GIL。因此,它们可以在任何场合使用,并且可以同时由多个线程使用。
使用此方法, Cython的性能会略有下降,最高可达 88% ,因为考虑到运行时必须保留单独的表,这并不奇怪。但特别是在多处理器环境(比如 Instagram)中,性能改进是值得的。
Ferreira 如此提示道:“你需要衡量一下,看看自己做地是否正确。”
共享不可变内容
绕过 GIL 的另一种方法是使用子解释器,这也是今年PyCon的一个热门话题。
子解释器架构允许多个解释器共享相同的内存空间,每个解释器都有自己的 GIL。
一个名为“memhive”的 Python 框架提供了这样的一个编排器,它实现了一个子解释器工作池以及一个 RPC 机制,以便它们可以共享数据。
它的创建者Yury Selivanov是 Python 核心开发人员和EdgeDB的首席执行官/联合创始人,他也在 Pycon 演讲中介绍了它。
Selivanov 首先在笔记本电脑上演示了一个程序,该程序使用 10 个 CPU 内核同时执行 10 个异步事件循环。它们共享相同的内存空间,即一百万个key的内存空间。
是什么阻止你在自己的机器上执行此操作?那个“老恶棍”——GIL。
Memhive 设置了一个主子解释器,然后可以根据需要生成任意数量的其它子解释器。
不可变对象是一个挑战,Python 中有很多这样的对象,例如字符串或元组。如果要更改它们,您必须创建一个新对象并复制每个元素。从计算上讲,这是一个非常昂贵的操作,如果再考虑到更新缓存,则成本会加倍。
Memhive 使用一种共享数据结构,称为结构共享(隐藏在Python 库中的hamt.c),其中捕获后续更改,但引用而不是复制旧的不可变数据结构的部分,从而节省了大量工作。
共享参考结构化信息而不再复制副本,从而有效节省时间。
Selivanov 说:
“如果你想添加一个键,不必复制整个树,你只需创建缺失的新分支,并引用其他分支,所以如果你有一个包含数十亿个键的集合,并且你想添加一个新键,你只需创建几个底层节点,其余的节点就可以重复使用。你不需要对它们做任何事情。”
结构化共享为并行处理打开了大门,因为数据是不可变的,从而允许多个子解释器在同一数据集上并行工作。
“因为我们使用的是不可变的东西,所以我们实际上可以安全地访问底层内存,而无需获取锁或任何东西,”他说。这可以将速度提高 6 倍到 150,000 倍,具体取决于复制的数量。
即使变更的数量急剧增加,变更所需的时间仍在控制之中。
结语
Python 并不是最快的编程语言,如果许多特性和改良要得以实现,将需要数年时间。但如果开发者意识到 Python 本身的速度和灵活性之间的权衡,那么他们现在就可以做很多的事情。
Python 是一种出色的编程语言,可以将业务逻辑的不同部分粘合在一起。而其它语言则非常适合极低级别的优化,通常可以实现快速优化,我们需要找到这些方面的正确平衡点。