优化备注:帮助编译器生成更好代码¶
标题:Optimization Remarks - “Remarks Helping the Compiler Generate Better Code”
日期:2024/12/06
作者:Ofek Shilon
链接:https://www.youtube.com/watch?v=prC1Pe-F8Jo
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注:之前有相同主题的演讲(同作者),这个补充多一点信息,比如 GCC 的进展。
好的,我叫 Ofek Shilon,非常欢迎大家来听关于优化备注(optimization remarks)的演讲。接下来的大约一个小时,我们将学习如何帮助我们的编译器生成更好的代码。那么,优化备注是来自 Clang 生态的一个术语,我们演讲的大部分内容将围绕 LLVM 生态展开,但在接近尾声时我们会稍微超出这个范围。这些本质上是一种日志形式。像任何成熟的软件一样,编译器,特别是编译器内部的优化通道(optimization passes),会留下日志。有些日志是调试日志,非常冗长和具体,其含义只有实际编写它们的开发者才能理解。但优化备注更简洁,经过一些解读努力后,可以告诉我们尝试了哪些优化、在何处尝试的、是否成功,以及如果失败了,原因是什么?有时它们会为我们开发者提供可操作的信息。因此,我的目标是,在本次演讲结束时,你们将知道如何解读一些优化备注,解决编译器在其中警示的一些问题,以及同样重要的,如何为你们自己的项目获取这些信息。
现在,我们将在演讲的前半部分,在一个无菌实验室中学习优化备注。我可能有点偏见,但最好的实验室当然还是 Compiler Explorer。那么,如何在 Compiler Explorer 中获取优化备注呢?如果你选择任何版本的 Clang 作为编译器,可以添加一个新的“Opt Remarks”窗格。添加后,这个窗格会出现,并显示优化备注的文本,这些文本是彩色的行,与源代码行交错显示。
优化备注可以显示错过的优化(missed optimizations)、已执行的优化(passed optimizations)或只是一些通用信息。默认情况下,Compiler Explorer 将优化备注过滤为仅显示错过的优化。那么,让我们来看一些具体的优化备注。
这里有一个例子:
Whatever
不会被内联进F
,因为它的定义不可用。
在这个特定的玩具代码片段中,非常明显的是,只有 whatever
的声明而没有定义在这个翻译单元内可用。但一般来说,这条信息可能隐藏在一个复杂的包含文件网络中。事实上,当我看到这个特定的优化备注出现在,比如说,平凡(trivial)或近乎平凡的析构函数和构造函数上时,我感到很惊讶。而当我看到它时,我可以做一件非常简单的事情。我能做什么呢?这个优化备注意味着我包含的头文件或类定义只包含了一个方法的声明。我可以做的是让该实现成为内联的(inline)。直接把它移到头文件里。即使我这样做了,也不能保证内联一定会发生。观察下一个优化备注:
std::basic_string<...>
等等等等,没有被内联进F
,因为内联成本太高。
这里发生的情况是,编译器对内联的成本进行了一些启发式近似估算,这个成本是整体代码大小的成本,然后将其与某个阈值进行比较,如果超过阈值,就禁止内联。现在,再次强调,这可能是完全可以接受的。需要运用一些判断力。但在某些情况下,我希望干预这个决定。一种情况是当我知道自己在热点路径上,并且我想加速这个特定的调用,我不关心整体的调用大小。另一种情况是,比如说,我知道这个特定的函数只在这里被调用。所以如果它被内联,对整体代码大小的影响可能不大。如果我想干预编译器的这个决定,有人有建议吗?我能做什么?__attribute__((force_inline))
。非常感谢。这正是我接下来要建议的。顺便说一下,如果你想在项目范围内干预这个决定,在 Clang 和 GCC 中都有开关可以修改这个阈值。
好的。让我们来看点更有趣的东西。“被存储操作破坏(Clobbered by store)”。我这里有一个带傻循环的函数。这个函数接受一个包含 10 个整数的缓冲区 a
,以及一个期望加到缓冲区上的增量 b
。循环遍历 a
的 10 个元素,对每个元素加上 b
。如果是我手动编译这个,我可能会做的是:取出 b
并把它放入一个寄存器。然后遍历 a
的 10 个位置,从寄存器中取出 b
加到 a
的每个内存位置上。但这不是编译器做的。编译器从内存加载 b
到寄存器 EAX,然后加 EAX 到 a[0]
。再次从内存加载 b
并加到 a[1]
。再次从内存加载 b
并加到 a[2]
,等等等等。现在你可能已经看出问题所在了,但我们假装没看出来。我们去优化备注里找线索。
这里有一个看起来相关的优化备注:
类型为
i32
的加载操作未被消除。
我(编译器)想要消除这个加载操作。我指的是将 b
从内存加载到寄存器的操作。但我没能做到,因为它“被存储操作破坏(is clobbered by store)”。问题中的存储操作指向另一个位置。是存储到 a
的操作。所以存储到 a
的操作破坏了 b
。“破坏(Clobbers)”意味着类似“可能使 b
失效”的意思。这怎么可能呢?别名(Aliasing)。谢谢。编译器是在防范这样一种场景:有人调用这个函数,并给 b
传递了,比如说,a[4]
的地址。如果问题是 a
和 b
指向了相同的内存,那么在这个循环的第五次迭代之后,增量 b
的值就改变了。编译器无法看到所有的调用者,因此它无法排除这种可能性。因此,它被迫在每次迭代中都从内存重新加载 b
。
现在,这种情况可能是故意的,但可能非常罕见。对此我们可以做一些事情。我们可以采取一些措施来告诉编译器这两个变量不指向相同的内存。这可能是…不,在说最简单的方法之前。有人对我们能做什么有建议吗?按值传递(Pass by value)。按值传递。太好了。如果我按值传递 b
,那么通过存储到 a
来修改 b
就没有合法的途径了。我也可以将 b
复制到一个局部变量。如果 b
是 int
,这两种方法都有效,也都是有效的解决方案。如果 b
是一个更大的类,你可能就不想这样做了。所以这里还有其他方法。我们可以用 restrict
修饰其中一个参数。这个修饰告诉编译器,被修饰的名称在作用域内不会与其他任何名称指向相同的内存。具体来说,a
和 b
不指向相同的内存。一旦我添加了这个修饰,关于优化失败的备注就消失了,并且生成的代码确实好多了。在这个特定的例子中,向量化(vectorization)生效了。
需要说明的是。本次演讲将包含大量汇编。会有很多汇编代码清单。但如果你不熟悉汇编,别担心。事实上,你可以说本次演讲的主要观点是,你不需要深入钻研汇编来理解优化。即使你不跟随汇编部分的讨论,你也能获得演讲的全部价值。
好的,关于别名问题我们还能做点别的。这里是完全相同的原始代码,有完全相同的原始汇编和优化备注。这里有一个看似无害的修改:我把 int
改成了 long
。生成的代码更好了。向量化确实生效了。这里发生了什么?向量化确实生效了。向量化参与了这个过程,但实际上我想到的术语是严格别名(strict aliasing)。严格别名,我这里没有粘贴标准文本,因为它相当晦涩,但我引用 Mike Acton 的话:严格别名是我们赋予编译器的一种许可,允许它假设不同类型的对象不会指向相同的内存。具体来说,long*
和 int*
不会指向相同的内存。这甚至是一个比 restrict
更强大的工具。restrict
可以在任何地方使用,但编译器实际上只在它修饰函数参数时才会处理它。类型则不受此限制。你可以在代码中的任何地方通过类型来传达不指向相同内存的信息。这听起来非常有用。
但是假设我不想真的把 a
从 int
改成 long
。我不想把我的存储空间翻倍。假设我想把 a
伪装成一个与 int
类似但不同的类型。我能做什么?怎么实现?强类型定义(Strong typedefs)。我认为强类型定义是一个极好的方向。让我们详细讨论一下,可能比你想听的更详细。顺便说一下,typedef
不是答案。typedef
不会生成新类型。它们是为现有类型生成一个新的别名、一个新名字。强类型定义是实际生成新类型。已经有几次尝试在语言中添加这种机制。不幸的是,没有一个获得委员会通过。但是有一些库可以实现强类型定义。它们通过获取一个 int
,用一个结构体包装它,然后为该结构体提供所有模仿 int
行为的运算符来实现这一点。这确实应该作为一种反别名(anti-aliasing)机制工作。但在实践中,如今的编译器处理得并不好。我想给你们看一个这种困境的例子。
我们拿一个 int
或 long int
,通过把它放进这个包装器(wrapper)里,使它成为一个强类型定义。这里是一个包含两个这种伪装 int
(即两个这种包装器)的结构体。现在这里是拷贝构造函数的两种可能实现。这两个函数都接受 s2
并尝试将其赋值给 s1
。如果我完全显式地进行赋值,s2.a.t
赋给 s1.a.t
,优化确实生效了。这个特定的优化通道是 SLP(超字级并行,Superword-Level Parallelism),但这不重要。如果我尝试依赖为 wrapper
自动生成的拷贝构造函数,即我只是将 s2.a
赋给 s1.a
,那么优化就不会生效。优化备注说:类型为 i64
的加载操作未被消除,因为它“被存储操作破坏(is clobbered by store)”。编译器担心 s2.b
可能与 s1.a
指向相同的内存。编译器错了。这让我非常恼火。事实上,我试图修复它。我深入 Clang 尝试去修改它。当我这样做时,我发现一个修复已经存在了。这是 Ivan Kosarev 的工作,它只是隐藏在一个未公开的 Clang 开关后面:-Xclang -new-struct-path-tbaa
。这是 2017 年的工作,从那以后一直处于实验状态。我担心它在 Clang 18 中退步了,但我报告了,修复已经在进行中。所以,希望如果你在使用 Clang 17,你已经能够享受它了。如果不行,我希望到 Clang 19 时,别名分析会再次变好。
(观众提问)所以,这些函数 f1
和 f2
实际上接收的是相同类型的引用。是的。所以我在想,为什么编译器能够假设它们永远不会引用同一个实际的结构体?
(Ofek)我给你两个半答案。一个是经验性的。你可以从经验上看到,我的意思是,这个评论本应适用于 f1
,但它并没有。第二是,即使 s1
和 s2
指向相同的内存,s1.a
也不会与 s2.b
指向相同的内存。这正是别名分析所要防范的确切场景。可能实际上有一个更好的答案藏在某处。如果我能想到什么,我会再找你。
(另一位观众)那就是正确答案。编译器丢失了它们来自同一个结构体的事实…
(主持人)你能把麦克风给 Fidor 吗?
(Fidor)在进行分析的那个点上,编译器丢失了它们来自同一个结构体的事实,而两个相同类型的结构体不可能“半别名”。是的。它们要么起始于同一个地址,要么一个接一个。但编译器丢失了这个信息,它只看到两个 wrapper
类型的对象,忘记了它们来自同一个结构体,然后说,好吧,两个 wrapper
类型的对象,凭空出现,它们可能指向相同的内存。这基本上就是正在发生的事情。你是说这个分析是在 SROA(Scalar Replacement of Aggregates,聚合体的标量替换)之后进行的吗?
(Ofek)可能。我不确定。为此,我必须查看实际的编译器代码。
(Fidor)正是。我不确定这个。我需要查一下。要么是那样,要么就是状态不够。也许有可能确定这不应该发生,他们只是没有查看状态。或者也许他们做了你说的那样。你得看源码。
(Ofek)我需要查一下。可能有一个更权威的答案。
好的。我想添加到你们对抗别名问题武器库中的最后一样东西是手动反别名(manual anti-aliasing)。有几次尝试将这种能力添加到 C++ 标准中。唉,两次尝试,都没有成功。但 Clang 的作者们决定不等了,添加了他们自己的机制:__builtin_assume_separate_storage
。这就是你在这个特定例子中的用法。你只需要给它两个地址。通过这种方式,你在编译时向编译器声明它们不指向相同的内存。当你这样做时,优化确实生效了。
关于别名问题讲得够多了。我们来看点更有趣的东西。
(观众提问)好的。所以 Clang 有 __builtin_assume
,现在它也在标准中作为 assume
。如果我说 assume address of s1 not equal address of s2
,会有效吗?我的意思是,assume
是提示。编译器可以忽略它们。但编译器会忽略这个吗?我想… 我其实测试过这个,但我不记得了。我认为对于 Clang 18,还不行。我可能错了。这很容易检查。请不要相信我说的。
好的。我们来讨论“被调用破坏(clobbered by call)”。措辞相似,但根本问题… 根本现象非常不同。这里有另一个傻函数。它接受一个 int i
,调用 some_func(i)
,some_func
的签名在上面。然后,i
自增,调用 whatever()
。再自增 i
,调用 whatever()
。再自增 i
,调用 whatever()
。现在让我们做和之前一样的练习。如果我是人类编译器,我可能做的第一件事是将 i
的这三次自增合并成一次增加 3。第二件事我可能会完全消除这些自增操作。在这个调用之后,i
似乎完全没用了。这不是编译器做的。编译器不仅没有合并三次自增,不仅没有消除 i
,它仍然保持自增操作是分开的。为了理解原因,我们首先去优化备注里找线索。
类型为
i32
的加载操作未被消除,因为它“被调用破坏(is clobbered by call)”。第 5 行,第 5 列(call 5)。
这是破坏 i
的调用。注意 some_func
通过引用接受其参数。一旦它这样做,i
的地址在 some_func
的实现内部就是可用的。用编译器术语来说,这意味着 i
的地址逃逸了。发生了指针逃逸(pointer escape)。现在,some_func
可能将这个地址存储到某个全局位置。更糟的是,whatever
可能访问这个全局存储。whatever
的返回值可能依赖于 i
的值。为了完全按照此处编写的代码保留语义,编译器别无选择,只能保持 i
的自增操作分开。
尽管如此,让我们看看是否能做点什么。这里有一个方法:__attribute__((pure))
。属性 pure
告诉编译器,被修饰的函数 some_func
不能修改全局状态。也就是说,编译器防范的事件链中的第一个事件不可能发生。一旦我们添加这个修饰,生成的汇编确实好多了。但请注意,这有一些令人惊讶的副作用。这里完全看不到对 some_func
的调用了。它被完全消除了。如果一个函数是 pure
的,它不修改任何全局状态,并且在这个玩具例子中返回 void
,它本质上什么也不做。它没有可观察的副作用。编译器能够做出这个推理,并完全消除对 some_func
的调用。这对 GCC 来说仍然是成立的,对 Clang 17 及以前也是成立的。对于 Clang 18,行为略有改变。Clang 18 看到这段代码会说:这绝不可能是故意的,伙计。如果一个函数返回 void
,你绝不可能打算让它成为 pure
的。它会发出警告:pure
属性在返回 void
的函数上,属性被忽略。顺便说一下,如果我们修改这个例子,让 some_func
不返回 void
,GCC 成功执行了优化,而 Clang 没有。这是一个更复杂的问题。我提了一个 Clang issue,这里就不深入讨论了。非常欢迎大家点击链接加入讨论。
这里还有一件事我们可以做:__attribute__((const))
。这是一个比 pure
限制性更强的属性。它表示被修饰的函数 whatever
不仅不能修改全局状态,甚至不能读取它。因此,即使在调用 some_func
时发生了指针逃逸并且指针被存储到某个全局位置,whatever
也不能依赖它。在这个声明之后,生成的代码明显变短了,这是优化质量的一个合理代理指标。但观察另一个令人惊讶的副作用。如果 whatever
不依赖于全局状态,并且在这个玩具例子中不接受参数,那么无论何时调用它,它都必须返回相同的值。完全没有必要调用它三次。完全没有必要调用它三次。完全没有必要调用它三次。编译器能够做出这个推断,并且确实只调用 whatever
一次。返回值在 EAX 中,它被复制到 res[0]
、res[1]
和 res[2]
。这可能完全没问题,但当我在这里添加 const
时,这并不是我打算发生的事情。
因此,关于这种特定现象(指针逃逸或被调用破坏),我想留给你们使用的最后一个工具是这个属性:__attribute__((noescape))
。注意,这是一个函数参数的声明,而不是函数的声明。它的含义正如其名。这个参数不会发生指针逃逸。一旦我用这个属性修饰这个参数,生成的代码就如我所愿了。对 some_func
的调用被保留,对 whatever
的三次单独调用也被保留,而对 i
的自增操作被消除了。
(主持人)好的,我们有时间… 抱歉,我有个问题。哪里?谁在提问?
(观众 Miro)是的,请说,Miro。
(Miro)noescape
是否也意味着该函数不能修改该值?因为 i
不是 const
,对吧?所以该函数在技术上被允许通过 const_cast
去掉 const
来修改 i
?
(Ofek)是的,noescape
对此没有预设。该函数仍然被允许修改它。
(Miro)好的。这是一个条件吗?
(Ofek)不。它会修改它。
(Miro)是的,确切地说。那是什么?生成的代码在 some_func
改变 i
的情况下仍然是有效的。
(Ofek)好的,这个不那么重要,但这是一个有趣且令人惊讶的轶事,所以我简要提一下。事实证明,如果我做一个看似无意义的修改,将 i
替换为 +i
,自增操作也会被消除。这让我很困惑。有人能看出原因吗?
(观众)抱歉?临时值?有关联。
(Ofek)是的,一个临时对象。你说得对。发生的情况是,+i
在语义上是一个临时对象。所以即使指针逃逸确实在 some_func
中发生了,它也不再是指向 i
的指针。它是指向虚拟临时对象 +i
的指针。因此,对它的修改不会应用到 i
上。是的,我确实打算…
我再简要提一件事。有时违规代码不是你自己写的。有时它甚至是标准库的一部分。标准库中有一些函数可以通过更好的修饰来获得额外的优化。在这个特定的例子中,违规函数是 ofstream
的 <<
输出运算符。
好的,我想详细讨论的最后一个优化备注是这个:
未能移动具有循环不变地址(loop invariant address)的加载操作。
这里有一个带有 const bool
成员的类和两个 const
方法。方法一循环五次,检查 m_cond
,如果为真,则调用方法二。五次。现在让我们再玩一次“人类编译器”游戏。如果你要自己编译这个,你能看到什么明显的优化?Feeder 说“提升(hoist) m_cond
”。这正是我想尝试做的。我会尝试检查 m_cond
一次,如果为真,则调用 method_two
五次。这不是编译器做的。编译器… 我就不看汇编了。编译器检查 m_cond
五次,每次如果为真就调用 method_two
。优化备注说:未能移动具有循环不变地址的加载操作,因为循环可能使其值失效。
有一个完整的优化通道专门用于这些确切的转换。它叫做 LICM(Loop Invariant Code Motion,循环不变代码外提),它试图做的正是这种提升操作。这是它在失败时发出的备注之一。所以,循环体(本质上是调用 method_two
)可能通过调用 const
方法 method_two
使 const bool m_cond
的值失效。这里发生了什么?这里发生的是编译器错了。没有定义良好的方式可以通过调用 method_two
来修改 m_cond
。这实际上是一个更深层次限制的冰山一角。截至目前,LLVM IR 还不够丰富,无法充分表达 C++ 的常量性(constness)。结果,这种类型的分析在 Clang 中是残缺的。我知道一个尝试补救的措施。这来自… 哇,这是九年前了。这是九年前由 Larisse Woffu 做的。为了完整起见,我在这里链接了一份出色的设计文档,它详细介绍了 LLVM IR 中缺少什么,以及也许可以做些什么。这项工作产生了四个 PR(Pull Request),但遗憾的是它们没有进入主干。我希望这项工作的某些部分仍然可以被挽救。如果不能,我希望还能做些别的来解决它。
那么,我们能做什么呢?事实上,你已经有一些工具可以帮助修复这个错过的优化。属性 const
可能有帮助,但我想借此机会添加一个新工具。本质上,如果编译器不为你做,你可以自己做。让我们把 m_cond
提升到循环外部。我们可以把它复制到循环外部的一个局部变量中,然后使用这个局部变量而不是成员变量。一旦我们这样做,生成的代码就如我所预期的那样了。cond
只检查一次,如果为真,则调用 method_two
五次。现在,请注意,这不是好的 C++ 代码。我恳请你们不要仅仅为了好玩就在代码中到处复制成员变量到局部变量。只有当你有非常具体的理由时才这样做。意思是当你看到这对性能有可衡量的影响时才做。
好了。这足够了。这些是我想要详细演示的四个优化备注。现在让我们超越 Compiler Explorer 中的代码片段。
(主持人)是的。抱歉,我们需要递麦克风。能给我们麦克风吗?谢谢。是的,这里。
(观众提问)那么,编译器最初未能优化的原因是因为我们有 const_cast
吗?或者可能有其他原因?既然对象是作为 const
创建的,m_cond
是作为 const
创建的,通过 const_cast
去掉这个 const
是未定义行为。编译器目前只是无法做出这种推理。但如果是未定义行为,那么这个优化是允许的。编译器在理论上应该能够做出这种推理。它目前只是没有做到。
(主持人)能请递一下麦克风吗?
(观众 Tom)是的,Tom。你说 Clang 缺失了这个优化。其他编译器呢?
(Ofek)是的,GCC 也失败了。其中一些我在 MSVC 上检查过。这个我不记得了。如果你感兴趣,稍后再来,我们马上检查一下。
(主持人)好的。
(另一位观众)是的,const
目前还不是 AST(抽象语法树)的一部分。但标准对此有非常明确的声明。任何通过 const_cast
或其他方式修改定义为 const
的对象都是未定义行为。所以,有方法你可以在不使用 const_cast
的情况下修改它。而标准只是做了一个笼统的声明:任何在定义时为 const
的对象被修改,就是未定义行为。所以,是的,编译器应该能够做到这点。
(Ofek)所以,在这个演讲的早期版本中我有… 也许我现在还有。是的。这里有一种方法让一个 const
方法在不使用 const_cast
的情况下修改一个成员。这是未定义行为。这是未定义行为。在这个玩具代码片段中,没有违反任何特定的语言规则。但因为结果是修改了 const this
,所以它是未定义行为。所以,就这样吧。
好的,让我们超越 Compiler Explorer 中的玩具代码片段。获取优化备注的最早方法是一个存在已久的编译器开关,叫做 -Rpass
,这就是它输出的东西。希望我们能做得更好。
迈向更好的第一步是 Hal Finkel 的一个工具,叫做 llvm-opt-report
,它输出一些编码后的优化备注,并附带源代码的列。它仍然存在,但我们现在有更好的工具了。迈向更好的一个主要步骤是由一个叫做 opt-viewer
的工具完成的。这就是 opt-viewer
输出的样子。它是一个 HTML 文件,包含了你的源代码,并用这些彩色行标注了优化备注的文本,还有一些其他有用的附加信息,我们稍后会提到一些。这是 2016 年 Apple 的 Adam Nemet 的工作。我强烈推荐观看他在 LLVM 会议上介绍它的演讲。如果你有一个克隆的 LLVM,你已经拥有这个工具了。如果没有,你可以通过开发包下载它。
以下是使用方法:
首先,在你的构建中添加一个开关,一个开关就够了:-fsave-optimization-record
。这样做之后,.opt.yaml
文件会与你的目标文件一起生成。这是这样一个 YAML 文件的片段。它是文本的。顺便说一下,它不一定是文本的。默认是文本的。它勉强可读,但显然是为机器而非人类准备的。而这个消费机器是一个 Python 脚本:opt-viewer.py
。你运行它,告诉它在哪里生成 HTML 文件,从哪里获取源代码,以及从哪里获取生成的 YAML 文件。一旦你运行它,它会运行一段时间。可能会是一个漫长的运行过程。并生成我们现在看到的这种 HTML 文件。
让我们讨论一下这个显示中的两个额外好处:
首先,左边的这一列。这些是 PGO(配置文件引导优化)数字。这些是热度(hotness)数字。如果你有幸能够使用 PGO 构建你的源代码,你就有了热度数字。你知道在参考基准测试中,函数执行的相对时间中有多少花费在每一个源代码行上。opt-viewer
足够智能,能找到这些信息并将其整合到显示中,从而帮助你确定优先级。
另一个好处是右边的这一列。内联上下文(Inlining context)。每个函数都可能被内联到各种不同的调用点(call sites)。当它被内联时,可能会做出不同的优化决定。可能会发出不同的优化备注。在这里,你第一次可以看到发出的优化备注及其内联上下文。还有… 这是生成该备注的通道。这里还有其他好处,但我们就不多讨论了。
我真的很喜欢这个工具。我真的很喜欢 opt-viewer
,但它非常沉重,沉重到使用起来令人不快。在真实项目上运行时,我不得不在它消耗超过 100 GB 内存时中断运行,并且它生成的 HTML 文件大到会撑爆浏览器。大部分生成的信息对我来说是噪音。大部分对我来说是不可操作的。所以我开始对它进行修改。这催生了一个改进版本,叫做 OptView2
。它在 GitHub 上公开可用。它生成看起来非常非常相似的 HTML 文件,但希望以更友好的方式生成。我简要讨论一下投入其中的一些工作。我只收集优化失败的信息。我排除系统头文件。我可能在那里放弃了一些可操作的信息,但不多。我移除了大量的重复项,现在有一个详细的配置文件和许多命令行开关,可以过滤到我感兴趣的优化备注。我把运行拆分到子文件夹中。这实际上是一件大事。这使得在大型项目上运行变得可行。有一个巨大的索引空间(,完全由我的朋友兼 JavaScript 高手 Ilan Ben Haggai 从头重写。还有其他一些工作。但我只是鼓励你们,如果你们认为优化备注可能对你们有用,你们可能想试试它。
好的,让我们超越经典的 Clang 工具链。我们简要地做一下。首先,GCC 有类似的东西。一个 -fopt-info
开关家族。如果我用这个开关在 GCC 中构建这个特定的代码片段,我会得到这个,这相当相似。Not inlinable, function body not available
(不可内联,函数体不可用)。Statement clobbers memory
(语句破坏内存)。它不区分“被存储破坏(clobbered by store)”和“被调用破坏(clobbered by call)”,但这是一个开始调查的合理起点。曾有一个短暂的尝试,让 GCC 以类似于 Clang 的格式生成优化备注。这是 2018 年由 Red Hat 的 GCC 开发者 David Malcolm 的工作。GCC 至今仍然接受相同的开关:-fsave-optimization-record
。但这不会生成 YAML 文件。它生成格式略有不同的 JSON 文件。将这些文件处理成 HTML 的脚本没有进入 GCC 主干。它在 David Malcolm 的私人仓库中可用。这是一个例子。这是处理 GCC 优化备注后生成的 HTML 文件的截图。但这还不是生产就绪的。这非常具有原型(prototype)质量。而且我担心开发已经停止了。但你可能会从 GCC 原生的 -fopt-info
开关中获得一些价值。
我不会讨论 Clang CL(Windows 兼容的 Clang)。MSVC 中没有类似的功能。但 Visual Studio 能够使用 Clang CL 进行构建。这是你可能想手动添加到 Visual Studio 的命令行,以便在你的 Windows 项目上获取优化记录。
我有时间讨论 LTO 吗?我简要说说。LTO(Link Time Optimization,链接时优化)。在座有多少人知道 LTO 是什么?好的,超过一半。当使用 LTO 时,粗略地说,就像让编译器将所有翻译单元作为一个单一的翻译单元来编译。理论上,我们讨论过的许多关于别名或逃逸的问题可以被消除。理论上,编译器有足够的信息,不仅能够防范指针逃逸,还能直接查看实现并检查指针是否逃逸。例如,在实践中,这并没有发生。LTO 没有充分发挥其优化潜力。这是 Johannes Doerfert 的引述:“在 LLVM 中,过程内(intraprocedural)、函数内部(function internal)的分析占主导地位。” 这里有一段 Nikita Popov 的更生动的引述:“至于 FAT LTO 管道,我们不谈论 FAT LTO 管道。它的设计介于荒谬和不存在之间。” 这确实符合我的经验。LTO 在内联方面带来了非常显著的改进。仅此而已。
所以,为了完整起见,我在这里粘贴一下。如果出于某种原因,你确实想为 LTO 构建获取优化备注,这里是你需要使用的魔法咒语。但这不会愉快。它会非常非常慢且消耗资源。我敢说,你不会了解到关于你项目的、没有 LTO 就无法了解到的新东西。我们不讨论这个。我们不讨论这个。
好的。很自然地会问,付出额外的努力去获取优化备注是否值得。简短的答案是我不知道。我的意思是,对于你的具体情况,我不知道。但我可以提供一些参考数据点。有一些半学术性的(好吧,不是半学术性的)工作,主要由 Johannes Doerfert 领导,试图研究这个问题。理论上的天花板是什么?由于别名、指针逃逸和类似现象的分析限制,理论上留在桌面上的最大性能是多少?让我偷一句 Doug 的话。研究这个问题的基础方法是添加一个新的优化级别,比如 -O yolo
(“不管三七二十一”优化)。就像是,让我们假设没有别名发生,没有指针逃逸,看看我们能获得多少性能收益。结果差异非常大。我不敢对此做任何绝对的断言。根据这些研究,对于“是否值得”这个问题,答案似乎是“有时”。
我可以从个人经历中再添加一个数据点。当我在 HFT(高频交易)工作时,我们的热点路径上有几个小函数,对我们产品的性能至关重要。使用类似这样的技术,我们能够从一个这样的函数中挤出 25% 的速度提升,虽然只有几微秒,但对我们来说是一个不错的胜利。
(观众 Chris)是的,我出去了一分钟,所以我不确定是否错过了。在分析并发现问题之前,不要优化。如果没有问题,就不要优化。
(Ofek)感谢这个提醒。你在浪费你的时间。谢谢你。谢谢你。
(Chris)所以我实际上正要说这些完全相同的话。这些都是微优化。它们应该是你优化旅程中的最后一步。我敢说,如果你专注于已知的瓶颈点,并且你要么在亚毫秒级别工作,要么在一些非常大且紧密的循环中工作,那么你可能不会浪费精力。但要留意 Chris 的话。不要仅仅为了优化而做这些。
(观众 Fido)我也想提出一个反对点,那就是也不要事先就悲观化。例如,除非你在写模板,否则没有理由通过 const
引用传递 int
。如果你在写 int
而不是模板参数,按值传递它们,你就不必担心该参数的别名问题,根本不用。这不是优化。这是首先避免陷入麻烦。
(Ofek)是的。所以意识总是好的。
关于这一点,我想分享的最后一个想法是,我特别喜欢优化备注的一点是,这是与编译器对话的开始,这对我来说是新的。我们习惯于把编译器看作一个黑盒子。我们不和它对话。我们是独白。我们把代码扔给它然后跑开,希望得到最好的结果。这不一定非得如此。编译器可以向你提问,你可以回答并改善最终的代码质量。并不总是如此。
这就是我要说的全部。我可以通过邮件、LinkedIn 和 GitHub 上的 Eksilon
联系到。我很乐意在接下来的七分钟内、在外面或通过邮件回答任何问题。非常感谢。
(主持人)麦克风在哪里?
(观众提问)所以你说很多问题源于一些函数定义在不同的翻译单元,所以编译器不知道那里发生了什么,无法优化。还有一种加速编译的技术,比如 jumbo builds(巨型构建),当你把所有源文件…
(主持人)你能大声点吗?抱歉。
(观众提问)巨型构建(Jumbo builds),当你把所有源文件放进一个巨大的源文件然后编译它,它编译得更快,但它也能帮助避免所有这些陷阱吗?
(Ofek)我其实不熟悉“巨型构建”这个术语。它和统一构建(unity builds)是一回事吗?
(观众)是的,差不多一样。
(Ofek)我不知道。我猜不会。我猜 Doerfert 的引述,即“在 LLVM 中,过程内分析是 Clang 投入其优化预算的主要领域”,对于统一构建也成立。但我可能错了。这需要测试。
(观众)但在那种情况下,至少所有函数定义都是立即可用的,所以内联应该有效。编译器也可以检查实际的函数。
(Ofek)不,也许我误解你了。一旦内联发生,是的,就没有进一步的别名或指针逃逸问题了。过程内分析,不再有函数边界需要跨越。这是真的。对于统一构建,和 LTO 构建一样,会发生更多的内联。但对于那些保持独立、没有被内联的函数,我猜你不会看到额外的价值。
(观众)所以那实际上意味着,如果你有两个函数定义在同一个 C++ 文件中,这也意味着… 并且没有互相内联。是的,所以它发生了。
(Ofek)好的。谢谢。
(主持人)是的。
(观众提问)对于我遇到的一种情况,你有什么建议?我有一个函数,例如,接收 mdspan
作为输入和输出。我想避免内存破坏,但不想创建大量的强类型… 我不能在参数上使用 restrict
,因为 mdspan
不是指针。那是不允许的。
(Ofek)如果 mdspan
… 你指的是一个函数接收多个 mdspan
…
(观众)是的。
(Ofek)…模板参数化在相同类型上?
(观众)是的。其中一些是 const
限定的,因为它们是读取的,另一些是可变的(mutable)来写入结果。
(Ofek)好的。
(观众)我需要实际检查添加或移除 const
限定是否会使生成的 mdspan
算作不同的类型。如果算,那么理论上严格别名应该生效。
(Ofek)嗯,这些 span
可能有不同的类型,但它们读写的数据值类型(value type)是相同的。
(观众)是的。所以…
(Ofek)你是说它们是相同的?是的。
(观众)两个指向同一个缓冲区的 mdspan
?不。它们不是… 数据不指向相同的内存,但编译器不知道。所以我有指向 float
缓冲区的 mdspan
用于读取,另一个 mdspan
指向不同的 float
缓冲区用于写入结果。
(Ofek)好的。是的。或者 span
。嗯,好的。
(Ofek)在这个例子中,我会使用的工具是这个。我的意思是,如果你能观察到这样的别名问题,而你又无法通过严格别名或 restrict
绕过它,我会建议,留意 Chris 的警告。只有在知道这是瓶颈时才做这个。当你知道别名问题很重要时,我会尝试直接解决它…
(观众)对于像 godbolt
这样的列表(good list?可能指特定代码模式),你会为每个元素都做。确实如此。确实如此。
(Ofek)对于列表,我目前没有解决方案。
(观众)好的。非常感谢。