专用与混合分析器
迄今为止,我们探讨的大多数工具属于采样分析器(sampling profilers)类别。当你想要识别代码中的热点时,这类工具非常出色,但在某些情况下,它们可能无法提供分析所需的粒度。根据分析器的采样频率和程序的行为,大多数函数可能足够快,以至于不会出现在分析器中。在某些场景下,你可能希望手动定义程序的哪些部分需要被一致地测量。例如,视频游戏平均以每秒 60 帧(FPS)渲染帧(屏幕上显示的最终图像);某些显示器支持高达 144 FPS。在 60 FPS 下,每帧只有 16 毫秒来完成工作,然后才能进入下一帧。开发人员特别关注超过此阈值的帧,因为这会在游戏中造成明显的卡顿,并可能破坏玩家体验。这种情况很难用采样分析器捕捉。
开发人员创造了在特定环境中提供有用功能的分析器,通常带有一个标记 API(marker API),可以用来手动插桩代码。这使你能够观察特定函数或代码块(以下称为区域(zone))的性能。继续以游戏行业为例,这一领域有几款工具:一些直接集成到游戏引擎中(如 Unreal),而另一些则作为外部库和工具提供,可以集成到项目中。一些最常用的分析器有 Tracy、RAD Telemetry、Remotery 和 Optick(仅限 Windows)。接下来,我们展示 Tracy,1 因为它似乎是最受欢迎的项目之一;但这些概念同样适用于其他分析器。
使用 Tracy 可以做什么
- 调试程序中的性能异常,例如慢帧。
- 将慢事件与系统中的其他事件相关联。
- 找出慢事件之间的共同特征。
- 检查源代码和汇编。
- 代码更改后进行"前后"对比。
使用 Tracy 不能做什么
- 检查 CPU 微架构问题,例如收集各种性能计数器。
案例研究:使用 Tracy 分析慢帧
在本示例中,我们将使用 ToyPathTracer2 程序,这是一个简单的路径追踪器(path tracer),是一种简化的光线追踪技术,通过向场景中每个像素射出数千条光线来渲染逼真的图像。为了处理一帧,该实现将每行像素的处理分配给一个独立的线程。
为了模拟 Tracy 可以帮助诊断问题根源的典型场景,我们手动修改了代码,使某些帧比其他帧消耗更多时间。代码清单 TracyInstrumentation 展示了代码的大纲以及添加的 Tracy 插桩。注意,我们随机选择帧来进行减速。此外,我们包含了 Tracy 的头文件,并向我们想要追踪的函数添加了 ZoneScoped 和 FrameMark 宏。FrameMark 宏可以插入以在分析器中标识各个帧。每帧的持续时间将在时间线上可见,这非常有用。
代码清单:Tracy 插桩
#include "tracy/Tracy.hpp"
void DoExtraWork() {
ZoneScoped;
// imitate useful work
}
void TraceRowJob() {
ZoneScoped;
if (frameCount == randomlySelected)
DoExtraWork();
// ...
}
void RenderFrame() {
ZoneScoped;
for (...) {
TraceRowJob();
}
FrameMark;
}
每帧可以包含许多由 ZoneScoped 宏指定的区域。与帧类似,每个区域有许多实例。每次进入区域时,Tracy 会为该区域的新实例捕获统计信息。ZoneScoped 宏在栈上创建一个 C++ 对象,该对象将记录对象生命周期范围内代码的运行时活动。Tracy 将此范围称为区域(zone)。在区域入口处,会捕获当前时间戳。一旦函数退出,对象的析构函数将记录新的时间戳,并存储此时序数据以及函数名称。
Tracy 有两种操作模式:它可以存储所有时序数据直到分析器连接到应用程序(默认模式),或者只有在分析器连接时才开始记录。后一种选项可以通过在编译应用程序时指定 TRACY_ON_DEMAND 预处理器宏来启用。如果想分发一个可以按需分析的应用程序,应优先选择此模式。使用此选项,追踪代码可以编译到应用程序中,除非分析器附加,否则它几乎不会对运行中的程序造成开销。分析器是一个独立的应用程序,连接到正在运行的应用程序以捕获和显示实时分析数据,也被称为"飞行记录仪"(flight recorder)模式。分析器可以在单独的机器上运行,以免干扰正在运行的应用程序。但请注意,这并不意味着插桩代码造成的运行时开销消失了。它仍然存在,但在这种情况下避免了可视化数据的开销。
我们使用 Tracy 来调试程序,找出某些帧比其他帧慢的原因。数据在配备 Ryzen 7 5800X 处理器的 Windows 11 机器上捕获。程序使用 MSVC 19.36.32532 编译。Tracy 的图形界面相当丰富,但遗憾的是细节太多,无法放在一张截图中,所以我们将其分解为几个部分。顶部是如图 Tracy_Main_View 所示的时间线视图,为适应页面进行了裁剪。它只显示了第 76 帧的一部分,该帧渲染耗时 44.1 毫秒。在该图中,可以看到在那帧期间活跃的 Main thread(主线程)和五个 WorkerThread(工作线程)。所有线程,包括主线程,都在执行工作以推进最终图像的渲染。如前所述,每个线程在 TraceRowJob 区域内处理一行像素。每个 TraceRowJob 区域实例包含许多更小的区域,这些区域不可见。Tracy 会折叠内部区域,只显示折叠实例的数量。例如,主线程中第一个 TraceRowJob 下方的数字 4,109 就是这个意思。注意嵌套在 TraceRowJob 区域下的 DoExtraWork 区域实例。这个观察结果已经可以引导发现问题,但在实际应用程序中,可能不会如此明显。我们暂且搁置这个问题。

Tracy 主时间线视图。显示了渲染一帧时的主线程和五个工作线程。
在主面板的正上方,有一个显示所有已记录帧时间的直方图(见图 Tracy_Frame_Time_View)。这使得发现那些花费比平均时间更长的帧变得更加容易。在本示例中,大多数帧约需 33 毫秒(黄色柱)。然而,有些帧需要更长时间,以红色标记。如截图所示,当你将鼠标指向直方图中的某个柱时,会显示该帧的详细信息提示框。在本示例中,我们显示的是最后一帧的详细信息。

Tracy 帧时序。可以找到比其他帧需要更多渲染时间的帧。
图 Tracy_CPU_Data 展示了分析器的 CPU 数据部分。该区域显示某个线程在哪个核心上执行,还显示上下文切换(context switches)。本部分还会显示在 CPU 上运行的其他程序。如图所示,当鼠标悬停在 CPU 数据视图的某个部分时,会显示该线程的详细信息。详细信息包括线程运行所在的 CPU、父程序、各线程和时序信息。可以看到 TestCpu.exe 线程在整个程序运行期间只在 CPU 1 上活跃了 4.4 毫秒。

Tracy CPU 数据视图。可以查看每个 CPU 核心在任意给定时刻的执行情况。
接下来是提供程序时间分配信息(热点)的面板。图 Tracy_Hotspots 是 Tracy 统计窗口的截图。可以查看记录的数据,包括某个函数的总活跃时间、调用次数等。还可以在主视图中选择时间范围,以过滤对应时间区间的信息。

Tracy 函数统计。一个常规的"热点"视图,提供程序时间分配的信息。
最后一组面板使我们能够更深入地分析单个区域实例。点击任意区域实例(例如在主时间线视图或 CPU data 视图中),Tracy 将打开一个 Zone Info(区域信息)窗口(见图 Tracy_Zone_Details 的左侧面板),显示该区域实例的详细信息。它显示了区域本身或其子级消耗了多少执行时间。在本示例中,TraceRowJob 函数的执行耗时 19.24 毫秒,但函数本身不包含被调用函数的时间(自身时间,self time)仅为 1.36 毫秒,只占 7%。其余时间由子区域消耗。
很容易发现对 DoExtraWork 的调用占用了大部分时间,19.24 毫秒中的 16.99 毫秒(见图 Tracy_Zone_Details 的左侧面板)。注意,这个特定的 TraceRowJob 实例运行时间几乎是平均情况的 4.4 倍(图中"437.93% of the mean time"所示)。找到了!我们发现了其中一个慢实例,其中 TraceRowJob 函数因一些额外工作而变慢。一种处理方式是点击 DoExtraWork 行以检查该区域实例。这将更新区域信息视图,显示 DoExtraWork 实例的详细信息,以便我们深入了解性能问题的原因。该视图还显示区域起始的源文件和代码行。因此,另一种策略是检查源代码,以了解当前 TraceRowJob 实例为何比平时花费更多时间。

