使LLVM再次快速

2020-05-11 08:03:03

Clang是一个“LLVM原生的”C/C++/Objective-C编译器,其目标是提供惊人的快速编译[…]。

我不确定这在过去是不是真的,但现在肯定不是真的。每个LLVM版本都比上一个版本慢几个百分点。LLVM10在这方面付出了一些额外的努力,不知何故,不知什么原因,使Rust编译速度整整慢了10%。

有人可能会争辩说,这是意料之中的,因为优化管道正在不断改进,而且更激进的优化有更高的编译时间要求。虽然这可能是真的,但我不认为这是一种理想的趋势:在大多数情况下,优化已经“足够好”了,而额外的优化有一种不幸的趋势,即牺牲大量的编译时间增加来换取非常微小(和/或非常罕见)的运行时性能改进。

更大的问题是LLVM根本不跟踪编译时回归。虽然LNT跟踪一段时间内的运行时性能,但对于编译时或内存使用情况却没有这样做。最终的结果是,补丁程序引入了无意的编译时回归,没有人注意到,并且在下一个版本推出时不再容易识别。

因此,当务之急是确保我们能够准确和及时地识别倒退。Rust通过在每次合并时运行一组基准测试来做到这一点,这些数据可以在Perform.rust-lang.org上找到。此外,还可以使用@rust-Timer机器人对拉请求运行基准测试。这有助于评估旨在提高编译时性能的更改,或怀疑具有不平凡的编译时成本的更改。

我已经为LLVM设置了一个类似的服务,结果可以在llvm-Compile-time-tracker.com上查看。可能最有趣的部分是相对指令和max-rss图,它们显示相对于基线的百分比变化。我想在这里简要描述一下设置。

度量基于CTMark,它是LLVM测试套件中一些较大程序的集合。这些是作为先前尝试跟踪编译时的一部分添加的。

对于每个测试提交,程序以三种不同的配置编译:O3、ReleaseThinLTO和ReleaseLTO-g。所有这些都在三种不同的LTO配置(无、瘦和胖)中使用-O3,最后一种也支持调试信息生成。

编译和链接统计信息是使用perf(大多数)、GNU时间(max-RSS和wall-time)和大小(二进制大小)收集的。以下统计数据可用:

指令(稳定且有用)max-RSS(稳定且有用)任务时钟(噪声太大)周期(噪声)分支(稳定)分支未命中(噪声)墙壁时间(噪声太大)size-总(完全稳定)大小-文本(完全稳定)大小-数据(完全稳定)大小-bss(完全稳定)大小。

最有用的统计数据是指令、max-rss和size-total/size-text,这些是我真正关注的唯一统计信息。“指令”是编译时的稳定代理度量。指令失效并不是一个完美的指标,因为它忽略了缓存/内存延迟、分支错误预测和ILP等问题,但影响LLVM的大多数性能问题往往比这更简单。

实际的时间指标任务钟和挂钟太吵了,没有用,而且还经历了“季节性变化”。可以通过多次运行基准测试来缓解这一问题,但我没有足够的计算能力来做到这一点。另一方面,失效的指令非常稳定,使我们能够自信地识别小到0.1%的编译时更改。

max-rss是最大驻留集大小,它是内存使用的一种可能度量(这是一个令人惊讶的难以确定的概念)。总体而言,除了ThinLTO配置之外,此指标也相对稳定。

二进制大小度量对于判断编译时间并不真正有用,但它们确实有助于识别更改是否对代码生成有影响。导致代码大小更改的编译时回归至少在做一些事情。并且优化过程中IR的数量和结构会对编译时间产生重大影响。

不同的基准有不同的方差。单个提交的详细比较页面(如此max-rss比较)突出显示红色/绿色的变化,这些变化可能会很大。高亮显示开始于3西格玛(无颜色),结束于4西格玛(清晰的红色/绿色)。突出显示与更改的大小无关,只与其重要性有关。有时0.1%的变化是显著的,而有时10%的变化并不显著。

除了这三种配置之外,比较视图还显示ThinLTO/LTO配置的仅链接数据,因为这些往往是构建瓶颈。还可以显示所有单个文件的数据(“每个文件的详细信息”复选框)。

