IT技术

绕过 GIL 提升 Python 性能的一般方法

作为一种使用动态类型的解释型语言,Python 的执行性能(主要指 CPU 运算方面)经常会让一些人感到纠结。如果说以牺牲代码执行效率来换取代码编写效率还可以接受的话,GIL 的存在就显得有些不讲道理了,因为它使得 Python 无法在线程级别利用多核心运算。

GIL

在并发访问过程中保持数据一致性是一个很普遍的问题,而使用锁则是该问题一个很普遍的解决方法。全局解释器锁(global interpreter lock)如其名运行在解释器主循环中,在多线程环境下,任何一条线程想要执行代码的时候,都必须获取(acquire)到这个锁,运行一定数量字节码,然后释放(release)掉,然后再尝试获取。这样 GIL 就保证了同时只有一条线程在执行。

一般来说,GIL 并不会带来麻烦,因为大多数程序的性能瓶颈都在 IO 上(IO-bound)。但当你运行计算密集型代码,而且计算量又非常大的时候,比如数据分析,你就会发现问题了——你的程序对 CPU 的使用率非常低。以我现在使用的 i5-460m 双核四线程(2C/4T)处理器来说,Python 最多只能占用 25% 的 CPU 负载,正好是一条线程的量。不论是多核心,还是 intel 的超线程技术,现在提升 CPU 性能的有效手段已经从提高单核性能转变为增加核心数量,即执行并行(parallelism)运算。显然 GIL 的存在与这种思路有些格格不入。

如果参考其他实现的话,你可能会问一个问题,为什么要使用全局锁,而不是一个更细粒度的锁呢?实际上 Linux 的文件系统就是这样做的,进程给目标文件加锁的时候,可以只加一定字节数的锁,只要另一个进程准备加的锁与其没有交集的话,这两个锁就可以共存,这两个进程也可以同时修改这一个文件(的不同部分)。因此对于 Python,也许可以给对象加锁(一切皆对象嘛),同时不限制线程的并行执行。但从网上的信息来看,似乎这种思路曾经被尝试实现过,但细粒度的锁会给单线程模式下的性能造成明显影响。所以还是用 GIL 吧~

通常,我们不会责备某个人笨,但要是他笨还不努力的话可能就得说道说道了。Python 现在的情况就是,使用这门语言的人基本都已经接受它不如 C 快这一点了,但是对于那些用着 4 核 8 线程,甚至 6 核 12 线程的 CPU 的人来说,看着自己的程序在 10% CPU 负载下执行两个小时确实是一件很别扭的事。

通过我前面不停提到的超线程(Hyper-Threading)技术,也许有人已经想到了,通过禁用超线程,是不是可以提升单线程性能呢?如果 i5-460m 只有两条线程的话,那 Python 解释器不就可以直接使用到 50% 负载吗?甚至在网上也常有这种说法——禁用超线程后,很多游戏会运行的更流畅。但是很可惜,事情没有那么简单。

超线程

intel 的超线程,是使一个物理核心可以在极短时间内“同时”执行两条或多条线程的技术,它在操作系统上的体现为:一个物理核心会被当做两个逻辑核心使用。这便是当你打开任务管理器,并切换到【性能】标签页后,会看到比你 CPU 核心数量多一倍的小方格的原因。

更加具体的物理实现这里就不说了,因为我也不懂。下面只通过一些实验数据来看一下超线程 CPU 在不同线程下的效率表现。禁用 HT 的操作必须在 BIOS 中进行,很可惜笔记本的 BIOS 是简化过的,没有这个选项,所以我用自己电脑进行的测试都是运行在 HT enable 的状态,禁用后的数据来自网络。

以双核四线程的 i5-460m 为例,在 Windows 上表现为 4 个逻辑核心,分别名为 “CPU 0”,“CPU 1”,“CPU 2” 和 “CPU 3”。其中 0 和 1 是同一个物理核心,2 和 3 是另一个物理核心。在任务管理器的进程标签页中可以为指定进程设置处理器相关性,即为其分配指定的逻辑内核。或者也可以通过 cmd 命令:start /affinity .exe 来在程序打开前指定处理器相关性,其中 应被替换为 CPU 编号的 16 进制掩码。如,为名为 test.py 的脚本分配 CPU 0 和 CPU 2 并执行的命令为:start /affinity 5 python test.py(2^0 ^+ 2^2 ^= 5)。对于如象棋或 CINEBENCH 这样的测试软件,直接在任务管理器中设置即可,出于执行时间考虑,本文使用国际象棋来进行测试。

