33

直到今天,我一直认为体面的编译器会自动将 struct pass-by-value 转换为 pass-by-reference,如果结构足够大,后者会更快。据我所知,这似乎是一个不费吹灰之力的优化。然而,为了满足我对这是否真的发生的好奇心,我在 C++ 和D中创建了一个简单的测试用例,并查看了 GCC 和 Digital Mars D 的输出。两者都坚持按值传递 32 字节结构,而所有的有问题的函数是将成员相加并返回值,没有修改传入的结构。C++ 版本如下。

#include "iostream.h"

struct S {
    int i, j, k, l, m, n, o, p;
};

int foo(S s) {
    return s.i + s.j + s.k + s.l + s.m + s.n + s.o + s.p;
}

int main() {
    S s;
    int bar = foo(s);
    cout << bar;
}

我的问题是,为什么编译器不会优化这样的东西以传递引用而不是实际将所有这些ints 推入堆栈?

注意:使用的编译器开关:GCC -O2(-O3 内联 foo().),DMD -O -inline -release。

编辑:显然,在一般情况下,按值传递与按引用传递的语义不会相同,例如是否涉及复制构造函数或在被调用者中修改了原始结构。然而,在许多现实世界的场景中,语义在可观察行为方面是相同的。这些是我要问的情况。

4

12 回答 12

30

不要忘记,在 C/C++ 中,编译器需要能够编译仅基于函数声明的函数调用。

鉴于调用者可能只使用该信息,编译器无法编译该函数以利用您正在谈论的优化。调用者不知道该函数不会修改任何内容,因此它不能通过 ref 传递。由于某些调用者可能由于缺乏详细信息而按值传递,因此必须假设按值传递来编译函数,并且每个人都需要按值传递。

请注意,即使您将参数标记为 ' const',编译器仍然无法执行优化,因为函数可能会撒谎并丢弃 const (只要传入的对象是实际上不是const)。

我认为对于静态函数(或匿名命名空间中的函数),编译器可能会进行您所说的优化,因为该函数没有外部链接。只要函数的地址没有传递给其他例程或存储在指针中,它就不能被其他代码调用。在这种情况下,编译器可以完全了解所有调用者,所以我想它可以进行优化。

我不确定是否有(实际上,如果有的话,我会感到惊讶,因为它可能不会经常应用)。

当然,作为程序员(使用 C++ 时),您可以强制编译器通过const&尽可能使用参数来执行此优化。我知道你在问为什么编译器不能自动完成,但我想这是下一个最好的事情。

于 2009-02-16T03:29:26.577 回答
13

问题是您要求编译器对用户代码的意图做出决定。也许我希望我的超大结构按值传递,以便我可以在复制构造函数中做一些事情。相信我,对于这种情况,有些人确实需要在复制构造函数中调用某些东西。切换到 by ref 将绕过复制构造函数。

让它成为编译器生成的决定将是一个坏主意。原因是它无法推理代码的流程。你不能看一个电话就知道它到底会做什么。您必须 a) 了解代码 b) 猜测编译器优化。

于 2009-02-16T03:21:29.370 回答
10

一个答案是编译器需要检测被调用的方法不会以任何方式修改结构的内容。如果是这样,那么按引用传递的效果将不同于按值传递的效果。

于 2009-02-16T03:19:30.267 回答
4

确实,如果某些语言的编译器可以访问被调用的函数并且可以假设被调用的函数不会更改,则它们可以这样做。这有时被称为全局优化,似乎某些 C 或 C++ 编译器实际上会优化诸如此类的情况 - 更有可能是通过内联此类微不足道的函数的代码。

于 2009-02-16T03:22:28.807 回答
4

我认为这绝对是您可以实施的优化(在某些假设下,请参见最后一段),但我不清楚它是否会盈利。与其将参数压入堆栈(或通过寄存器传递它们,取决于调用约定),不如压入一个指针,通过该指针读取值。这种额外的间接将花费周期。它还要求传递的参数在内存中(所以你可以指向它)而不是在寄存器中。只有当被传递的记录有很多字段并且接收记录的函数只读取其中的几个时,这才是有益的。间接浪费的额外周期必须弥补没有通过推送不需要的字段而浪费的周期。

您可能会惊讶于反向优化,参数提升,实际上是在 LLVM 中实现的。这会将引用参数转换为值参数(或聚合为标量),用于具有少量仅读取字段的内部函数。这对于通过引用传递几乎所有内容的语言特别有用。如果您按照此操作消除死参数,您也不必传递未触及的字段。

值得一提的是,改变函数调用方式的优化只有在被优化的函数在被编译的模块内部时才能起作用(你可以通过在 C 中声明一个函数static并在 C++ 中使用模板来实现这一点)。优化器不仅要修复函数,还要修复所有调用点。这使得此类优化的范围相当有限,除非您在链接时执行它们。此外,当涉及复制构造函数时(正如其他海报所提到的),永远不会调用优化,因为它可能会改变程序的语义,这是一个好的优化器永远不应该做的。

