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 函数生成(在生成前立即发生,被调用者的帧将在栈上)。
- PY_UNWIND: 在异常展开期间退出 Python 函数。
- CALL: Python 代码中的调用(事件发生在调用之前)。
- C_RETURN: 从任何可调用对象返回,除了 Python 函数(事件发生在返回之后)。
- C_RAISE: 从任何可调用对象引发的异常,除了 Python 函数(事件发生在退出之后)。
- RAISE: 引发异常,除了导致
STOP_ITERATION
事件的异常。 - EXCEPTION_HANDLED: 处理异常。
- LINE: 将要执行的指令的行号与前一个指令不同。
- INSTRUCTION – 将要执行的虚拟机指令。
- 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
sys.monitoring.use_tool_id
如果 id
正在使用,则会引发 ValueError
。sys.monitoring.get_tool
如果 id
正在使用,则返回工具的名称,否则返回 None
。
所有 ID 在虚拟机中关于事件的处理方式都相同,但以下 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)
激活code
的所有本地事件,这些事件在event_set
中设置。如果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()
不是特定于某个工具的,因此工具必须准备好接收它们选择禁用事件。
回调函数中的事件
事件在注册该回调函数的工具的回调函数及其被调用者中被挂起。
这意味着其他工具将在其他工具的回调函数中看到事件。这对于调试性能分析工具可能很有用,但会产生误导性的性能分析结果,因为调试器工具将显示在性能分析结果中。
事件顺序
如果一条指令触发多个事件,则按以下顺序发生
- 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 插件的行为超出了虚拟机的控制范围。 PEP 523 插件负责确保它们尊重此 PEP 的语义。不更改虚拟机状态并延迟执行到 _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
来以非常低的成本实现。
对于大量使用 instrumentation 的代码(例如,使用 LINE
),性能应该优于 sys.settrace
,但不会好太多,因为性能将主要受回调中花费的时间影响。
对于优化虚拟机,例如 CPython 的未来版本(以及 PyPy
如果它们选择支持此 API),在长时间运行的程序中间更改活动事件集可能会非常昂贵,可能需要数百毫秒,因为它会触发反优化。一旦发生这种反优化,性能应该会恢复,因为虚拟机可以重新优化 instrumentation 代码。
通常,这些操作可以被认为是快速的
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
指令。
请注意,这会干扰专门化,除了调用注册的可调用对象的开销外,还会导致某些性能下降。
当活动事件集发生变化时,虚拟机将立即更新任何线程调用栈上存在的所有代码对象。它还会设置陷阱以确保在调用时所有代码对象都得到正确的检测。因此,应尽可能减少更改活动事件集的频率,因为这可能是一项非常昂贵的操作。
其他事件,例如 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 的草稿版本建议让用户负责插入监控指令,而不是让虚拟机来做。但是,这给工具带来了过多的负担,并且会使附加调试器变得几乎不可能。
本 PEP 的早期版本建议将事件存储为 enums
class Event(enum.IntFlag):
PY_START = ...
但是,这会阻止在加载 enum
模块之前监控代码,并且可能导致不必要的开销。
版权
本文档置于公共领域或根据 CC0-1.0-Universal 许可证,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0669.rst