前端作者性能提示

摘要

本文档的目标读者是针对 LLVM IR 的语言前端的开发者。本文档包含了一系列关于如何生成易于优化的 IR 的提示。

IR 最佳实践

与任何优化器一样,LLVM 也有其优势和劣势。在某些情况下,源 IR 中非常小的改动可能会对生成的代码产生很大的影响。

除了下面列表中的特定项目外,值得注意的是,LLVM 最成熟的前端是 Clang。因此,您的 IR 与 Clang 可能发出的 IR 的差异越大,它被有效优化的可能性就越小。通常可以编写一个具有您尝试建模的语义的快速 C 程序,并查看 Clang 的 IRGen 对发出什么 IR 做出的决定。研究 Clang 的 CodeGen 目录也可以成为一个很好的想法来源。请注意,Clang 和 LLVM 是明确版本锁定的,因此您需要确保您使用的是与您使用的 LLVM 库相同的 git 版本或发行版构建的 Clang。与往常一样,强烈建议您跟踪树顶开发,尤其是在新项目的启动期间。

基础知识

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

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

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

alloca 的使用

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

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

避免创建聚合类型的值

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

此规则有一些例外情况

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

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

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

  • 在不创建值的情况下使用聚合类型是可以的。例如,它们通常用于 getelementptr 指令或 sret 等属性中。

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

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

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

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

将 GEP 索引 zext 到机器寄存器宽度

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

何时指定对齐

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

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

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

其他需要考虑的事项

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

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

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

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

  5. 使用配置文件元数据来指示静态已知的冷路径,即使没有动态概要分析信息可用。这可能会对代码放置以及紧密循环的性能产生很大影响。

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

  7. 在热循环中,考虑将来自以高度可预测的终止符结束的小基本块的指令复制到它们的后续块中。如果一个热后续块包含可以与复制的指令一起矢量化的指令,这可以提供明显的吞吐量改进。请注意,这并不总是划算的,并且确实会涉及代码大小的潜在大幅增加。

  8. 当检查一个值是否与一个常量相等时,使用一致的比较类型发出检查。GVN 传递 *将* 优化冗余的相等性,即使比较类型被反转,但 GVN 仅在管道的后期运行。因此,您可能会错过运行其他重要优化的机会。

  9. 除非您的源语言规范*要求*您发出特定的代码序列,否则避免使用算术内联函数。优化器非常擅长推断一般控制流和算术,它在推断各种内联函数方面远不如强大。如果对代码生成有利,优化器很可能会在优化管道的后期自行形成内联函数。在语言前端直接发出这些内联函数*非常*少见。此项明确包括使用 溢出内联函数

  10. 避免使用 假设内联函数,除非您已经确定 a) 没有其他方法来表达给定的事实,以及 b) 该事实对于优化目的至关重要。假设是一个很棒的原型设计机制,但它们会对编译时间和优化效率产生负面影响。前者可以通过足够的工作来修复,但后者与其设计的目的相当基本。如果您正在创建非终止符不可达指令或传递错误值,请使用 store i1 true, ptr poison, align 1 规范形式。

描述特定于语言的属性

在将源语言转换为 LLVM 时,找到方法来表达源语言中可用的概念和保证(LLVM IR 本身未提供),将大大提高 LLVM 优化代码的能力。例如,C/C++ 能够将每个加法标记为“无符号溢出 (nsw)”对于帮助优化器推断循环归纳变量,从而为循环生成更优化的代码大有帮助。

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

受限操作语义

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

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

描述别名属性

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

  2. 使用指针别名元数据,尤其是 tbaa 元数据,来传达其他无法推断的指针别名事实

  3. 在 gep 上使用 inbounds。这可以帮助消除一些别名查询。

未定义的值

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

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

建模内存效果

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

  2. 尽可能使用 lifetime.start/lifetime.end 和 invariant.start/invariant.end 内联函数。常见的有利用途包括堆栈式数据结构(从而允许消除死存储)以及描述 alloca 的生命周期(从而允许更小的堆栈大小)。

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

传递顺序

新的语言前端项目中最常见的错误之一是按原样使用现有的 -O2 或 -O3 传递管道。这些传递管道为任何语言的优化编译器提供了一个良好的起点,但它们已针对 C 和 C++ 进行了精心调整,而不是您的目标语言。您几乎肯定需要使用自定义传递顺序才能实现最佳性能。一些具体的建议

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

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

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

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

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

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

添加到此文档中

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

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