于 2009-02-16T04:17:58.893 回答
2

按值传递的原因有很多,让编译器优化您的意图可能会破坏您的代码。

例如,如果被调用函数以任何方式修改结构。如果您打算将结果传递回调用者,那么您可以传递一个指针/引用或自己返回它。

您要求编译器做的是更改代码的行为,这将被视为编译器错误。

如果您想进行优化并通过引用传递,那么一定要修改某人现有的函数/方法定义以接受引用;这并不难做到。您可能会对自己造成的损坏感到惊讶而没有意识到这一点。

于 2009-02-16T03:26:16.130 回答
2

从按值更改为按引用将更改函数的签名。如果函数不是静态的,这将导致其他编译单元的链接错误,这些编译单元不知道您所做的优化。
实际上,进行这种优化的唯一方法是通过某种链接后全局优化阶段。众所周知,这些很难做到,但一些编译器在某种程度上做到了。

于 2009-02-16T03:33:55.660 回答
2

引用传递只是地址传递/指针传递的语法糖。所以函数必须隐式地取消引用一个指针来读取参数的值。取消引用指针可能比按值复制的结构副本更昂贵(如果在循环中)。

更重要的是,就像其他人提到的那样,按引用传递与按值传递具有不同的语义。const引用并不意味着引用的值不会改变。其他函数调用可能会更改引用的值。

于 2009-02-16T04:41:22.377 回答
2

struct即使函数声明表明按值传递,有效地按引用传递也是一种常见的优化:只是它通常通过内联间接发生,因此从生成的代码中并不明显。

但是,要做到这一点,编译器需要知道被调用者在编译调用者时不会修改传递的对象。否则,它将受到平台/语言 ABI 的限制,该 ABI 准确地规定了值如何传递给函数。

即使没有内联也可能发生!

尽管如此,即使在没有内联的情况下,一些编译器也确实实现了这种优化,尽管情况相对有限,至少在使用 SysV ABI(Linux、OSX 等)的平台上,由于堆栈布局的限制。考虑以下简单示例,直接基于您的代码:

__attribute__((noinline))
int foo(S s) {
    return s.i + s.j + s.k + s.l + s.m + s.n + s.o + s.p;
}

int bar(S s) {
    return foo(s);
}

在这里,在语言级别bar调用foo具有 C++ 所需的按值传递语义。但是,如果我们检查gcc 生成的程序集,它看起来像这样:

foo(S):
        mov     eax, DWORD PTR [rsp+12]
        add     eax, DWORD PTR [rsp+8]
        add     eax, DWORD PTR [rsp+16]
        add     eax, DWORD PTR [rsp+20]
        add     eax, DWORD PTR [rsp+24]
        add     eax, DWORD PTR [rsp+28]
        add     eax, DWORD PTR [rsp+32]
        add     eax, DWORD PTR [rsp+36]
        ret
bar(S):
        jmp     foo(S)

请注意,bar只是直接调用foo,而不制作副本:bar将使用s传递给bar(在堆栈上)的相同副本。特别是它不会像语言语义所暗示的那样进行任何复制(忽略as if)。所以 gcc 已经完全按照您的要求进行了优化。但是 Clang 并没有这样做:它在它传递给foo().

不幸的是,这可以工作的情况相当有限:SysV 要求这些大型结构在堆栈中的特定位置传递,因此只有当被调用者期望对象位于完全相同的位置时,才能进行这种重用。

这在foo/bar示例中是可能的,因为 bar 以S与 相同的方式将其作为第一个参数foobar并进行尾调用foo避免了隐式返回地址推送的需要,否则会破坏重用堆栈参数的能力。

例如,如果我们简单地将 a 添加+ 1到对 的调用中foo

int bar(S s) {
    return foo(s) + 1;
}

这个技巧被破坏了,因为现在的位置与它所期望bar::s的位置不同,我们需要一个副本:foos

bar(S):
        push    QWORD PTR [rsp+32]
        push    QWORD PTR [rsp+32]
        push    QWORD PTR [rsp+32]
        push    QWORD PTR [rsp+32]
        call    foo(S)
        add     rsp, 32
        add     eax, 1
        ret

这并不意味着调用者bar()必须完全微不足道。例如,它可以在传递它之前修改它的 s 副本:

int bar(S s) {
    s.i += 1;
    return foo(s);
}

...并且优化将被保留:

bar(S):
        add     DWORD PTR [rsp+8], 1
        jmp     foo(S)

原则上,这种优化的可能性在使用隐藏指针传递大型结构的 Win64 调用约定中要大得多。这为重用堆栈或其他地方的现有结构提供了更大的灵活性,以便在幕后实现传递引用。

