【翻译】如何使用Tracy对React Native中的原生代码进行性能分析

24 阅读15分钟

原文链接:www.callstack.com/blog/how-to…

作者:Mariusz Pasiński

“我们为何陷入今日的现状,未来又将走向何方?”这个问题始终萦绕在我心头,但其中必定存在某种缘由,对吧?难道只有我这么觉得吗?早期的软件响应似乎更灵敏——更“快”(且更少bug)——尽管如今的硬件速度已提升数百倍?令人惊讶的是,即便在我第一台个人电脑上,早期版本的Visual Studio启动速度仍快于最新版本在顶级机器上的运行速度。

游戏开发者是我最喜欢的例子——他们不断突破16.7毫秒(或VR场景的11.1毫秒)的极限,在极短时间内完成海量运算;而与此同时,当网页应用超过一秒无响应时,人们却毫不在意。这可是慢了62倍啊!

尼古拉斯·维特曾有句名言:“软件变慢的速度,远快于硬件变快的速度。”让我们在此稍作停顿,细细品味这句话的深意……

通过本文,我希望引起大家对性能的关注,阐述如何监控性能,并为一项恐怕正逐渐变得神秘莫测的技术——性能分析——提供一个良好的入门指南。

为何要进行性能分析?

如何判断是否存在“性能问题”?当然,你可以说它“感觉很慢”或“卡顿”,但这纯属主观感受。更何况,你怎么知道今天的版本比上次运行的版本“更好”?

测量并收集“(性能)指标”的行为(或过程)称为性能分析,因此执行此任务的工具被称为“性能分析器”。仅收集指标并不能带来太多洞察,因为性能是相对的。

如同任何科学流程,我们必须尽职尽责确保数据以可靠且确定的方式采集。我们需要信任测量结果!首先,需谨慎选择“指标”以维持足够的信噪比——采集所有数据反而适得其反。其次,必须控制环境及外部“变量”。

性能分析器并非魔法工具。它们不会直接告诉你如何加速代码运行,而只是帮助发现“房间里的大象”——即代码中的瓶颈和热点(运行时间最长的部分)。这些将成为你的“嫌疑人”,而你作为侦探,必须从这里开始调查。

不同类型的工具

并非所有性能分析器都相同,因此选择合适的工具是至关重要的第一步。性能分析器基本分为两类,各有优劣。

采样式性能分析器

这类工具最为简单——它们只是频繁“监视”应用程序并记录运行痕迹。它们实质上是挂载在进程上,以高且固定的频率周期性地检查应用程序的堆栈跟踪(或至少检查指令寄存器)。通过这种方式,采样型分析器在“记录”过程中收集大量样本并进行统计分析。最终生成的“跟踪记录”将显示程序执行特定指令时被“当场抓获”的次数。

最棒的是采样分析器无需任何修改就能适用于任何应用程序。这些监控工具如此高效,以至于被监控的应用可能毫无察觉。我们只需在应用所在的设备上运行分析器(采集器)即可。

在我看来,其最大缺陷在于数据平均处理过程及“收集情报”的深度(我们对采集指标的控制力较弱),这使得异常值或偶发故障更难被发现。此外,这类分析器的工作机制还存在特定弊端——它们需要处理器持续运行,因此在核心休眠时需格外谨慎。

在此领域值得关注的顶尖工具包括:英特尔vTune、AMD μProf、Windows事件追踪器以及Very Sleepy。顶级硬件厂商推出自有工具实属意料之中。许多现代CPU内置硬件计数器,可收集极低级别的指标(如L1/L2缓存未命中次数)。该类别还包含Xcode的“Instruments”工具(如Time Profiler)和Android Profiler。GPU领域同样如此,值得关注的有NVIDIA Nsight、Radeon GPU Profiler、Intel GPA和PIX!

基于仪器化的分析器

这类分析器需要您的协助与参与。您需要通过“仪器化”来修改应用程序——在函数和方法的开头(有时也需在结尾)添加一小段代码。这意味着您通常需要构建一个专用于分析的定制版本。

基于仪器化的分析器的最大优势在于能获取丰富的测量数据,不仅限于“开始”和“结束”时间。通过修改源代码,您可以对锁机制(如互斥锁和信号量)、内存分配器等进行封装,进而充分利用这些数据点。此外,专为游戏设计的分析器通常具备帧概念,可供自由运用。基本操作是在“帧”末尾添加标记,从而实现逐帧分析,精准定位卡顿或超出预算的帧。

此类分析器还具有以下优势:

  • 不受编译器优化(如内联)影响
  • 区域和标记可添加标签 (通常可自定义颜色)
  • 可随时启用/禁用剖析功能,或保持常驻运行
  • 无需外部进程进行数据采集

