1.2 综合考虑
要完全明白高性能编程面临的问题,仅熟悉计算机的基本部件还不够,还需知道它们如何相互影响以及如何协同工作来解决问题。本节将探索一些简单问题,看看它们的理想解决方案是如何工作的,以及Python又是如何解决它们的。
本节可能让你感到绝望,因为它好像主要是说Python无法应对性能方面的问题。但实际情况并非如此,其原因有两个。首先,这里讨论高性能计算时,忽略了一个重要的因素——开发人员。Python本身的性能虽然不高,但使用它可快速开发程序,这就弥补了这种缺陷。其次,通过利用本书后面将介绍的模块和理念,可轻松地消除这里介绍的众多问题。因此,使用Python可快速开发程序,同时规避众多性能约束。
理想的计算模型与Python虚拟机
为了更深入地理解高性能编程的因素,来看一段简单的代码,它判断一个数是否是素数:
import math def check_prime(number): sqrt_number = math.sqrt(number) for i in range(2, int(sqrt_number) + 1): if (number / i).is_integer(): return False return True print(f"check_prime(10,000,000) = {check_prime(10_000_000)}") # check_prime(10,000,000) = False print(f"check_prime(10,000,019) = {check_prime(10_000_019)}") # check_prime(10,000,019) = True
我们先根据抽象计算模型分析这段代码,再将其与Python运行这段代码的情况进行比较。与其他抽象一样,这里也将忽略理想计算模型和Python运行这些代码时涉及的众多细节。在解决问题前,先像这里这样做通常都是一个不错的主意:想想算法的基本组成部分以及最佳的解决方案是什么样的。知道理想情况以及Python中的实际情况后,就可不断调整Python代码,使其更接近最优状态。
1.理想的计算模型
在上述代码中,首先将number的值存储到了RAM中。为计算sqrt_number,需要将number的值传递给CPU。在理想情况下,只需传递这个值一次:它将存储在CPU的L1/L2缓存中,而CPU将执行计算并将结果传回给RAM进行存储。这是最理想的情况,最大限度地减少了从RAM中读取number值的次数,转而从L1/L2缓存中读取这个值,而这样做的速度要快得多。另外,通过使用与CPU直接相连的L1/L2缓存,最大限度地减少了通过前端总线传输数据的次数。
提示:对优化来说,确保数据在合适的地方并尽可能少地移动它们至关重要。所谓“繁重的数据”指的是花费大量时间和精力来移动数据,这是需要避免的。
对于上述代码的循环部分,我们希望一次性将number和多个i值传递给CPU,而不是每次传递一个i值。这是因为CPU能够向量化操作,且不会增加时间开销,换句话说,CPU能够同时执行多项独立的计算。因此,我们希望将number传递给CPU缓存,并根据缓存的容量传递尽可能多的i值。对于所有number和i值组合,都将它们相除并检查结果是否是整数,再返回一个信号,指出是否有结果是整数。如果有结果是整数,整个函数就到此结束;如果没有,就重复前述过程。通过这样做,可针对多个i值返回一个结果,避免了通过速度缓慢的总线返回每个i值的结果。这里利用了CPU的向量化功能,即在一个时钟周期内对多项数据执行同一条指令。
下面的代码演示了向量化的概念:
import math def check_prime(number): sqrt_number = math.sqrt(number) numbers = range(2, int(sqrt_number)+1) for i in range(0, len(numbers), 5): # the following line is not valid Python code result = (number / numbers[i:(i + 5)]).is_integer() if any(result): return False return True
这里对流程做了设置,使得每次循环根据5个i值执行除法运算并检查是否有结果为整数。如果正确地进行了向量化,CPU就能一步执行完整行代码,而无须针对每个i值分别进行计算。理想情况下,CPU可独立完成操作any(result),而无须将结果传回给RAM。第6章将更详细地介绍向量化,包括其工作原理以及在什么情况下使用它可提高代码的性能。
2.Python虚拟机
Python解释器做了大量的工作,力图对程序员隐藏它使用的计算部件。这让程序员根本不用考虑如下问题:如何给数组分配内存、如何组织这些内存以及其中的数据是以什么样的顺序发送给CPU的。这是Python的一个优势,让程序员能够专注于要实现的算法,但付出的代价是性能可能急剧下降。
需要指出的是,Python运行的确实是经过极度优化的指令,但你需要掌握一些诀窍,让Python以正确的顺序执行这些指令,以进一步提高性能。例如,在下面的示例中,search_fast的速度比search_slow快,这很容易判断出来,这是因为虽然这两个函数的运行时间都是O(n),但search_fast通过提前结束循环避免了多余的计算。然而,在涉及派生类型、特殊的Python方法或第三方模块时,情况可能更复杂。例如,对于下面的函数search_unknown1和search_unknown2,你能迅速判断出哪个的速度更快吗?
def search_fast(haystack, needle): for item in haystack: if item == needle: return True return False def search_slow(haystack, needle): return_value = False for item in haystack: if item == needle: return_value = True return return_value 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])
前面演示的是找出无用操作并将其删除,与之类似的是通过剖析找出速度缓慢的代码,并寻找效率更高的计算方式。虽然最终的结果是一样的,但通过这样做,可极大地减少计算次数和数据传输次数。
前述抽象层带来的影响之一是,无法直接利用向量化。在前述判断素数的函数中,对于每个i值都将运行一次循环迭代,而不会将多个迭代合并。如果你再看看前述向量化示例,将发现它并非合法的Python代码,因为在Python中,不能将浮点数与列表相除。在这种情况下,诸如numpy等外部库可提供帮助,它让你能够执行向量化数学运算。
另外,Python所做的抽象还会影响这样的优化,即它依赖于将相关的数据保留在L1/L2缓存中,以供下一次计算时使用。导致这种结果的原因很多。首先,Python对象在内存中并不是以最优方式排列的。这是因为Python是一种垃圾收集语言——根据需要自动分配和释放内存。这会导致内存碎片,进而可能影响将数据传输到CPU缓存的过程。与此同时,你无法直接调整数据结构在内存中的排列,这意味着即便与特定计算相关的数据量低于总线宽度,也可能无法通过总线一次性传输它们[4]。
[4] 第6章将演示如何重获这种控制权,进而对代码进行优化,细致到其内存使用模式。
其次,Python不是编译型语言,且其使用的类型是动态的。凭借多年的经验,很多C语言程序员都发现,编译器通常比自己聪明。编译静态代码时,编译器能够巧妙地调整布局以及CPU运行指令的方式,从而对代码进行优化。Python不是编译型语言,雪上加霜的是,其类型是动态的,这意味着根据算法推断出可能的优化机会要难得多,因为在运行期间,代码的功能可能发生变化。缓解这种问题的途径有很多,其中居首的是使用Cython,它让Python代码能够被编译,还让开发人员能够将代码的动态程度告知编译器。
最后,前面提到的GIL也可能影响并行代码的性能。例如,假设为利用多个CPU核心对前述代码进行修改,让每个核心处理2~sqrtN的一个子范围。每个核心都可独立地处理分配给它的子范围,再在处理完毕后比较结果。虽然这样做会失去提前结束循环的好处(因为每个核心都不知道其他核心的处理结果),但可减少缩小每个核心需要处理的数字范围(如果有M个核心,则每个核心需要做的检查将为sqrtN/M次)。但是,由于GIL的存在,不能同时使用多个核心。这意味着效果与非并行版本相同,而且不能提前结束循环。为避免这种问题,可使用模块multiprocessing实现多进程(而不是多线程),还可使用Cython或外部函数。