万万没想到,除了香农计划,Python3.11竟还有这么多性能提升!

Python客栈

共 9796字,需浏览 20分钟

 ·

2022-11-29 08:36

众所周知,Python 3.11 版本带来了较大的性能提升,但是,它具体在哪些方面上得到了优化呢?除了著名的“香农计划”外,它还包含哪些与性能相关的优化呢?本文将带你一探究竟!

作者:Beshr Kayali

译者:豌豆花下猫@Python猫

英文:https://log.beshr.com/python-311-speedup-part-1

Python 3.11 在几天前发布了,它照例带来了很多新特性,例如异常组、细粒度的错误位置与堆栈回溯、标准库对 TOML 的解析支持,当然,还有备受大家期待的由 faster CPython 项目带来的速度提升。

根据 pyperformance 的基准测试,CPython 3.11 比 CPython 3.10 平均快 25%。这项改进的原因之一是 Guido 命名的“香农计划”(即 faster CPython)。对于 3.11 版本,这个计划在两个主要方向进行了大量优化:启动时和运行时。

除此之外,Python 3.11 还包含有其它的优化,这些优化不属于香农计划。

在本文中,我将详细介绍 3.11.0 稳定版中常规优化的细节(即非 faster CPython 项目的改进)。


目录

优化了一些 printf 风格 % 的格式化代码优化了 Python 大整数的除法优化了数字 PyLongs 求和精简列表的扩容操作,提升了 list.append 性能减少了全 unicode 键的字典的内存占用提升了使用asyncio.DatagramProtocol 传输大文件的速度对于 math 库:优化了 comb(n, k) 与 perm(n, k=None)对于 statistics 库:优化了 mean(data)、variance(data, xbar=None) 与 stdev(data, xbar=None)纯 ASCII 字符串的 unicodedata.normalize(),提升到常数时间

优化了一些 printf 风格 % 的格式化代码

使用格式化的字符串字面量(formatted string literals[1])是最快的格式化字符串的方法。

Python 3.10 中的一个简单基准测试:

$ python -m pyperf timeit -s \  'k = "foo"; v = "bar"' -- '"%s = %r" % (k, v)'.....................Mean +- std dev: 187 ns +- 8 ns

但是使用 f-string 似乎要快 42%:

$ python -m pyperf timeit -s \  'k = "foo"; v = "bar"' -- 'f"{k!s} = {v!r}"'.....................Mean +- std dev: 131 ns +- 9 ns

优化性能的手段是将简单的 C 风格的格式化方法转换为 f-string 方法。在 3.11.0 中,只转换了 %s、%r 和 %a 三种,但是目前有一个待合入的 pull request[2],将会支持:%d、%i、%u、%o、%x、%X、%f、 %e、%g、%F、%E、%G。

例如,下面是 Python 3.11 中相同基准测试的结果:

$ python -m pyperf timeit -s \  'k = "foo"; v = "bar"' -- '"%s = %r" % (k, v)'.....................Mean +- std dev: 100 ns +- 5 ns

大约快了 87%!当然,3.11 中其它的优化对此也有影响,比如更快的解释器启动时间。

优化了 Python 大整数的除法

在 Python 3.10 中:

python -m pyperf timeit -s 'x=10**1000' -- 'x//10'.....................Mean +- std dev: 1.18 us +- 0.02 us

在 Python 3.11 中:

python -m pyperf timeit -s 'x=10**1000' -- 'x//10'.....................Mean +- std dev: 995 ns +- 15 ns

大约快了18%。

这项优化源自 Mark Dickinson 的一个发现,即编译器总会生成 128:64 的除法指令,尽管处理的是 30 位的数值。

即使在 x64 上,Python 的除法也有些残缺。假设是 30 位数字,则多精度除法所需的基本结构是 64 位除以 32 位的无符号整数除法,产生一个 32 位的商(理想情况下还会产生一个 32 位余数)。有一个 x86/x64 指令可以做到这一点,也就是 DIVL。但是如果不使用内联汇编,当前版本的 GCC 和 Clang 显然做不到从 longobject.c 中发出该指令——它们只会在 x64 上使用 DIVQ(128 位除以 64 位的除法,尽管被除数的前 64 位被设为零),而在 x86 上则使用固有的 __udivti3 或 __udivti4。

——Mark Dickinson(全文)

优化了数字 PyLongs 求和

这里有一个 issue[3],它发现 Python 2.7 中 sum 的速度比 Python 3 快得多。不幸的是,在某些条件下,3.11.0 似乎仍然如此。

Python 2.7:

$ python -m pyperf timeit -s 'd = [0] * 10000' -- 'sum(d)'.....................Mean +- std dev: 37.4 us +- 1.1 us

Python 3.10:

$ python -m pyperf timeit -s 'd = [0] * 10000' -- 'sum(d)'.....................Mean +- std dev: 52.7 us +- 1.3 us

Python 3.11:

$ python -m pyperf timeit -s 'd = [0] * 10000' -- 'sum(d)'.....................Mean +- std dev: 39.0 us +- 1.0 us