基于仪器化的剖析器示例包括:RAD Telemetry、Tracy、Optick(原名Brofiler)和systrace。

多数剖析器同时支持采样式与仪器化两种指标采集方式。

准备进行性能分析

直接在性能分析器下运行应用程序可能非常诱人,但……你真的清楚自己的目标吗?要收集哪些指标?能否确保测量结果具有可重复性?如何判断某项操作是否“缓慢”?“缓慢”究竟意味着什么?

假设你有两个测量值——姑且称之为“基准值”和“当前值”——通过对比就能判断某项操作变快或变慢,对吧?但它应该达到多快?多快才算够快?如何判断已触及性能上限?你是否定义了性能预算?

我并非在吹毛求疵。这些都是至关重要的问题!我们需要制定计划、建立可复现流程并设定假设,以便逐项测试验证。这听起来或许耳熟,但正是科学方法的核心!

影响应用性能的因素不胜枚举:或许存在几个操作共享状态的单例,需要加锁保护?或许我们在堆上不断分配内存又立即释放?我们可能陷入I/O瓶颈——CPU因等待网络请求而空闲。更糟的情况是内存瓶颈,这往往源于内存布局不佳、数据局部性缺失或随机/不可预测的内存访问模式——这些问题会彻底“关闭”CPU内置的所有优化机制!

由此可见,我们仍需掌握领域知识。我们不仅需要了解应用程序的结构,更理想的是至少对硬件工作原理有所直觉。后者将极大助力我们识别任何性能下降的根本原因,并将发现转化为可执行的优化步骤。否则,你如何发现原子操作过于密集导致虚假共享?不过微架构优化话题我们留待另文探讨。

让我们看个例子

这篇文章可能有些理论化且枯燥。我知道理论上理论与实践并无二致,但在实践中却大相径庭……所以,让我们动手实践吧?

假设我需要为React Native开发一个C++ Turbo模块,实现像素级别的简单图像处理。我保证不会打开潘多拉魔盒,把大家拖进数字信号处理的奇幻世界。这次只做亮度对比度调整这类基础线性变换。哦对了,为了增加趣味性,请记住我正饱受“非我发明症候群”的折磨——这次禁止使用帧处理器!

性能分析将采用开源跨平台工具Tracy。它仅需29MB的独立文件即可运行!我将逐步演示配置流程,包括如何在源代码中添加监控点以及如何执行性能分析。

为简化讨论,我们假设不存在GPU或着色器。同时坚持使用单线程代码。毕竟,若能在单线程环境下提升速度,多核运行时表现必然更佳(但别轻信我的话,记得亲自测量验证)。

获取工具

您需要从 GitHub 获取 Tracy 源代码的最新克隆版本。由于没有预编译版本,您需要自行构建服务器应用程序。该程序使用 CMake,因此只需在终端中执行两条命令即可:

客户端部分需放入您的应用程序中...所需内容均位于public目录内。可将所有文件复制到项目中,仅需编译TracyClient.cpp文件——它将包含所有必需组件。

现在让我们回到应用程序。

是什么拖慢了亮度和对比度的调整?

那么,问题出在哪里呢?调整亮度不过是给每个像素值加(或减)一个常数。调整对比度本质上就是将每个像素乘以另一个常数。如前所述,尽管迭代之间不存在数据依赖关系,我们仍坚持采用单线程执行。

让我们快速浏览一下初级实现方案。

相当直观吧?每个方法只做一件事(职责分离),我们(几乎)消除了所有代码重复(DRY原则)!

请注意我在每个函数开头添加的 ZoneScoped; 语句。这行代码正是使函数在分析器中可见的“仪器化”标记!它会在函数初始化时记录开始时间,并在作用域结束时记录结束时间。

Tracy还提供了该宏的不同变体。ZoneScopedN(“Your name”)可将函数的推断名称覆盖为任意自定义名称。我个人常用的另一种是ZoneScopedNC(name, color)。更多用法请查阅官方文档。

你还需要在cpp文件开头包含Tracy的头文件:

最后,需在项目构建设置中添加名为TRACY_ENABLE(注意末尾没有D!)的预处理宏/定义。缺少此宏时,Tracy将不会被包含在构建中。

现在运行应用程序!为简化演示,我选择在模拟器中运行。虽然这并非最佳选择,但作为起点已足够。

等等,指标数据在哪里查看?

连接分析器

Tracy采用客户端-服务器架构。出于实际考虑,您的被监控应用(客户端)仅负责收集指标数据,并通过网络将其发送至您的笔记本电脑(服务器)进行分析和存储。因此,您只需在笔记本电脑上启动用户界面,连接至被监控应用即可。您只需知道被分析设备的IP地址!这很方便,不是吗?

Tracy欢迎界面的截图。其中“客户端IP地址”字段显示为本地主机地址。

