Python高性能编程第3版1了解高性能Python-2理想计算模型和Python虚拟机3为什么要使用Python


1.2 理想化计算与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 Falsels
    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

执行结果:

# time python check_prime.py
check_prime(10,000,000) = False
check_prime(10,000,019) = True

real    0m0.014s
user    0m0.012s
sys     0m0.003s

让我们使用计算的抽象模型来分析这段代码,然后比较 Python 运行这段代码时会发生什么。与任何抽象模型一样,我们会忽略理想化计算机和 Python 运行代码时的许多微妙之处。不过,这通常是在解决问题前进行的一项很好的练习:思考算法的一般组件,以及计算组件组合在一起找到解决方案的最佳方式。通过理解这种理想情况,并了解 Python 引擎盖下实际发生的情况,我们就能不断地使我们的 Python 代码更接近最佳代码。

警告: 本节的大部分内容似乎在说,Python 本身无法处理性能问题。这是不真实的,原因有二。首先,在所有 “高性能计算的组成部分 ”中,我们忽略了一个非常重要的组成部分:开发者。Python 在性能上可能有所欠缺,但它在开发速度上马上就能弥补。此外,在本书中,我们将介绍一些模块和理念,它们可以帮助我们相对轻松地缓解这里描述的许多问题。通过这两方面的结合,我们将保持 Python 的快速开发思想,同时消除许多性能限制。

1.2.1 理想化计算

代码开始时,我们在 RAM 中存储了数的值。为了计算 sqrt_number,我们需要将 number 的值发送到 CPU。理想情况下,我们只需发送一次数值,它就会存储在 CPU 的 L1/L2 缓存中,CPU 会进行计算,然后将数值发回 RAM 存储。这种方案非常理想,因为我们最大限度地减少了从 RAM 中读取数值的次数,而是选择从 L1/L2 缓存中读取,这样速度会更快。此外,通过使用直接连接 CPU 的 L1/L2 高速缓存,我们还最大限度地减少了通过前端总线传输数据的次数。

提示: 将数据保存在需要的地方,并尽可能少地移动数据,这一点在优化时非常重要。重数据 “的概念指的是移动数据所需的时间和精力,而这正是我们希望避免的。

对于代码中的循环,我们不希望一次只向 CPU 发送 i 的一个值,而是希望同时向 CPU 发送数字和 i 的多个值进行检查。这样做是可行的,因为 CPU 在进行矢量化操作时不会产生额外的时间成本,这意味着它可以同时进行多个独立的计算。因此,我们要将数字发送到 CPU 缓存,同时还要发送缓存所能容纳的 i 的尽可能多的值。我们将对每个数字/数对进行除法运算,并检查结果是否为整数;然后我们将发回一个信号,指示其中是否有数值确实是整数。如果是,函数结束。如果不是,则重复执行。通过这种方式,我们只需要为 i 的多个值传回一个结果,而不是每个值都依赖于慢速总线。这就利用了 CPU 的矢量化计算能力,即在一个时钟周期内对多个数据运行一条指令。

下面的代码说明了矢量化的概念(以下代码只是说明概念,不能实际执行):

def check_prime(number, V=8):
    sqrt_number = math.sqrt(number)
    numbers = range(2, int(sqrt_number)+1)
    for i in range(0, len(numbers), V):
        # the following line is not valid Python code
        result = (number / numbers[i:(i + V)]).is_integer()
        if any(result):
            return False
    return True

在这里,我们设置的处理方式是,每次对一组 i 的 V 值进行除法和整数检查。理想情况下,any(result)操作也将在 CPU 中进行,而无需将结果传回 RAM。我们将在第 6 章中详细讨论矢量化、它是如何工作的,以及它何时对代码有益。

1.2.2 Python 的虚拟机

Python 解释器做了大量工作,试图抽象出正在使用的底层计算元素。在任何时候,程序员都不需要担心为数组分配内存、如何安排内存或以何种顺序将内存发送给 CPU。这是 Python 的一个优点,因为它可以让您专注于正在实现的算法。然而,这需要付出巨大的性能代价。

重要的是要认识到,Python 的核心确实是运行一组非常优化的指令。然而,诀窍在于让 Python 以正确的顺序执行这些指令,以获得更好的性能。例如,很容易看出,在下面的示例中,search_fast 会比 search_slows运行得更快,因为它跳过了由于没有提前终止循环而导致的不必要的计算,尽管两个解决方案的运行时间都是 O(n)。然而,在处理派生类型、特殊 Python 方法或第三方模块时,情况会变得复杂。例如,您能立即判断出 search_unknown1 和 search_unknown2 哪个函数会更快吗?

