使用Prodfiler优化eBPF编译器性能:零代码修改实现近2倍提升

使用Prodfiler优化eBPF优化器(重发版)

如何在不修改任何代码的情况下将应用程序性能提升近2倍?请继续阅读!

这是我在2021年10月4日首次发表在prodfiler.com上的博客重发版。Prodfiler已被Elastic收购,现称为Elastic Universal Profiler。

本文将详细介绍如何使用Prodfiler发掘K2(论文视频)中的优化空间。K2是一个eBPF优化编译器,完全受CPU限制,采用依赖高速创建和检查候选方案的引导搜索技术。通过Prodfiler,我们可以轻松发现K2中消耗最多CPU周期的组件,从而进行相应优化。最终结果是获得速度提升1.4-1.9倍的K2版本,这意味着在相同资源下可以探索更大的搜索空间。

优化改进概述

发现的改进主要来自三个方面:

  1. 用性能更好的mimalloc替换系统分配器
  2. 辅助编译器自动向量化热点循环
  3. 对K2及其重要依赖Z3应用PGO和LTO

引言

随着eBPF的更多用例被发现(许多处于关键路径上),编译器生成的eBPF代码性能变得越来越重要。虽然clang通常能生成高效程序,但有时会产生存在冗余、异常指令序列选择、不必要的窄操作数宽度等问题的代码,导致手动优化程序可以显著更快。手动优化eBPF指令具有挑战性、耗时且容易出错,因此与更传统目标平台的编译一样,存在对在编译阶段投入时间以换取运行时性能的工具的需求。

今年8月,罗格斯大学的研究人员发布了K2,这是一个用于eBPF代码的优化编译器。K2以现有eBPF程序为输入,搜索语义等同于原始程序但更快更小的程序。在他们的论文中,他们证明相对于最佳clang编译程序,他们的方法可以将程序大小减少6-26%,将这些程序的平均数据包处理延迟降低1-55%,并将吞吐量提高高达5%。

K2架构简要概述

K2的核心是MCMC与Metropolis-Hastings接受标准[1]。对于我们的目的,只需将MCMC视为一种搜索技术,在其内部循环中必须从当前状态生成新候选并为其分配成本。然后使用当前状态和候选状态的成本随机决定下一个当前状态。给定固定的时间预算(并假设合理的候选生成和成本函数),结果的质量与在该时间段内可以探索的状态数量直接相关。因此,基于MCMC的应用程序是Prodfiler等工具的主要目标,因为我们能够挤出的任何性能增益都可能使应用程序使用相同资源找到更高质量的结果。

设置基准测试

K2可以在GitHub上找到,作者还上传了包含用于生成论文结果的测试和基准测试的会议工件。我克隆了这个存储库这里以添加一些辅助脚本。主要的K2存储库关于如何实际安装和运行的信息有点少,但幸运的是有一个安装脚本这里,可以按需遵循。如何运行K2优化eBPF程序的示例可以在这个脚本中找到。该脚本将在11个不同的eBPF程序上调用K2,以尝试找到更高效的实现。我们将以其作为基准测试的基础,因为输入eBPF程序多样化,并且我们可以相对确定,如果我们找到使K2在这些输入目标上运行更快的优化,那么它们应该具有通用性。

使用Prodfiler进行基准测试和优化

开始使用Prodfiler很简单。按照这里的文档创建新项目并在几次点击中部署Prodfiler。一旦启动并运行,我们就可以通过运行上述基准测试来收集基线数据。我创建的用于执行此操作的脚本可以在这里找到。默认情况下,它运行每个基准测试15次。我们将使用它来收集结果并确保我们的优化具有通用性,但还有一个"快速"模式,运行3个基准测试各3次,这对于快速检查假设很有用。

第一幕:MCMC中的M和C代表MalloCMalloC

打开Prodfiler的Top N Functions视图给我们以下内容(提示:独占CPU意味着函数单独的CPU使用率,不包括它调用的函数的CPU使用率。包含CPU意味着函数本身及其调用的函数的CPU使用率)。