Python3.10 和 3.11 之间的区别在于,通过在 sum 函数的快速加法分支中内联对单个数字 PyLongs 的解包,可以提升在单个数字 PyLongs 上调用 sum 的性能。这样做可以避免在解包时调用[4] PyLong_AsLongAndOverflow[5]

值得注意的是,在某些情况下[6],Python 3.11 在整数求和时仍然明显慢于 Python 2.7。我们希望在 Python 中通过实现更高效的整数[7],获得更多的改进。

精简列表的扩容操作,提升了 list.append 性能

在 Python 3.11 中,list.append 有了显著的性能提升(大约快 54%)。

Python 3.10 的列表 append:

$ python -m pyperf timeit -s \  'x = list(map(float, range(10_000)))' -- '[x.append(i) for i in range(10_000)]'.....................Mean +- std dev: 605 us +- 20 us

Python 3.11 的列表 append:

$ python -m pyperf timeit -s \  'x = list(map(float, range(10_000)))' -- '[x.append(i) for i in range(10_000)]'.....................Mean +- std dev: 392 us +- 14 us

对于简单的列表推导式,也有一些小的改进:

Python 3.10:

$ python -m pyperf timeit -s \  '' -- '[x for x in list(map(float, range(10_000)))]'.....................Mean +- std dev: 553 us +- 19 us

Python 3.11:

$ python -m pyperf timeit -s \  '' -- '[x for x in list(map(float, range(10_000)))]'.....................Mean +- std dev: 516 us +- 16 us

译注:记得在 3.9 版本的时候,Python 优化了调用 list()、dict() 和 range() 等内置类型的速度,在不起眼处,竟还能持续优化!

减少了全 unicode 键的字典的内存占用

这项优化令 Python 在使用全为 Unicode 键的字典时,缓存的效率更高。这是因为使用的内存减少了,那些 Unicode 键的哈希会被丢弃,因为那些 Unicode 对象已经有哈希了。

例如,在 64 位平台上,Python 3.10 运行结果:

>>> sys.getsizeof(dict(foo="bar", bar="foo"))232

在 Python 3.11 中:

>>> sys.getsizeof(dict(foo="bar", bar="foo"))184

(译注:插个题外话,Python 的 getsizeof 是一种“浅计算”,这篇《Python在计算内存时应该注意的问题?》区分了“深浅计算”,可以让你对 Python 计算内存有更深的理解。)

提升了使用asyncio.DatagramProtocol 传输大文件的速度

asyncio.DatagramProtocol 提供了一个用于实现数据报(UDP)协议的基类。有了这个优化,使用asyncio UDP 传输大文件(比如 60 MiB)将比 Python 3.10 快 100 多倍。

这是通过计算一次缓冲区的大小并将其存储在一个属性中来实现的。这使得通过 UDP 传输大文件时,asyncio.DatagramProtocol 有着数量级的提速。

PR msoxzw 的作者提供了以下的 测试脚本[8]

对于 math 库:优化了 comb(n, k) 与 perm(n, k=None)

Python 3.8 在math 标准库中增加了 comb(n, k) 和 perm(n, k=None) 函数。两者都用于计算从 n 个无重复的元素中选择 k 个元素的方法数,comb 返回无序计算的结果,而perm 返回有序计算的结果。(译注:即一个求组合数,一个求排列数)

3.11 的优化由多个较小的改进组成,比如使用分治算法来实现 Karatsuba 大数乘法,以及尽可能用 C 语言unsigned long long 类型而不是 Python 整数进行comb计算(*[9])。

另外一项改进是针对较小的 k 值(0 <= k <= n <= 67):

(译注:以下两段费解,暂跳过)

对于 0 <= k <= n <= 67comb(n, k) always fits into a uint64_t. We compute it as comb_odd_part << shift where 2 ** shift is the largest power of two dividing comb(n, k) and comb_odd_part is comb(n, k) >> shiftcomb_odd_part can be calculated efficiently via arithmetic modulo 2 ** 64, using three lookups and two uint64_t multiplications, while the necessary shift can be computed via Kummer's theorem: it's the number of carries when adding k to n - k in binary, which in turn is the number of set bits of n ^ k ^ (n - k)*[10]

One more improvement is that the previous popcount-based code for computing the largest power of two dividing math.comb(n, k) (for small n) got replaced with a more direct method based on counting trailing zeros of the factorials involved. (*[11]).

Python 3.10:

$ python -m pyperf timeit -s \  'import math' -- 'math.comb(100, 55)'.....................Mean +- std dev: 3.72 us +- 0.07 us
# ---
$ python -m pyperf timeit -s \ 'import math' -- 'math.comb(10000, 5500)'.....................Mean +- std dev: 11.9 ms +- 0.1 ms

Python 3.11:

$ python -m pyperf timeit -s \  'import math' -- 'math.comb(100, 55)'.....................Mean +- std dev: 476 ns +- 20 ns
# ---
$ python -m pyperf timeit -s \ 'import math' -- 'math.comb(10000, 5500)'.....................Mean +- std dev: 2.28 ms +- 0.10 ms