import timeit

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])

if __name__ == "__main__":
    iterations = 10000
    haystack = list(range(1000))
    setup = "from __main__ import (haystack, needle, search_fast, search_slow)"

    needle = 5
    print(f"Testing search speed with {len(haystack)} items and needle close to the head of the list")

    t = timeit.timeit(stmt="search_fast(haystack, needle)", setup=setup, number=iterations)
    print(f"search_fast time: {t/iterations:.5e}")

    t = timeit.timeit(stmt="search_slow(haystack, needle)", setup=setup, number=iterations)
    print(f"search_slow time: {t/iterations:.5e}")

    needle = len(haystack) - 10
    print(f"Testing search speed with {len(haystack)} items and needle close to the tail of the list")

    t = timeit.timeit(stmt="search_fast(haystack, needle)", setup=setup, number=iterations)
    print(f"search_fast time: {t/iterations:.5e}")

    t = timeit.timeit(stmt="search_slow(haystack, needle)", setup=setup, number=iterations)
    print(f"search_slow time: {t/iterations:.5e}")

执行结果:

# python reducing_operations.py
Testing search speed with 1000 items and needle close to the head of the list
search_fast time: 1.00603e-07
search_slow time: 9.79065e-06
Testing search speed with 1000 items and needle close to the tail of the list
search_fast time: 1.16150e-05
search_slow time: 1.01535e-05

通过剖析找出代码中的慢代码区域,并找到更有效的方法来进行相同的计算,就像找到这些无用的操作并删除它们一样;最终的结果是相同的,但计算和数据传输的数量会大大减少。
search_unknown1 和 search_unknown2 的例子特别邪恶。你知道对于一个小干草堆来说,哪个函数会更快吗?如果是一个较大但已分类的干草堆呢?如果干草堆没有顺序呢?如果针在起点或终点附近呢?这些因素都会改变哪种方法更快,原因又是什么。这就是主动剖析代码如此重要的原因。我们还希望,当你读完这本书时,你能直观地了解哪些情况会影响不同的函数、为什么以及会产生什么影响。

这个抽象层的影响之一是无法立即实现矢量化。我们的初始质数例程将为每个 i 值运行一次循环迭代,而不是合并几次迭代。然而,观察抽象后的矢量化示例,我们会发现这并不是有效的 Python 代码,因为我们无法用 list 除浮点数。像 numpy 这样的外部库将通过增加矢量化数学运算的能力来帮助解决这种情况。

此外,Python 的抽象会损害任何依赖于保持 L1/L2 缓存充满下一次计算所需相关数据的优化。这有很多原因,首先是 Python 对象在内存中的布局不是最理想的。这是 Python 作为一种垃圾回收语言的后果–内存会在需要时自动分配和释放。这就造成了内存碎片,可能会影响向 CPU 缓存的传输。此外,没有机会直接在内存中改变数据结构的布局,这意味着总线上的一次传输可能不包含计算的所有相关信息,尽管这些信息可能都在总线宽度之内。

第二个更基本的问题来自 Python 的动态类型和未编译的语言。正如许多 C 语言程序员多年来学到的那样,编译器往往比你更聪明。在编译类型化和静态的代码时,编译器可以使用很多技巧来改变事物的布局方式,以及 CPU 运行某些指令的方式,从而对它们进行优化。然而,Python 并不是编译的;更糟糕的是,它有动态类型,这意味着从算法上推断任何可能的优化机会都难上加难,因为代码的功能可以在运行时改变。有很多方法可以缓解这个问题,其中最重要的是使用 Cython,它允许对 Python 代码进行编译,并允许用户向编译器创建 “提示”,说明代码的实际动态程度。此外,Python 正在向即时 (JIT) 编译器迈进,这将允许在运行时对代码进行编译和优化(更多信息请参见 “Python 是否有 JIT?)

