LLVM 优化备注¶
标题:LLVM Optimization Remarks
日期:2023/01/06
作者:Ofek Shilon
链接:https://www.youtube.com/watch?v=qmEsx4MbKoc
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注:主要是分享一些 opt 工具经验和好用的 __attribute__
。
大家好,非常感谢大家前来。我的名字是 Ofeq Shilon。我在一家名为 Istra Research 的高频交易(HFT)公司担任开发人员,公司位于以色列。我可以通过 Ofeq Shilon 这个用户名在所有主要平台上联系到我。今天我将向大家介绍一个在 LLVM 领域内鲜为人知的工具,称为优化备注(optimization remarks)。
那么,什么是 Clang 优化备注?优化器通常会留下一些日志。诚然,这些日志是由编译器作者编写的,并且面向编译器作者。因此,理解它们往往需要相当大的破译功夫。但一旦付出这份努力,它们有时对于关注优化的开发人员来说可能是一座金矿。我的意思是,这不再是一些关于陷阱的模糊警告,比如“小心别名(aliasing),它可能会降低你的优化效果”。这些备注会明确指出哪些优化被别名问题(例如)阻止了,以及发生在哪里。
因此,我期望在这次演讲结束时,你们都能知道如何获取优化备注,如何解读其中的一部分,以及如何利用这种理解在遇到错失的优化机会时采取行动。演讲的前 60% 将仅限于 Clang 领域,稍后我们会拓宽范围。
那么,我们如何获取优化备注呢?最古老的此类工具已经存在相当一段时间了,它是一个简单的编译器开关 -Rpass
。R 可能代表备注(remarks),我不确定。它会将优化过程(pass)的日志,也就是优化备注,输出到标准输出,看起来大概像这样。这种显示方式会让我的目光变得呆滞。这是一堵没有结构、没有上下文、没有分组的文本墙,我们应该能做得更好。
迈向更好的第一步是一个叫做 llvm-opt-report
的工具,它看起来像这样。这好一些了。这是编译后源代码的文本显示,旁边是这个列,这里编码(更好的词是“加密”)了大致发生了什么以及在哪里发生的(即优化备注)。这个工具是活跃的。它有文档。但请不要使用它。我们今天有更好的东西。
一个重大的进步是这个工具,Opt Viewer。它的输出看起来像这样。这是编译后源代码的 HTML 文件,旁边是优化备注,作为注释交织在源代码行之间,带有完整的文本,在适用时带有指向源代码中其他位置的链接,以及其他各种我们稍后至少会提及的好东西。这项工作始于 2016 年,由苹果公司的 Adam Nemet 领导。我衷心推荐大家观看他在此链接中的演讲。非常精彩。这个工具如今是 LLVM 主干(master)的一部分。如果你克隆了 LLVM,你就已经拥有它了。如果没有,或者你不想克隆,你可以通过下载开发包来获取它。
获取带有优化备注注释的如此漂亮的 HTML 源代码的最基本方法如下:在你的构建脚本(无论它是什么)中添加一个开关:-fsave-optimization-record
。一旦你这样做,YAML 文件(OPTIAML 文件)就会与你的 OBJ 文件一起生成。这里是一个此类 YAML 文件的片段。显然,它是供机器而非人使用的。而这个消费者是一个名为 opt-viewer
的 Python 脚本。运行 opt-viewer.py
,告诉它在哪里生成 HTML 文件,从哪里获取源代码,以及从哪里获取由之前的编译器开关(即 -fsave-optimization-record
)生成的 YAML 文件。
因此,本次演讲的大部分内容将致力于理解这些橙色的交织行(指备注注释)。但让我们花点时间讨论一下此视图中存在的其他好东西。
首先,左边列上的这些数字是热度(hotness)数字。如果你有幸能够使用 PGO(Profile-Guided Optimization,配置文件引导优化)进行构建,你就拥有一些热度数据。OptViewer 足够聪明,能够整合这些数据并将其与源代码行一起显示在这里,从而帮助你确定哪些是重要的,哪些不那么重要。
其次,右边这个列是内联上下文(inlining context)。每个函数都可以(通常也确实会)被内联到各种不同的位置(原文为“colors”,此处按上下文理解为“位置”或“调用点”)。一旦发生内联,优化过程可以(并且正在)做出不同的优化决策。因此会发出不同的优化备注。在这里,你第一次能够在它的内联上下文中查看优化备注。
最后但同样重要的是,这个后半句:“未能消除类型为 i32 的加载(Load of type I32 not eliminated)”。这句话,原样,自 -Rpass
时代起就可用。Adam Nemet 的工作添加了后半句:“因为它被存储操作破坏了(in favor of store because it is clobbered by store)”。我知道这两半句仍然不透明,但我们将深入详细探讨。这部分包含大量信息。
好的。现在有一个更高级的版本叫 OptViewer 2,我不会详细介绍它,但会花一张半幻灯片提一下。OptViewer 很棒,但它非常沉重,以至于使用起来令人不快。在中等或大型项目上运行它时,我不得不在它消耗了大约 100 GB 内存后停止运行。即使我能运行它,它生成的 HTML 文件大小也会让浏览器崩溃。即使我能克服所有这些障碍,我最终得到的也是海量信息的转储。其中大部分对我来说都是噪音。
因此,在过去一年左右的时间里,我一直在修改这些脚本,试图让它们更好地适应我的个人需求,并希望也能适应其他普通开发人员的需求。我只收集优化失败的备注。我排除了系统头文件。我可能因此放弃了一些可操作的信息,但在大多数情况下并没有。我删除了大量重复项。我现在能够根据备注类型和文本通过正则表达式进行过滤。我可以分割到子文件夹。这实际上是个大事。正是这一点使得在中等或大型项目上运行变得可行。还有其他事情在进行中,不仅是我,还有朋友和同事的帮助。我不会详细讨论它的原因是因为它现在正处于被合并到 LLVM 主干的过程中。所以,如果你碰巧是从 2023 年或更晚观看这个演讲,这个仓库应该已经废弃了。如果你想在近一两个月内尝试这些工具,请访问这个仓库。我将展示的大部分内容将取自那里。
最后一点小福利,OptViewer 的大部分功能如今在 Compiler Explorer 中可用,并且已经有一段时间了。如果你访问 Compiler Explorer 并选择任何 Clang 版本,你可以添加新的优化面板(new optimization panel)。一旦添加,你会得到类似这样的东西。它没有交织的优化备注,但它有这个彩色的侧边栏。当你将鼠标悬停在其上时,你会看到真实的优化备注文本。剩下的幻灯片大部分将是来自 Godbolt (Compiler Explorer) 的截图,检查一些代码片段,试图理解优化备注。
我们开始吧。从一个简单的例子开始,内联(inlining)。这不是 Godbolt 截图。这是分析某个 OpenCV 源代码时的截图。好的。“结构体 mat 不会被内联到任何地方,因为其定义不可用”。这应该很简单。这是什么意思?有人猜一下吗?好的。意思是 mat 的析构函数在头文件中只有其声明可用,正如头文件通常所做的那样。如果我是一个 OpenCV 开发人员,我可能会感到惊讶,因为 mat 的析构函数足够简单,至少应该被考虑进行内联。由于编译器无法看到它的实现(实现没有包含在头文件中),编译器甚至无法考虑内联它。
即使实现被包含在头文件中,这仍然不能保证该函数会被内联。看看下面的评论:“函数 yada yada 没有被内联到 yada yada yada,因为内联成本太高”。这是一个不同的原因。因此,编译器对因内联导致的代码膨胀成本进行了一些启发式近似估计,将其与某个阈值进行比较,在这种情况下(以及其他一些情况)决定不进行内联。这可能是正确的做法。这里需要运用判断力。但假设我对这个优化决策不满意。有人知道我可能对此做些什么吗?抱歉?改变阈值。改变阈值。这是一个极好的选择。至少在 GCC 和 Clang 中,有开关可以改变它。这通常是项目范围或至少是文件范围的设置。也许还有另一个想法,我该如何处理这个特定函数。强制内联。强制内联。这正是我接下来要建议的第二件事。非常感谢。
好的。让我们看一些更有趣的东西。“被存储操作破坏(clobbered by store)”。我这里有一个函数,里面有一个傻傻的循环。这个函数接受一个包含 10 个 int 的缓冲区 A
和一个期望的增量 B
。函数遍历 A
的 10 个槽位,并将 B
加到每个槽位上。现在,如果出于某种奇怪的原因,有人要求我手动为这个函数生成汇编代码,我会做的是将 B
存储在一个寄存器中,遍历 A
的 10 个槽位,并将 B
加到每一个上。这不是编译器所做的。顺便说一句,这次演讲会涉及很多汇编。别担心。即使你对汇编不熟悉,也没有理由不理解这里的所有主要思想。主要思想是你不需要深入反汇编来理解优化。
所以,编译器将 B
从其内存地址加载到寄存器 EAX 中,然后使用 EAX 来递增 A[0]
。再次从内存加载 B
到 EAX 并使用它来递增 A[1]
。再次从内存加载并递增 A[2]
,等等。我猜你们大多数人都已经知道发生了什么,但让我们假装不知道。让我们看看优化备注寻找线索。让我们将鼠标悬停在这个红色矩形上看看这个。错过了一个优化。未能消除类型为 i32 的加载。编译器想要消除类型为 i32 的加载,即 B
。当编译器说“加载”时,它们的意思是从内存加载到寄存器。但编译器无法做到这一点。为什么?因为它被存储操作破坏了。截至今天,我们甚至有一个列标记(column marker)。我们知道行内的位置。存储到 A[i]
破坏了 B
。
在这一点上,每个人都应该明白发生了什么。这个现象有一个名字。就在今天,至少有两个演讲提到过它。这个现象叫什么?别名(Aliasing)。非常感谢。编译器是在防范这样一种情况:B
被作为对 A
中某个元素的引用传入。如果确实如此,如果 B
是对 A[3]
的引用,那么在第四次迭代之后,增量值将被改变,必须再次从内存中加载。
好的。现在有一些简单的方法可以解决它。最简单的是用 restrict
修饰。restrict
是一种向编译器传达信息的方式:A
所指向的内存不能(也不会)与作用域内的任何其他名称产生别名。一旦我们这样做,优化备注就消失了,生成的代码也明显更好。在这个特定情况下,被别名问题抑制的优化是向量化(vectorization)。但实际上,这对我的观点来说并不重要。
现在,看一些更有趣的东西。这是原始源代码和原始生成的汇编代码。现在,让我做一个看似微不足道的修改。让我把 int
改成 long
。优化备注消失了,而且这不是偶然。生成的代码更好了。这是一个稍微更特殊的 C++ 现象。如果你不知道它,别难过。这叫做……抱歉,有人说出名字了吗?严格别名规则(Strict aliasing)。非常感谢。这就是严格别名规则在起作用。如果你从未听说过它,别难过。严格别名规则……本来我这里有一张带有标准化定义的幻灯片,但它对我来说完全无法理解。让我们用人话来说。严格别名规则是我们赋予编译器的一种许可,允许它假设不同类型的对象不会产生别名。这就是我们在上一张幻灯片中看到的情况。这可能是有用的。这可能是我们向编译器传达我们知道不存在别名的一种方式。
你应该知道,restrict
至今的使用相当有限。它到处都被接受,但只有在修饰函数参数时才产生实际影响。严格别名规则没有这种限制。这应该有效,但在实践中却没有。我提交了几个 LLVM 问题。这是一个链接。我这里不会进一步详细讨论。非常欢迎你点击并加入讨论。
好的。稍等一下。关于别名,到目前为止有什么问题吗?好的,请说。为什么编译器或者你使用的编译器……抱歉。我很难听清。你能到麦克风前吗?谢谢。在你的例子中,它是一个常量引用 const &b
。所以,从技术上讲,我不允许写入 B
,因为它应该是 const 的。为什么编译器不能假设这个东西不会改变,这意味着它不应该能与 A
的 int 产生别名?这是一个常见的误解。谢谢你提出来。这不是 const
的作用。const
表示你不能通过名称 B
来修改 B
的内容。它没有说明通过其他名称进行的间接访问。为此也要说一句,以防不明显,如果 B
不是通过引用传递的,这一切都不会成为问题。我们可以做的另一件简单的事情是将 B
复制到一个局部变量中,那么所有的别名担忧就都消失了。
好的。让我们继续看一些更有趣的东西。“被调用破坏(Clobbered by call)”。我这里还有另一个傻傻的函数。它叫 F
。它接受一个 int i
,调用 some_func(i)
(签名在上面),然后递增 i
再调用 whatever()
,再递增 i
再调用 whatever()
。同样,如果某个来自 Math Riddles 的施虐狂典狱官强迫我手动为这段代码生成汇编,我首先要做的可能是将 i
的这三个递增合并为一个加 3 的操作。再看一眼,我可能会完全消除 i
。在调用 some_func
之后,它完全未被使用。再次强调,这不是编译器所做的。编译器不仅没有消除 i
,而且还分别递增了它三次。
现在,这是一个不太为人所知的现象。所以,我想你们中知道发生了什么的人会更少。所以,让我们看看优化备注寻找线索。当我将鼠标悬停在这里时,我看到:“错过(Missed)。未能消除类型为 i32 的加载,因为它被调用破坏了”。措辞相似,但根本现象非常、非常不同。注意 some_func
通过引用接受其参数。因此,在这次调用期间,i
的地址在 some_func
内部是可用的。这种现象的技术术语是指针逃逸(pointer escape)发生了。一旦 i
的地址逃逸了,各种疯狂的事情都可能发生。some_func
可以把它存储到某个全局位置或某个成员中。更糟的是,whatever()
可以访问那个成员。它的行为,它的返回地址可能取决于 i
的值。因此,为了保留此处代码所列出的语义,i
需要被单独递增。这是无法避免的。这听起来很疯狂,但编译器无法看到这些实现,因此无法排除这种行为。我们身处编译器领域。常识在这里没有力量。所有疯狂的事情都可能发生。
所以,我可以建议,事实上,有好几种方法可以尝试对抗这些错失的优化。第一个是 __attribute__((pure))
。pure
属性是一种向编译器传达信息的方式:some_func
不修改全局状态。这是编译器所防范的事件链中的第一个事件(即存储逃逸的地址)不会发生。确实,生成的代码要好得多。然而,这有点作弊。如果 some_func
不修改全局状态并且不返回任何东西,它实际上什么也没做。它没有可观察的副作用。编译器能够做出这种推理。注意它完全消除了对 some_func
的调用。所以 pure
属性是一个值得记住的工具,但在这个特定例子中,它可能没有达到我的本意。
这里有另一个好工具:__attribute__((const))
。const
属性对被修饰的函数施加了更严格的限制。它意味着 whatever()
不仅不修改全局状态,甚至不读取它。它甚至无法访问它。因此,即使 i
的逃逸地址被存储在某个全局位置,它也无法影响 whatever()
的行为。确实,生成的代码更好了。对 some_func
的调用被保留了,但注意只进行了一次对 whatever()
的调用。如果 whatever()
不接受参数并且不依赖任何外部状态,绝对没有理由调用它三次。所有三次调用必须产生相同的返回值。编译器足够聪明,能做出这个推论。它调用 whatever()
一次,并将返回值复制到三个 res
槽位中。所以,再次强调,这可能是也可能不是你想要的,但有一个更好的工具能最好地捕捉我打算传达的语义。那就是 __attribute__((noescape))
。注意这不是函数的属性。它是一个参数的属性。它表达的意思正如其名:该参数的地址不会通过这次调用逃逸。生成的代码正是我手动汇编所期望的。所有这三个工具都值得牢记。它们在不同的上下文中都很有用。
现在,这里还有一个令人惊讶的小知识。这里有一个让优化备注消失的令人惊讶的方法。我不是写 i
,而是写了 +i
。有人知道这里发生了什么吗?是的。看起来语义没有改变,但事实上它们改变了。+i
现在是一个临时对象。即使它的地址通过调用 some_func
逃逸了,它也不再是 i
的地址。对 i
的修改不会影响这个逃逸的地址。优化可以自由应用了。
好的。关于这个优化备注我想说的最后一点是,有时有问题的代码不是你自己写的。这里我把调用 some_func
替换成了调用 std::ofstream
的输出操作符 <<
。相同的优化备注和相同的被抑制优化出现了。实际上,我今天早上(不,抱歉,是昨天)和 Marshall Clow 聊过。据我们所知,这甚至从未被考虑过。所以可能值得研究一下。但情况仍然如此。
好的。稍等一下。到目前为止有问题吗?好的。在之前的幻灯片那个例子中,如果那个函数进行了链接时优化(LTO)并且 some_func
的定义在 LTO 阶段可见,那是否会进一步优化?会被重新优化吗?好问题。谢谢。答案肯定是“有时会”。不,我其实打算详细讨论 LTO。我不确定现在是否有时间,但让我这样说:我常常对 LTO 的附加价值感到不愉快的惊讶。理论上,如果你用 LTO 构建,很多额外的分析是可用的。理论上可以消除很多别名问题或逃逸问题。编译器在 LTO 阶段有足够的信息来解决它们,但它通常不这样做。如果有时间,我们会详细讨论。
好的。我想详细考察的最后一个优化备注例子是这个:“未能移动地址为循环不变量的加载(Failed to move load with loop invariant address)”。这里,我有一个类。终于,这是一个 C++ 会议,而这次演讲中还没有出现类(class)。类有一个 bool m_cond
和一些方法。我循环五次,检查 m_cond
,并调用 F()
。同样,如果我是一个人类编译器,我会将检查 m_cond
提升(hoist)到循环之外。我会检查 m_cond
一次,然后要么调用 F()
五次,要么什么都不做就返回。这不是编译器所做的。编译器检查了 m_cond
并分别调用了 F()
五次。现在我们有了一个工具来停止猜测,并试图获得一些线索来理解原因。有疑问?不,我在猜。请说,猜猜看。好的。但请到麦克风前,这样大家都能听到。“F
可能在调用时修改了 m_cond
”。请留在那里。这怎么可能?F
不接受参数,甚至不是一个类方法。它怎么能修改 C
的成员?“如果它有一个指向 C
实例的指针的话”。谢谢。谢谢。你说得对。我应该从这点开始。你说得对。别担心。需要说明的是,当你在类内部工作时,对抗指针逃逸的战斗是一场必败之战。不仅 this
指针在所有类方法中都是可访问的,你正在运行的那个特定实例的 this
指针也可能存储在程序内存的任何地方。编译器无法排除这种可能性。因此它也无法排除 F
修改 m_cond
的可能性。是的。谢谢。能请你解释一下,如果 m_cond
是私有的,F
理论上如何修改它吗?“友元(friend)。I. 也许是友元,对吧?”我其实……我明晚有一个闪电演讲,主题非常相似。我想我这里有些东西可以澄清类似的问题。大多数人担心的是这方面的常量性(constness)。无论是私有还是常量(const),就编译器而言都不能保证任何东西。存在合法的 C++ 方式来修改两者。这里有一个例子。这非常不可能发生,但它是可能的。如果我们有更多时间,我会展示更多此类行为的例子。
好的。事实上,你已经拥有可以对此做点什么的工具了。抱歉。还有问题?“你展示了两个不同的警告,’被调用破坏(clobbered by call)’和’被调用破坏(clobbered by invoke)’。我不理解其中的区别。”这真的不重要。invoke
是 Clang 用于可能抛出异常的函数的术语。仅此而已。
实际上,将 F
修饰为 const
属性就足够了,但我想借此机会在你的工具库中添加另一个工具:如果编译器自己无法提升成员访问,也许我可以。我可以(手动提升)。一旦我这样做,优化备注就消失了,生成的代码也如你所愿。请注意,这不是好的 C++ 代码。除非你有非常具体的原因并且看到了可衡量的影响,否则请不要为了改进优化而去将成员复制到局部变量。
好的。作为参考,我把我们考察过的四个例子集中在这里。“被存储操作破坏(Clobbered by store)”是别名问题的典型迹象。“被加载破坏(Clobbered by load)”——抱歉,这是个错误,是打字错误(应为 Clobbered by call)。“被调用破坏”是逃逸的迹象。“未能移动地址为循环不变量的加载(Failed to move load loop invariant)”通常也是逃逸问题。需要说明的是,当你以这种详细程度检查编译器优化时,比你预期更频繁地,你会遇到确实没有任何语义理由的错失优化机会。这比你能接受的(或者比我愿意承认的)更频繁。这些是真正的编译器限制。我已经提交了几个关于此的 LLVM 问题,但到目前为止它们没有得到太多关注。
好的。我们还有时间讨论其他构建配置的至少某些方面。LTO 构建已经被提到过。我会简要介绍一下。在常规构建中,优化在编译步骤执行。打开开关 -fsave-optimization-record
确实会让这些编译步骤为我们稍后分析而发出优化备注。
在 LTO 构建中,编译步骤不执行任何优化(或只执行非常粗略的优化)。优化由链接步骤执行。这导致了一些非常令人困惑的结果,因为打开 -fsave-optimization-record
开关会导致这些编译步骤发出优化备注。它们可能非常令人困惑。你会被大量“无法内联因为无定义”的备注轰炸,这些备注显然是虚假的。而这些构建步骤本身是沉默的(不会提示 LTO 优化未记录)。
因此,经过一番努力,我能够设计一些技巧,使得从 LTO 的链接阶段获取优化备注成为可能。我在这里将它们转储出来是为了完整性。本质上,是使用一个不同的工具 llvm-lto
,它几乎完全没有文档。它包含在 LLVM 发行版中。它非常难用。没有并行化。没有并行性。它是一个处理巨大文件的单进程。我在那里没有运气获得新的信息。根据我的个人经验,如果你检查常规构建的优化备注,你会得到非常相似的收获。你在那里能够应用的任何改进都会对 LTO 构建增加价值。我不敢说任何绝对的话。这是我个人的经验。
好的。现在让我们把目光转向不同的编译器,甚至可能是不同的语言。在这项工作两年后的 2018 年,David Malcolm(一位来自 Red Hat 的 GCC 开发人员)在 GCC 中构建了一个类似的原型。GCC 至今仍接受 -fsave-optimization-record
开关。这个原型没有进入 GCC 主干。要处理生成的备注,你需要 David Malcolm 个人仓库中存在的脚本。这些不是 YAML 文件。它们是 JSON 文件。信息是分层的。还有各种其他细微差别。这里是一个此类输出的片段截图。我不会再详细讨论这个了,因为它实际上自 2018 年以来就停滞了。我实际上也没能从中获得有意义的结果。即使你能使用该开关完成编译器的运行,通常也无法完成处理脚本的运行。我认为这是具有巨大潜力的伟大工作。我确实希望看到它复兴。但目前情况并非如此。
我展示的缓解工具(mitigation tools)只在 Clang 中,但其他编译器有非常相似的。GCC 有 restrict
、pure
和 const
。Intel 的编译器有 restrict
和 const
。MSVC 也有 restrict
和 const
,但词汇不同,语义略有差异。如果你想将这些结论应用于 MSVC,请在这样做之前阅读文档。
需要说明的是,与 LLVM 相比,其他编译器的诊断工具相当原始。它们没有真正的优化备注,但你在分析代码(即使只是在 Clang 上分析片段)时得出的结论,通常也适用于其他编译器。这不是绝对的。这是一个基于大量实验的经验法则。所有编译器都在与别名问题斗争。所有编译器都在与指针逃逸斗争。你能够在 Clang 中很好地分析它们,但在这些地方,所有编译器都可以利用额外的信息。如果你在跨编译器环境中工作,你可能希望将这些属性包裹在某种编译器敏感的宏中来应用。一个很好的起点是一个名为 Hedley 的项目。它本质上是一个单头文件,包含一堆像这样的宏。如果你这样做,请注意这个限制(指属性有效性)是否仍然成立。
好的。我们还有一点时间来讨论一些相关语言。Rust 在别名分析方面带来了一些好消息。这不是 Rust 代码。这是 C 代码。这是我用来演示别名备注的玩具代码。这是用 Rust 表达的相同代码。不需要任何修饰或奇特的技巧,这段相同的代码就生成了优化后的汇编代码。原因是 Rust 开箱即用地配备了反别名机制。Rust 的借用检查器(borrow checker)意味着你必须克服各种障碍并编写一些不安全的代码才能首先产生别名。嗯,现在是 2022 年 9 月。没有提到 Carbon 的 C++ 会议是不完整的。Carbon 可能会在指针逃逸方面带来一些好消息。这是我用来演示逃逸分析的 C++ 代码。这是用 Carbon 表达的相同代码。注意在 Carbon 中,你不必在参数 i
前加 var
。如果你在 i
前加 var
,意味着 i
是一个变量。例如,你可以获取它的地址。如果你不给 i
加 var
前缀,i
可能没有地址。它不是变量。它可以通过寄存器传递。如果这是 some_func
的签名,指针逃逸就不可能发生。目前还没有完整的 Carbon 编译器来演示这些结果。但这是一个非常合理的期待。
一个合理的问题通常会浮现:这种努力值得吗?简短的回答是:我不知道。我可以给你一些数据点。
首先,在 LLVM 开发者社区内部,有越来越多的学术工作致力于(我该如何表述)证明对内建属性(internal attribute machinery)进一步投资的合理性。这些都是我非常推荐阅读或观看的三项工作。它们非常出色,大部分由 Johannes Doerfert 领导。它们得出了相当不同的结论。Petospa 展示了使用并非都可用于 C++ 的属性(它们是内部的 IR 属性)实现了 50% 到 20% 的加速。Oracle(一项仅关注别名查询的工作)显示没有影响。HTO(Header Time Optimization,头文件时间优化)——我仍然希望它有一天能被合并到主干——展示了仅在 C++ 中使用扩展属性(extended attributes)就能达到 LTO 50% 的收益(原文为“ceiling of 50% of LTO without LTO”,理解为通过头文件优化达到接近 LTO 50% 的潜力)。这些是你通过谨慎应用这些属性可能期望达到的上限。
我可以再讲一个数据点,就是我个人的经验。在我自己的工作中,我使用了类似的技巧来攻击一个已知的性能瓶颈函数,它耗时 6 微秒。在我们给予它一些“优化备注关爱(optimization remarks love)”之后,它减少了 25%,这是一个不错的胜利。所以这是一个已知的瓶颈,并且它已经接受了一些优化关爱。然而我们仍然能够进一步减少它。
所以,我最接近建议的是:**专注于已知的瓶颈。**如果你用这些工具扫描你的项目,你会得到成千上万的优化备注。首先查看你知道重要的地方。特别是,如果你工作在亚毫秒级别或在非常、非常紧凑的循环中,你浪费时间和精力的风险是最小的。我几乎可以保证你会看到可衡量的影响。
最后一点。我想说这可能是新方法的开始。我希望它能进一步发展。我们不习惯与编译器进行对话。我们习惯于独白,比如编写我们的代码,把它扔给编译器,然后跑开并祈祷最好的结果。这不一定非得如此。正如你所看到的,编译器可以和你对话。它可以问你问题。如果你掌握了这项技能,你就能理解这些问题并回答。并非总是如此。我展示了四个例子。截至 Clang 14,目前还有大约 20 个其他优化备注类别。其中大部分对我来说是不可操作的。
最后我想说的是,我想感谢参与这项工作的朋友和同事。Ilan Ben-Chagai, Oded Sharon, Gal Falcon, Oybar Khan 和 Lior Solodkin 都帮助完成了这项工作,无论是通过 PRs、讨论,还是破译我自己无法理解的优化备注谜题。非常感谢你们。如果你们有任何问题,我将非常高兴尝试回答。
我有个在线问题:“为什么标准除了向后兼容性之外,仍然允许以这种不明显的方式修改常量变量?”标准允许什么?“为什么标准允许以不明显的方式修改常量变量?”你指的是我展示的那个不在主演讲内的幻灯片吗?我假设是。它允许吗?我认为是。它试图不允许。但开发人员显然比委员会成员更狡猾。我认为这本质上是标准中的一个漏洞。我不认为这是一个深思熟虑的决定。顺便说一句,如果这个函数被修饰为 __attribute__((const))
而不仅仅是 C++ 的 const
,编译……嗯,其实我应该测试一下,但它应该会失败。或者至少这段代码将不再是定义良好的行为。
好的,请说。“我有一个关于常量引用修改的问题。你说这只是保证那个变量不能通过它的名字被改变。但如果这是真的,如果我们不使用任何 const_cast,编译器仍然假设它不能被修改,那么创建任何 char*
都会扼杀任何优化,因为 char*
基本上可以指向任何东西。是这样吗?”你说什么?是的,抱歉。基本上我问的是,如果我在我的函数内部创建一个 char*
,编译器可能会假设它指向任何局部或全局变量,常量或非常量。那么它基本上不应该优化这些变量中的任何一个?“不要优化任何这些”这个说法太严厉了。但是的,这是真的。在编译器无法推理的指针存在的情况下,许多许多优化被抑制了。这完全正确。这是我的经验。
抱歉,你能对着麦克风重复一下吗?“类型双关(Type punning)仍然是非法的。”确实。确实。“他问的是 char*
指针。”但就像你不能从 long
转到 char*
再转到 int*
。这是真的(指违反严格别名规则是UB)。所以它仍然没有在全球范围炸开一个大洞。我想这就是严格别名规则的历史动机,允许某种优化。没有它,我们会被削弱到无法使用的地步。
我有个评论实际上与此相关,但在发生指针逃逸的幻灯片上指出来会更容易。给我一秒钟。“带有 sum
函数和 whatever
的那个。”是的。那个。是的。所以这里实际上有更微妙的事情在发生,这对某人可能是好事,因为某人可能有个问题我想指出。所以名义上 some_func
不应该能够通过参数修改 i
,因为它是一个常量引用 const &
。而且你不应该能够将 const int &
转换为 int*
(非常量指针),这将是一个全局指针。那将是未定义行为。那是对的。“除了你可以抛弃常量性(casting away const)。”抛弃常量性本身不是 UB,除非对象被创建为常量(指底层对象是 const 的)。所以如果你让参数是 const int
(值传递),那么从技术上讲编译器应该能够进行优化。我不知道是否有任何编译器实际这样做,但那样技术上(通过抛弃常量性修改)将是 UB。这关系到那个在线提出的问题。
C++ 中的 const
意味着很多不同的东西。看起来他们问的是如何声明一个对象本身是不可变的(immutable)。“你创建了 const
。理论上它意味着它是不可变的。”大多数编译器没有给予足够的关注。我认为……谢谢你。让我对此做两点评论。我还有时间。我还有时间。
首先,我确实考虑过……实际上,第一个版本这里确实是 const int
,但我选择演示通过对 i
的递增来展示逃逸。所以这不在讨论范围内。
现在,确实有一些情况,在之前的 CppCon 演讲中也出现过,编译器不合理地忽略了 const
。但根据我的经验,大多数时候,我可以想出像这样狡猾的构造,通过合法的 C++ 来修改常量变量。我的印象是,在某个时候,编译器编写者干脆放弃了,并决定……考虑 const
不值得付出努力。所以,我们需要知道……请明天来参加闪电演讲。我可以展示一个例子,如果一个对象被创建为 const
,你仍然可以修改它。
请到麦克风前。“我认为这里的要点是,如果它是作为 const
创建的,那么你能修改它意味着你只是发现了未定义行为。因为最有可能它会抛出某种硬件异常之类的东西。”我不同意。我认为我有合法的 C++ 代码可以做到。但这不是这次演讲的重点。我们明天再进一步讨论。我可能会学到新东西。也许我在某些间接方式中触发了 UB,而我自己没有意识到。
还有问题吗?好的,伙计们,非常欢迎你们尝试这个工具。请告诉我它是否对你们有用。更重要的是,告诉我它是否没用。这仍然是一项进行中的工作。我非常乐意接受建议,甚至拉取请求。非常感谢。
谢谢。谢谢。