Top N Functions视图是我通常的起点,以了解应用程序在哪里花费时间。相当频繁地,Top 10 Functions中的一个或多个条目会让我想"嗯,这很奇怪",并提供潜在性能获胜的线索。这个视图特别擅长浮现每个单独执行很便宜但函数被调用如此频繁以至于意外主导执行时间的函数。

在K2的Top 10 Functions中,有两个特别突出。第一个是大量时间花费在从STL容器分配和检索项上。基于函数名称和相关的源文件,我们可以推断该容器是std::vector<bool>,一个 notoriously weird 容器。在内存使用受关注的情况下,std::vector<bool>可能是正确的选择,但如果主要关注CPU使用率,那么有更好的选择。好的,这是一个好的开始,但让我们暂时搁置它,继续查看列表,看看是否有更容易的收益可以找到。

第二个突出的项目是在位置5和6我们可以看到mallocfree函数。实际上,在将上述列表扩展到前20个函数时,我发现malloc相关函数占据了前20个位置中的5个。如果我们求和它们的CPU使用率,我们了解到应用程序整整10%的CPU时间花费在内存管理上!花费如此多时间在内存管理上的应用程序几乎总是可以通过两种方式加速。一种方法是分析应用程序的内存使用情况,并尝试调整它以简单减少对内存管理函数的调用。第二种方法是用其他东西替换系统分配器。后一种方法通常工作少得多[2],这就是我们在这里要做的。如今有许多分配器选择,其中jemalloc和tcmalloc特别知名。一个更新的条目是mimalloc。在基准测试中,mimalloc与其他可用选项相比表现良好,是我们本文的选择。

mimalloc是系统分配器的直接替代品。使用它就像将其安装位置添加到LD_PRELOAD并运行应用程序一样简单。

通过这个更改,我们可以看到free函数完全退出了Top 10 Functions,如果我们求和mimalloc中所有函数的使用率,我们发现内存管理已降至约5%,而不是之前的10%。优秀!这是5%的CPU预算,现在希望分配给更有用的事情。

那么,我们从这个变化中获得什么样的性能增益呢?下图显示了mimalloc运行时与默认运行时的对比。X轴显示mimalloc运行的速度提升作为默认运行的因子。注意:Y轴从0.8截断,以便更容易看到变化的大小。

在基准测试中,我们看到平均速度提升1.08倍,最小1.05倍,最大1.12倍。对于零努力更改来说不错,但让我们看看还能找到什么!

第二幕:std::vector谋杀性能

随着最简单的获胜方式解决,我们现在可能必须做一些实际工作。幸运的是,我们有一个清晰的起点。回到Top N Functions,我们可以看到前两个项目 alone 占用了15%的CPU预算,并且与std::vector<bool>容器的访问相关,如先前所述。这是分配给任何内置数据结构的相当极端的CPU预算比例,我在这种情况下的预期是有收益可以获得。首先,我们需要弄清楚std::vector<bool>被用来表示什么,以及它如何被使用。为了回答这个问题,Prodfiler为每个函数提供了调用图,可以通过在Top N Functions视图中单击函数名称(或在Flamegraph视图中ctrl-clicking函数)访问。std::vector<bool>::operator=的调用图如下所示:

我们可以看到两个函数(prog_state::init_safety_chkinout_t::operator=)负责 practically all 对这个函数的调用。这两个相同的函数也负责所有对std::vector<bool>::operator[]的调用,这是Top N Functions列表中的第二个函数。有了这个,我们可以跳入代码,试图弄清楚为什么这么多时间花费在操作这些向量上,以及我们可以做些什么。

init_safety_chk函数如下(来源):

所以我们有一系列bools在每次候选的安全检查上被复制。表示寄存器是否可读和内存是否可读的两个容器具有固定大小,分别为11和512。查看inout_t::operator=的代码,我们看到类似的模式。inout_t::operator=中的复制将比init_safety_chk中的复制具有更大的影响,因为这个赋值运算符在候选的错误成本计算中被调用,次数与可用于解释该候选的具体输入次数相同。

