高性能Python编程(1)

高性能Python编程(1):理解Python的“性能”

引子

Python的性能常常被人诟病,但是另一方面Python却又常常出现在高性能计算库中,比如tensorflow,numpy等等,这是为什么呢?Python的性能究竟差在哪里?理想的高性能程序应该是什么样的?如何能写出高性能的Python程序?这个系列我们就尝试探索这些问题的答案。

首先我们退一步看一下组成计算机的基本部分,这样我们才能下手分析和提高程序的执行性能。

计算机三大部分

计算机的设备主要分成三类:计算单元、存贮单元、通讯单元

典型的计算机由计算单元(CPU),CPU会连接两类存贮单元:内存和硬盘,通讯单元(总线)就是连接计算单元和存储单元的桥梁。

你可能会问:显卡呢?键盘呢?显示器呢??懂行的你可能还会问:南北桥呢?细想一下,显卡其实是计算单元(GPU)和存储单元(显存)的组合体;而键盘、显示器、鼠标、网卡这些基本属于IO设备,所谓IO就是Input,字节写入内存,Output,字节读出内存,这类设备通常通过总线与内存沟通,利用中断与CPU协作。硬盘在这个意义上也是IO设备,因此这些设备我们姑且认为属于存储单元吧。南北桥其实算是通讯单元的司令部,北桥芯片,主要负责控制显卡、内存与CPU之间的数据交换;靠近PCI槽的为南桥芯片,主要负责硬盘、键盘以及附加卡的数据交换。

说到底,计算过程就是:把字节(Byte)从存储单元放进计算单元“计算”,然后把结果放回存储单元,而“计算”就是改变字节。

可以看出,影响程序执行效率的主要有两个因素:移动字节的速度和计算的速度。移动字节的速度主要受限于存储介质的读写速度和通讯的速度;计算速度的影响因素主要是计算单元的类型和架构,比如CPU和GPU对不同的指令执行效率不尽相同,而更极端的例子量子计算芯片,QPU,则拥有完全不同的“计算”方式。

计算单元

计算单元种类繁多,有我们常见的CPU,GPU,也有一些不太常见的,比如TPU(张量处理器),QPU(量子计算处理器)等等。不过不论是那种计算单元,他们的工作就是输入一串比特,输出另一串比特。计算单元的主要衡量标准就是单位时间内可以进行的计算量。

我们那常见的CPU的性能举例,我们关心两个指标:一个cycle可以执行的指令数量;一秒钟最大的cycle数量。前者我们用IPC(Instruction Per Cycle)衡量,后者我们用时钟频率衡量。不同的CPU架构会有截然不同的特性,比如酷睿处理器的IPC非常高,但是频率较低;而早期的奔腾处理器IPC较低,但是频率非常高。

提高主频对提高计算速度有直接的帮助,而提高IPC则可以有效的提高“向量化”操作的速度,即同时处理多组计算。你可以理解成一个是纵向提升,一个是横向提升。

有时候你的编译器也会分析程序,然后根据CPU指令集做一些优化,进一步提升计算效率。

在多核时代,程序尽可能多的利用多个带来的并行能力也是提升计算效率的常见手段。而且,由于目前CPU工艺经很难进一步提高时钟频率了,计算机更倾向于安装更多处理器核心,而不是主频更高的处理器。

不过,在Python诞生的年代(上个世纪90年代),还是单核的时代,CPython实现引入了GIL,即Global Interpreter Lock,来处理多线程下的同步问题,你可以把他理解成一把大锁,用来保护解释器内部状态。这是设计其实在当年大大提高了单核执行效率,特别后来更新GIL调度方式以后,对IO多线程有巨大的贡献。然鹅,进入20世纪后,多核处理器成为了主流,Python的GIL导致解释器不能有效利用多核进行并行计算,不过Python社区也提出了多种方法解决这个问题,比如multiprocessing库,一些第三方库,比如numpy, numexpr等等,Cython编译器,以及利用计算集群进行计算。

以上这些方法,我们在后续的文章中逐步介绍。

存储单元

存储单元主要负责储存比特,这些比特可以是计算的输入,也可以是计算的输出。衡量存储单元的指标主要有三个:容量、读写速度、寻址速度。

