Frontend 作者的性能提示

摘要

本文档的目标受众是面向 LLVM IR 的语言前端的开发人员。本文档汇集了关于如何生成优化良好的 IR 的技巧。

IR 最佳实践

与任何优化器一样,LLVM 也有其优点和缺点。在某些情况下,源 IR 中出人意料的微小变化可能会对生成的代码产生重大影响。

除了下面列表中的具体项目外,值得注意的是,LLVM 最成熟的前端是 Clang。因此,您的 IR 越偏离 Clang 可能发出的 IR,就越不可能被有效优化。编写一个快速 C 程序来模拟您尝试建模的语义,并查看 Clang 的 IRGen 在发出哪些 IR 方面做出的决策通常很有用。研究 Clang 的 CodeGen 目录也可能是一个很好的思路来源。请注意,Clang 和 LLVM 显式地版本锁定,因此您需要确保您使用的 Clang 是从与您使用的 LLVM 库相同的 git 修订版或发行版构建的。与往常一样,强烈建议您跟踪树的顶端开发,尤其是在启动新项目期间。

基础知识

  1. 确保您的模块同时包含数据布局规范和目标三元组。没有这些部分,任何特定于目标的优化都将无法启用。这可能会对生成的代码质量产生重大影响。

  2. 对于发出的每个函数或全局变量,请使用尽可能私有的链接类型(最好是 private、internal 或 linkonce_odr)。这样做将使 LLVM 的过程间优化更加有效。

  3. 避免高入度基本块(例如,具有数十或数百个前驱的基本块)。在其他问题中,已知寄存器分配器在面对此类结构时性能不佳。此指南的唯一例外是具有高入度的统一返回块是可以的。

allocas 的使用

alloca 指令可用于表示函数作用域的堆栈槽,但也可以表示动态帧扩展。在表示函数作用域的变量或位置时,应优先将 alloca 指令放置在入口块的开头。特别是,将它们放在任何调用指令之前。调用指令可能会被内联并替换为多个基本块。最终结果是,随后的 alloca 指令将不再位于入口基本块中。

SROA(聚合的标量替换)和 Mem2Reg pass 仅尝试消除入口基本块中的 alloca 指令。鉴于 SSA 是优化器预期使用的规范形式;如果 allocas 无法被 Mem2Reg 或 SROA 消除,则优化器的效果可能不如预期。

避免创建聚合类型的值

避免创建聚合类型(即结构体和数组)的值。特别是,避免加载和存储它们,或使用 insertvalue 和 extractvalue 指令操作它们。相反,只加载和存储聚合的各个字段。

此规则有一些例外

  • 在全局变量初始化器中使用聚合类型的值是可以的。

  • 如果这样做是为了表示在寄存器中返回多个值,则返回结构体是可以的。

  • 处理 LLVM 内在函数(例如 with.overflow 系列内在函数)返回的结构体是可以的。

  • 使用聚合类型而不创建值是可以的。例如,它们通常用于 getelementptr 指令或像 sret 这样的属性。

避免加载和存储非字节大小的类型

避免加载或存储非字节大小的类型,如 i1。相反,将它们适当地扩展到下一个字节大小的类型。

例如,当处理布尔值时,通过将 i1 零扩展到 i8 来存储它们,并通过加载 i8 并截断为 i1 来加载它们。

如果您确实在非字节大小的类型上使用加载/存储,请确保您始终使用这些类型。例如,不要先存储 i8,然后再加载 i1

将 Zext GEP 索引扩展到机器寄存器宽度

在内部,LLVM 通常将 GEP 索引的宽度提升到机器寄存器宽度。当这样做时,为了安全起见,它将默认使用符号扩展 (sext) 操作。如果您的源语言提供有关索引范围的信息,您可能希望使用 zext 指令手动将索引扩展到机器寄存器宽度。

何时指定对齐

如果您不指定对齐,LLVM 将始终生成正确的代码,但可能会生成效率低下的代码。例如,如果您的目标是 MIPS(或较旧的 ARM ISA),那么硬件不处理未对齐的加载和存储,因此如果您执行低于自然对齐的加载或存储,您将进入陷阱和模拟路径。为了避免这种情况,对于所有加载/存储在 IR 中没有足够高的对齐的情况,LLVM 将发出较慢的加载、移位和掩码序列(或 MIPS 上的 load-right + load-left)。

对齐用于保证 allocas 和全局变量的对齐,尽管在大多数情况下这是不必要的(大多数目标具有足够高的默认对齐,它们会很好)。它也用于向后端提供一个约定,即“此加载/存储要么具有此对齐,要么是未定义的行为”。这意味着后端可以自由地发出依赖于该对齐的指令(并且中级优化器可以自由地执行需要该对齐的转换)。对于 x86,它没有太大区别,因为几乎所有指令都与对齐无关。对于 MIPS,它可能会产生很大的影响。

请注意,如果您的加载和存储是原子的,则后端将无法将未对齐的访问降低为本机对齐的访问序列。因此,对齐对于原子加载和存储是强制性的。

其他需要考虑的事项

  1. 谨慎使用 ptrtoint/inttoptr(它们会干扰指针别名分析),优先使用 GEP

  2. 优先使用全局变量而不是常量地址的 inttoptr - 这为您提供可解引用信息。在 MCJIT 中,使用 getSymbolAddress 提供实际地址。

  3. 警惕有序和原子内存操作。它们很难优化,并且可能无法被当前的优化器很好地优化。根据您的源语言,您可以考虑使用 fences 代替。

  4. 如果调用已知会抛出异常(unwind)的函数,请使用 invoke 和包含 unreachable 指令的正常目标。这种形式向优化器传达调用异常返回。对于既不正常返回也不需要在当前函数中进行 unwind 代码的 invoke,如果需要,您可以使用 noreturn call 指令。这通常不是必需的,因为优化器会将具有 unreachable unwind 目标的 invoke 转换为 call 指令。

  5. 使用 profile 元数据来指示静态已知的冷路径,即使动态分析信息不可用也是如此。这可能会对代码放置产生很大影响,从而影响紧密循环的性能。

  6. 在为循环生成代码时,尽量避免过早地终止循环头的基本块。如果循环头基本块的终止符是循环退出条件分支,则 LICM 对于不在头中的加载的有效性将受到限制。(这是因为 LLVM 可能不知道这样的加载可以安全地推测执行,因此除非它可以证明退出条件未被采用,否则无法提升原本循环不变的加载。)在某些情况下,即使这些指令在很少执行的退出循环的路径上未使用,将这些指令发出到头中也可能是有益的。如果终止循环头的条件本身是不变的,或者可以通过检查循环索引变量轻松解除,则此指南不适用。

  7. 在热循环中,考虑将来自以高度可预测的终止符结尾的小基本块的指令复制到其后继块中。如果热后继块包含可以与复制的指令进行向量化的指令,这可以显着提高吞吐量。请注意,这并非总是有利可图,并且确实涉及代码大小的潜在大幅增加。

  8. 当针对常量检查值时,请使用一致的比较类型发出检查。GVN pass 优化冗余等式,即使比较类型是反转的,但 GVN 仅在管道后期运行。因此,您可能会错过运行其他重要优化的机会。

  9. 除非您的源语言规范要求您发出特定的代码序列,否则避免使用算术内在函数。优化器非常擅长推理通用控制流和算术,但在推理各种内在函数方面远没有那么强大。如果为了代码生成目的有利可图,优化器很可能会在优化管道后期形成内在函数本身。在语言前端直接发出这些内在函数非常少见地有利可图。此项明确包括使用溢出内在函数

  10. 在您确定 a) 没有其他方法可以表达给定的事实,并且 b) 该事实对于优化目的是至关重要的之前,避免使用 assume 内在函数。Assumes 是一种很好的原型设计机制,但它们可能会对编译时间和优化效果产生负面影响。前者可以通过足够的努力来解决,但后者对于其设计目的来说是相当根本的。如果您正在创建非终止符 unreachable 指令或传递 false 值,请使用 store i1 true, ptr poison, align 1 规范形式。

描述语言特定的属性

当将源语言翻译成 LLVM 时,找到表达您的源语言中可用的概念和保证的方法,这些概念和保证不是由 LLVM IR 本身提供的,将大大提高 LLVM 优化代码的能力。例如,C/C++ 将每个加法标记为“no signed wrap (nsw)”的能力对于帮助优化器推理循环归纳变量,从而为循环生成更优化的代码大有帮助。

LLVM LangRef 包含许多用于使用附加语义信息注释 IR 的机制。强烈建议您高度熟悉本文档。下面的列表旨在突出显示几个特别感兴趣的项目,但绝不是详尽无遗的。

受限操作语义

  1. 根据需要添加 nsw/nuw 标志。推理溢出对于优化器来说通常很困难,因此从前端提供这些事实可能非常有影响力。

  2. 如果合法,在浮点运算上使用 fast-math 标志。如果您不需要严格的 IEEE 浮点语义,则可以执行许多额外的优化。这对于浮点密集型计算可能非常有影响力。

描述别名属性

  1. 根据需要向函数参数和返回值添加 noalias/align/dereferenceable/nonnull

  2. 使用指针别名元数据,特别是 tbaa 元数据,来传达其他方式无法推导出的指针别名事实

  3. 在 geps 上使用 inbounds。这可以帮助消除某些别名查询的歧义。

未定义值

  1. 尽可能使用 poison 值而不是 undef 值。

  2. 尽可能使用 noundef 属性标记函数参数。

建模内存影响

  1. 在已知时将函数标记为 readnone/readonly/argmemonly 或 noreturn/nounwind。优化器将尝试推断这些标志,但可能并非总是能够做到。手动注释对于优化器无法分析的外部函数尤为重要。

  2. 尽可能使用 lifetime.start/lifetime.end 和 invariant.start/invariant.end 内在函数。常见的有利用途是用于类似堆栈的数据结构(从而允许死存储消除)和描述 allocas 的生命周期(从而允许更小的堆栈大小)。

  3. 使用 !invariant.load 和 TBAA 的常量标志标记不变位置

Pass 排序

新的语言前端项目最常犯的错误之一是按原样使用现有的 -O2 或 -O3 pass 管道。这些 pass 管道是任何语言的优化编译器的良好起点,但它们是为 C 和 C++ 精心调整的,而不是为您的目标语言。您几乎肯定需要使用自定义 pass 顺序才能获得最佳性能。以下是一些具体建议

  1. 对于具有大量很少执行的保护条件(例如,空值检查、类型检查、范围检查)的语言,请考虑在您的 pass 顺序中添加额外执行一两次的 LoopUnswitch 和 LICM。为 C 和 C++ 应用程序调整的标准 pass 顺序可能不足以从循环中删除所有可解除的检查。

  2. 如果您的语言使用范围检查,请考虑使用 IRCE pass。它目前不是标准 pass 顺序的一部分。

  3. 一个有用的健全性检查是再次通过 -O2 管道运行您优化的 IR。如果您看到生成的 IR 有明显的改进,您可能需要调整您的 pass 顺序。

我仍然找不到我要找的东西

如果您在上面没有找到您要找的东西,请考虑提出一个元数据,该元数据提供您需要的优化提示。此类扩展相对常见,并且通常受到社区的欢迎。如果您希望将其贡献给上游,则需要确保您的提案足够通用,以便使其他人受益。

您还应该考虑在 Discourse 上描述您面临的问题并寻求建议。完全有可能有人以前遇到过您的问题,并且可以给出很好的建议。如果有多个感兴趣的方,这也增加了元数据扩展被整个社区广泛接受的机会。

向本文档添加内容

如果您遇到您认为应该在此处涵盖的情况,请发送补丁到 llvm-commits 以供审核。

如果您对这些项目有疑问,请在 Discourse 上提出。您能够为您的问题提供越相关的上下文,就越有可能得到解答。