鉴于这些向量具有固定大小,并且原则上至少应包含很少的数据,人们可能想知道为什么我们要在复制它们上花费如此多的CPU,即使复制确实每个候选发生多次。在具有SIMD指令的CPU上,复制这些数据量不应该每次都是无循环的指令块吗?好吧,让我们看看编译器如何处理该代码。注意:本文中的反汇编屏幕截图不是来自Prodfiler,而是来自Binary Ninja。

上述代码是为复制stack_readable向量的内容而生成的。如我们所见,我们肯定没有直接的SIMD指令序列,而是中间有一个分支的循环,将迭代STACK_SIZE次。如果我们查看stl_bitvector.hoperator=的实现,这个原因变得明显:

好的,所以编译器显然无法在这里帮助我们并自动向量化。那么我们的选择是什么?嗯,K2没有非常高的内存占用,并且使用std::vector<bool>而不是例如将相同信息表示为字节向量的内存节省在宏伟方案中并不显著。我的第一个想法是简单地将bool类型替换为uint8_t。然而,在这样做并重新运行基准测试后,性能改进微不足道,并且一点也不像我期望的那样,如果上述逐字节复制被直接SIMD替换。回到反汇编,我们发现复制循环变成了以下内容:

出于某种原因,编译器决定产生逐字节复制循环,其中每次迭代都从内存加载源和目标指针。我在Twitter上询问了这个,Travis Downs回应并指出问题是C++中uint8_t类型可以别名所有其他类型!因此编译器不能保证对向量的每次写入不修改源和目标指针,因此必须在每次循环迭代时从内存重新加载它们。Travis有一篇优秀的博客文章进一步扩展了这个这里

Travis有许多建议来解决这个问题,我决定采用的方法是使用C++20的char8_t类型而不是uint8_t。这个类型没有别名问题,并给我们想要的直接SIMD代码:

如您在左侧所见,还有生成的代码进行逐字节复制,但这在实践中永远不会达到,因为它仅在源和目标向量重叠时输入。那么,这如何帮助我们的性能呢?

结果相当好!通过用vector<char8_t>替换vector<bool>并启用编译器自动向量化相关循环,我们比mimalloc版本平均速度提升1.31倍,最大1.57倍,最小1.12倍。相对于默认版本,我们现在平均速度提升1.43倍,最大1.75倍,最小1.22倍。查看Top N Functions视图,我们还可以看到operator=operator[]现在分别占总数的0.82%和0.62%,从12.3%和3.93%下降[3]。

这在实践中意味着,随着1.22x-1.75x的速度提升,给定相同的CPU预算,K2可以执行显著更大的搜索。具体来说,在具有最显著改进的基准测试(xdp_map_access)中,默认K2可以每秒探索约4600个候选,但通过我们的改进,这跃升至约8000个候选每秒!

第三幕:Z3

平均速度提升1.43倍不错,但在结束之前,让我们最后看一下分析数据,看看是否有其他突出的东西。

瞥一眼Top N Functions告诉我们内存管理函数仍然重要,mallocfree(位置1和10)约占7.5%的CPU。我们可以采取几个方向,包括审查K2的内存分配模式以查看它们是否在某些方面次优,或者可能对mimalloc应用PGO/LTO以使其更快。前一个选项可能有点工作,后一个选项我觉得不太可能给出超过几个百分点的改进。位置7的函数也很有趣,因为从调用图中我们可以看到read_encoded_value_with_base被用作C++异常处理的一部分。查看代码显示K2使用异常作为机制从调用栈深处通信非致命错误到更高层函数。我们可以重写这个以使用返回值或输出参数来指示相同的信息并节省这种开销,但这再次可能是相当多的工作而收益不多。我从这些数据中的最终观察是,Top 10函数中有4个在Z3中,这是我们将深入研究的那个,因为它暗示任何对Z3的优化都可能产生显著影响。

