Python 对 Linux perf 分析器的支持

作者:

Pablo Galindo

Linux perf 分析器 是一个非常强大的工具,它可以让你分析应用程序的性能并获取相关信息。perf 还有一个非常活跃的工具生态系统,这些工具可以帮助分析其生成的数据。

使用 perf 分析器处理 Python 应用程序的主要问题是,perf 只获取关于原生符号的信息,即用 C 语言编写的函数和过程的名称。这意味着你的代码中 Python 函数的名称和文件名不会出现在 perf 的输出中。

自 Python 3.12 以来,解释器可以运行在一种特殊模式下,允许 Python 函数出现在 perf 分析器的输出中。当启用此模式时,解释器会在每个 Python 函数执行之前插入一小段即时编译的代码,并使用perf 映射文件告诉 perf 这段代码与关联 Python 函数之间的关系。

备注

目前,perf 分析器支持仅适用于特定架构的 Linux 系统。请检查 configure 构建步骤的输出,或者检查 python -m sysconfig | grep HAVE_PERF_TRAMPOLINE 的输出来查看你的系统是否支持。

例如,考虑以下脚本

def foo(n):
    result = 0
    for _ in range(n):
        result += 1
    return result

def bar(n):
    foo(n)

def baz(n):
    bar(n)

if __name__ == "__main__":
    baz(1000000)

我们可以运行 perf 以 9999 赫兹的频率采样 CPU 堆栈跟踪

$ perf record -F 9999 -g -o perf.data python my_script.py

然后我们可以使用 perf report 来分析数据

$ perf report --stdio -n -g

# Children      Self       Samples  Command     Shared Object       Symbol
# ........  ........  ............  ..........  ..................  ..........................................
#
    91.08%     0.00%             0  python.exe  python.exe          [.] _start
            |
            ---_start
            |
                --90.71%--__libc_start_main
                        Py_BytesMain
                        |
                        |--56.88%--pymain_run_python.constprop.0
                        |          |
                        |          |--56.13%--_PyRun_AnyFileObject
                        |          |          _PyRun_SimpleFileObject
                        |          |          |
                        |          |          |--55.02%--run_mod
                        |          |          |          |
                        |          |          |           --54.65%--PyEval_EvalCode
                        |          |          |                     _PyEval_EvalFrameDefault
                        |          |          |                     PyObject_Vectorcall
                        |          |          |                     _PyEval_Vector
                        |          |          |                     _PyEval_EvalFrameDefault
                        |          |          |                     PyObject_Vectorcall
                        |          |          |                     _PyEval_Vector
                        |          |          |                     _PyEval_EvalFrameDefault
                        |          |          |                     PyObject_Vectorcall
                        |          |          |                     _PyEval_Vector
                        |          |          |                     |
                        |          |          |                     |--51.67%--_PyEval_EvalFrameDefault
                        |          |          |                     |          |
                        |          |          |                     |          |--11.52%--_PyLong_Add
                        |          |          |                     |          |          |
                        |          |          |                     |          |          |--2.97%--_PyObject_Malloc
...

正如你所看到的,Python 函数没有显示在输出中,只显示了 _PyEval_EvalFrameDefault(评估 Python 字节码的函数)。不幸的是,这并没有多大用处,因为所有 Python 函数都使用相同的 C 函数来评估字节码,所以我们无法知道哪个 Python 函数对应哪个字节码评估函数。

相反,如果我们在启用 perf 支持的情况下运行相同的实验,我们会得到

$ perf report --stdio -n -g

# Children      Self       Samples  Command     Shared Object       Symbol
# ........  ........  ............  ..........  ..................  .....................................................................
#
    90.58%     0.36%             1  python.exe  python.exe          [.] _start
            |
            ---_start
            |
                --89.86%--__libc_start_main
                        Py_BytesMain
                        |
                        |--55.43%--pymain_run_python.constprop.0
                        |          |
                        |          |--54.71%--_PyRun_AnyFileObject
                        |          |          _PyRun_SimpleFileObject
                        |          |          |
                        |          |          |--53.62%--run_mod
                        |          |          |          |
                        |          |          |           --53.26%--PyEval_EvalCode
                        |          |          |                     py::<module>:/src/script.py
                        |          |          |                     _PyEval_EvalFrameDefault
                        |          |          |                     PyObject_Vectorcall
                        |          |          |                     _PyEval_Vector
                        |          |          |                     py::baz:/src/script.py
                        |          |          |                     _PyEval_EvalFrameDefault
                        |          |          |                     PyObject_Vectorcall
                        |          |          |                     _PyEval_Vector
                        |          |          |                     py::bar:/src/script.py
                        |          |          |                     _PyEval_EvalFrameDefault
                        |          |          |                     PyObject_Vectorcall
                        |          |          |                     _PyEval_Vector
                        |          |          |                     py::foo:/src/script.py
                        |          |          |                     |
                        |          |          |                     |--51.81%--_PyEval_EvalFrameDefault
                        |          |          |                     |          |
                        |          |          |                     |          |--13.77%--_PyLong_Add
                        |          |          |                     |          |          |
                        |          |          |                     |          |          |--3.26%--_PyObject_Malloc