最后,如果试图并行化代码,前面提到的 GIL 可能会影响性能。例如,假设我们将代码改为使用多个 CPU 内核,这样每个内核都能得到从 2 到 sqrtN 的一组数字。每个内核可以对各自的数字块进行计算,然后,当计算全部完成后,各内核可以比较各自的计算结果。虽然由于每个内核都不知道是否找到了解决方案,因此我们失去了提前结束循环的机会,但我们可以减少每个内核必须进行的检查次数(如果我们有 M 个内核,则每个内核必须进行 sqrtN / M 次检查)。然而,由于 GIL 的存在,每次只能使用一个内核。这意味着我们实际上运行的是与无并行版本相同的代码,但不再有提前终止功能。我们可以通过使用多进程(使用多处理模块)而不是多线程,或者使用 Cython 或外来函数来避免这个问题。

参考资料

1.3 为什么要使用Python?

Python 具有很强的表现力,而且易于学习–新程序员很快就会发现,他们可以在很短的时间内完成很多事情。例如,scikit-learn 机器学习系统封装了 LIBLINEAR 和 LIBSVM(两者都是用 C 语言编写的),numpy 库包括 BLAS 以及其他 C 和 Fortran 库。因此,正确使用这些模块的 Python 代码的速度确实不亚于同类的 C 代码。

Python 被称为 “包含电池”,因为它内置了许多重要的工具和稳定的库。其中包括

  • io
    用于字节、字符串和所有编码的各种IO

  • array
    用于原始类型的节省内存的数组

  • math
    基本数学运算,包括一些简单的统计

  • sqlite3
    流行的基于文件的 SQL 存储引擎 SQLite3 的包装器

  • collections
    各种对象,包括 deque、计数器和字典变体

  • asyncio
    使用 async 和 await 语法为 I/O 绑定任务提供并发支持

可在核心语言之外找到大量库,其中包括:

  • numpy
    一个数值 Python 库(与矩阵有关的任何内容的基石库)

  • scipy
    大量值得信赖的科学库,通常封装了备受推崇的 C 和 Fortran 库

  • pandas
    基于 scipy 和 numpy 的数据分析库,类似于 R 的数据框或 Excel 电子表格

  • polars

pandas 的替代库,内置查询规划器,可更快地并行执行查询

  • scikit-learn
    基于 scipy 的默认机器学习库

  • PyTorch 和 TensorFlow
    来自 Facebook 和 Google 的深度学习框架,提供强大的 Python 和 GPU 支持

  • NLTK、spaCy 和 Gensim
    深度支持 Python 的自然语言处理库

  • 数据库API
    用于与几乎所有数据库通信,包括 Redis、Elasticsearch、HDF5 和 SQL

  • 网站开发框架
    用于创建网站的高性能系统,如 aiohttp、django、pyramid、fastapi 或 flask

  • OpenCV
    计算机视觉API

  • 其他API
    用于轻松访问流行的网络 API,如 Google、Twitter 和 LinkedIn

有多种托管环境和外壳可供选择,以适应各种部署方案,包括以下内容:

  • 标准发行版,可从 http://python.org 获取
  • 用于简单、轻量级和可移植 Python 环境的 pipenv、pyenv 和 virtualenv
  • Docker 用于开发或生产的简单启动和重现环境
  • Anaconda 公司的 Anaconda,一个以科学为重点的环境
  • IPython,科学家和开发人员大量使用的交互式 Python shell
  • Jupyter Notebook 和 JupyterLab,基于浏览器的 IPython 扩展,大量用于教学和演示

Python 的主要优势之一是可以快速创建想法的原型。由于支持库种类繁多,因此即使首次实现可能很不稳定,也可以很容易地测试一个想法是否可行。
如果你想让你的数学例程更快,那就找 numpy 吧。如果你想尝试机器学习,可以试试 scikit-learn。如果你要清理和处理数据,那么 pandas 是个不错的选择。
一般来说,提出这样的问题是明智的:”如果我们的系统运行得更快,那么从长远来看,我们作为一个团队是否会运行得更慢?如果投入足够的工作时间,总是有可能从系统中挤出更多的性能,但这可能会导致脆性和理解不透的优化,最终绊倒团队。
其中一个例子就是 Cython 的引入(参见 “Cython”),这是一种基于编译器的方法,可以用类似 C 语言的类型来注解 Python 代码,这样转换后的代码就可以用 C 语言编译器进行编译。虽然速度上的提升可能令人印象深刻(通常只需相对较少的努力就能达到类似 C 语言的速度),但支持这种代码的成本将会增加。特别是,支持这个新模块可能会更加困难,因为团队成员的编程能力需要达到一定的成熟度,才能理解在离开带来性能提升的 Python 虚拟机时发生的一些权衡。