Prodfiler提供了两种方法来深入了解这些函数的使用方式。第一种是我们之前看到的调用图,第二种是火焰图,我们现在将使用它。火焰图在人们想要洞察最昂贵的调用栈时非常有用,以比调用图更信息密集和易于导航的方式(但代价是不表示与每个函数相关的CPU使用率总和,而是表示每个调用栈的数据)。

火焰图证实了我们的假设,即Z3是一个值得优化的目标。我们可以在图中看到Z3_solver_check()函数及其子函数负责K2完成的整整约46%的工作!我们可以通过两种方式攻击这个:

  1. K2可能调用Z3比需要的多。如果您从前面回忆,Z3仅在K2未能通过在一组输入上解释该程序来区分候选程序与原始程序时调用。通过生成更多样化的输入集,我们可能能够区分候选与原始程序而不经常去Z3。
  2. 我们可以尝试使Z3本身表现更好。

给定足够的时间,我们很可能会同时做(1)和(2),甚至可能从(1)开始。然而,到这个阶段,我开始享受尝试通过尽可能最少侵入的更改来改进K2性能的挑战,因此决定单独进行(2)。现在,改进Z3,世界上最强大和流行的定理证明器之一,如果我们实际上想通过算法或实现更改来这样做,实际上会比选项(1)多很多工作。不过,我们还有另一个选项可用:修改Z3的编译方式。

gcc和clang都支持Profile Guided Optimization (PGO) 和 Link Time Optimization (LTO) [4]。PGO和LTO是互补的,使用两者通常可以获得5-20%范围的性能改进,高个位数改进是合理的期望。由于各种原因,很少开源软件分发编译时带有PGO/LTO,甚至没有启用它们的构建目标(尽管这正在改变)。幸运的是,通常自己动手很简单[5]。

PGO是一个三步编译过程,其中首先构建应用程序的仪器化[6]版本,然后在该应用程序上运行一系列输入以收集数据,最后使用该数据重新编译应用程序的优化版本。对于第一阶段,我随机选择了三个基准测试(socket-0、xdp_kern_xdp1和xdp_cpumap_enqueue)并各运行三次。这三个基准测试包含在下面的图中,但值得记住的是,存在过度拟合[7]其特性的可能性,这可能意味着人们希望折扣这些结果并专注于其他结果。在下面各种最小/最大/平均值计算中,我已排除了它们。

对Z3应用PGO和LTO使我们比先前版本平均获得1.1倍性能改进,最大1.17倍,最小1倍(与之前性能相同)。总体而言,这使我们比原始版本平均改进1.54倍,最小1.37倍,最大1.79倍!

作为最终努力,我决定还PGO(但不LTO[8])K2本身以给出最终结果:

PGO'ing K2本身平均又带来1.03倍性能增益,最大1.09倍,最小1倍。这些是适度的改进,但值得记住的是,平均44%的K2执行时间实际上花费在Z3内部,因此PGO'ing其余代码只能做这么多。

所以,最终,在换入mimalloc、用std::vector<char8_t>替换std::vector<bool>并应用PGO/LTO之后,我们平均性能改进1.62倍,最大1.91倍,最小1.42倍。在实践中,这意味着如果我们 originally 每秒探索10,000个状态,平均我们现在在相同时间预算下探索约16,000个,在最佳情况下我们探索几乎两倍!

结论

在本文中,我们详细介绍了如何将Prodfiler用作迭代过程的一部分以显著提高应用程序性能。本文中的顺序是我实际使用Prodfiler的方式 -- (1) 设置基准测试(理想情况下真实工作负载;Prodfiler的开销足够低,这是可行的),(2) 运行它们,(3) 然后迭代地处理顶部函数、火焰图和调用图,寻找对应用程序本身、环境、第三方依赖或配置的更改,这些更改可以提高性能。