Tracy 区域详情窗口。显示 `TraceRowJob` 区域慢实例的统计信息。
请记住,在图 Tracy_Frame_Time_View 中,我们看到还有其他慢帧。让我们看看这是否是所有慢帧的共同问题。如果点击 Statistics(统计)按钮,将显示 Find Zone(查找区域)面板(图 Tracy_Zone_Details 的右侧)。在这里可以看到聚合所有区域实例的时间直方图。这对于确定执行函数时存在多大变化特别有用。观察右侧的直方图,可以看到 TraceRowJob 函数的中位持续时间为 3.59 毫秒,大多数调用在 1 到 7 毫秒之间。然而,有几个实例超过 10 毫秒,峰值达到 23 毫秒。注意时间轴是对数刻度。Find Zone 窗口还提供其他数据点,包括所检查区域的均值、中位数和标准差。
现在可以检查其他慢实例,以找出它们之间的共同点,这将帮助我们确定问题的根本原因。在此视图中,可以选择一个慢区域。这将更新 Zone Info 窗口,显示该区域实例的详细信息;通过点击 Zoom to zone(缩放到区域)按钮,主窗口将聚焦到这个慢区域。从这里可以检查所选的 TraceRowJob 实例是否具有与我们刚才分析的实例类似的特征。
Tracy 的其他功能
Tracy 监控整个系统的性能,而不仅仅是应用程序本身。它也像传统采样分析器一样工作,报告与被分析程序同时运行的应用程序的数据。该工具通过追踪内核上下文切换来监控线程迁移和空闲时间(需要管理员权限)。区域统计信息(调用计数、时间、直方图)是精确的,因为 Tracy 捕获每次区域的进入/退出,但系统级数据和源代码级数据是采样的。
在示例中,我们对代码中感兴趣的区域进行了手动标记。然而,这并不是开始使用 Tracy 的严格要求。可以对未修改的应用程序进行分析,并在知道需要在哪里添加插桩后再添加。Tracy 还提供了许多其他功能,太多无法在本概述中全部涵盖。以下是一些值得注意的功能:
- 追踪内存分配和锁。
- 会话比较。这对于确保更改带来预期收益至关重要。可以加载两个分析会话,比较更改前后的区域数据。
- 源代码和汇编视图。如果调试符号可用,Tracy 还可以像 Intel VTune 和其他分析器一样在源代码和相关汇编中显示热点。
与 Intel VTune 和 AMD uProf 等其他工具相比,使用 Tracy 无法获得相同级别的 CPU 微架构洞察(例如,各种性能事件)。这是因为 Tracy 不利用特定平台的硬件特性。
使用 Tracy 进行分析的开销取决于激活了多少区域。Tracy 的作者提供了他在一个图像压缩程序上测量的一些数据点:两种不同压缩方案的开销分别为 18% 和 34%。共分析了 2 亿个区域,每个区域的平均开销为 2.25 纳秒。该测试对一个非常热的函数进行了插桩。在其他场景中,开销将会低得多。虽然可以将开销保持在较小范围内,但需要谨慎选择要插桩的代码部分,特别是如果决定在生产环境中使用它的话。
1. Tracy - https://github.com/wolfpld/tracy ↩
2. ToyPathTracer - https://github.com/wolfpld/tracy/tree/master/examples/ToyPathTracer ↩