基准测试服务器只通过Git进行通信:每当它空闲时,它都会从GitHub上的许多额外的LLVM分支获取上游LLVM的主分支,以及以perf/开头的任何分支。这些perf/分支可用于运行实验,而无需提交到上游。如果有人对做LLVM编译时工作感兴趣,我可以很容易地添加额外的分叉来监听。

在执行测量之后,数据被推送到存储原始数据的llvm编译时数据储存库。该网站显示来自该存储库的数据。

运行此功能的服务器只有2个核心,因此完整的LLVM构建可能需要两个多小时。对于较小的更改,从ccache构建LLVM并编译基准大约需要20分钟。这对于测试每一个提交都太慢了,但是我们并不真的需要这样做,只要我们自动分割任何有显著变化的范围即可。

自从我开始跟踪以来,CTMark上的地理编译时间减少了8-9%,如下图所示。

大多数编译时改进和回归往往很小,只有很少的大跳跃。0.25%的变化已经值得一看。1%的变化是很大的。在下面,我将描述我在过去几周中实现的一些改进。

最大的一个是将字符串属性切换到使用映射,这带来了3%的改进,这在影响方面是一个完全的异常值。

LLVM中的属性有两种形式:枚举属性和字符串属性,枚举属性是预定义的(例如,非空或可解除引用),字符串属性可以是自由格式(";use-soft-flow";=";true";)。枚举属性存储在位集中以进行高效查找,而字符串属性只能通过扫描整个属性列表并逐个比较属性名称来访问。由于Clang倾向于生成相当多的函数属性(20个枚举和字符串属性是正常的),这会带来很大的开销,特别是在查找未实际设置的属性时。该补丁引入了从名称到属性的额外映射,以提高查找效率。

一项仍在审查中的相关更改是将空指针是有效的字符串属性转换为枚举属性,这将使性能再提高0.4%。这是最常查询的字符串属性之一,因为它从根本上影响指针语义。

LLVM中性能问题的一个常见来源是culteKnownBits()查询。这些是递归查询,用于确定值的任何位是否已知为0或1。虽然查询的深度是有限的,但它仍然可以探索相当多的指令。

有两种方法可以优化这一点:降低查询成本,或者减少对查询的调用。要使查询更便宜,最有用的技术是如果我们已经知道不能进一步改进结果,则跳过额外的递归查询。

短路GEP计算给我们带来了0.65%的改进。Getelementptr实质上是将按类型缩放的偏移量添加到指针。如果我们不知道基指针的任何位,我们就不应该费心计算偏移量的位。对添加/订阅指令执行同样的操作会带来更温和的0.3%的改进。

当然,如果我们一开始就能避免调用culteKnownBits()调用,那就最好了。InstCombine用于对每条指令执行已知位计算,希望所有位都是已知的,并且指令可以折叠成一个常量。可以预见,这种情况很少发生,但会占用大量的编译时间。

移除此折叠后,性能提高了1%。这需要做一些很好的基础工作,以确保所有有用的案例都能真正被其他文件夹覆盖。最近,我在InstSimplify中去掉了相同的折叠层,又提高了0.8%。

在热门LazyValueInfo代码中使用SmallDenseMap可以提高0.5%。这可以防止分配通常只有一个元素的映射。

在查看sqlite3内存配置文件时,我注意到ReachingDefAnalysis机器传递控制了峰值内存使用,这是我没有预料到的。核心问题是它存储每个机器基本块的每个寄存器单元(在x86上约为170个)的信息(此测试用例约为3000个)。

我对这段代码应用了许多优化,但有两个优化效果最大:第一,避免了循环的完全重新处理,这在sqlite上使编译时间缩短了0.4%,内存使用量减少了1%。这是基于这样的观察,即我们不需要重新计算指令defs两次,它足以跨块传播已经计算出的信息。

其次,将到达定义存储在TinyPtrVector中,而不是SmallVector中,这比sqlite的内存使用率提高了3.3%。TinyPtrVector以8字节表示0或1到达定义(到目前为止最常见),而SmallVector使用24字节。