我将Prodfiler视为一个"Huh?"-生成器,因为它的各种视图往往在我的大脑中诱发"Huh -- that's weird"的想法,这是沿着路径弄清楚为什么某些意外组件被分配尽可能多CPU的第一步。有时该路径的终点只是解决我对目标软件的误解,但通常是发现一些被忽视的特性以未预期的方式消耗CPU。这是Prodfiler的真正价值 -- 它允许以零摩擦收集精确数据,从单个应用程序到整个集群,并将该数据可视化,以便工程师可以轻松扫描它以寻找"Huh?"时刻。所以,总之,无论是Prodfiler还是其他,我推荐您找到一个"Huh?"-生成器。结合一些持久性,在现代软件堆栈中可以找到巨大的性能改进。狩猎愉快!

脚注

1\] 这提供了对MCMC/MH的良好介绍,STOKE论文值得一读,以获取程序优化中随机搜索的另一个示例。 \[2\] 除非您换入的"其他东西"是自定义分配器,专门为应用程序的内存分配模式设计。这当然在某些场景中是合理的方法,但可能过度杀伤,除非您真的耗尽了所有其他替代方案。 \[3\] 在将`inout_t`和`prog_state`类中的`vector`替换为`vector`并重新运行基准测试后,我看到`map_t::clear`现在是第二昂贵的函数,占用3-4%的CPU预算。在调查后,我发现了另一个`vector`实例导致编译器产生逐字节循环来清零向量。我也用`vector`替换了它,允许编译器使用`memset`而不是循环。显示的结果也包括此更改的效果。 \[4\] 对PGO和LTO的有用高级概述可以在这里找到。参见gcc和clang的文档以获取更多信息。 \[5\] 在未来的博客文章中,我将专门介绍Z3的PGO/LTO,但gcc关于标志`-fprofile-generate`、`-fprofile-use`和`-flto`的文档是一个好的起点。这个StackOverflow帖子也有一些关于PGO的高级信息。 \[6\] 还有另一种PGO方法使用采样分析器而不是仪器。Google AutoFDO论文是信息的一个好起点。 \[7\] 我不确定这种过度拟合的可能性实际上有多显著。K2随机生成给定起始状态的候选,因此即使从相同状态开始,探索的搜索空间也可能不相同,因此Z3的使用也不会相同。 \[8\] LTO'ing K2导致操作我们引入的`vector`类型的函数在各种位置内联,并且出于某种原因,一旦发生这种情况,gcc将不再展开和向量化那些循环。我将在未来的文章中深入探讨这一点。

相关推荐
悟空聊架构3 小时前
用 CrewAI 和 A2A 创建绘画智能体
人工智能
weixin_550083153 小时前
大模型入门学习微调实战:基于PyTorch和Hugging Face电影评价情感分析模型微调全流程(附完整代码)手把手教你做
人工智能·pytorch·学习
竹子_234 小时前
《零基础入门AI:YOLOv2算法解析》
人工智能·python·算法·yolo
墨风如雪5 小时前
你的AI分析师已上线:阿里巴巴“神助攻”开启数据洞察新纪元!
aigc
陈西子在网上冲浪5 小时前
SEO关键词布局总踩坑?用腾讯云AI工具从核心词到长尾词一键生成(附青少年英语培训实操案例)
人工智能·云计算·腾讯云
卡尔曼的BD SLAMer6 小时前
计算机视觉与深度学习 | 基于深度学习的图像特征提取与匹配算法综述及MATLAB实现
人工智能·深度学习·算法·计算机视觉·matlab
嘀咕博客6 小时前
美图设计室-AI帮你做设计
人工智能·ai工具
桂花饼6 小时前
谷歌 “Nano Banana“ 深度解析:AI 图像的未来是精准编辑,而非从零生成
人工智能·aigc·gpt-4o·gpt-5·claude 4.1·nano banana
MisterZhang6666 小时前
Java使用apache.commons.math3的DBSCAN实现自动聚类
java·人工智能·机器学习·自然语言处理·nlp·聚类
艾醒7 小时前
大模型面试题剖析:PPO 与 GRPO 强化学习算法核心差异解析
人工智能·深度学习·机器学习