Python 3.11速度提升显著:Faster CPython项目带来的性能改进与基准测试
[id_201[id_153[id_1722132429]3579]564457]id_[id_585338106[id_1441713123]834850841]

我决定对一些 Python 脚本进行基准测试,想看看仅仅更新 Python 版本后它们能获得多少速度。
开始
以下是将要使用的脚本示例,我们检查所有 3 个属性是否存在,并且它们确实存在。
在语言表达中,permission(许可)和 forgiveness(原谅)是两个不同的概念。Permission 主要指给予或获得允许去做某事,强调的是一种授权或准许;而 forgiveness 则侧重于对他人过错的原谅或宽恕,更多地涉及到情感和态度方面。例如,你需要获得父母的 permission 才能去参加那个派对,这是关于行为的准许;而当你原谅了朋友的过错,这是在情感上对他的一种宽恕。两者在语义和使用场景上都有明显的区别。
class [id_1462021890]:
[id_1075383643] = "world"
bar = "world"
baz = "world"
class Foo(BaseClass[id_2111485439]
pass
FOO = Foo()
# 三思而后行
def test_permission2[id_145745984]
if hasattr(FOO, "hello") and hasattr(FOO, "bar") and hasattr(FOO, [id_39[id_1577018508]6640]):
FOO.hello
FOO.bar
FOO.baz
# 请求原谅
def test_forgiveness2():
try:
FOO.hello
FOO.bar
FOO.baz
except AttributeError:
pass
示例 2,我们检查一个属性,但该属性不存在:
在探讨语言使用中的一个现象时,我们发现对于某些行为,人们会在语言表达上区分许可和原谅这两种不同的态度。许可主要强调对行为的允许和认可,而原谅则更侧重于对他人过错的宽恕和谅解。例如,在某些特定的情境下,我们会用不同的词汇和表达方式来传达这两种不同的含义。许可可能会用“可以”“允许”等词汇来表达,而原谅则可能会用“宽恕”“谅解”等词汇来体现。这种语言表达上的差异反映了人们在看待行为和他人过错时的不同视角和态度。
class BaseClass:
pass
class Foo(BaseClass):
pass
FOO = Foo()
# 三思而后行
def test_permission3():
if hasattr(FOO, "hello"):
FOO.hello
# Ask for forgiveness
def test_forgiveness3():
try:
FOO.hello
except AttributeError:
pass
在一个列表中查找元素 - 利用 for 循环和生成器表达式,来找出能够被 42 和 43 整除的第一个数字。它们在内部都使用了 count()函数。
# find_item.py
from itertools import [id_[id_1334088305]0326354]
def count_numbers():
[id_925133351] [id_1370935316] in count(1):
if (item % 42 == 0) and (item % 43 == 0):
return item
def generator():
return next(item for item in count(1) if (item % 42 == 0) and (item % 43 == 0))
for 循环和用于创建过滤数字列表的列表推导:
# filter_list.py
MILLION_NUMBERS = list(range(1_000_000))
def for_loop():
output = []
for element in MILLION_NUMBERS:
if not element % 2:
output.append(element)
return output
def list_comprehension():
return [number for number in MILLION_NUMBERS if not number % 2]
排序列表时,list.sort() 可用于对随机数列表进行排序,sorted() 也可用于对随机数列表进行排序。
# sorting.py
from random import sample
# 随机排列的1000 000个整数列表
MILLION_RANDOM_NUMBERS = sample(range(1_000_000), 1_000_000)
def test_sort():
random_list = MILLION_RANDOM_NUMBERS[:]
return random_list.sort()
def test_sorted():
random_list = MILLION_RANDOM_NUMBERS[:]
return sorted(random_list)
使用 for 循环从列表中删除重复项,先将列表转换为集合,然后再将集合转换回列表。这样就能从列表中删除重复项了。
# duplicates.py
from random import randrange
DUPLICATES = [randrange(100) for _ in range(1_000_000[id_1415308583]
def test_for_loop():
unique = []
for element in DUPLICATES:
if element not in unique:
unique.append(element)
return unique
def test_set():
return list(set(DUPLICATES))
慢脚本
为了能更好地进行衡量,我们需要再添加两个函数。这两个函数的计算量比较大,并且至少需要运行几秒钟。
冒泡排序是一种排序算法,它的速度相当慢。我们在一个有 10000 个数字的列表中让它按降序运行,在我的计算机上这大概需要几秒钟。
# bubble_sort.py
DESCENDING_10_000 = list(range(10_000, 0, -1))
def bubble_sort():
numbers = DESCENDING_10_000[:]
changed = True
while changed:
changed = False
for i in range(len(numbers) - 1):
if numbers[i] > numbers[i+1]:
numbers[i], numbers[i+1] = numbers[i+1], numbers[i]
changed = True
return numbers
π 数的蒙特卡洛估计是一个简单模拟。我们先画一个边长为 1 的正方形,接着在里面画一个直径为 1 的圆。然后在那个正方形内扔一堆飞镖,或者生成随机点(以防没有一大堆虚拟飞镖)。这样我们就能通过计算落在正方形和圆形中的飞镖数量来估计它们的面积。根据定义,所有飞镖最终都会落在正方形内,而只有部分飞镖会落在圆圈内。最后,从学校得知圆的面积除以正方形的面积等于 π/4,于是我们做了那个除法,从而得到了 π 的估计。我们扔的飞镖越多,估计就越准确,这就是此方法的直观解释。
同样,存在更有效的算法可用于进行此模拟,比如使用 NumPy。然而,这里故意想要一个运行速度较慢的版本。
# pi_estimation.py
from random import random
from math import sqrt
# 投掷的飞镖总数.
TOTAL = 100_000_000
def estimate_pi():
# 落在圆圈内的飞镖数.
inside = 0
for _ in range(TOTAL):
x2 = random()**2
y2 = random()**2
# 检查 x 和 y 点是否位于圆内
if sqrt(x2 + y2) < 1.0:
inside += 1
return (float(inside) / TOTAL) * 4
基准
我们有 14 个需要检查的函数,接着就可以开始基准测试了。为了能同时运行所有这些功能,我创建了一个简单的 bash 脚本,用于在不同的 Python 版本下运行它们。我先使用 pyenv 安装最新版本的 Python,从 3.7 开始。然后,我使用每个版本的 Python 可执行文件。最后,我把结果打印在一个漂亮的表格中。
以下代码属于 bash 脚本。倘若你不清楚它的工作原理,无需忧虑,先将其运行起来即可。一个月之后,或许我也无法理解它。
#!/usr/bin/env bash
# 要测试的python版本
PYENV_VERSIONS=(3.7.14 3.8.14 3.9.14 3.10.7 3.11.0)
# 设置代码和我们将进行基准测试的实际功能
COMMANDS=(
从 permission_vs_forgiveness 中导入 test_permission2 ,然后调用 test_permission2() 。
从 permission_vs_forgiveness 中导入 test_forgiveness2 ,然后调用 test_forgiveness2() 。
从 permission_vs_forgiveness2 中导入 test_permission3 ,然后执行 test_permission3() 。
从 permission_vs_forgiveness2 中导入 test_forgiveness3 并执行 test_forgiveness3()
从 find_item 导入 count_numbers 并执行 count_numbers()
从 find_item 模块中导入 generator 函数,然后调用 generator 函数。
从 filter_list 中导入 for_loop ,然后调用 for_loop() 。
从 filter_list 中导入 list_comprehension ,然后调用 list_comprehension() 函数
从 sorting 模块中导入 test_sort 函数,然后调用 test_sort()函数。
从 sorting 模块中导入 test_sorted 函数,然后调用 test_sorted()函数。
从 duplicates 模块中导入 test_for_loop 函数,然后调用 test_for_loop()函数。
从 duplicates 模块中导入 test_set 函数,然后调用 test_set 函数。
从 bubble_sort 模块中导入 bubble_sort 函数,然后调用 bubble_sort 函数
从 pi_estimation 模块中导入 estimate_pi 函数,然后调用 estimate_pi()函数。
)
OUTPUT="Function,"
# 创建带有版本号环境
for v in ${PYENV_VERSIONS[@]}
do
OUTPUT+="$v,"
done
最后一列会包含 PYENV_VERSIONS 里 Python 的首个版本与最后一个版本之间的差别
OUTPUT+="${PYENV_VERSIONS[0]} 到 ${PYENV_VERSIONS[${#PYENV_VERSIONS[@]}-1]}"
OUTPUT+="\n"
for (( i = 0; i < ${#COMMANDS[@]} ; i++ ))
do
# 从函数名称中删除单引号
OUTPUT+=$(echo ${COMMANDS[$i]##*\ } | tr -d "'")
for v in ${PYENV_VERSIONS[@]}
do
OUTPUT+=","
OUTPUT+=$(eval 使用 /Users/switowski/.pyenv/versions/$v 路径下的 bin 目录中的 python ,然后执行 -m timeit 命令,接着执行 ${COMMANDS[$i]} 这个命令 | sed -e 's/.*: \(.*\) per loop/\1/')
done
划分出第一个 Python 版本的时间并将其添加到最后一列,同时划分出最后一个 Python 版本的时间并将其添加到最后一列。
v1=$(eval 使用 /Users/switowski/.pyenv/versions/下的第一个版本的 Python 解释器,即 /Users/switowski/.pyenv/versions/${PYENV_VERSIONS[0]}/bin/python ,然后运行 -m timeit 命令,同时执行 ${COMMANDS[$i]} 这个命令。 | sed -e 's/.*: \(.*\) per loop/\1/' -e 's/[^0-9\.]//g')
[id_1832267684]=$(eval /Users/switowski/.pyenv/versions 后面跟着一个变量,这个变量是 PYENV_VERSIONS 数组中最后一个元素,然后是 /bin/python ,接着是 -m timeit ,最后是 COMMANDS 数组中的第 i 个元素 | sed -e 's/.*: \(.*\) per loop/\1/' -e 's/[^0-9\.]//g')
difference=$(echo "scale=2; $v1 / $v2" | bc)
OUTPUT+=[id_1692834310]
OUTPUT+="\n"
done
# 以类似表格的格式打印
printf "$OUTPUT" | column -ts,
我把所有代码示例以及基准测试脚本和结果都放在了这个存储库里。这里还有一个实际的基准测试脚本的版本,是为了以防你不在意表格,而更关注 timeit 函数的原始输出。
结果
看看结果,数字小则给定代码示例运行得快。在最后一列,能看到在 Python 3.7 和 Python 3.11 中运行代码所需时间的比较。“1.68”意味着此示例在 Python 3.7 中运行速度降低了 68%。
我做了一些清理工作,做法是将单位移动到函数名称旁边,而不是像原始输出中那样移动到每个数字旁边。
Function3.7、Function143.8、Function143.9、Function143.10、Function73.11、Function03.7 以及 Function3.11 分别对应不同的功能或操作,这里的数字组合可能代表特定的参数、配置或标识,用于在特定的系统或环境中进行相关的处理和执行。
test_permission2() [nsec]
218
145
148
145
140
1.68
test_forgiveness2() [nsec]
91.9
70.4
72
83.1
71.7
1.31
test_permission3() [nsec]
77.4
60.9
61.9
57.1
40.5
1.88
test_forgiveness3() [µsec]
256
251
239
283
307
.83
count_numbers() [µsec]
46.8
47.5
47.4
46.6
41
1.14
generator() [µsec]
47.1
47.7
47.6
45.3
39.5
1.18
for_loop() [msec]
27.2
26.5
26.8
25.6
19.4
1.39
list_comprehension() [msec]
18.3
18
18.6
17.7
17.3
1.04
test_sort() [msec]
175
175
176
176
175
.97
test_sorted() [msec]
183
183
186
183
185
1.00
test_for_loop() [msec]
360
364
316
305
308
1.17
test_set() [msec]
5.59
5.57
5.83
6.09
6.08
.91
bubble_sort() [sec]
8.05
8.24
8.23
7.89
4.69
1.72
estimate_pi() [sec]
17.1
17.9
18.1
17.4
14.3
1.21
我们可以看到,在多数情况下,我们的示例在对 Python 进行版本升级时运行速度会加快。Python 3.11 给我们带来了最为显著的改进。如果你期望提升速度,那么现在升级你的 Python 版本比以往更具意义。
对于某些示例,我们发现性能出现了下降。test_sort()的 0.97 与 test_set()的 0.91 差异极小。我觉得这是基准测试结果的小随机性所致。然而,test_forgiveness3()在 Python 3.11 中性能下降了约 20%,这很有趣。我查看了 Python 3.11 的发行说明,试图查找可能导致此问题的原因,但没有找到任何结果。所以我决定比较 Python 在处理最常见示例时对于异常的情况,尤其是除以零的情况。
# division.py
def divide_by_zero():
try:
1/0
except ZeroDivisionError:
pass
在不同的 Python 版本当中对上述代码展开基准测试,从而得出了以下这些结果。
在 Python 3.11.0 里,它的速度几乎和 Python 3.7 或 3.8 差不多一样慢。所以,我的 test_forgiveness3() 的减速情况似乎只是针对这个特定的示例,并非是我们需要担忧的事情。尽管这个例子的速度比较慢,但是所有其他用于测试许可和宽恕的例子,在较新的 Python 版本中变得更快了。在 Python 3.11 中,“请求许可”从“零成本”异常处理中获得了额外的速度提升。
“零成本”异常处理
Python 3.11 引入了一种名为“零成本”的异常处理方式。此功能的关键在于,“try”块(即异常的“快乐路径”)内的所有内容如今都会变得更快,几乎与完全没有 try/except 块时的速度一样快。
让我们看看这个实际应用。
我创建了一个简短的基准测试脚本。我采用了 3 个代码示例,分别是用于过滤列表的循环、冒泡排序和 pi 预估。我把它们最内部的指令都包装在 try/except 块中,这样就能尽可能频繁地执行此 try/except 块。同时,由于没有异常发生,“except”块永远不会被调用,所以我可以在里面放置 pass。
因此,例如,第一个测试用例将比较这两个变体:
MILLION_NUMBERS = list(range(1_000_000))
def for_loop():
output = []
for element in MILLION_NUMBERS:
if not element % 2:
output.append(element)
return output
def for_loop_with_try_except():
output = []
for element in MILLION_NUMBERS:
if not element % 2:
try:
output.append(element)
except Exception:
pass
return output
Python 3.11 通过零成本异常处理,能够比 Python 3.10 或者 3.9 更快地运行这些代码示例。
我们通过运行 exceptions_benchmark.sh 脚本去查看结果。
Function3.9.143.10.73.11.0
Filter [msec]
26.7 (28.4)
26 (27.1)
19.6 (20.4)
Pi [sec]
18.4 (19.2)
17.3 (17.5)
14.1 (14.3)
Bubble [sec]
8.26 (8.46)
7.96 (8.06)
4.72 (4.75)
每列的第一个数字为运行原始版本所耗费的时间,此版本未包含 try/except 块;括号内的数字则是使用多次调用的 try/except 块来运行同一函数所需要的时间。
所有 3 个 Python 版本中,这两个变体的差异较小。而在 Python 3.11 中,它们的差异更小。对这个简单的基准测试需持保留态度,但我希望它能有助于说明“零成本”异常处理的好处。
结论表明,在不更改代码的情况下,升级 Python 版本是使代码更快的几种方法之一。无论你是从 Python 3.7 升级到 3.8,还是从 Python 3.9 升级到 3.10,对于大型代码库,都能获得一些改进。而 Python 3.11 则真正努力加快了速度,根据发行说明,它能使代码加速约 10 - 60%。所以现在是考虑升级 Python 项目的好时机。
原文: