构建 FunctionTrace,一个图形化的 Python 分析器

Firefox Profiler 用于性能分析

Harald 的介绍

Firefox ProfilerQuantum 项目 时代成为了 Firefox 性能工作中不可或缺的一部分。当你 打开一个示例记录 时,首先你会看到一个功能强大的基于 Web 的性能分析界面,其中包含调用树、堆栈图、火焰图等等。所有数据过滤、缩放、切片、转换操作都保存在一个可共享的 URL 中。你可以在 Bug 中共享它,记录你的发现,与其他记录并排比较,或者将其移交给其他人进行进一步调查。Firefox DevEdition 对内置分析流程有一个抢先体验,使得记录和分享变得轻而易举。我们的目标是赋能所有开发者共同协作,提升性能,甚至超越 Firefox。

在早期,Firefox Profiler 可以导入其他格式,从 Linux perfChrome 的分析结果 开始。随着时间的推移,越来越多的格式被各个开发者添加进来。如今,第一个采用 Firefox 作为分析工具的项目正在涌现。FunctionTrace 就是其中之一,以下是由 Matt 讲述的关于他如何构建它的故事。

认识 FunctionTrace,一个针对 Python 代码的分析器

Matt 的项目

我最近构建了一个工具,旨在帮助开发者更好地了解他们的 Python 代码在做什么。 FunctionTrace 是一个针对 Python 的非采样分析器,它可以在未修改的 Python 应用程序上运行,并且开销非常低(<5%)。重要的是,它与 Firefox Profiler 集成在一起。这使你能够以图形化的方式与分析结果进行交互,从而更容易发现模式并改进代码库。

在这篇文章中,我将讨论我们为什么构建 FunctionTrace,并分享一些关于其实现的技术细节。我将展示像这样的工具如何将 Firefox Profiler 作为强大的开源可视化工具来使用。为了更好地理解,你也可以尝试一个 小型演示

Looking at a FunctionTrace profile

一个在 Firefox Profiler 中打开的 FunctionTrace 分析结果示例。

技术债务作为动机

代码库往往会随着时间的推移而变得越来越庞大,特别是在涉及多人协作的复杂项目中。一些语言在处理这方面拥有强大的支持,例如 Java,其 IDE 功能经过数十年的积累,或者 Rust,其强大的类型系统使重构变得轻而易举。其他语言的代码库在成长过程中有时会变得越来越难以维护。这在老旧的 Python 代码库中尤其明显(至少我们现在都使用 Python 3 了,对吧?)。

对不熟悉的代码进行大规模更改或重构可能是极其困难的。相比之下,当我能够看到程序在做什么以及它所有的交互时,我更容易做出正确的更改。通常,我甚至会发现自己对原本不打算修改的代码部分进行了改进,因为当这些效率低下问题在我的屏幕上清晰地呈现出来时,就会变得显而易见。

我希望能够理解我正在使用的 Python 代码库在做什么,而不需要浏览数百个文件。我无法找到任何令人满意的现有的 Python 工具,而且由于需要进行大量 UI 工作,我基本上放弃了自行构建工具的想法。然而,当我偶然发现了 Firefox Profiler 时,我对快速了解程序执行过程的希望重新燃起了。

Profiler 提供了所有“困难”的部分,一个直观的开源 UI,可以显示堆栈图、时间相关日志标记、火焰图,以及与主要 Web 浏览器绑定带来的稳定性。任何能够生成正确格式的 JSON 分析结果的工具都可以重复使用上述所有图形分析功能。

FunctionTrace 的设计

幸运的是,在我发现 Firefox Profiler 的几天后,我正好安排了一周的休假。我认识另一个朋友,他对和我一起构建这个工具也很感兴趣,并且在那周也请了假。

目标

在我们开始构建 FunctionTrace 时,我们有几个目标

  1. 提供查看程序中发生的一切的能力。
  2. 处理多线程/多进程应用程序。
  3. 开销足够低,以便我们可以在没有性能折衷的情况下使用它。

第一个目标对设计产生了重大影响,而后两个目标则增加了工程复杂性。根据过去使用这类工具的经验,我们都知道无法看到过短的函数调用带来的挫败感。当你以 1ms 的频率进行采样,但有重要的函数运行速度快于此,那么你就会错过程序内部发生的重要部分!

因此,我们知道我们需要能够跟踪所有函数调用,并且不能使用采样分析器。此外,我最近在代码库中花费了一些时间,其中 Python 函数会exec其他 Python 代码(通常通过中间 shell 脚本)。由此,我们知道我们希望能够跟踪子 Python 进程。

初始实现

为了支持多个进程和子进程,我们决定使用客户端-服务器模型。我们将对 Python 客户端进行检测,客户端会将跟踪数据发送到 Rust 服务器。服务器将聚合和压缩数据,然后生成一个可供 Firefox Profiler 使用的分析结果。我们选择 Rust 的原因有很多,包括强大的类型系统,对稳定性能和可预测内存使用量的渴望,以及易于原型设计和重构。

我们将客户端作为 Python 模块进行原型设计,通过python -m functiontrace code.py调用。这使我们能够轻松地使用 Python 的 内置跟踪挂钩 来记录执行的内容。初始实现看起来非常类似于以下代码

def profile_func(frame, event, arg):
    if event == "call" or event == "return" or event == "c_call" or event == "c_return":
        data = (event, time.time())
        server.sendall(json.dumps(data))

sys.setprofile(profile_func)

对于服务器,我们在 Unix 域套接字 上监听客户端连接。然后,我们从客户端读取数据并将它们转换为 Firefox Profiler 的 JSON 格式

Firefox Profiler 支持各种分析结果类型,例如 perf 日志。但是,我们决定直接输出到 Profiler 的内部格式。它比添加新的支持格式需要更少的空间和维护。重要的是,Firefox Profiler 保持了分析结果版本的向后兼容性。这意味着我们针对当前格式版本发出的任何分析结果都将在将来加载时自动转换为最新版本。此外,分析器格式通过整数 ID 来引用字符串。这允许通过重复数据删除来实现显著的空间节省(使用 indexmap 实现起来非常简单)。

一些优化

通常,初始基础功能运行良好。在每次函数调用/返回时,Python 会调用我们的挂钩。挂钩会通过套接字发送 JSON 消息,由服务器将其转换为正确的格式。然而,它非常慢。即使在对套接字调用进行批处理之后,我们也观察到在一些测试程序上开销至少是 8 倍!

在这一点上,我们使用 Python 的 C API 代替 C 进行降级。我们在相同的程序上将开销降低到了 1.1 倍。之后,我们能够通过将对time.time()的调用替换为 rdtsc 操作(通过clock_gettime())来进行另一个关键优化。我们将函数调用的性能开销减少到几个指令,并发出 64 位数据。这比在关键路径中使用一连串的 Python 调用和复杂的算术运算效率要高得多。

我已经提到过我们支持跟踪多个线程和子进程。由于这是客户端中最困难的部分之一,因此值得讨论一些更低级的细节。

支持多线程

我们通过threading.setprofile()在所有线程上安装一个处理程序。(注意:当我们设置线程状态时,我们通过这样的处理程序进行注册,以确保 Python 正在运行并且 GIL 当前正在被持有。这使我们能够简化一些假设。)

// This is installed as the setprofile() handler for new threads by
// threading.setprofile().  On its first execution, it initializes tracing for
// the thread, including creating the thread state, before replacing itself with
// the normal Fprofile_FunctionTrace handler.
static PyObject* Fprofile_ThreadFunctionTrace(..args..) {
    Fprofile_CreateThreadState();

    // Replace our setprofile() handler with the real one, then manually call
    // it to ensure this call is recorded.
    PyEval_SetProfile(Fprofile_FunctionTrace);
    Fprofile_FunctionTrace(..args..);
    Py_RETURN_NONE;
}

当我们的Fprofile_ThreadFunctionTrace()挂钩被调用时,它会分配一个struct ThreadState,其中包含线程记录事件和与服务器通信所需的必要信息。然后,我们将初始消息发送到分析服务器。在这里,我们通知它一个新线程已启动,并提供一些初始信息(时间、PID 等)。在此初始化之后,我们将挂钩替换为Fprofile_FunctionTrace(),该挂钩将在将来进行实际的跟踪。

支持子进程

在处理多个进程时,我们假设子进程是通过python解释器运行的。不幸的是,子进程不会使用-m functiontrace调用,因此我们不会知道要跟踪它们。为了确保子进程被跟踪,在启动时,我们将修改$PATH环境变量。反过来,这将确保python指向一个知道加载functiontrace的可执行文件。

# Generate a temp directory to store our wrappers in.  We'll temporarily
# add this directory to our path.
tempdir = tempfile.mkdtemp(prefix="py-functiontrace")
os.environ["PATH"] = tempdir + os.pathsep + os.environ["PATH"]

# Generate wrappers for the various Python versions we support to ensure
# they're included in our PATH.
wrap_pythons = ["python", "python3", "python3.6", "python3.7", "python3.8"]
for python in wrap_pythons:
    with open(os.path.join(tempdir, python), "w") as f:
        f.write(PYTHON_TEMPLATE.format(python=python))
        os.chmod(f.name, 0o755)

在包装器内部,我们只需要使用-m functiontrace的附加参数来调用真正的python解释器。为了完成此支持,我们在启动时添加一个环境变量。该变量告诉我们使用什么套接字与分析服务器通信。如果一个客户端初始化并看到此环境变量已设置,它会识别出一个子进程。然后,它连接到现有的服务器实例,使我们能够将其跟踪与原始客户端的跟踪相关联。

当前实现

FunctionTrace 的整体实现与上述描述有很多相似之处。从总体上讲,当以 python -m functiontrace code.py 方式调用时,客户端会通过 FunctionTrace 进行跟踪。这将为某些设置加载一个 Python 模块,然后调用我们的 C 模块以安装各种跟踪钩子。这些钩子包括上面提到的 sys.setprofile 钩子、内存分配钩子以及对各种“有趣”函数的自定义钩子,例如 builtins.printbuiltins.__import__。此外,我们还会启动一个 functiontrace-server 实例,设置一个用于与之通信的套接字,并确保将来的线程和子进程将与同一服务器通信。

在每次跟踪事件发生时,Python 客户端都会发出一个小的 MessagePack 记录。该记录包含最少的事件信息和一个指向线程局部内存缓冲区的的时间戳。当缓冲区填满(每 128KB)时,它会通过共享套接字转储到服务器,客户端继续执行。服务器异步监听每个客户端,快速将它们的跟踪日志消耗到另一个缓冲区,以避免阻塞它们。然后,与每个客户端相对应的线程能够解析每个跟踪事件,并将其转换为正确的最终格式。一旦所有连接的客户端退出,每个线程的日志将被聚合到一个完整的配置文件日志中。最后,它会被发出到一个文件中,该文件可以与 Firefox Profiler 一起使用。

经验教训

拥有一个 Python C 模块可以提供更大的功能和性能,但也有一定的代价。它需要更多代码,更难找到好的文档;而且很少有功能易于访问。虽然 C 模块似乎是编写高性能 Python 模块的未充分利用的工具(基于我看到的某些 FunctionTrace 配置文件),但我们建议保持平衡。将大多数非性能关键代码写入 Python,并在 Python 不擅长的部分(例如内部循环或设置代码)调用 C 模块。

当不需要人类可读性时,JSON 编码/解码可能会非常慢。我们切换到 MessagePack 用于客户端-服务器通信,发现它同样易于使用,同时将某些基准测试时间减少了一半!

Python 中的多线程分析支持非常复杂,因此可以理解为什么它似乎不是以前主流 Python 分析器中的一个关键功能。在我们对如何在保持高性能的同时绕过 GIL 有了很好的理解之前,我们尝试了多种不同的方法,并遇到了很多段错误。

请扩展分析器生态系统!

如果没有 Firefox Profiler,这个项目就不会存在。如果没有一个复杂的、经过验证的性能工具的前端,创建它将需要太多时间。我们希望看到其他项目针对 Firefox Profiler,要么像 FunctionTrace 一样添加对 Profiler 格式的原生支持,要么为自己的格式提供支持。虽然 FunctionTrace 还没有完全完成,但我希望在博客上分享它能让其他精明的开发人员意识到 Firefox Profiler 的潜力。Profiler 为一些关键的开发工具提供了绝佳的机会,使其能够超越命令行,进入一个更适合快速提取相关信息的 GUI。

关于 Matt Bryant

Matt 是一位程序员,喜欢设计安全且高性能的系统。他住在旧金山,大部分空闲时间都花在攀岩上。

更多 Matt Bryant 的文章…

关于 Harald Kirschner (digitarald)

Harald “digitarald” Kirschner 是 Firefox 开发人员体验和工具的产品经理,他致力于赋能创作者编码、设计和维护一个对所有人开放且可访问的网络。在他在 Mozilla 的 8 年时间里,他不断提升自己的技能,涵盖性能、Web API、移动设备、可安装 Web 应用、数据可视化和开发人员推广项目。

更多 Harald Kirschner (digitarald) 的文章…