如何启用 perf 分析支持

perf 分析支持可以通过以下方式启用:一开始使用环境变量 PYTHONPERFSUPPORT-X perf 选项,或者动态使用 sys.activate_stack_trampoline()sys.deactivate_stack_trampoline()

sys 函数优先于 -X 选项,-X 选项优先于环境变量。

示例,使用环境变量

$ PYTHONPERFSUPPORT=1 perf record -F 9999 -g -o perf.data python my_script.py
$ perf report -g -i perf.data

示例,使用 -X 选项

$ perf record -F 9999 -g -o perf.data python -X perf my_script.py
$ perf report -g -i perf.data

例如,在文件 example.py 中使用 sys API

import sys

sys.activate_stack_trampoline("perf")
do_profiled_stuff()
sys.deactivate_stack_trampoline()

non_profiled_stuff()

…然后

$ perf record -F 9999 -g -o perf.data python ./example.py
$ perf report -g -i perf.data

如何获得最佳结果

为了获得最佳结果,Python 应该使用 CFLAGS="-fno-omit-frame-pointer -mno-omit-leaf-frame-pointer" 进行编译,因为这允许分析器仅使用帧指针而不是 DWARF 调试信息进行栈回溯。这是因为为支持 perf 而插入的代码是动态生成的,因此不包含任何 DWARF 调试信息。

你可以通过运行以下命令来检查你的系统是否使用此标志编译

$ python -m sysconfig | grep 'no-omit-frame-pointer'

如果你没有看到任何输出,则意味着你的解释器没有使用帧指针编译,因此可能无法在 perf 的输出中显示 Python 函数。

如何在没有帧指针的情况下工作

如果你使用的 Python 解释器没有用帧指针编译,你仍然可以使用 perf 分析器,但开销会稍高,因为 Python 需要为每个 Python 函数调用即时生成栈回溯信息。此外,perf 处理数据的时间会更长,因为它需要使用 DWARF 调试信息来回溯栈,这是一个缓慢的过程。

要启用此模式,你可以使用环境变量 PYTHON_PERF_JIT_SUPPORT-X perf_jit 选项,这将为 perf 分析器启用 JIT 模式。

备注

由于 perf 工具中的一个错误,只有高于 v6.8 版本的 perf 才能与 JIT 模式一起工作。该修复也已反向移植到 v6.7.2 版本的工具中。

请注意,在检查 perf 工具的版本(可以通过运行 perf version 来完成)时,你必须考虑到某些发行版会添加一些包含 - 字符的自定义版本号。这意味着 perf 6.7-3 不一定就是 perf 6.7.3

使用 perf JIT 模式时,在运行 perf report 之前需要额外的步骤。你需要调用 perf inject 命令将 JIT 信息注入到 perf.data 文件中。

$ perf record -F 9999 -g -k 1 --call-graph dwarf -o perf.data python -Xperf_jit my_script.py
$ perf inject -i perf.data --jit --output perf.jit.data
$ perf report -g -i perf.jit.data

或使用环境变量

$ PYTHON_PERF_JIT_SUPPORT=1 perf record -F 9999 -g --call-graph dwarf -o perf.data python my_script.py
$ perf inject -i perf.data --jit --output perf.jit.data
$ perf report -g -i perf.jit.data

perf inject --jit 命令将读取 perf.data,自动获取 Python 创建的 perf 转储文件(在 /tmp/perf-$PID.dump 中),然后创建 perf.jit.data,将所有 JIT 信息合并在一起。它还应该在当前目录中创建许多 jitted-XXXX-N.so 文件,这些文件是 Python 创建的所有 JIT 跳板的 ELF 镜像。

警告

使用 --call-graph dwarf 时,perf 工具会拍摄被分析进程的堆栈快照,并将信息保存到 perf.data 文件中。默认情况下,堆栈转储的大小为 8192 字节,但你可以通过在逗号后传递大小来更改,例如 --call-graph dwarf,16384

堆栈转储的大小很重要,因为如果大小太小,perf 将无法回溯堆栈,并且输出将不完整。另一方面,如果大小太大,那么 perf 将无法像它希望的那样频繁地采样进程,因为开销会更高。

当分析以低优化级别(如 -O0)编译的 Python 代码时,堆栈大小尤为重要,因为这些构建往往具有更大的堆栈帧。如果你正在使用 -O0 编译 Python 并且在分析输出中没有看到 Python 函数,请尝试将堆栈转储大小增加到 65528 字节(最大值)

$ perf record -F 9999 -g -k 1 --call-graph dwarf,65528 -o perf.data python -Xperf_jit my_script.py

不同的编译标志会显著影响堆栈大小

  • 使用 -O0 的构建通常比使用 -O1 或更高优化级别的构建具有更大的堆栈帧

  • 添加优化(-O1-O2 等)通常会减小堆栈大小

  • 帧指针(-fno-omit-frame-pointer)通常提供更可靠的堆栈回溯