PEP 669 – CPython 低影响监控
- 作者:
- Mark Shannon <mark at hotpy.org>
- 讨论至:
- Discourse 帖子
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2021年8月18日
- Python 版本:
- 3.12
- 发布历史:
- 2021年12月7日, 2022年1月10日
- 决议:
- Discourse 消息
摘要
在CPython中使用分析器或调试器可能会严重影响性能。性能下降一个数量级是很常见的。
本PEP提出了一种用于监控CPython上运行的Python程序的API,该API将以低成本实现监控。
尽管本PEP未指定实现,但预计它将使用 PEP 659 的快速化步骤来实现。
将添加一个 sys.monitoring
命名空间,其中将包含相关函数和常量。
动机
开发人员不应为使用调试器、分析器和其他类似工具付出不合理的代价。
C++ 和 Java 开发人员期望能够在调试器下全速(或接近全速)运行程序。Python 开发人员也应该有这种期望。
基本原理
PEP 659 提供的快速化机制提供了一种动态修改正在执行的 Python 字节码的方法。这些修改的成本除了被修改的代码部分之外很小,并且对于被修改的部分来说成本相对较低。我们可以利用这一点来提供一种在 3.10 或更早版本中不可能实现的高效监控机制。
通过使用快速化,我们期望在 3.12 上在调试器下运行的代码应该优于在 3.11 上不带调试器运行的代码。分析仍然会减慢执行速度,但比 3.11 慢得多。
规范
Python 程序的监控是通过为事件注册回调函数和激活一组事件来完成的。
激活事件和注册回调函数是相互独立的。
注册回调和激活事件都是按工具进行的。可以有多个工具响应不同的事件集。
请注意,与 sys.settrace()
不同,事件和回调是每个解释器而不是每个线程的。
事件
当代码对象执行时,会发生各种工具可能感兴趣的事件。通过激活事件和注册回调函数,工具可以以任何适合它们的方式响应这些事件。事件可以全局设置,也可以为单个代码对象设置。
对于 3.12,CPython 将支持以下事件
- PY_START:Python 函数的开始(在调用后立即发生,被调用者的帧将在堆栈上)
- PY_RESUME:Python 函数的恢复(对于生成器和协程函数),除了 throw() 调用。
- PY_THROW:Python 函数通过 throw() 调用恢复。
- PY_RETURN:Python 函数的返回(在返回前立即发生,被调用者的帧将在堆栈上)。
- PY_YIELD:Python 函数的 yield(在 yield 前立即发生,被调用者的帧将在堆栈上)。
- PY_UNWIND:在异常展开期间从 Python 函数退出。
- CALL:Python 代码中的调用(事件在调用之前发生)。
- C_RETURN:从任何可调用对象返回,除了 Python 函数(事件在返回后发生)。
- C_RAISE:从任何可调用对象引发异常,除了 Python 函数(事件在退出后发生)。
- RAISE:引发异常,除了那些导致
STOP_ITERATION
事件的异常。 - EXCEPTION_HANDLED:异常已处理。
- LINE:即将执行的指令的行号与前一条指令的行号不同。
- INSTRUCTION – VM 指令即将执行。
- JUMP – 控制流图中的无条件跳转。
- BRANCH – 条件分支被采用(或不被采用)。
- STOP_ITERATION – 引发人工的
StopIteration
;请参阅 STOP_ITERATION 事件。
将来可能会添加更多事件。
所有事件都将是 sys.monitoring
中 events
命名空间的属性。所有事件都将由一个2的幂的整数表示,因此它们可以通过 |
运算符进行组合。
事件分为三组
本地事件
本地事件与程序的正常执行相关联,并发生在明确定义的位置。所有本地事件都可以禁用。本地事件有
- PY_START
- PY_RESUME
- PY_RETURN
- PY_YIELD
- CALL
- LINE
- INSTRUCTION
- JUMP
- BRANCH
- STOP_ITERATION
辅助事件
辅助事件可以像其他事件一样被监控,但由另一个事件控制
- C_RAISE
- C_RETURN
C_RETURN
和 C_RAISE
事件由 CALL
事件控制。C_RETURN
和 C_RAISE
事件只有在相应的 CALL
事件被监控时才会出现。
其他事件
其他事件不一定与程序中的特定位置绑定,并且不能单独禁用。
可以监控的其他事件有
- PY_THROW
- PY_UNWIND
- RAISE
- EXCEPTION_HANDLED
STOP_ITERATION 事件
PEP 380 指定在从生成器或协程返回值时会引发 StopIteration
异常。然而,这是一种非常低效的返回值方式,因此一些 Python 实现,特别是 CPython 3.12+,除非其他代码可见,否则不会引发异常。
为了允许工具监控实际异常而不会减慢生成器和协程的速度,提供了 STOP_ITERATION
事件。STOP_ITERATION
可以局部禁用,而 RAISE
则不能。
工具标识符
虚拟机最多可以同时支持6个工具。在注册或激活事件之前,工具应选择一个标识符。标识符是0到5范围内的整数。
sys.monitoring.use_tool_id(id, name:str) -> None
sys.monitoring.free_tool_id(id) -> None
sys.monitoring.get_tool(id) -> str | None
如果 id
正在使用中,sys.monitoring.use_tool_id
会引发 ValueError
。如果 id
正在使用中,sys.monitoring.get_tool
返回工具的名称,否则返回 None
。
所有ID在VM中对于事件的处理方式相同,但预定义了以下ID以方便工具协作
sys.monitoring.DEBUGGER_ID = 0
sys.monitoring.COVERAGE_ID = 1
sys.monitoring.PROFILER_ID = 2
sys.monitoring.OPTIMIZER_ID = 5
没有义务设置ID,也没有任何东西阻止工具使用即使已被占用的ID。但是,鼓励工具使用唯一的ID并尊重其他工具。
例如,如果调试器已附加且 DEBUGGER_ID
正在使用中,它应该报告错误,而不是不顾一切地继续执行。
OPTIMIZER_ID
为 Cinder 或 PyTorch 等希望优化 Python 代码但需要以依赖于更广泛上下文的方式决定优化内容的工具提供。
全局设置事件
事件可以通过修改被监控的事件集进行全局控制
sys.monitoring.get_events(tool_id:int)->int
返回表示所有活动事件的int
。sys.monitoring.set_events(tool_id:int, event_set: int)
激活event_set
中设置的所有事件。如果tool_id
未在使用中,则引发ValueError
。
默认情况下没有活动事件。
每个代码对象事件
事件也可以按代码对象进行控制
sys.monitoring.get_local_events(tool_id:int, code: CodeType)->int
返回code
的所有本地事件sys.monitoring.set_local_events(tool_id:int, code: CodeType, event_set: int)
激活event_set
中设置的code
的所有本地事件。如果tool_id
未在使用中,则引发ValueError
。
本地事件会添加到全局事件中,但不会屏蔽它们。换句话说,所有全局事件都会为代码对象触发,无论本地事件如何。
注册回调函数
要为事件注册可调用对象,请调用
sys.monitoring.register_callback(tool_id:int, event: int, func: Callable | None) -> Callable | None
如果已为给定的 tool_id
和 event
注册了另一个回调,则该回调将被注销并返回。否则,register_callback
返回 None
。
可以通过调用 sys.monitoring.register_callback(tool_id, event, None)
来注销函数。
回调函数可以随时注册和注销。
注册或注销回调函数将生成一个 sys.audit
事件。
回调函数参数
当活动事件发生时,会调用注册的回调函数。不同的事件将向回调函数提供不同的参数,如下所示
PY_START
和PY_RESUME
func(code: CodeType, instruction_offset: int) -> DISABLE | Any
PY_RETURN
和PY_YIELD
func(code: CodeType, instruction_offset: int, retval: object) -> DISABLE | Any
CALL
,C_RAISE
和C_RETURN
func(code: CodeType, instruction_offset: int, callable: object, arg0: object | MISSING) -> DISABLE | Any
如果没有参数,
arg0
将设置为MISSING
。RAISE
和EXCEPTION_HANDLED
func(code: CodeType, instruction_offset: int, exception: BaseException) -> DISABLE | Any
LINE
:func(code: CodeType, line_number: int) -> DISABLE | Any
BRANCH
:func(code: CodeType, instruction_offset: int, destination_offset: int) -> DISABLE | Any
请注意,
destination_offset
是代码下一步将执行的位置。对于未采取的分支,这将是分支后面指令的偏移量。INSTRUCTION
:func(code: CodeType, instruction_offset: int) -> DISABLE | Any
如果回调函数返回 DISABLE
,则该函数将不再为该 (code, instruction_offset)
调用,直到调用 sys.monitoring.restart_events()
。此功能适用于覆盖率和其他只对看到一次事件感兴趣的工具。
请注意,sys.monitoring.restart_events()
不是特定于某个工具的,因此工具必须准备好接收它们选择 DISABLE
的事件。
回调函数中的事件
对于注册了该回调的工具,回调函数及其被调用函数中的事件将暂停。
这意味着其他工具将在其他工具的回调函数中看到事件。这对于调试分析工具可能很有用,但会产生误导性分析结果,因为调试器工具将显示在分析中。
事件顺序
如果一条指令触发多个事件,它们将按以下顺序发生
- LINE
- INSTRUCTION
- 所有其他事件(每条指令只能发生其中一个事件)
每个事件都按照 ID 的升序传递给工具。
“call”事件组
大多数事件是独立的;设置或禁用一个事件对其他事件没有影响。然而,CALL
、C_RAISE
和 C_RETURN
事件构成一个组。如果这些事件中的任何一个被设置或禁用,那么该组中的所有事件都将被设置或禁用。禁用 CALL
事件不会禁用匹配的 C_RAISE
或 C_RETURN
,但会禁用所有后续事件。
sys.monitoring
命名空间的属性
def use_tool_id(id)->None
def free_tool_id(id)->None
def get_events(tool_id: int)->int
def set_events(tool_id: int, event_set: int)->None
def get_local_events(tool_id: int, code: CodeType)->int
def set_local_events(tool_id: int, code: CodeType, event_set: int)->None
def register_callback(tool_id: int, event: int, func: Callable)->Optional[Callable]
def restart_events()->None
DISABLE: object
MISSING: object
访问“仅调试”功能
标准库的一些功能对普通代码不可访问,但对调试器可访问。例如,设置局部变量或行号。
这些功能将对回调函数可用。
向后兼容性
本PEP在很大程度上是向后兼容的。
与 PEP 523 存在一些兼容性问题,因为 PEP 523 插件的行为不受 VM 控制。由 PEP 523 插件确保它们遵守本 PEP 的语义。不改变 VM 状态并将执行委托给 _PyEval_EvalFrameDefault()
的简单插件应该继续工作。
sys.settrace()
和 sys.setprofile()
将分别作为工具 6 和 7,因此可以与本 PEP 一起使用。
这意味着 sys.settrace()
和 sys.setprofile()
可能无法与所有 PEP 523 插件正常工作。尽管如上所述的简单 PEP 523 插件应该没有问题。
性能
如果没有活动事件,本 PEP 应该对性能产生小的积极影响。实验表明,不直接支持 sys.settrace()
可以带来 1% 到 2% 的加速。
sys.settrace()
的性能将大致相同。sys.setprofile()
的性能应该更好。然而,依赖于 sys.settrace()
和 sys.setprofile()
的工具可以通过使用本 PEP 提供的 API 变得更快。
如果只有少量事件处于活动状态,例如用于调试器,那么回调的开销将比 sys.settrace()
小几个数量级,并且比使用 PEP 523 便宜得多。
通过在所有回调中返回 DISABLE
,可以以非常低的成本实现代码覆盖率工具。
对于高度插装的代码,例如使用 LINE
,性能应该优于 sys.settrace
,但不会好很多,因为性能将主要由回调中花费的时间决定。
对于优化虚拟机,例如未来版本的 CPython(以及 PyPy
如果他们选择支持此 API),在长时间运行的程序中更改活动事件集可能会非常昂贵,可能需要数百毫秒,因为它会触发去优化。一旦发生这种去优化,性能应该会恢复,因为虚拟机可以重新优化插装代码。
通常这些操作可以认为是快速的
def get_events(tool_id: int)->int
def get_local_events(tool_id: int, code: CodeType)->int
def register_callback(tool_id: int, event: int, func: Callable)->Optional[Callable]
def get_tool(tool_id) -> str | None
这些操作较慢,但并非特别慢
def set_local_events(tool_id: int, code: CodeType, event_set: int)->None
这些操作应被视为缓慢
def use_tool_id(id, name:str)->None
def free_tool_id(id)->None
def set_events(tool_id: int, event_set: int)->None
def restart_events()->None
慢操作的速度取决于它们发生的时间。如果它们在程序早期,在模块加载之前完成,它们应该相当便宜。
内存消耗
不使用时,本PEP对内存消耗的影响可以忽略不计。
内存的使用方式在很大程度上是实现细节。但是,我们预计对于3.12,每个代码对象的额外内存消耗将 大致 如下
事件 | |||
---|---|---|---|
工具 | 其他 | LINE | INSTRUCTION |
一个 | 无 | ≈40% | ≈80% |
两个或更多 | ≈40% | ≈120% | ≈200% |
安全隐患
允许修改正在运行的代码具有一些安全隐患,但不会比生成和调用新代码的能力更多。
上述所有新函数都将触发审计钩子。
实施
本节概述了CPython 3.12的拟议实现。CPython后续版本和其他Python实现的实际实现可能存在很大差异。
本PEP的拟议实现将基于CPython 3.11的快速化步骤,如 PEP 659 中所述。插装的工作方式与快速化大致相同,字节码会根据需要替换为已插装的字节码。
例如,如果 CALL
事件被开启,那么所有调用指令都将被替换为 INSTRUMENTED_CALL
指令。
请注意,这将干扰专门化,除了调用注册的可调用对象的开销之外,还会导致一些性能下降。
当活动事件集发生变化时,VM 将立即更新所有线程调用堆栈上存在的代码对象。它还将设置陷阱,以确保在调用时正确地对所有代码对象进行插装。因此,更改活动事件集应尽可能少地进行,因为这可能是一项非常昂贵的操作。
其他事件,例如 RAISE
,可以廉价地开启或关闭,因为它们不依赖于代码插装,而是依赖于底层事件发生时的运行时检查。
需要插装的事件集是实现细节,但对于当前设计,以下事件将需要插装
- PY_START
- PY_RESUME
- PY_RETURN
- PY_YIELD
- CALL
- LINE
- INSTRUCTION
- JUMP
- BRANCH
每个经过检测的字节码将需要额外的8位信息来注明该检测适用于哪个工具。LINE
和 INSTRUCTION
事件需要额外的信息,因为它们需要存储原始指令,甚至如果它们与其他检测重叠,则需要存储经过检测的指令。
实现工具
本 PEP 的理念是,第三方监控工具应该能够实现高性能,而不是让它们容易实现高性能。
将事件转换为对用户有意义的数据是工具的责任。
所有事件都有成本,工具应尝试使用触发频率最低但仍能提供必要信息的事件集。
调试器
插入断点
可以通过设置每个代码对象事件(LINE
或 INSTRUCTION
)并对所有不匹配断点的事件返回 DISABLE
来插入断点。
单步执行
调试器通常提供按单个指令或行单步执行的功能。
像断点一样,单步执行可以通过设置每个代码对象的事件来实现。一旦恢复正常执行,就可以取消设置本地事件。
附加
调试器可以使用 PY_START
和 PY_RESUME
事件来得知何时首次遇到代码对象,以便可以插入任何必要的断点。
覆盖率工具
覆盖率工具需要跟踪控制图的哪些部分已执行。为此,它们需要注册 PY_
事件以及 JUMP
和 BRANCH
。
然后,这些信息可以在执行完成后转换回基于行的报告。
分析器
简单的分析器需要收集有关调用的信息。为此,分析器应注册以下事件
- PY_START
- PY_RESUME
- PY_THROW
- PY_RETURN
- PY_YIELD
- PY_UNWIND
- CALL
- C_RAISE
- C_RETURN
基于行的分析器
基于行的分析器可以使用 LINE
和 JUMP
事件。分析器实现者应该意识到,检测 LINE
事件将对性能产生巨大影响。
注意
插装分析器会产生显著的开销,并会扭曲分析结果。除非您需要精确的调用计数,否则请考虑使用统计分析器。
被拒绝的想法
本 PEP 的一个草案版本提议由用户负责插入监控指令,而不是由 VM 完成。然而,这给工具带来了太大的负担,并且会使附加调试器几乎不可能。
本 PEP 的早期版本建议将事件存储为 enums
class Event(enum.IntFlag):
PY_START = ...
然而,这将阻止在 enum
模块加载之前监控代码,并可能导致不必要的开销。
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0669.rst