函数 API 的隐藏开销¶
标题:Hidden Overhead of a Function API
日期:2024/11/26
作者:Oleksandr Bacherikov
链接:https://www.youtube.com/watch?v=PCP3ckEqYK8
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
我在 Snap 从事增强现实工作。涉及各种领域,比如计算机视觉、机器学习、图形和移动设备上的实时渲染,全部使用 C++。所以如果你感兴趣,请来找我聊聊。正如你所想象的,为此我们非常关心性能。我还要向 Sergi Huralnik 和 Eduardo Madrid 表示衷心的感谢,我在 Snap 与他们共事非常愉快,他们在准备这次演讲时给了我极大的帮助,并且在这些主题上比我经验丰富得多。所以如果你有任何问题,我请你记住幻灯片编号。方便的是,这张幻灯片没有编号,但你会找到它的。请在演讲后提问,因为我们有很多内容要讲。但如果你想有时间,演讲结束后随时可以来找我,会议期间或是在 Discord 上都行。
正如 Tony Van Eerd 在 C++ Now 2023 上那句名言所说,人们写的函数还不够多。在那之前,在 Cpp North 2022、C++ Now 2021,还有这里的 CppCon 2017 上他也说过。你知道吗?人们仍然没有写足够多的函数。所以值得重复这一点。但当人们终于开始写函数时,我们更希望只得到那些设计良好的,对吧?否则 Tony 会不高兴的。本次演讲将聚焦于这部分,特别是从性能角度。但在谈论性能时,我们通常只考虑函数逻辑并优化那部分。但我们会看到,一个设计良好的函数 API 有时会产生更大的影响。
那么,我们如何开始比较性能呢?嗯,这个级别的基准测试并不可靠,也不能很好地代表大型项目中的性能。因为大型项目不仅仅是循环运行一小段代码。它们同时做更多的事情。但我们还是会做一些基准测试,因为人们喜欢它们。像动态指令计数这样的东西在现代 CPU 上更可靠。但我们将主要使用简单的例子。所以我们可以直接比较编译器生成的指令数量。这应该是一个很好的估计。
为了给它一个现实世界的背景,有一篇文章《使用 Bolt 加速大规模应用程序》,谈论了大小在几十到几百兆字节的机器码。它提到,在许多情况下,近 30% 的处理时间花在将指令条从内存获取到 GPU 上。所以如果我们达到减少指令数量的目标,我们不仅改善了执行时间,还有助于指令缓存。现在我需要声明一下,我们的讨论仅适用于非内联函数。但这是否意味着你现在就该去门口呢?嗯,官方的 ISOC++ Wiki 试图回答一个问题:内联函数能提高性能吗?答案是:视情况而定。他们说它们通常与速度完全无关。我还要感谢 Halil Estelle,他允许我使用他昨天幻灯片中的数据,他测量了 Firefox 函数大小的分布。在那里我们看到,超过一半的函数实际上小于 127 字节,这大概比大多数人预期的非内联函数要小。所以我认为这是一个相当重要的案例。现在,为了理解编译器如何从你的 C++ 代码生成机器码,你需要遵循这个流程图。在顶部,我们从 C++ 标准开始,但它并未涵盖所有的实现细节。为此,根据平台不同,你需要向下查看 Itanium C++ ABI(它本身将部分内容委托给 System V 通用 ABI),并结合特定于平台的 ABI,如 ARM、x86 等。在另一边,微软则用他们的 Windows ABI 做自己的事情。
我希望这次演讲对尽可能多的听众都有意义。所以我们将涵盖以下六个平台。首先,左列是 ARM 架构,主要与智能手机和新的 M 系列 Mac 相关。请注意第二列和第三列实际上共享架构,但它们不共享 ABI,这造成了差异。所以你看到那里有相同的指令。中间列主要是关于 Linux 服务器和旧款 Mac。右边是关于 Windows 设备的。另外,第一列主要是 64 位现代架构,所以我们将重点放在这些上,因为它们今天更相关。最下面一行适用于古老的设备和低端 Android 设备。但在有有趣差异时我们会看看它们。抱歉,我对嵌入式没有任何经验,所以我不是谈论它的合适人选。也许 ARMv7 涵盖了一部分。
为了理解代码,我们需要查看我链接在幻灯片中的所有这些 ABI 规范,特别是关于调用约定的部分。这里我也提到了使用的编译器。这些是这些平台的典型编译器,第一列是 Clang,第二列是 GCC,第三列是 MSVC。所以我们也涵盖了所有主要的编译器。但最终,在我们的例子中,编译器应该无关紧要,因为优化空间不大。正如你可能已经看到的,事情很复杂。但别担心,我们将寻找一些简单的指导方针来应对这种复杂性。幸运的是,我们有 C++ 核心指南,看起来是一个不错的候选方案。好的,那么让我们从返回值开始。确实,我们有一个关于它的指南:对于输出值,优先使用返回值而非输出参数。他们提到了一些关于自文档化的东西,但我们不关心这个。我们今天只关心性能。
让我们用一些简单但不太平凡的类型来检查这个指南,比如 unique_ptr。我们有两个选项。一个通过值返回空指针,第二个通过输出参数。现在,从性能角度快速投票。谁认为这些选项是相同的?
好的,很少人举手。谁更喜欢按值返回?好的,更多人举手。输出参数呢?是的,人们不喜欢输出参数。那么让我们看看结果。确实,在我们所有三个主要平台上,我们看到输出参数产生了更多的指令。你可以在那里注意到的是对 operator delete
的调用,这在上面是没有的。那么为什么会这样呢?嗯,如果你看代码,就很清楚了。我们将一些现有的输出参数传入函数,所以它可能非空。因此在设置某些东西之前,我们需要销毁那个对象。是的,但这还不是全部。在查看函数时,同时查看调用点也很重要。因为代码中的调用点远不止一个函数。对于这个例子,我强制函数定义不被内联,以确保它们不是内联的。因为,是的,这些都是非常小的例子。它们几乎肯定会被内联,但为了本次演讲的目的,最好让它们清晰可见差异。我们有两个选项。一个调用我们的第一个函数,第二个调用带输出参数的第二个函数。查看结果,我们看到第二个选项产生了更多的指令。如果你只看 GCC 的中间输出,你可能会感到困惑。因为在 ret
指令之后,有一些代码,还有一个跳转到未定义标签的指令。但那只是 Compiler Explorer。你可以调整设置以显示编译器的完整输出,但默认情况下它会隐藏一些东西。但如果你看其他列,有一个很大的提示表明发生了什么。提到了“unwind”,这意味着在抛出异常时进行栈展开。
那么为什么会出现这个呢?嗯,在我们的第二个函数中,在调用带输出参数的函数之前,我们需要构造这个对象。所以如果我们的函数抛出异常,它必须被销毁,因为它是一个局部变量。
所以那部分是有道理的。而且 unique_ptr 有一个非平凡的析构函数。
但之后,如果你放大看 GCC 的输出,仍然有一些差异。在输出参数的情况下,有一条指令将零放入某个内存中。
再次,如果你看代码,就很清楚发生了什么。
unique_ptr 必须在代码之前被默认构造,或以某种方式构造。
所以对于 unique_ptr,这仅仅是将其设置为零。但对于更复杂的类型,你可以想象在这个地方会有更多的开销。也许你在想,也许对于至少是平凡可构造(trivially constructible)的类型我们可以避免它。但是,嗯,核心指南要求我们总是初始化对象。我希望你在你的代码库中将未初始化变量视为错误。所以这没有帮助。哦,对了,只是为了幻灯片,我做了一个快速的基准测试,可能没有代表性。但对于这个简单的情况,输出参数显示了 25% 的开销。所以如果你的函数变得更复杂和增长,它会下降到比如 10%、5%、1%。但这个开销仍然会存在。并且有一些尝试改进输出参数,比如这篇文章。然后最后的例子是这个对象的延迟构造,然后我们将其传递给我们的函数。嗯,它解决了我们的问题吗?嗯,它肯定避免了函数调用前的默认构造函数。但除非我们完全信任用户做正确的事情并且完全没有异常,否则它有一些问题。嗯,异常时的栈展开(stack unwind)仍然是必要的,并且它占用了相当多的代码。我们还需要一个额外的 bool
标志来知道函数是否真的初始化了值以及没有多次初始化。实际上,这就是库所做的。所以存在额外的开销,就像在 std::optional
中一样。但猜猜看?任何 C++ 编译器都会检查函数中的每条执行路径是否都以 return
语句结束。我们需要做的只是按值返回。我们不需要这个运行时标志。所以到这里,希望有足够的证据说服你,输出参数是个坏主意。但现在让我们更深入地了解按值返回是如何工作的,特别是对于 C++ 抽象。
同样,核心指南建议我们使用 unique_ptr,并说这是安全传递指针的最便宜方式。所以让我们尝试这样做。我们比较一个返回指针的函数,但第一个返回原始指针,第二个返回 unique_ptr。让我们快速投票。谁认为在这种情况下 unique_ptr 增加了一些开销?好的。一些手慢慢地举起来。谁认为没有开销?是的,我觉得一半一半吧。
我们在汇编中看到 unique_ptr 实际上增加了一些开销。让我们解释一下。首先,在阅读汇编时我们需要知道,像 X0、X1、EAX 等是机器寄存器。它们是你的程序在机器上可用的最快存储。当你看到像下面那样方括号中的寄存器时,意味着该寄存器存储一个地址,我们正在访问该地址指向的内存。根据定义,它应该比直接使用寄存器慢。但也许你想知道这到底慢多少。抱歉,有个问题。如果你有问题,请用麦克风。
实际上,这段代码在我看来,上面一行所有东西都被优化掉了,因为 EAX 只是被设为零。不,这是一个实际的函数体。所以我们返回一个空指针。所以它所做的就是返回一个空值,嗯,设为零。
所以是的。那么也许你在想它有多快。嗯,我为这个简单的例子做了一个快速的基准测试。结果发现它慢了 3 倍。输出。当我们使用内存时,它比使用寄存器慢了 3 倍。
再次强调,即使你的函数运行了一百次,这个开销仍然会占你执行时间的几个百分点,这取决于你的用例是否能接受它。
那么回到核心指南,嗯,也许 unique_ptr 确实是安全传递指针的最便宜方式,但不幸的是它并不是免费的。为了理解为什么会发生这种情况,让我们看一个更简单的例子和一些更平凡的东西,因为你知道,unique_ptr 确实涉及一些内存管理。这很复杂。所以让我们写一个简单的只包装 int
的包装器。你可能会想,为什么会有人想这样做?嗯,有很多库依赖于此。首先,嗯,智能指针类似地包装原始指针。有 std::chrono
和其他单位库。顺便说一句,同时还有一个关于 mp-units
的演讲。所以你可以告诉那里的人你在接下来的幻灯片上看到的东西。这会很有趣。还有安全整数和其他库的绑定。好吧。总之,我们正在包装一个 int
,而且没有人能阻止我们,对吧。在这里,你可以想象一个认真的人明确写下了所有操作,这样我们可以看到里面没有发生任何意外的事情。我们尝试使用这个包装器作为返回类型,只返回一分钟的秒数。并将其与 int
进行比较。当我们编译这段代码时,嗯,再次看到了那些讨厌的方括号,它们增加了开销。这可能会让人困惑为什么。而且它在所有平台上都会发生。所以我们去阅读 Itanium C++ ABI。它说如果返回类型是一个非平凡的类类型,调用者会传递一个地址作为隐式参数。我们在这里看到了这个地址。这正是我们的问题所在。然后你重温一下 C++ 知识,想起平凡类(trivial class)被定义为可平凡复制的(trivially copyable)并满足其他一些条件。是的。所以这就是我们想要的。所以让我们通过移除拷贝和移动(为了保险起见)使我们的类变成可平凡复制的。一种方法是直接删除它们。第二种方法是像注释中那样默认它们。好的。我们没有自定义拷贝。很好。但没有任何变化。好吧。我们需要读更多。所以我认为也许我定义了一个类型,出于调用目的,如果它有非平凡的拷贝构造函数、移动构造函数和析构函数,它就属于非平凡类型。是的,对于“可平凡复制”这个名字来说,这部分有点不平凡(non-trivial)。但现在我们知道该怎么做了。顺便提一下,请注意这与 C++ 可平凡复制的定义不同,后者还要求平凡的拷贝赋值和移动赋值。好的。我们移除析构函数。突然,至少在三个平台上,我们在某些地方得到了一些东西。方括号消失了。好的。所以此时,你在想,好吧,也许我们找到点门道了。也许删除更多代码就能解决我们所有的问题,对吧?所以我们删除了我们能删除的最后一个东西,构造函数,并保持默认初始化为零。猜猜怎么着?它在 Windows 上起作用了。但在 x86 上不行。所以此时,是时候深入研究 ABI 的规范了。但别担心现在就读这个。因为我为你把重要的部分收集到了这个表格里。你首先可能看到的是…
是的,首先,我们在这里只看复合类型,因为大多数基本类型可以在寄存器中返回。但对于复合类型,首先你可以看到所有平台上都有大小限制。而且它们差别很大。而且它们都有额外的要求。嗯,我们记得要有平凡性(trivial)的要求。但微软 ABI 还引入了 C++03 POD 的要求。这意味着根本不能有自定义构造函数,不能有私有成员等等。而在 x86 上,只允许基本类型。
好的?顺便说一下,核心指南有这个指南:如果可以的话,避免定义默认操作。这被称为零法则(Rule of Zero)。我想在看过我们的例子之后,我们可以出于性能原因强烈赞同它。此时,你可能会想,当然所有流行的库都正确处理了这个问题,对吧?对吧?嗯,在看到秒数的例子后,我想你期待着看到 std::chrono
。这里我们再次返回相同的值。但第一个函数是原始 int
。第二个是 chrono::seconds
。我们检查了类型实际上是相同的。是 int64_t
。我们把它放进编译器,在 Itanium 平台上一切正常。
有趣的是,各处的原因是不同的。在 MSVC 上,问题在于它不是 POD,因为它封装了值。在 x86 上,它根本不是基本类型(fundamental type)。在 ARMv7 上,问题是大小大于 4。并且对基本类型和自定义类型的处理方式不同。
那么我们能做些什么呢?嗯,std::chrono
将不得不放弃任何封装才能在 Windows 上达到最高效率。不确定这是否合理。而且它不能仅仅为了一个架构就只使用小于 int64_t
的类型。因为秒数可能无法放入其他类型中。
另一个有趣的例子。std::pair
。拷贝和移动构造函数是默认的。我们这里没问题。但直到 C++17,std::pair
才可以是平凡可析构的(trivially destructible)。如果你一直在关注,你会看到这是一个 ABI 破坏。因为在那之前,它只能通过内存传递。现在它可以通过寄存器传递。但我快速谷歌了一下,令人惊讶的是,在第一页我只看到一个关于这个破坏的抱怨。下一部分。拷贝和移动赋值运算符只在 MSVC 上是平凡的。正如我们之前澄清的,这对函数调用来说不是问题。但对于需要此属性的 memcpy
和 bit_cast
来说,这是一种性能问题。哦,std::tuple
是一个有趣的家伙。它从来不是平凡可移动构造的(trivially move constructible)。我不知道这是怎么发生的。那么我们能做些什么呢?嗯,如果可以,不要使用 std::pair
或 tuple
。一个命名的结构体(named struct)出于这些原因,在代码可读性和性能上都更好。回到我们的 unique_ptr 例子,我想我们能做什么?唯一的想法是手动将实现提取到一个单独的函数中,该函数在某个隐藏的地方返回一个原始指针。然后在实际的 API 中(希望总是内联的),我们将其包装成一个 unique_ptr。嗯,但这并不好,需要大量手动工作。此时,我们实际上有了一切来讨论返回值优化(Return Value Optimization),或者称为复制省略(copy elision)。快速回顾一下,自 C++17 起,一个纯右值会直接在它的最终目的地的存储中构造。从而避免了对移动和拷贝构造函数的额外调用。我们实际上已经看到了它是如何工作的。我们已经在 Itanium ABI 中看到了这个段落。如果返回类型是一个非平凡的类,调用者会传递一个地址作为隐式参数。被调用者将返回值构造到这个地址中。为了澄清,它是作为第一个参数传递给函数的。所以如果你为了保持一致性而使用输出参数,我也会把它作为第一个参数。
如果你仔细想想,它实际上正是一个输出参数,但由编译器为你正确地完成。并且它只在必要时才做,即当我们不能使用更快的寄存器时。在星期一,有一个关于 RVO 的精彩演讲,几乎变成了一场激战。它展示了如何在函数内部搞砸 RVO 的好例子。但我们的焦点不同。我们不看函数内部,只看它的 API。但我认为我们仍然可以为此添加一些有趣的东西。我们将看一个将函数结果插入容器的用例。对于这种情况,用一个像这样的结构体模拟一个大的结构体是有用的,它声明了所有标准构造函数但没有定义。这样在汇编中,我们会看到哪些被调用了。我们有一个 make_large
函数,同样我们没有定义它,以确保它不会以任何方式搞砸我们的 RVO。我们把结果放入一个 std::optional
。在这里,我们在幻灯片上没有做任何犯罪的事情,但我们运行了编译器。在输出中,我们看到移动构造函数被调用,析构函数出现了两次,一次在栈展开(stack unwind)部分。那么问题是什么?嗯,原来我们在使用这个 std::optional
的构造函数。它给我们的参数起了一个名字 value
,这将纯右值变成了右值,从而禁用了常规的 RVO。然后显然它将这个值转发到存储中,这禁用了命名返回值优化(NRVO)。那么我们能做些什么呢?哦,它基本上影响了所有的构造函数和 emplace
、insert
、push_back
等,涉及所有容器,optional
、variant
、vector
、map
等。那么我们能做些什么呢?首先,我在 Arturo Dwyer 的博客上看到了这个解决方案,他称之为 with_result_of<T>
,它引用了 Andrzej Krzemieński 的这篇博客,他称之为 rvalue
。幻灯片上的 Robert Leahy 称之为 Alight
,甚至还有一个同名的标准提案。但我最喜欢 Ivan Čukić 使用的名字,他实际上用了 lazy
来表示更复杂的东西,那个东西还会缓存值。但我认为简单的名字应该留给简单的东西。所以在我的库中,我添加了这个简单的包装器 lazy
,它只是存储函数。重要的部分是它到函数结果的转换运算符。在底部,我们不直接调用 make_large
,而是将这个 lazy
传递给 optional
的构造函数。嗯,我不会深入探讨为什么这有效的细节。你可以阅读列出的文章。但核心思想是我们不传递值,而是传递函数。并且这个转换运算符只在最后被调用,这启用了我们的 RVO。
我们查看汇编,确实,所有不必要的开销都消失了。我们只有一个对我们函数的调用和一些围绕它的 optional
的东西。在这里,我想提请你注意,我们正在见证一种罕见的负开销抽象(negative overhead abstractions)的实例,这也很棒。回到我们开始的地方,我们的指南:我们应该优先使用返回值而不是输出参数吗?我认为我们可以高度赞同这一点。但它也提到引用可以是输入输出(in-out)或仅输出(out-only)参数。所以让我们看看这个。我认为输出参数仍然有有效的用例,但主要是在返回值无法在栈上分配的情况下。例如,如果它是一个运行时大小的范围(range),我们有像 ranges::transform
和 sort
这样的算法。如果我们将分配与数据处理分离,代码会变得更具可重用性,这是有道理的。
但稍微离题一下,想象你是一个 C++ 新手,你看到这个 transform
调用。你怎么知道哪个参数是输出的?有三个选项。对于 sort
也是。你知道参数是被修改了还是返回了一个新的范围?
所以我想过这个问题,在我的库中,我引入了一些简单的 out
和 in_out
包装器,它们目前不提供任何保证,但至少你可以清晰地放入函数签名以及每个调用点。另外,如果你有 sort
,你可以做两个重载。一个原地修改范围,另一个返回一个新的。
我认为这使代码更易读。
是的,这就是第一节的结束。让我们短暂休息一下,然后继续讨论参数传递。好的。
同样,我们有一个核心指南,它说:对于输入参数(in parameters),传递廉价拷贝的类型使用传值,其他的使用常量引用(by reference to const)。对于“廉价拷贝”,它说取决于机器架构,但通常两到三个字(words)最好使用传值。让我们研究一下这个。让我们从一个简单的只带输入参数的例子开始,分别使用传值和传引用,并在函数内部做一些简单的事情。那么,谁认为从性能角度来看它们是相同的?好的,没有人。谁认为这里传值更好?好的。你们大多数人。传引用呢,有人喜欢吗?没有。很好。我们看汇编,确实当我们传引用时,又看到了那些讨厌的方括号,这意味着访问内存。在这种情况下,我认为这完全合理。我们传递引用,所以需要解引用。
但再次强调,当谈论函数 API 时,我们也需要查看调用点。为此,我们只是将常量 1
传递给我们的两个函数。发生了什么?对于传引用,生成了更多的汇编代码。
发生了什么?在顶部,你看到一个常量 1
被放入寄存器并调用函数,这正是我们想看到的。但在底部,因为我们需要引用,我们首先需要把这个常量 1
放到栈上,然后取它的地址(引用)。在函数调用之后,我们还需要清理栈。
人们喜欢谈论过早优化(premature optimization)或剖析器引导优化(profiler guided optimization)。但对于这样的情况,我认为任何剖析器都不会在这里引导你,因为调用函数的这段代码散布在所有调用点上。很可能单个调用点的开销很小,剖析器无法注意到。我再次做了一个快速的基准测试,令人惊讶的是,在这里使用内存与使用寄存器的开销也像之前的基准测试一样大约是 200%。但这可能不太有代表性。
但还有另一个有趣的事情。我们可以看到我们的大多数函数并不像我们看到的那么简单,它们会调用其他函数。
所以我们定义了一些额外的函数来调用。在我们的例子中,我们首先复制参数,然后调用这个额外的函数,然后将副本与输入值进行比较。我认为这应该总是返回 true
,对吧?
嗯,猜猜看?对于传引用的情况,编译器在调用我们的函数之后,总是有一条 CMP
指令来比较值。
在顶部(传值),它确实为我们返回了 true
。那么问题是什么?嗯,当我们有一个不透明的函数调用(opaque function call)时,标准不允许编译器… 嗯,标准强制编译器假设任何函数都可能改变任何值。
即使它不接收任何参数。
这包括我们传递给函数的引用所指向的值。
所以编译器被迫再次读取它然后进行比较。另外,趁我们在这里,谈谈完美转发很重要。根据一些定义,完美转发是将函数参数传递给另一个函数同时保留其引用类别的行为。在实践中,主要目的是在我们能调用 new
的时候避免额外的拷贝。它通常用于像 make_unique
这样的函数,它接受一些参数并简单地将其转发给类型 T
的构造函数。但我要说它并不完美。首先,这里有 forward
,它会破坏 RVO。但还有第二部分。有这个转发引用,它仍然是一个引用。所以它实际上阻止了在寄存器中传递参数。所以如果这部分没有被内联,我们会遇到我们在上一张幻灯片上看到的问题。希望这有足够的证据说服你,至少对于内置类型,它们应该按值传递。但现在让我们看看还有哪些 C++ 抽象也应该按值传递。没有关于开销的讨论能避开提到 std::unique_ptr
。在这里,她已经走过这个例子,我们按值传递 unique_ptr
。所以我们不会重复这个。但我们只是从 ABI 规范中澄清:如果参数类型是非平凡的类类型,调用者必须为临时对象分配空间并通过引用传递该临时对象。所以我们仍然得到了这种按引用传递,但它不同于我们直接按引用传递类型。因为在这种情况下,正如你所看到的,在栈顶产生了一个拷贝。所以,作为对非平凡类型的结论,最好通过引用传递它们。嗯,除非你无论如何都需要函数内部的副本。好的,现在让我们看一些有趣的平凡类型(trivial types)。核心指南极力建议我们使用 span
。这句话出现了大约 16 次。它说了关于错误的东西,但我们不关心错误,我们关心性能。所以我们就这么做,尝试使用 span
。好的,让我们实现一些简单的操作,比如 span_back
。用旧的 C 方式传递指针和大小来做,然后用 std::span
来做。好的,谁认为这里使用 span
没有开销?
很少人举手。谁认为有开销?
嗯,更多人举手。看起来我让你们太悲观了。实际发生的是,在大多数平台上,实际上没有开销。在 x86 上,我不确定发生了什么。看起来操作是一样的。它们只是被拆分成单独的指令了。所以我算它通过了。但只在 x64 MSVC 上,有一些开销。但在解释发生了什么之前,让我们看看。让我们看看我们能否在其他编译器或平台上也找出问题。让我们拿一些更复杂的东西,比如 C++23 的 mdspan
。不是直接使用它,我用一个简单的结构体模拟它,这样我们可以清楚地看到数据和我们传递给原始函数的数据是一样的。好的,我们编译这段代码,然后因为多了几条指令,又打破了另外两个平台。这里的问题是什么?嗯,再次,我们需要阅读 ABI 规范,这里有一些相关的部分。但我把重要的部分收集到了表格里。如我们所见,对于复合类型,再次有大小限制,通常是 16。但在 MSVC 上,由于某些原因它是 8,这对于大小为 16 的 std::span
来说是个问题。另外,我们需要在表格中再加一行,因为在 x86 上,微软提供了两种不同的调用约定。当使用 __fastcall
时,它允许在寄存器中传递基本类型。否则,它只使用栈。并且在 x86 上只允许 SIMD 类型。
所以再次,相当多样化。
那么回到核心指南,嗯,我们应该使用 span
吗?嗯,你需要知道它并不是免费的,取决于平台。但既然我不太用 Windows,我想我可以接受。现在,既然我们在讨论类型萃取,也让我们讨论一个有趣的角落案例,比如空参数。令人惊讶的是,它有很多用例。所有你传递给标准算法的谓词(predicates)和转换函数(transform functions),它们通常是空的。还有标签分发(tag dispatch)的用例,这在 C++20 概念(concepts)之后变得有些过时。但思想是你有不同种类的迭代器。如果你有一个随机访问迭代器,那么你可以更有效地递增它,只需加上偏移量,而不是递增到结束次数。还有一个有趣的用例,像访问令牌之类的东西,使某些 API 仅对有限的用户可用,这有点像 Java 中默认的包私有访问修饰符。好的,让我们模拟一些标签分发,假设我们试图通过以标签形式提供某种定制来改进 std::copy
。这就是为什么我们的算法要精确使用。所以相同的函数,只是第二个接受一个空参数。当我们编译这个时,嗯,在某些架构上没有开销,但在某些架构上有。我试图深入探究这个,告诉你,也许 ABI 规定对于空类,它们的大小和对齐是 1,但 ABI 可以覆盖这个规则。看起来大小 1 正是 MSVC 所做的。这就是它产生开销的原因。但我试图查阅 ARM 规范,没有找到覆盖这一点的部分。所以也许我只是没搜到,或者也许规范中有些灰色地带。我不确定。那么回到我们开始的指南:我们应该通过传值传递廉价拷贝的类型吗?我想我们可以赞同这一点。唯一让我困惑的是他们从哪里得到的“两到三个字(words)”这个说法?因为在我们看到的所有常见平台上,能通过寄存器传递的要么是一个字,要么是两个字(words)。但总的来说,这个想法是完全正确的。哦,关于参数,还有另一个特殊情况很重要,那就是类成员函数中的 this
参数。我想 ABI 说非静态成员函数接收一个指针类型的隐式 this
参数,并且它作为第一个参数传递。你马上就看到,这对于小类来说是个问题,因为按值传递会更高效。但幸运的是,它不在幻灯片上,但幸运的是昨天我发现 C++23 实际上为函数调用运算符引入了 static
关键字,这将有助于缓解这个问题。哦,它在另一面,抱歉。为了理解我在说什么,主要影响在函数对象(function objects)上,比如这里展示的 std::plus
。当然,std::plus
在大多数情况下很可能被内联。但如果你定义一些更复杂的操作作为函数对象而不是函数,你就会得到这种开销。C++23 为此提供了一个解决方案。好的,关于单一参数的章节到此结束。现在我们将讨论具有多个参数的函数。
好的,让我们从这个例子开始。假设我们有一个带两个参数的 sum
函数,我们希望它对这些值进行加法。现在我们想用它来加三个值。有不同的组合值的方式。在数学上,结果当然总是相同的。但出于实际目的,你认为哪种实现方式性能最高?当我们先取参数 1 和 2,还是 1 和 3,还是 2 和 3?投票给 1-2。好的,相当多的人举手。1-3。没有人举手。2-3。是的,有一些人举手。很好。很好。出于兴趣,我再加一个函数进行比较,它也使用参数 1 和 2,但在传递给 sum
函数时交换了它们。我不会展示汇编,因为选项太多了。但我们看到在所有平台上一致地,1-2 比其他选项指令更少。然后除了 MSVC,指令计数基本上都增加了一条。好的,让我们解释一下。哦,如果我们深入研究其中一个情况的汇编,比如 GCC 的,我们看到 1-3 选项在调用第一个 sum
函数之前有一个额外的移动(mov
)指令。
好的,为什么会发生这种情况?你需要知道的重要事情是参数的顺序在每个 ABI 中是固定的。或者更准确地说,为这些参数分配寄存器的操作是按特定顺序完成的。所以如果你有一个带参数 1 和 2 的函数,它们会进入为此槽位(slot)指定的寄存器。第三个参数将进入第三个寄存器。所以当我们调用第一个函数时,在第一种情况下,x1
和 x2
都已经在正确的位置、正确的寄存器中了。所以我们可以以最小的准备直接调用这个函数。在第二种情况下,x3
不在正确的位置。所以我们需要移动它,这增加了一条移动指令,正是我们看到的。在第三种情况下,两个都不在正确的位置。在第四种情况,我们有 1 和 2 在正确的位置,但交换了。我想这里每个人都知道交换需要三条移动指令来解决。这与我们看到的指令计数非常一致,除了 MSVC。不知道那里发生了什么。在这里,我建议你看看 Eduardo Madrid 的这个精彩演讲,他研究了 std::function
的性能。在右边,你可以看到他的例子生成的汇编。这个汇编唯一有用的工作是突出显示的对某个其他地址的调用,即被包装的函数。在他演讲的后面,他展示了如何移除所有那些多余的汇编。其中一个技巧甚至在这张幻灯片上可见,就是这个额外的 void*
参数。实际上,为了应用这个演讲中的优化,我们已经具备了所有必要的知识。我们需要了解这个参数传递,因为 std::function
或 function_ref
是一个函数对象。我们需要确保参数顺序一致,那个额外的指针就保证了这一点。而且我们还需要使用增强的完美转发(enhanced perfect forwarding),它保留了在寄存器中传递,而不是用引用替换它们。好的。
现在让我们看一个不同的例子,也来自核心指南,它说不要将数组作为单个指针传递。他们有像 ranges::copy
这样的例子,并建议在其中使用 span
来避免访问不相关的内存。好的。那么让我们试试这样做。但我们已经了解了参数的顺序。所以如果你想调用 memcpy
,我们的函数最好有相同的参数顺序。我们在原始版本中这样做。在第二个版本中我们也这样做,但我们附加了,嗯,我们不使用 std::span
,因为我们已经看到它在某些平台上有开销。所以为了分离那部分,我们通过传递指针和大小来模拟 span
,但我们传递了一个额外的大小。我们同时传递源大小和目标大小。为什么我们需要它们?嗯,可能我们想断言它们相同,但我们确保这个断言在编译时被移除,因为我们不是在调试模式。好的。
对于这个例子,谁认为这个参数没有开销?没有人,所以看起来每个人都预期第二个函数会有一些开销,但你知道吗?我们编译它,函数在所有情况下都直接调用了 memcpy
。但再次,我们需要记住,我们也需要调查调用点。所以再次,我们声明我们的函数并在某个数组上调用它们。当我们编译这段代码时,我们看到到处都多了一条指令。你可以在这里看到它,并认为这完全合理,因为我们传递了两次大小。这正是发生的事情。所以对于这个指南,嗯,你需要知道它并不是免费的。如果你使用指南中提出的完全相同的 API(使用 span
并在内部调用 memcpy
),它会导致一些寄存器移动,因为顺序不同。理想情况下,我们希望那个断言从函数体中被提取到调用点,这样我们就不需要在内部传递额外的信息。也许合约(contracts)会帮助我们。我不确定。我们要看的最后一个指南说我们需要保持函数参数数量少。它说了一些关于缺失抽象、单一职责的东西。我们对这部分不感兴趣,主要是关于性能。所以我试图为此找到一个合理的例子。我找到的一个是三重积(triple product),它取三个三维向量并计算基于它们的平行六面体(parallelepiped)的体积。所以这是一个在数学上有意义的例子。维基百科文章提供了许多选项,说明如何通过交换参数来计算它。但再次,我们记得如果我们的输入是 A、B、C,我们最好先消费 A 和 B,并且按那个顺序。而且如果它们的输出也作为第一个参数传递会更好。至少在 ARM 架构上。所以我们使用最后一个等价的公式。嗯,通常人们会直接采用第一个公式而忘记它。我们将研究两个选项。一个是在各处都使用 int
。再次说明,为了本次演讲的目的,我限制自己使用整数。但对于浮点数,原则是相似的。我们有一个 triple_product
函数,它接受九个 int
。在第二个版本中,我们使用向量类并通过引用到处传递它。所以只有三个引用参数。好的,首先让我们看看向量版本。好的,看起来合理。但在 MSVC 中,我们看到我们无法避免在这次演讲中谈论安全性。Nicolas Fracetta 有一篇很好的文章解释了为什么会发生这种情况。但我们是专业人士,所以我们知道自己在做什么。或者至少我们认为我们知道。所以我们用这个 __declspec
禁用了那些安全检查。我们得到了合理的东西。好的。
现在看第一个版本,我们在各处都使用 int
,我们看到代码量稍多。但在这种情况下,我不想声称它因此更慢。因为在第一种情况下,我们在辅助函数内部可能会有更多的内存访问。这里我只想强调一些事情。嗯,首先,我们有很多移动(mov
),对吧?这有点像是核心指南提到的问题。所以如果你有更多的参数,你会有更多的移动。如果你通过像 10 层抽象来转发相同的参数,这很可能是个问题。第二个有趣的方面是我们看到使用了 SP
或 RSP
,这是栈指针(stack pointer)的寄存器。它被大量使用,尤其是 MSVC。你可能在想,好吧,我们只用了基本类型(primitive types)。我们记得基本类型应该通过寄存器传递。那么这里发生了什么?嗯,部分答案是在调用第一个函数之前,我们需要在栈上保存一些状态。但还有另一部分,再次来自 ABI 规范的一些引用,总结在我们正在构建的表格的最后一列中。这里发生的是,寄存器的数量在所有地方都是有限的。并且这个数量在每个平台上都不同。有趣的是,对于 ARM,这些寄存器既用于输入也用于输出。在其他平台上,返回值有专门的寄存器。所以你可以使用这个表格作为参考,但请记住,它只适用于存储整数和指针的通用寄存器(GPR)。但对于浮点类型,我们可以构建一个类似的表格。
那么回到核心指南关于保持函数参数数量少的建议。我认为总的来说,这个想法是正确的,但“少”是多少取决于每个用例。所以最好进行测量。
现在我们来得出结论。你看到编译器对我们的代码做了相当出乎意料的事情。但这并不是因为编译器愚蠢。而是因为它必须遵循所有规范,而不仅仅是 C++ 标准。但 Compiler Explorer 是你的朋友,可以在汇编中清晰地看到这些意外情况。C++ 核心指南从性能角度来看实际上相当合理,尽管它们没有过多谈论性能。从这个演讲中我们可以得出的最重要的指南是,以避免完全不必要的函数调用开销:首先,通过值返回。然后,传递平凡类型使用传值,其他使用传引用。遵循零法则,或者至少让你的类型可平凡复制。同样非常重要,保持 API 一致,特别是如果你有很长的函数调用链。当使用抽象时,嗯,最好理解它们在目标平台上的代价,特别是 Windows,因为正如我们所看到的,它对类型有最奇怪的限制。所以是的,就这些了。非常感谢大家的关注。
感谢你的演讲,Alexander。在你的整个演讲中,你一直在说规范强制编译器这样做,而编译器做的比我们预期的更差。这是你演讲的一个要点。如果规范强制编译器做错误的事情,我们难道不应该得出结论说规范是错误的吗?
嗯,我完全同意我很乐意修改它们,但在我们的世界里改变 ABI 是一个困难的过程,需要很多时间,并且在这个过程中会让很多人感到不安。所以理想情况下,我们会修复这个问题,但我不确定怎么做。
关于保持参数消费顺序与传递顺序一致,这仅当你的参数是平凡类型并将进入寄存器时才相关,还是如果我传递一些常量引用也相关?
当然,它总是相关的,因为当你传递引用时,并不是你的值进入寄存器,而是引用本身,比如存储为地址的指针,再次使用与整数类型相同的寄存器,并且顺序相同,所以是的,它总是重要的。是的?它尤其更重要,因为编译器将实际对象放入栈中,然后如果你改变拥有者(owner),栈也必须在你的范围内(range)。不,更多是在那里。哦,好的。
好的,Ignace Bogdanovic。我先对 Eduardo 关于 ABI 和你在这里谈论的内容发表评论。缩写 ABI 的含义取决于上下文。所以有语言 ABI,有运行时 ABI,有操作系统 ABI,实际上还有平台 ABI。所以从这个角度来看,如果你真的关心性能,很可能你会定义你自己的。然而,你需要理解其代价,主要是兼容性的代价。如果你端到端控制一切,没有什么能阻止你拥有 16 个通过寄存器传递的参数。对吧?但那样就别指望你能使用所有可用的东西。
另一个问题是给你的。如果你能翻到第 151 张幻灯片。好的。是关于这一长串可怕的指令列表。实际上,你在代码生成器输出上看到的东西,并不一定意味着它会在平台上执行。移动大多是不昂贵的。对吧?你在这里看到的来回移动寄存器并不会被执行。那基本上会在重命名阶段(rename stage)被最终确定。另一件事是处理器不执行指令(instructions)。它执行操作(operations)。你需要看看这最终转化成的代价。解码(Decoding)并不昂贵,尤其是在 x86 上。这主要是一个线性过程。关于栈的另一个方面。首先,栈是被缓存的。其次,当代平台,即使代码显示它在处理栈,它实际上根本不处理内存。所以如果你在做性能剖析(profiling)并触及栈内存,你会得到非常不同的性能结果。而如果你不触及它,它根本不会离开处理核心之外。这些都是深层的实现细节。然而,这些实现细节对整体性能有着非常非常重要的影响。所以并不是说输出上有更多汇编指令就意味着更差。不,这不一定成立。但是的,非常感谢你的澄清。
对于这个具体的例子,我想我提到过,在比较它们时,我并不是说性能更差。但至少从指令缓存的角度来看,避免完全没有用处的、完全不必要的指令,我认为这绝对是好的。是的。所以你需要看上下文。如果你只运行一次,你可能不在乎,因为它会被淹没。如果你在一个只做移动的循环中运行它,你可能在乎,但问题是它的实用性,对吧?那是一个合成测试。如果你做一些实际的事情,你会有更昂贵的指令,这些指令实际上才是主要的成本。你来回移动一些参数的事实,那只是寄存器重命名。它们甚至不会进入执行单元,对吧?所以它们将可用于你将要做的实际工作。所以这,嗯,基本上是在你看到的输出和平台上实际发生的情况以及这如何影响性能之间的微妙平衡。是的。非常感谢。
谢谢。好的。还有一个问题。是的,请。
那真是一场非常精彩的演讲。谢谢。我只是,所以对于更大的数据结构,有两种不同情况,对吧?比如把它们拆开(taking them apart)可能会更快。我在使用 mdspan
时肯定观察到了。所以有时,尤其是如果你在一个嵌套循环中,并且你需要本质上获取多维跨度(MD span)的子视图(sub views)。会有一些权衡,有时比起调用 submdspan
,你会更想直接拆开多维跨度(MD span)。所以我认为这里有一个重要的设计点,就是那些类型的数据结构,你需要能够把它们拆开。有时,有时你需要能够获取指针、数量和整数,因为有时那样更快。就像你观察到的那样。所以感谢你指出这一点。
是的。不幸的是,这并不总是可能的。是的,我拆解了 span
。它在一些平台上有效,但在 Windows 上,这没有帮助,因为它只允许最多 8 个字节通过寄存器传递。所以甚至不确定在那里该做什么。是的。谢谢。
是的。看起来没有问题了。所以非常感谢大家的参与。谢谢。谢谢。谢谢。