内联

然而,除此之外,这种优化发生的主要方式是通过内联。

例如,在-O2编译时,所有 clang、gcc 和 MSVC都不会复制 S 对象1。clang 和 gcc 都没有真正创建对象,只是或多或少地直接计算结果,甚至没有引用未使用的字段。MSVC 确实为副本分配了堆栈空间,但从不使用它:它只填写 only 的一个副本S并从中读取,就像传递引用一样(在这种情况下,MSVC 生成的代码比其他两个编译器差得多)。

请注意,即使foo被内联到main编译器中,也会生成该foo()函数的单独独立副本,因为它具有外部链接,因此可以被此目标文件使用。在此,编译器受应用程序二进制接口的限制:SysV ABI(用于 Linux)或 Win64 ABI(用于 Windows)根据值的类型和大小准确定义了必须如何传递值。大型结构由隐藏指针传递,编译器在编译时必须尊重这一点foo。它还必须尊重编译某些调用者foo何时 foo 无法看到:因为它不知道foo会做什么。

因此,编译器几乎没有什么窗口可以进行有效的优化,将按值传递转换为按引用传递,因为:

1)如果它可以同时看到调用者和被调用者(mainfoo你的例子中),如果被调用者足够小,很可能会被内联到调用者中,并且随着函数变大并且不可内联,效果诸如调用约定开销之类的固定成本变得相对较小。

2) 如果编译器不能同时看到调用者和被调用者2,它一般要根据平台ABI 分别编译。由于编译器不知道被调用者将做什么,因此在调用站点没有优化调用的范围,并且在被调用者内部没有优化的范围,因为编译器必须对调用者做了什么做出保守的假设。


1我的示例比您的原始示例稍微复杂一些,以避免编译器完全优化所有内容(特别是,您访问未初始化的内存,因此您的程序甚至没有定义的行为):我填充了一些s字段argc这是编译器无法预测的值。

2编译器可以“同时”看到两者通常意味着它们要么在同一个翻译单元中,要么正在使用链接时间优化。

于 2018-07-20T00:20:12.433 回答
1

好吧,简单的答案是结构在内存中的位置不同,因此您传递的数据也不同。我认为更复杂的答案是线程。

您的编译器需要检测 a) foo 没有修改结构;b) foo 不对结构元素的物理位置进行任何计算;并且 c) 调用者或调用者生成的另一个线程在 foo 完成运行之前不会修改结构。

在您的示例中,可以想象编译器可以做这些事情 - 但节省的内存无关紧要,可能不值得猜测。如果您使用具有 200 万个元素的结构运行相同的程序会发生什么?

于 2009-02-16T04:06:21.323 回答
1

编译器需要确保传入的结构(在调用代码中命名)没有被修改

double x; // using non structs, oh-well

void Foo(double d)
{
      x += d; // ok
      x += d; // Oops
}

void main()
{
     x = 1;
     Foo(x);
}
于 2009-02-16T05:31:49.090 回答
1

在许多平台上,大型结构实际上是通过引用传递的,但是调用者将被期望传递对副本的引用,该函数可以随意操作1,或者被调用的函数将被期望复制它接收到一个引用然后对副本执行任何操作的结构。

虽然在许多情况下实际上可以省略复制操作,但编译器通常很难证明可以消除这些操作。例如,给定:

struct FOO { ... };

void func1(struct FOO *foo1);
void func2(struct FOO foo2);

void test(void)
{
  struct FOO foo;
  func1(&foo);
  func2(foo);
}

编译器无法知道foo在执行期间是否会被修改func2(func1可能已经将其副本foo1或从它派生的指针存储在文件范围对象中,然后由func2) 使用。但是,此类修改不应影响收到的foo(即)副本。如果通过引用传递并且没有复制,则影响的操作将不正确地影响.foo2func2foofunc2foofoo2

请注意, evenvoid func3(const struct FOO);没有意义:被调用者被允许丢弃const,并且正常的 asm 调用约定仍然允许被调用者修改保存按值副本的内存。

不幸的是,单独检查调用者或被调用函数足以证明可以安全地省略复制操作的情况相对较少,而且在许多情况下,即使检查两者都不够。因此,用 pass-by-reference 代替 pass-by-value 是一个困难的优化,其回报通常不足以证明困难是合理的。


脚注 1:例如,Windows x64通过非常量引用传递大于 8 字节的对象(被调用者“拥有”指向的内存)。这根本无助于避免复制。动机是使所有函数 args 每个都适合 8 个字节,以便它们在堆栈上形成一个数组(在将寄存器 args 溢出到影子空间之后),使可变参数函数易于实现。

相比之下,x86-64 System V 对大于 16 字节的对象执行问题描述的操作:将它们复制到堆栈。(较小的对象被打包到最多两个寄存器中。)

于 2018-07-19T17:58:27.827 回答