Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 增强提案

PEP 669 – CPython 的低影响监控

作者:
Mark Shannon <mark at hotpy.org>
讨论列表:
Discourse 讨论帖
状态:
最终版
类型:
标准跟踪
创建日期:
2021年8月18日
Python 版本:
3.12
历史记录:
2021年12月7日, 2022年1月10日
决议:
Discourse 消息

目录

重要提示

此 PEP 是一份历史文档。最新的规范文档现已可在 sys.monitoring 中找到。

×

请参阅 PEP 1,了解如何提出更改建议。

摘要

在 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.monitoringevents 命名空间的属性。所有事件都将用 2 的幂整数表示,以便它们可以与 | 运算符结合使用。

事件分为三组

本地事件

本地事件与程序的正常执行相关联,并且发生在明确定义的位置。所有本地事件都可以禁用。本地事件包括:

  • PY_START
  • PY_RESUME
  • PY_RETURN
  • PY_YIELD
  • CALL
  • LINE
  • INSTRUCTION
  • JUMP
  • BRANCH
  • STOP_ITERATION

辅助事件

辅助事件可以像其他事件一样被监控,但受另一个事件控制

  • C_RAISE
  • C_RETURN

C_RETURNC_RAISE 事件受 CALL 事件控制。C_RETURNC_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 正在使用,则会引发 ValueErrorsys.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_idevent 注册,则将其注销并返回。否则,register_callback 返回 None

可以通过调用 sys.monitoring.register_callback(tool_id, event, None) 来注销函数。

回调函数可以随时注册和注销。

注册或注销回调函数将生成一个 sys.audit 事件。

回调函数参数

当发生活动事件时,会调用已注册的回调函数。不同的事件将为回调函数提供不同的参数,如下所示

  • PY_STARTPY_RESUME
    func(code: CodeType, instruction_offset: int) -> DISABLE | Any
    
  • PY_RETURNPY_YIELD
    func(code: CodeType, instruction_offset: int, retval: object) -> DISABLE | Any
  • CALLC_RAISEC_RETURN
    func(code: CodeType, instruction_offset: int, callable: object, arg0: object | MISSING) -> DISABLE | Any

    如果没有参数,则将 arg0 设置为 MISSING

  • RAISEEXCEPTION_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” 事件组

大多数事件是独立的;设置或禁用一个事件不会影响其他事件。但是,CALLC_RAISEC_RETURN 事件形成一个组。如果设置或禁用了这些事件中的任何一个,则该组中的所有事件都会受到影响。禁用 CALL 事件不会禁用匹配的 C_RAISEC_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 位信息来记录检测应用于哪个工具。 LINEINSTRUCTION 事件需要额外的信息,因为它们需要存储原始指令,或者如果它们与其他检测重叠,则甚至需要存储检测后的指令。

实现工具

本 PEP 的理念是,第三方监控工具应该能够实现高性能,而不是让它们很容易做到这一点。

将事件转换为对用户有意义的数据是工具的责任。

所有事件都有成本,工具应尝试使用触发频率最低的事件集,同时仍然提供必要的信息。

调试器

插入断点

断点可以通过设置每个代码对象的事件来插入,可以是 LINEINSTRUCTION,并且对于任何不匹配断点的事件返回 DISABLE

单步执行

调试器通常提供逐指令或逐行执行的功能。

与断点类似,步进可以通过设置每个代码对象的事件来实现。一旦要恢复正常执行,就可以取消设置本地事件。

附加

调试器可以使用 PY_STARTPY_RESUME 事件来获知何时第一次遇到代码对象,以便插入任何必要的断点。

覆盖率工具

覆盖率工具需要跟踪控制图的哪些部分已执行。为此,它们需要注册 PY_ 事件以及 JUMPBRANCH 事件。

然后,可以在执行完成后将此信息转换回基于行的报告。

分析器

简单的分析器需要收集有关调用的信息。为此,分析器应注册以下事件

  • PY_START
  • PY_RESUME
  • PY_THROW
  • PY_RETURN
  • PY_YIELD
  • PY_UNWIND
  • CALL
  • C_RAISE
  • C_RETURN

基于行的分析器

基于行的分析器可以使用 LINEJUMP 事件。分析器实现者应该意识到,检测 LINE 事件会对性能产生很大影响。

注意

检测分析器具有很大的开销,并且会扭曲分析结果。除非您需要准确的调用次数,否则请考虑使用统计分析器。

被拒绝的想法

本 PEP 的草稿版本建议让用户负责插入监控指令,而不是让虚拟机来做。但是,这给工具带来了过多的负担,并且会使附加调试器变得几乎不可能。

本 PEP 的早期版本建议将事件存储为 enums

class Event(enum.IntFlag):
    PY_START = ...

但是,这会阻止在加载 enum 模块之前监控代码,并且可能导致不必要的开销。


来源:https://github.com/python/peps/blob/main/peps/pep-0669.rst

上次修改:2024-02-07 11:51:52 GMT