测试数据(HT 启用)

单核单线程:
CPU 0 CPU 1 CPU 2 CPU 3
1645 1627 1726 1704

双核单线程:
CPU 0+1 CPU 1+2 CPU 2+3
1654 1743 1735

双核心跑单线程时,CPU 负载会分布在两个核心中。这时如果把资源监视器里面的 CPU 视图截图下来,并把两个核心中的一个(上图)做垂直旋转,然后以 50% 透明度叠加到另一个核心的视图(下图)上,就能看到他们基本是完全互补的。0+1 和 1+2 有少量重叠的阴影存在,原因不是很确定,可能是其他进程的负载吧。这样两个核心的负载总和只有单逻辑核心的 100%,即 i5-460m 的 25% 。

205211_I0Ev_660175

四核单线程:

1747

双核双线程:
CPU 0+1 CPU 0+2 CPU 0+3 CPU 1+2 CPU 1+3 CPU 2+3
2282 3537 3496 3494 3526 2375

四核四线程:

4472
测试数据(HT 禁用)

HT 禁用模式下的数据来源于网络,因为与我的环境不同,这里直接给结论吧:

单线程模式下,HT 开关对结果分数无影响 —— 引用来源
多线程模式下,禁用 HT 会带来 20%+ 的性能损失 —— 引用来源

结论

上面数据中的看点,或者说比较出乎我意料的地方在于:

双核双线程时,如果这两个核心属于同一个物理核心,那么结果的分数并非双核单线程的两倍,而是只多出 30%+;如果这两个逻辑核心来自于不同的物理核心,则可以达到两倍的分数。即是说,即使按最初预想的,关闭 HT 后,单物理核心可以在单线程模式下发挥全部性能的话,也仅是增加 30%+,而不是一倍。
而实际上关闭超线程后,这 30%+ 的性能提升也是不存在的,因为从结果来看开关 HT 对单线程运算力没有影响。而从 CPU 整体来看,禁用超线程也带了 20%+ 的性能损失。即禁用超线程的结果并非简单拿掉了一种线程切换机制,更是屏蔽了一部分性能,我倾向于将其理解为物理相关的,即核心中有一部分元件被禁用了。
4 核心 4 线程(即满负载)时的分数正好为(同一物理核心中)双核双线程分数的两倍:4472 ≈ 2282 * 2,这很科学。

所以禁用超线程这条路算是走不通啦~其实最初看到 CPU 负载 25% 的时候,实际情况并没有想象的那么差。单线程的 Python 程序对 i5-460m 运算性能的使用率应该为 1654 / 4472 = 37% ,性能与负载之间并非线性对应关系。

多进程

想要不改动任何代码地实现性能提升看来是不可行了。其实对于 GIL 的问题,包括官方给出的最常见的解决方案是:使用多进程,multiprocessing 模块。

multiprocessing 模块拥有和多线程模块 threading 很类似的 API。但有一些值得注意的细节,出于时间考虑这里就先不研究了,毕竟现在还用不到。因为多进程编程在你依赖第三方包的时候会变得有些麻烦,而 Python 代码会出现 CPU 性能瓶颈的地方,也基本都与科学计算或数据分析的第三方包有关(numpy、pandas 等)。这种情况下,自己编写的某种运算的多进程版未必能比第三方包内建的单线程版快上多少,尤其在你的 CPU 并没有很多核心的时候,而且还需要注意 bug。因此这种情况下,将多进程运算集成进第三方包可能是一个更好的方法。

Cython

上一节说到自己写的多进程程序未必比单线程的第三方包快,一方面的原因在于,计算密集型的扩展包很多都是用 C/C++ 这样的编译型语言写的。而这类扩展包有一个很重要的特性在于,它可以在被调用时选择释放 GIL,从而在不影响主程序的情况下独立运算。

但问题在于 C 代码不好写,更别说需要使用第三方数据结构的情况了。这时候在 C 扩展和 Python 之间的一种折中方案就是 Cython,对于 Cython 可以简单地将其看成是拥有静态类型并能嵌入 C 函数的 Python。Cython 也同样支持释放 GIL 后的并行。

不论是多进程,还是编写扩展,在实行前都得计算一下成本收益,依据可以节省的时间的量来决定付出多少的努力。对于多数偶发性的情况,可能优化一下自己的代码就能带来很好的效率提升,又不必为此花掉太多时间。因此对于 multiprocessing 和 Cython 的实现细节,这里就先不深究了。

 

发表评论