将MCExpr更改为使用子类数据对于LTO with debuginfo链接步骤来说,内存使用率提高了2%。此更改使MCExpr中以前未使用的填充字节可由子类使用。

最后,清除BPI中的值句柄比SQLite基准测试的内存使用率提高了2.5%。这是分析管理中的一个普通错误。

我没有从事的一项重要的编译时改进是在LLVM use-list实现中去掉了路线标记。这比编译时间提高了1%,但在某些基准测试中也降低了2-3%的内存使用率。

以前使用道路标记是为了避免显式存储对应于使用的用户(或“父”)。相反,用户的位置编码在使用列表指针的对齐位中(跨多个指针)。这是一种时空权衡,据报道在最初引入时大大减少了内存使用量。如今,节省的内存似乎要少得多,因此取消了这一机制。(我内心的愤世嫉俗者认为现在的影响更小了,因为其他一切都需要更多的内存。)。

当然,在这段时间里也有其他的改进,但这是最主要的一个。

积极改善编译时间只是等式的一半,我们还需要确保恢复或缓解回归。以下是一些没有发生的回归:

对主导者树实现的更改会导致3%的回归。这是由于构建失败而恢复的,但我也报告了回归。老实说,我真的不太明白这个变化有什么作用。

看似无害的TargetLoweringInfo更改导致0.4%的回归。事实证明,这是由于在热代码中查询一个新的";veclib&34;String属性引起的,这是前面提到的属性改进的原始动机。由于不相关的原因,此更改也被恢复,但当它重新启动时,对性能的影响应该会小得多。

对SmallVector实现的更改导致编译时间和内存使用率下降1%。此补丁将SmallVector更改为将uintptr_t大小和容量用于像char这样的小元素类型,而通常使用uint32_t来节省空间。

事实证明,这种回归是由于将POD类型的向量增长实现移动到头文件中而导致的,这(自然地)导致了过度内联。内存使用量的增加并不是因为SmallVectors占用了更多空间,而是因为clang二进制大小增加了很多。后来重新应用了改进版本的更改,基本上没有任何影响。

事实证明,这是由内联过程中释放的对齐保持假设造成的。这些假设提供的实际好处很少,同时增加了编译时间并使优化变得悲观(这是使用新的基于操作数束的假设系统解决过程中的一个一般性LLVM问题)。

我们以前在使用Rust时遇到了这个问题,并在那里禁用了该功能,因为Rustc绝对会在任何地方发出对齐信息。这与Clang相反,后者只在例外情况下发出对齐。引用的SRET变化是该方法的第一个偏差,因此,对齐假设也是第一次出现问题。默认情况下,通过禁用对齐假设缓解了这种回归。

我没能阻止的一个倒退是max-rss的稳步增加。这种增加主要是由clang二进制大小的增加引起的。我最近才开始跟踪这一点(参见clang二进制大小图表),在此期间二进制大小增加了近2%。这是由于添加了ARM SVE的内置组件(如此提交)造成的。我不熟悉内置的tablegen系统,也不知道是否可以更简洁地表示它们。

我不能说10%的改进就能让LLVM再次变得更快,我们需要10倍的改进才能配得上这个标签。但这是一个开始,…。

这里的一个关键问题是基准的选择。这些都是用Clang编译的C/C++程序,它生成的IR与rustc非常不同。其中一个的改善可能不会转化为另一个的改善。对一个人来说是中性的变化对另一个人来说可能是很大的倒退。在CTMark中包含一些Rustc位码输出可能是有意义的,这样可以更好地表示非Clang正面。

我认为当涉及到LLVM编译时改进时时,仍然有相当多的容易摘到的果实。还有一些更大的正在进行的工作大多已经停滞不前,比如迁移到新的PASS管理器,迁移到不透明的指针(这将消除许多位广播指令),或者NewGVN PASS。

相反,当启用编译时回归时,可能会有一些持续进行的工作,例如Attributor框架、知识保留框架和基于MemorySSA的DSE。

我们将在LLVM11发布时看看情况如何。

如果你喜欢这篇文章,你可能想浏览我的其他文章,或者在Twitter上关注我。

由Disqus提供支持的博客评论