当你的Python代码太慢时,你需要找出导致它的瓶颈:你需要了解你的代码在做什么。幸运的是,除了预先存在的剖析工具外,还有各种方法可以让你探究Python程序,以更好地了解它们在内部做什么。
这允许你进行一次性的内省,为你的程序添加可以打开和关闭的剖析工具,建立自定义的工具,并且总的来说,更好地了解你的程序在做什么。
这些功能中的一些是相当糟糕的,但这并不重要。性能调试是一种与编写长期可维护代码不同的编码方式。
在这篇文章中,我们将介绍。
- 运行时对象突变("猴子补丁")。
- 代码修补。
- C类型的运行时突变。
- 审计钩子。
sys._current_frames().- 剖析和跟踪钩子。
- 还有更多!
这篇文章的范围
为了使本文不至于太长。
- 本文省略了操作系统的特定设施,如Linux的
LD_PRELOAD、ptrace()、eBPF、/proc/<pid>/mem,等等。 - 相反,它将专注于Python特定的能力,特别是那些由CPython支持的能力,大多数人使用的默认Python解释器。其中一些API将被其他的Python实现所支持,比如PyPy;另一些则不支持。
- 由于这个列表太长了,这篇文章还省略了与内存使用有关的能力,以及那些看起来对调试而不是性能非常有用的能力。
1.运行时对象的变异
几乎所有的Python对象都可以通过在正确的地方设置一个属性而被替换成另一个你选择的对象。这有时被称为 "猴子补丁"。
>>> import os
>>> os.listdir(".")
['somefile.txt']
>>> def mylistdir(path):
... return ["LIES"]
...
>>> os.listdir = mylistdir
>>> os.listdir(".")
['LIES']
你可以重写一个模块。
>>> import sys
>>> sys.modules["os"]
<module 'os' from '/usr/lib/python3.10/os.py'>
>>> sys.modules["os"] = 123
>>> del os
>>> import os
>>> os
123
你也可以替换一个类的方法,等等,等等。唯一需要注意的是,在你覆盖之前存在的对该对象的任何引用都不会被替换。例如,如果其他模块已经做了from os import listdir ,之后更新os.listdir ,将不会影响到做该导入的模块中的版本。
为什么这对性能测量很有用?想象一下,你想知道一个特定的类实例被创建了多少次。一种方法是通过覆盖该类的__init__ 。
from ipaddress import IPV4Address
counter = 0
original_init = IPV4Address.__init__
def override_init(*args, **kwargs):
# Note this isn't thread-safe...
global counter
counter += 1
return original_init(*args, **kwargs)
IPV4Address.__init__ = override_init
其他想法。
- 你可以不使用计数器,而只使用
print(),并结合专门的剖析工具,如counts. - 你可以用
sys._getframe()来获取调用函数,如果你想知道谁在调用这个函数。
builtins
有一些内置的 Python 函数和类型,你不需要导入,比如open() 和list 。它们驻扎在builtins 模块中,你也可以对它进行突变;这将改变你对所有模块调用的内置函数。
>>> open
<built-in function open>
>>> def myopen(*args):
... print("FAKE")
...
>>> import builtins
>>> builtins.open = myopen
>>> open
<function myopen at 0x7f62a6995510>
>>> open("file.txt")
FAKE
2.代码修补
我们之前注意到猴子打补丁的一个限制:任何现有的引用都不会被改变。所以如果你想用猴子补丁改变一个函数的行为,你需要在所有的模块中交换所有的引用,这可能很麻烦,甚至不可能。
幸运的是,这是 Python,所以你可以做很多不同的坏事。特别是,你可以改变函数运行的代码;所有现有的引用,以及未来的引用,都将运行新的代码。
>>> def one():
... return 1
...
>>> def two():
... return 2
...
>>> one()
1
>>> two()
2
>>> one.__code__ = two.__code__
>>> one()
2
patchy
对于性能检测,你可能只想改变或增加一小部分代码,其他方面的行为都是一样的。这样做的一个方法是使用 patchy库,它可以让你在函数的源代码上应用一个差异。这样你就不必重写它的所有代码,如果函数的源代码改变了,你的修补就会以有用的错误信息失败。
例如,我们可以修补IPv4Address.__init__ (或者至少是 Python 3.10.4 中的版本)。
from ipaddress import IPv4Address
from patchy import patch
patch(IPv4Address.__init__, '''\
@@ -14,5 +14,6 @@
AddressValueError: If ipaddress isn't a valid IPv4 address.
"""
+ print("IPv4Address created")
# Efficient constructor from integer.
if isinstance(address, int):''')
addr = IPv4Address("127.0.0.1")
print(repr(addr))
并在运行这个例子时。
$ python example.py
IPv4Address created
IPv4Address('127.0.0.1')
注意,这将修补对IPv4Address.__init__ 的每次调用,无论哪个模块调用它。
3.C类型的运行时变异与forbiddenfruit
Monkey 补丁对用 C 或其他低级语言实现的 Python 扩展类型不起作用。
>>> list.append = lambda self, i: print("Appending", i)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: cannot set 'append' attribute of immutable type 'list'
当Python失败时,我们可以用像C这样的内存不安全的语言,或者像C这样的内存不安全的API做可怕的事情。 ctypes.
在这种情况下,该 forbiddenfruit库已经为我们完成了所有繁重的工作。
>>> from forbiddenfruit import curse
>>> original_append = list.append
>>> def myappend(self, item):
... print("Appending", item)
... return original_append(self, item)
...
>>> curse(list, "append", myappend)
>>> l = [1, 2]
>>> l.append(3)
Appending 3
>>> l
[1, 2, 3]
4.审计钩子
每当某些事件发生时,Python 将创建一个审计事件。你可以用一个注册的审计钩子函数来监听这些审计事件,通过使用 sys.addaudithook.例如,你可以跟踪所有的文件打开情况。
>>> def audit(event, args):
... if event == "open":
... print("Opened", args[0])
...
>>> import sys
>>> sys.addaudithook(audit)
>>> f = open("/etc/passwd")
Opened /etc/passwd
自定义事件
除了内置的审计事件,你也可以发出你自己的事件,例如,跟踪某个函数被调用的次数。你可以用 sys.audit():
sys.audit("myevent", 1, 2)
如果没有注册审计钩子,按照Python的标准,这是很便宜的。
$ python -m timeit -s "import sys" "sys.audit('myevent', 1, 2)"
10000000 loops, best of 5: 21.8 nsec per loop
(感谢David Reid的想法。)
C API
你可以发射自定义事件,用 PySys_Audit和注册一个钩子,用 PySys_AddAuditHook.像往常一样,使用C语言的API应该能大大减少性能开销。
5.sys._current_frames()
你可以用 sys._current_frames()来获取指向所有运行线程中的当前框架的指针。框架是跟踪当前函数调用的对象,所以它将有一个对当前函数的(间接)引用,以及对范围内的locals的引用。
>>> import sys, time, threading
>>> def mythread():
... time.sleep(100)
...
>>> threading.Thread(target=mythread).start()
>>> sys._current_frames()
{139693033064000: <frame at 0x7f0cd1a88cc0, file '<stdin>', line 2, code mythread>,
139693046566912: <frame at 0x7f0cd1a88810, file '<stdin>', line 1, code <module>>}
例如,这可以用来实现一个简单的采样分析器。
6.剖析和跟踪钩子
sys.setprofile
sys.setprofile让你注册一个 Python 函数,当一个 Python 函数被调用或返回时,以及当一个用 Python 封装的 C 函数被调用或返回时,它将被调用。
import sys
def profile(*args):
print(args)
sys.setprofile(profile)
def g():
return 2
def f():
return g()
f()
而当我们运行它时
$ python example.py
...
(<frame at 0x7f3dc39bc, file 'example.py', line 11, code f>, 'call', None)
(<frame at 0x7f3dc3945, file 'example.py', line 8, code g>, 'call', None)
(<frame at 0x7f3dc3945, file 'example.py', line 9, code g>, 'return', 2)
(<frame at 0x7f3dc39bc, file 'example.py', line 12, code f>, 'return', 2)
...
sys.settrace
sys.settrace与sys.setprofile 的工作方式相同,但它报告的事件略有不同。Python函数的调用和返回、操作码和行。这给了你更精细的执行追踪,代价是更高的性能开销。
C APIs
这两个API都有对应的C语言。 PyEval_SetProfile和 PyEval_SetTrace.主要的好处是减少了性能开销。
关于使用这些的细节,请看Ned Batchelder的这篇有用的博文。
7.使用自定义剖析指标cProfile
Python内置的剖析器 cProfilePython 内置的剖析器使用PyEval_SetProfile 来剖析你的代码,输出的结果可以呈现为一个表格,或者你可以用像SnakeViz 这样的工具来进一步可视化。
cProfile 的一个方便的特点是,你可以用你选择的任何指标来使用它,只要它是一个增加的数字。这意味着你可以轻松地编写基于cProfile 的自定义剖析器。
8.自定义框架评估器
在Python 3.9或更高版本中,或者3.7或更高版本中,如果你愿意使用额外的私有API,你可以替换C函数,使用 _PyInterpreterState_SetEvalFrameFuncAPI来取代包装评估框架的C函数。由于默认的框架函数仍然可用,你可以把它作为一种快速的方式来获得函数调用开始和结束的通知。这也是Sciagraph连续剖析器工作方式的一部分。
不幸的是,这也依赖于CPython的内部细节,尤其是在3.11中,对稳定性的保证更弱。目前还不清楚这个API在Python 3.12中是否仍然存在。
更多细节见PEP 523。
为什么要收集承受力
我们上面提到的许多能力是可怕的黑客,如果可能的话,你不应该在生产中使用。但问题是:性能是一个实现细节很重要的领域。这是很难得的。因此,你用来调试性能问题的工具不一定是你用来编写正常代码的工具。
这就是为什么值得在头脑中收集所有你的平台给你的能力,包括那些可怕的能力。迟早你会遇到一个神秘的性能问题或错误。当这种情况发生时,你知道的工具越多,你就会有更多的方法来尝试诊断问题,以便你能修复它。