保持localhostIP地址不变,直接点击“连接”按钮。现在我们看到数据流并获取了数值,接下来该如何处理这些数据呢?

Tracy分析器的截图,顶部显示火焰图,底部为“查找区域”窗口,展示了"adjustBrightness"区域的所有统计数据。数据显示平均执行时间为1.64秒。

让我们查看adjustBrightness方法,它理应是最快的(仅需逐像素加法运算)。整个函数平均运行时间竟高达1.64秒!同时调整亮度和对比度竟耗时3.29秒……

底部的“查找区域”窗口清晰展示了区域统计摘要及直方图!样本越多,曲线越趋近钟形分布。直方图上方标注着“平均值:1.64秒”及“中位数:1.63秒”。σ符号表示标准差为13.48毫秒——数值越低,测量噪声越小。您可通过输入名称搜索区域,或在火焰图视图中点击区域后,在“区域信息”窗口顶部点击“统计”按钮进行查看。

极限在哪里?

如果我们已经触及硬件极限,只能让用户购买更新更快手机的话呢?我们能达到多快的速度?又该如何估算这个极限?

仅从代码层面观察,我们发现程序正在遍历数组并执行基础运算。假设处理一张全高清图片(1920×1080像素),每个像素占用4字节存储空间——红绿蓝三原色及Alpha通道各占1字节,因此循环主体将执行8,294,400次。没错,这张图片占用了8100 KiB内存!而如今我们处理的照片尺寸远超此数!

让我们编写一个尽可能简化的合成基准测试:分配足够大的数组,并尝试执行相同次数的类似操作。为获得更可靠的数据,我们将重复测试数次。如果解决方案的测试结果处于相同数量级,那么我们确实无能为力…… Tracy分析器的截图,顶部显示火焰图,底部为“查找区域”窗口,其中包含“SyntheticTransform”区域的所有统计数据。截图显示该区域的平均执行时间为528毫秒。

刚才发生了什么?为什么在我们的亮度函数中,乘法和加法组合运算的速度竟比单纯的加法更快?在相同迭代次数下,运行时间从1.64秒骤降至“仅”528毫秒——速度提升了310%!难道是因为三个循环比单个循环更耗费资源?

对于初学者,行距指相邻行之间的距离(以字节为单位)。在本例中,它仅等于图像宽度乘以4字节,但在极少数情况下,可能需要额外填充以实现对齐。

Tracy分析器的截图,顶部显示火焰图,底部为“查找区域”窗口,展示了“SyntheticTransform2D”区域的所有统计数据。数据显示其平均执行时间为699毫秒。

为何如此缓慢?好吧,我不想让文章再拖沓下去。原因在于我们次优的内存访问模式!CPU的运算速度正以远超内存的速度飞速提升,两者之间的性能差距正日益扩大!

那么我们的内存访问模式问题出在哪里?关键在于CPU并非按字节访问内存,而是整条缓存行(通常为64字节)进行读取。而我们处理图像数据时采用的是列式处理而非行式处理。这导致CPU为处理4字节数据而读取整条缓存行,其余60字节被白白浪费——多么巨大的资源浪费!

为充分利用缓存行读取效率,我们需要处理全部64字节数据。64字节除以每个像素4字节,正好对应16个像素。你可能难以置信,只需交换两个循环的顺序就能实现!太神奇了吧?

你可能还注意到,此版本可省去内层循环中对常量i的重复计算。我们能将i从两个循环中“提升”出来,将其转化为变量,并在最内层循环中递增。

Tracy分析器的截图,顶部显示火焰图,底部为“查找区域”窗口,展示合成基准测试3中“SyntheticTransform2D”区域的所有统计数据。数据显示平均执行时间为542毫秒。

太棒了!我们重回快车道!

还有两点补充说明:

  1. 这种访问模式开启了另一项优化机会——相关硬件优化技术已存在至少十年。当访问模式可预测时,CPU能提前为我们预取“下一条”缓存行!
  2. 由于像素数据紧密排列且我们遍历相邻像素,编译器应能利用此特性,通过SIMD指令自动向量化代码!通俗来说,CPU能用单条指令修改多个数据!

太棒了,案情告破!我们已锁定首个案例的瓶颈!剩余的低难度优化留作读者练习,不过可以透露个小提示:代码重复有时反而有益。

既然掌握了新知识,现在就去优化原始函数吧!

下期内容将分享更多关于多线程应用程序性能分析(及优化)的技巧!令人惊讶的是,锁竞争问题远比我想象中更常见……

附注:再留个谜题:原始函数除了执行循环外还做了什么?它似乎有70%的时间在处理其他任务……有线索吗? Tracy性能分析器截图显示,原始的“adjustBrightness”方法仅有30%的时间用于执行循环。