对于 statistics 库:优化了 mean(data)、variance(data, xbar=None) 与 stdev(data, xbar=None)

3.11 优化了statistics模块中的 meanvariancestdev 函数。如果入参是一个迭代器,则会直接用于计算,而不是先将其转换为列表。这种计算方法[12] 的速度比之前的快了一倍。*[13]

Python 3.10:

# Mean$ python -m pyperf timeit -s \  'import statistics' -- 'statistics.mean(range(1_000))'.....................Mean +- std dev: 255 us +- 11 us
# Variance$ python -m pyperf timeit -s \ 'import statistics' -- 'statistics.variance((x * 0.1 for x in range(0, 10)))'.....................Mean +- std dev: 77.0 us +- 2.9 us
# Sample standard deviation (stdev)$ python -m pyperf timeit -s \ 'import statistics' -- 'statistics.stdev((x * 0.1 for x in range(0, 10)))'.....................Mean +- std dev: 78.0 us +- 2.2 us

Python 3.11:

# Mean$ python -m pyperf timeit -s \  'import statistics' -- 'statistics.mean(range(1_000))'.....................Mean +- std dev: 193 us +- 7 us
# Variance$ python -m pyperf timeit -s \ 'import statistics' -- 'statistics.variance((x * 0.1 for x in range(0, 10)))'.....................Mean +- std dev: 56.1 us +- 2.3 us
# Sample standard deviation (stdev)$ python -m pyperf timeit -s \ 'import statistics' -- 'statistics.stdev((x * 0.1 for x in range(0, 10)))'.....................Mean +- std dev: 59.4 us +- 2.6 us

纯 ASCII 字符串的 unicodedata.normalize(),提升到常数时间

对于 unicodedata.normalize() 方法,如果提供的入参是纯 ASCII 字符串,则通过 unicode 快速检查算法[14] 迅速返回结果。这项检查使用的是PyUnicode_IS_ASCII 实现。

Python 3.10:

$ python -m pyperf timeit -s \  'import unicodedata' -- 'unicodedata.normalize("NFC", "python")'.....................Mean +- std dev: 83.3 ns +- 4.3 ns

Python 3.11:

$ python -m pyperf timeit -s \  'import unicodedata' -- 'unicodedata.normalize("NFC", "python")'.....................Mean +- std dev: 34.2 ns +- 1.2 ns

最后的话:

我写这篇文章是为了加深自己对 Python 3.11 最新成果的认识。如果内容有错,请通过email[15] 或者 Twitter[16]告诉我。(译注:本翻译是出于促进自己学习及加强理解的目的,若有错漏,欢迎指正!)附 HackerNews 上的评论[17]在下一篇文章中,我将分析 faster CPython 项目带来的优化点。敬请期待!

References

[1] formatted string literals: https://peps.python.org/pep-0498/
[2] pull request: https://github.com/python/cpython/pull/26160
[3] issue: https://github.com/python/cpython/issues/68264
[4] 调用: https://github.com/scoder/cpython/blob/125cdcf504a5d937b575cda3552b233dd44ba127/Python/bltinmodule.c#L2485-L2490
[5] PyLong_AsLongAndOverflow: https://github.com/python/cpython/blob/de6981680bcf6496e5996a853b2eaa700ed59b2c/Objects/longobject.c#L489
[6] 在某些情况下: https://github.com/python/cpython/issues/68264#issuecomment-1285351158
[7] 实现更高效的整数: https://github.com/faster-cpython/ideas/discussions/147
[8] 测试脚本: https://gist.github.com/msoxzw/8ae5c488edbc2985d41563c4d9c9cc04
[9] *: https://github.com/python/cpython/pull/29090#issue-1031333783
[10] *: https://github.com/mdickinson/cpython/blob/03dccc557adf39db0150410e7c448ff3164e7022/Modules/mathmodule.c#L3583
[11] *: https://github.com/python/cpython/pull/30313#issue-1091542983
[12] 计算方法: https://github.com/rhettinger/cpython/blob/208abcd8f1726646f8d86306616b0db802d8064c/Lib/statistics.py#L205
[13] *: https://docs.python.org/3/whatsnew/changelog.html
[14] unicode 快速检查算法: https://www.unicode.org/reports/tr15/#Detecting_Normalization_Forms
[15] email: mailto:me@beshr.com
[16] Twitter: https://twitter.com/beshr
[17] 评论: https://news.ycombinator.com/item?id=33382022


Wang
Qi
Tui
Jian


1、好家伙!一个敢教,一个敢用!

2、官方出品!微信聊天机器人教你做“渣男!

3、暴雪和网易分手后,我突然觉得有点迷茫。。。

4、老板的快乐,你想象不到。(读者真实案例改编)

5、苹果,微软,Google终于决定要干掉密码了!


点击关注公众号,阅读更多精彩内容


浏览 23
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报