这是关于结合静态和动态分析进行性能优化的两篇系列文章的第一篇。我将其拆分,以免篇幅过长;第二篇将在本周晚些时候发布。本文阐述了一些高层背景,讨论了为何要结合分析,并提供了一个关于分析结合的初步思考框架。然后,我将解释一种将连续性能分析与库名称和版本的模式匹配直接结合的方法,该方法已在真实客户基础设施中实现每个服务60%以上的性能提升。
在后续文章中,我将讨论去年所做的实验性工作:使用CodeQL在C/C++代码库中寻找性能优化机会。CodeQL通常用于发现安全漏洞,但我将展示它如何也能用于发现可能导致编译器生成比潜在性能慢10倍甚至更多的机器代码的C++代码模式。这些模式主要与阻止编译器自动向量化循环的代码结构有关,通过进行一些微小的类型更改或语句重排序,我们可以实现显著的性能改进。
为什么结合静态和动态分析?
几年前,我联合创立了optimyze.cloud,我们构建了Prodfiler(现为Elastic Universal Profiler)——一个覆盖整个集群、持续、全系统的性能分析器。也就是说,这是一个效率足够高的分析器,可以始终开启,无处不在,分析您集群中每台机器上的整个系统。启用Prodfiler/EUP可以让您精确了解在整个集群中,哪些代码行正在消耗CPU资源,无论是在用户空间、内核、本地代码还是某些高级运行时中。这非常棒,但当面对集群中最昂贵的10个或100个函数时,问题就出现了:这些函数如此昂贵是“正常”的吗?如果不是,它们为什么如此昂贵?有什么办法可以补救?
如果您幸运的话,这些昂贵的函数位于您编写和理解的代码中,这些问题可能很容易找到答案。如果您不走运,它们可能位于您不熟悉的某个第三方库中,用您不知道的语言编写,并建立在您从未听说过的运行时之上。我们的用户经常问的一个问题是:除了提供昂贵的函数列表,我们是否还能建议根本原因,甚至更好的修复方案?正是从这里,我们认识到需要某种补充分析,能够“推断”为什么特定的代码块可能很昂贵,并在可能的情况下建议使其减少昂贵的方法。
我从构建错误查找和漏洞利用生成工具中吸取的一个教训是,工具的最佳结合点通常包括:
- 大规模的、快速的动态分析,涉及执行软件/系统,并在大量多样化的输入集下观察其行为。
- 自动化静态分析,可以增强动态分析或被其增强。
- 设计良好的UI/UX,即使在存在大量数据的情况下也能支持探索性工作流。UI应汇集所有可用上下文,同时支持过滤、切片和搜索,几乎没有可感知的延迟。
有了Prodfiler,我们具备了第1和第3点,但在性能领域,尤其是在与连续性能分析结合的背景下,第2点相对尚未被充分探索。
我相信,就像安全领域一样,性能分析的“甜点区”也在于第1-3点的交集,动态和静态分析相辅相成,而直观且高性能的UI则是改变游戏规则的关键。在本文中,我将专门关注方程中的静态分析部分。如果您想了解更多关于如何构建高性能连续性能分析引擎的信息,我们在Elastic博客上有一篇关于此主题的初始博文,未来还会有更多内容。
结合动态与静态分析的模式
总的来说,在程序分析研发领域,对于什么效果好、什么效果差并没有硬性规定,这是一个在学术界和工业界都有大量持续工作的领域。也不一定非要结合一种静态分析和一种动态分析。您甚至可以决定完全跳过一种分析形式,而将一系列纯粹的动态分析链接起来,反之亦然。例如,出于寻找错误的目的,可以对代码库中的每个函数应用一种过度近似但快速的基于抽象解释的分析,以发现潜在的错误。然后将潜在有错误的函数传递给符号执行引擎,在过程内上下文中验证这些错误。最后,可以使用模糊测试来尝试找到通往那些错误函数的路径,这些路径满足触发错误所需的条件。有关具体示例,请查看Sys,它结合了静态分析和符号执行,以利用各自的优势在Web浏览器中查找错误[代码][论文与演讲]。
在性能优化和分析方面,我可以想到三种操作模式,其中多种分析可能相辅相成:
- 基于上下文的排序:动态分析提供关于系统中最昂贵部分的基本事实,最好是在生产环境真实工作负载下执行时。然后,这些上下文可以用来对静态分析的结果进行排序。排序很重要,原因有二。首先,开发人员的时间是有限的。一个带有误报的近似分析需要开发人员验证每个结果。如果我们希望开发人员实际使用该工具,那么我们需要某种方式确保在开发人员时间或精力耗尽之前,他们的努力能应用于最具影响力的发现。其次,静态分析工具旨在发现性能问题,但一个发现是否有效可能取决于相关代码是否位于热路径上。静态分析的结果本质上是完整的,除非结合表明哪些代码在生产工作负载下是热的上下文。一个“基于上下文的排序”的例子可能是:利用生产环境中的连续CPU分析来对某个分析(该分析列出程序中编译器未能自动向量化的循环)的输出进行排序。如果一个循环在仅在启动时执行一次的函数中未被向量化,那可能无关紧要;但连续分析为您提供了一种过滤掉此类噪音并专注于重要循环的方法。
- 基于排序的分析:轻量级动态分析提供有关系统中最昂贵部分的信息,以便可以将重量级分析的重点放在那里(或者,可以在那些区域进行更多轻量级分析)。某些分析运行起来消耗大量CPU/RAM资源。例如符号执行。如果我们在性能优化的背景下使用此类分析或类似的重型分析,我们会希望使用其他数据源来指示哪些函数最昂贵,并将我们的努力集中在那里。
- 基于上下文的操作:一种分析提供的数据对于另一种分析的运行是必要的。这方面的例子包括配置文件引导优化/AutoFDO或像Facebook的BOLT这样的链接后优化器。对于这些分析,性能分析信息是它们能够运行所必需的。我将在下一节中给出另一个此类分析的例子,这个例子非常简单,但非常有效。
低垂而甜美的果实
本系列的第二篇文章将讨论使用CodeQL寻找优化机会,属于“值得进一步研究的、具有智力趣味性的研究”范畴,但仍处于早期阶段。为了将一些实用建议与象牙塔式的思考相结合,这里有一个简单、明显且非常有效的分析组合,它已反复为我们的客户在各种系统中带来了好处。
结合静态和动态分析进行性能优化的最“低垂的果实”就是简单地对应用程序性能分析中最显著的库和函数进行模式匹配,并且我们知道存在功能等效的、经过优化的版本可用。我们有真实的客户数据,遵循这种方法实现了巨大的性能提升。在Prodfiler中,可以通过查看TopN视图(该视图对整个基础设施中开销最大的前N个函数进行排名)来实现这一点。
我们遇到的一些具体例子:
- 如果大量时间花费在与内存分配相关的函数上(例如malloc、free、realloc等),并且应用程序使用的是标准的系统分配器,那么通过切换到更优化的分配器(如mimalloc或jemalloc),很可能会获得性能提升。这方面的一个例子在这篇博客文章中有所描述,我发现一个应用程序10%的时间花在处理内存分配上。用mimalloc替换系统分配器将此时间减少了一半,并将应用程序的整体性能提高了约10%。Elastic的一个内部团队遇到了类似的模式,除了应用程序的CPU开销降低外,通过将分配器替换为jemalloc,他们报告RAM使用量下降了20%。
- 如果大量时间花费在zlib上,那么切换到zlib-ng可以带来改进。我们的一个客户通过此更改实现了应用程序性能30%的提升。
- 如果大量时间花在处理JSON,或序列化和反序列化上,那么执行这些操作的第三方库的性能差异很大。通常运行时提供的版本或最流行的版本可能比最先进的版本性能差得多。我们的一个客户通过将其使用的JSON编码器替换为orjson,在其最繁重的工作负载之一中实现了60%的减少。
这个过程简单明了,并且拥有出色的努力回报比——查看结果,谷歌搜索“optimised library X”,确保它满足您的功能和非功能需求,替换,重新部署,测量,然后继续前进。如果您刚开始结合分析,请从这里开始。这可以轻松实现自动化,并与您选择的通知框架集成。
实际上,实现这种“grep for libraries”解决方案有几种方法。一种是编写一个脚本,将分析器中Top N输出中的函数和/或库名称与已知可替换库的列表进行比较。这将是一个“基于上下文的操作”分析组合的例子,性能分析为regex/grep脚本的运行提供了上下文。或者,您可以拥有一个工具,扫描所有基础容器镜像,查找您知道存在更快替代版本的库。然而,仅仅将这些信息作为警报发送给开发人员只会惹恼他们,因为谁也不知道这些库是否被加载,更不用说其代码是否在热路径上。但是,结合连续分析器,您可以过滤掉此类噪音,根据它们在整个集群中的影响对结果进行排序,然后仅在替换库可能带来显著收益的情况下分派警报(这是“基于上下文的排序”的一个例子)。
结论
我希望上述内容有助于建立一个关于如何组合分析的思考框架。如果您是SRE/SWE,并在您的环境中集成了连续性能分析/grep-to-win方法,我很想听听效果如何。在这个领域还有很多研发的空间,所以如果您有任何有趣且有用的分析示例,请通过Twitter给我发私信。我将在本周晚些时候跟进本系列的第二部分,该部分将讨论使用CodeQL寻找循环向量化机会。