通常存储单元是分层的,容量从低到高,读写、寻址速度从高到低:

  • CPU缓存L1、L2、L3
  • 内存
  • 固态硬盘
  • 机械硬盘
  • 网络

为了提高计算的性能,我们应该尽可能多的使用读写速度更快的单元作为缓存,减少不同层次之间存储单元的数据移动,因为这种数据的时间成本非常高,而且约下层越高。这也是为什么编程时,Locality特别的重要,Locality不但可以降低寻址的时间,还可以最大程度的利用高速缓存。

对于Python而言,Locality通常是被破坏的,这是CPython的实现机制导致的,不过我们也有办法克服。

通讯单元

典型计算机的通讯是通过某类总线(Bus)实现的,比如前端总线负责L1L2缓存和内存的通讯;而网卡可以认为是机器和机器之间存储单元的通讯。

衡量总线性能指标主要由两个:一次移动比特的数量,即带宽;移动比特的频率,即总线频率。

理想的高性能程序

现在我们了解了计算机计算的三个组成部分,下面我们用一个检测素数的小程序来说明如何构造一个“完美”的高效程序。

首先我们写一段Python伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
import math


def check_prime(number):
sqrt = math.sqrt(number)
for i in range(2, int(sqrt)+1):
if (number / i).is_integer():
return False

return True

check_prime(11)

我们抛开Python的实现机制,看看上面的程序是如何执行的。

首先,为了计算sqrt,我们需要把number的值从内存送往CPU,理想情况下number会驻留在CPU的缓存。CPU计算sqrt后把值送回内存。然后我们进入循环,理想情况我们会一次性把numberi同时送往CPU,然后检查是否整除。

如何进一步优化程序?第一个方法就是利用CPU的向量化能力,一次计算多组numberi。在这过过程中,充分利用缓存来存储尽可能多的i值和结果,这样就可以减少数据传输的时间。

1
2
3
4
5
6
7
8
9
def check_prime(number):
sqrt_number = math.sqrt(number)
numbers = range(2, int(sqrt_number)+1)
for i in range(0, len(numbers), 5):
result = (number / numbers[i:(i + 5)]).is_integer()
if any(result):
return False

return True

Python慢在哪里

好了,下面我们来说说Python究竟慢在哪里了。Python的解释器(虚拟机)是非常好的抽象,程序员不太需要考虑我们上面提到的三种计算单元,比如绝大多数情况下,Python程序员不需要考虑如何分配内存,如何优化缓存,或者如何把变量送到CPU。这是Python的优势,但是这些抽象会损害程序的执行效率。话句话说,Python的执行循环中有很多指令都是为了支持这些抽象的。

当我们谈到Python的性能,其实有不同的维度。

首先,Python代码的性能不容易直接推断,如果你恰好知道一些小技巧,你的程序就会快很多,而同样的业务逻辑其他的实现方法就会慢一些。我举个例子:

1
2
3
4
5
6
def search_unknown1(haystack, needle):
return any((item == needle for item in haystack))


def search_unknown2(haystack, needle):
return any([item == needle for item in haystack])

你觉得上面两个函数那个运行更快??所以,Python这类语言提升性能之前,更应该首先进行Profiling,来确定性能瓶颈。

其次,Python(CPython)的Object内存规划并没有很高效,这与Python的GC和内存池有关。但是这种内存使用和回收机制会破坏缓存,也会导致不能充分利用总线带宽。

另外,Python语言灵活性的代价就是动态类型,解释器需要花费大量时间弄清楚对象的类型,寻找正确的函数等等。不过这个问题其实容易解决,只要我们可以识别性能瓶颈,就可以采用Cython这样的工具突破瓶颈。

最后,就是GIL。上文已经提到了,GIL限制了并行计算能力,但是这个问题其实也很容易解决。

那为啥Python还是这么火?

既然Python这么“慢”,为啥Python还是这么火??网上已经很多讨论了,我就说几个:

1、近乎完美的生态。从大数据、机器学习、数值计算到Web,再到量子计算。
2、对非计算机科学的小伙伴极度友好
3、计算机专业的人用起来真的很舒服,抽象能力还是很不错
4、大觉部分性能问题可以很轻松地解决(哈哈哈,这就是这个系列的重点)

OK,下一篇我们来谈谈如何Profiling程序,这是提高执行效率的第一步!

参考

  • 《High Performance Python》