大家好 今天,我们想分享
在Python开发人员课程启动前夕准备的另一种翻译。 走吧

在过去的4-5年中,我比其他任何编程语言都更频繁地使用Python。 Python是在Firefox,测试和CI工具下进行构建的主要语言。 Mercurial也大多是用Python编写的。 我还为此写了很多第三方项目。
在工作中,我对Python性能及其优化工具有了一点了解。 在本文中,我想分享这些知识。
我在Python方面的经验主要与CPython解释器有关,尤其是CPython 2.7。 并非我的所有观察都对所有Python发行版或在类似版本的Python中具有相同特征的那些通用。 我将在叙述中尝试提及这一点。 请记住,本文不是Python性能的详细概述。 我只会谈论我自己遇到的事情。
由于启动和导入模块的特性而导致的负载
毫秒级的启动Python解释器和导入模块是一个相当长的过程。
如果您需要在任何项目中启动数百或数千个Python进程,那么这种以毫秒为单位的延迟将变成长达几秒钟的延迟。
如果使用Python提供CLI工具,则开销可能会导致用户明显冻结。 如果您立即需要CLI工具,则每次调用都运行Python解释器将使获取此复杂工具更加困难。
我已经写过这个问题。 我过去的一些笔记谈到了这一点,例如
2014年 ,
2018年5月和
2018 年 10月 。
没有什么可以减少启动延迟的事情:解决此问题是指操纵Python解释器,因为正是这种情况控制着代码的执行,而这花费了很多时间。 最好的办法是在调用中禁用
站点模块的导入,以避免在启动时执行额外的Python代码。 另一方面,许多应用程序都使用site.py模块的功能,因此,使用此方法后果自负。
我们还应该考虑导入模块的问题。 如果不解释任何代码,Python解释器有什么用? 事实是,通过使用模块,代码更经常提供给解释器。
要导入模块,您需要执行几个步骤。 在它们每个中,都有潜在的负载和延迟源。
由于搜索模块并读取其数据,会发生一定的延迟。 正如我在
PyOxidizer上演示的
那样 ,用结构上更简单的解决方案代替从文件系统中搜索和加载模块,该解决方案包括从内存中的数据结构读取模块数据,对于此任务,您可以在初始解决方案时间的70%至80%的时间内导入标准Python库。 每个文件系统文件只有一个模块,这会增加文件系统的负载,并可能在关键的前几毫秒内降低Python应用程序的速度。 像PyOxidizer这样的解决方案可以帮助避免这种情况。 我希望Python社区了解当前方法的这些成本,并正在考虑过渡到模块的分发机制,这些分发机制并不太依赖于模块中的各个文件。
一个模块的额外导入成本的另一个来源是在导入过程中该模块中代码的执行。 某些模块在模块的功能和类之外的区域中包含部分代码,这些模块在导入模块时执行。 执行此类代码会增加导入成本。 解决方法:不要在导入时执行所有代码,而仅在必要时执行它。 Python 3.7支持
__getattr__
模块,如果未找到模块的属性,则会调用该模块。 这可用于在第一次访问时延迟填充模块属性。
摆脱导入速度降低的另一种方法是延迟导入模块。 您可以注册返回返回存根的自定义导入模块,而不是在导入期间直接加载模块。 首次访问此存根时,它将加载实际模块并“变异”成为该模块。
如果绕过文件系统并避免运行模块的不必要部分,则导入数十个模块的应用程序可以节省数十毫秒的时间(模块通常是全局导入的,但仅使用某些模块功能)。
延迟导入模块是一件易碎的事情。 许多模块的模板包含以下内容:
try: import foo
;
except ImportError:
懒惰的模块导入器可能永远不会抛出ImportError,因为如果这样做,他将不得不在文件系统中查找模块以找出其原理上是否存在。 这将增加额外的负担并增加花费的时间,因此懒惰的进口商原则上不这样做! 这个问题很烦人。 惰性模块的导入器Mercurial处理无法延迟导入的模块列表,它必须绕过它们。 另一个问题是
from foo import x, y
的语法,在foo是模块(与包相对)的情况下,它也中断了懒惰模块的导入,因为必须仍然导入该模块才能返回对x和y的引用。
PyOxidizer有一组固定的模块连接到二进制文件中,因此可以有效地提高ImportError。 Python 3.7中的__getattr__模块为惰性模块导入程序提供了更多的灵活性。 我希望将可靠的惰性导入器集成到PyOxidizer中以使某些过程自动化。
避免启动解释器并导致时间延迟的最佳解决方案是在Python中启动后台进程。 如果您将Python进程作为守护进程启动(例如,对于Web服务器),则可以做到这一点。 Mercurial提供的解决方案是启动一个提供
命令服务器协议的后台进程。 hg是C可执行文件(或现在为Rust),它连接到此后台进程并发送命令。 为了找到命令服务器的方法,您需要做很多工作,它非常不稳定并且存在安全问题。 我正在考虑使用PyOxidizer交付命令服务器的想法,以便可执行文件具有其优势,并且通过创建PyOxidizer项目解决了软件解决方案本身的成本问题。
函数调用延迟
在Python中调用函数是一个相对较慢的过程。 (这种观察不适用于可以执行JIT代码的PyPy。)
我看到了Mercurial的数十个补丁,这些补丁使对齐和组合代码成为可能,从而避免了调用函数时不必要的负载。 在当前的开发周期中,已进行了一些努力来减少更新进度条时调用的函数的数量。 (我们将进度条用于可能需要一些时间的任何操作,以便用户了解正在发生的事情)。 例如,当我们谈论一百万次执行时,获取调用
函数的结果并避免在
函数之间进行简单的搜索可以节省数十万毫秒的执行时间。
如果您在Python中有紧密的循环或递归函数,可能会发生成千上万个函数调用,那么您应该意识到调用单个函数的开销,因为这非常重要。 请记住简单的内置函数以及组合函数的功能,以避免开销。
属性查找开销
这个问题类似于函数调用的开销,因为含义几乎相同!
在Python中寻找解析属性可能很慢。 (同样,在PyPy中,这更快。) 但是,处理此问题是我们在Mercurial中经常要做的事情。
假设您有以下代码:
obj = MyObject() total = 0 for i in len(obj.member): total += obj.member[i]
我们省略了编写此示例的更有效方法(例如,
total = sum(obj.member)
),并请注意,循环需要在每次迭代时定义obj.member。 Python具有定义
属性的相对复杂的机制。 对于简单类型,它可能足够快。 但是对于复杂类型,对属性的这种访问可以自动调用
__getattr__
,
__getattribute__
,各种
dunder
方法甚至
@property
用户定义的函数。 这类似于快速搜索可以进行多个函数调用的属性,这将导致额外的负载。 如果使用
obj.member1.member2.member3
类的
obj.member1.member2.member3
,则可能会加重此负载。
每个属性定义都会导致额外的负担。 而且由于Python中几乎所有内容都是字典,因此可以说每个属性搜索都是字典搜索。 根据有关基本数据结构的一般概念,我们知道字典搜索的速度不如索引搜索快。 是的,当然,CPython中有一些技巧可以消除由于字典搜索而产生的开销。 但是我想谈的主要主题是任何属性搜索都是潜在的性能泄漏。
对于硬循环,尤其是那些可能超过数十万次迭代的硬循环,可以通过将值分配给局部变量来避免这些可观的开销以查找属性。 让我们看下面的例子:
obj = MyObject() total = 0 member = obj.member for i in len(member): total += member[i]
当然,只有在不定期更换的情况下,才能安全地完成此操作。 如果发生这种情况,则迭代器将保留到旧元素的链接,并且一切都可能爆炸。
调用对象的方法时,可以执行相同的技巧。 相反
obj = MyObject() for i in range(1000000): obj.process(i)
您可以执行以下操作:
obj = MyObject() fn = obj.process for i in range(1000000:) fn(i)
还值得注意的是,在属性搜索需要调用方法的情况下(如前面的示例),Python 3.7的
速度比以前的版本
要快 。 但是我可以确定,首先,过度的负担与函数调用有关,而不是与属性搜索的负担有关。 因此,如果您放弃对属性的额外搜索,一切都会更快地工作。
最后,由于属性搜索为此调用了函数,因此可以说,与函数调用导致的负载相比,属性搜索通常没有问题。 通常,要注意到速度的显着变化,您需要消除很多属性搜索。 在这种情况下,只要您可以访问循环中的所有属性,就可以在调用函数之前仅在循环中谈论10或20个属性。 循环次数少至数千次或少于数万次,可以快速提供数十万或数百万个属性搜索。 所以要小心!
对象加载
从Python解释器的角度来看,所有值都是对象。 在CPython中,每个元素都是一个PyObject结构。 解释器控制的每个对象都在堆上,并且具有自己的内存,其中包含引用计数,对象的类型和其他参数。 每个对象都由垃圾收集器处理。 这意味着由于引用计数,垃圾回收等原因,每个新对象都会增加开销。 (同样,PyPy可以避免这种不必要的负担,因为它对短期值的寿命更加“谨慎”。)
通常,您创建的值和Python对象越独特,对您而言工作就越慢。
假设您要遍历一百万个对象的集合。 您调用一个函数来将此对象收集到一个元组中:
for x in my_collection: a, b, c, d, e, f, g, h = process(x)
在此示例中,
process()
将返回一个8元组的元组。 不管是否销毁返回值都没有关系:该元组需要在Python中创建至少9个值:1用于元组本身,而8用于其内部成员。 好吧,在现实生活中,如果
process()
返回对现有对象的引用,则值可能会更少。 或者相反,如果它们的类型不简单并且需要很多PyObject来表示,则可能会更多。 我只想说,在解释器的作用下,确实存在着杂物,它们可以完整地呈现某些结构。
根据我自己的经验,我可以说这些开销仅与以本机语言(例如C或Rust)实现时可提高速度的操作有关。 问题在于,CPython解释器无法简单地执行字节码,以至于由于对象数量而导致的额外负载很重要。 相反,您最有可能通过调用函数或进行繁琐的计算等来降低性能。 在您注意到物体带来的额外负担之前。 当然,也有一些例外,即元组或具有多个值的字典的构造。
作为开销的一个具体示例,您可以引用带有解析底层数据结构的C代码的Mercurial。 为了提高解析速度,C代码的运行速度比CPython快一个数量级。 但是,一旦C代码创建了PyObject来表示结果,速度就会下降数倍。 换句话说,负载涉及创建和管理Python元素,以便可以在代码中使用它们。
解决此问题的一种方法是减少Python中的元素数量。 如果需要引用单个元素,则启动函数并返回它,而不是元组或N个元素的字典。 但是,请勿因函数调用而停止监视可能的负载!
如果您有很多代码可以使用CPython C API足够快地运行,并且需要在不同的模块之间分配元素,那么请不要使用将不同数据表示为C结构的Python类型,并且已经编译了访问这些结构的代码而不是通过CPython C API。 通过避免使用CPython C API访问数据,您将摆脱很多额外的负担。
将元素视为数据(而不是具有连续访问所有函数的功能)将是pythonist的最佳方法。 已经编译的代码的另一个解决方法是延迟实例化PyObject。 如果您在Python中创建一个自定义类型(PyTypeObject)来表示复杂元素,则需要定义
tp_members或
tp_getset字段来创建自定义C函数以查找该属性的值。 如果您说编写解析器,并且知道客户将只能访问所分析字段的子集,则可以快速创建一个包含原始数据的类型,返回该类型并调用C函数来搜索处理PyObject的Python属性。 您甚至可以延迟解析,直到不需要解析时调用该函数以节省资源! 这项技术非常罕见,因为它需要编写非平凡的代码,但是却给出了肯定的结果。
初步确定馆藏规模
这适用于CPython C API。
创建列表或字典之类的集合时,如果在创建时已经定义了新集合的大小,请使用
PyList_New()
+
PyList_SET_ITEM()
填充新集合。 这将预先确定集合的大小,以便能够在其中容纳有限数量的元素。 这有助于在插入项目时跳过检查是否有足够的集合大小。 创建数千个项目的集合时,这将节省一些资源!
在C API中使用零复制
Python C API确实喜欢创建对象的副本,而不是返回对它们的引用。 例如,
PyBytes_FromStringAndSize()将 char*
复制到Python保留的内存中。 如果您对大量值或大数据执行此操作,那么我们可以讨论千兆字节的内存I / O以及分配器上的相关负载。
如果您需要编写不使用C API的高性能代码,那么您应该熟悉
缓冲区协议和相关类型,例如
memoryview 。Buffer protocol
内置在Python类型中,并允许解释器将类型从/强制转换为字节。 它还允许C代码解释器接收一定大小的
void*
描述符。 这使您可以将内存中的任何地址与PyObject关联。 许多处理二进制数据的功能可以透明地接受任何实现
buffer protocol
对象。 并且,如果要接受任何可以视为字节的对象,则在接收函数参数时需要使用
s*
,
y*
或
w*
格式的单位 。
使用
buffer protocol
,可以为解释器提供使用
zero-copy
操作的最佳机会,并拒绝将多余的字节复制到内存中。
通过使用
memoryview
形式的Python中的类型,您还将允许Python通过引用访问内存级别,而不是进行复制。
如果您要在Python程序中传输数十亿字节的代码,那么对零复制的Python类型的深刻使用将使您免受性能差异的困扰。 我曾经注意到
python-zstandard比任何Python LZ4绑定都要快(尽管应该相反),因为我使用了太多的
buffer protocol
,并且避免了
python-zstandard
过多的内存I / O!
结论
在本文中,我试图谈论我在优化Python程序几年中学到的一些知识。 我再说一遍,它绝不是Python性能改进方法的全面概述。 我承认我使用Python的要求可能比其他人更高,并且我的建议不能应用于所有程序。
在阅读本文之后,您绝不应该大规模地更正您的Python代码并删除例如对属性的搜索 。 与往常一样,在性能优化方面,首先要修复代码特别慢的地方。
py-spy Python. , , Python, . , , , !
, Python . , , Python - . Python – PyPy, . Python . , Python , . , « ». , , , Python, , , .
;-)