为什么不通过引用传递struct一个常见的优化?

时间:2009-02-16 03:12:30

标签: performance optimization compiler-construction assembly struct

直到今天,我一直认为正确的编译器会自动将struct pass-by-value转换为pass-by-reference,如果struct足够大,后者会更快。据我所知,这似乎是一个简单的优化。但是,为了满足我对这是否真的发生的好奇心,我在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;
}

我的问题是,为什么这样的东西不会被编译器优化为传递引用而不是实际将所有int推送到堆栈上?

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

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

12 个答案:

答案 0 :(得分:23)

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

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

请注意,即使您将参数标记为“const”,编译器仍然无法执行优化,因为该函数可能是撒谎并抛弃了常量(这是允许的并且定义为传入的对象实际上不是const)。

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

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

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

答案 1 :(得分:10)

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

答案 2 :(得分:10)

问题是你要求编译器做出关于用户代码意图的决定。也许我希望我的超大型结构可以通过值传递,这样我就可以在复制构造函数中执行某些操作。相信我,那里有人有一些他们有效地需要在复制构造函数中调用这样的场景。切换到ref将绕过复制构造函数。

将此作为编译器生成的决策将是一个坏主意。原因是它无法推断代码的流动。你不能看电话,知道它究竟会做什么。你必须a)知道代码和b)猜测编译器优化。

答案 3 :(得分:4)

确实,如果某些语言的编译器可以访问被调用的函数,并且可以假设被调用的函数不会改变,那么它们就可以执行此操作。这有时被称为全局优化,并且似乎某些C或C ++编译器实际上可能会优化这种情况 - 更可能是通过内联这些简单函数的代码来实现。

答案 4 :(得分:3)

我认为这绝对是您可以实施的优化(在某些假设下,见最后一段),但我不清楚它是否有利可图。您可以按下指针来读取值,而不是将参数推送到堆栈(或通过寄存器传递它们,具体取决于调用约定)。这种额外的间接是成本周期。它还需要传递的参数在内存中(所以你可以指向它)而不是在寄存器中。如果传递的记录有许多字段并且接收记录的函数只读取其中的一些字段,那将是有益的。通过间接方式浪费的额外周期将不得不通过推送不需要的字段来弥补不浪费的周期。

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

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

答案 5 :(得分:2)

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

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

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

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

答案 6 :(得分:2)

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

答案 7 :(得分:2)

传递引用只是传递地址/指针的语法糖。因此该函数必须隐式取消引用指针以读取参数的值。取消引用指针可能更昂​​贵(如果在循环中)然后是结构副本,用于按值复制。

更重要的是,正如其他人所提到的,pass-by-reference具有与传值不同的语义。 const引用 not 表示引用的值不会更改。其他函数调用可能会更改引用的值。

答案 8 :(得分:1)

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

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

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

答案 9 :(得分:1)

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

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

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

void main()
{
     x = 1;
     Foo(x);
}

答案 10 :(得分: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收到的foo2(即func2)的副本。如果foo是通过引用传递的,而func2没有进行复制,则影响foo的操作将不适当地影响foo2

请注意,即使void func3(const struct FOO);也没有意义:被调用者可以抛弃const,并且正常的asm调用约定仍然允许被调用者修改保存按值副本的内存。 / p>

不幸的是,在相对较少的情况下,单独检查调用方或被调用函数足以证明可以安全地省略复制操作,并且在许多情况下,即使同时检查这两者也是不够的。因此,用传递引用代替传递值是一个困难的优化,其收益通常不足以证明这一困难。


脚注1: 例如,Windows x64 passes objects larger than 8 bytes by non-const reference(被调用者“拥有”指向的内存)。这完全无助于避免复制。这样做的动机是使所有函数args各自适合8个字节,以便它们在堆栈上形成一个数组(在将寄存器args溢出到影子空间之后),从而使可变参数函数易于实现。

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

答案 11 :(得分:1)

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

但是,为此,编译器需要知道 callee 在编译调用方时不会修改传递的对象。否则,它将受到平台/语言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使用C ++要求的具有按值传递语义的foo进行调用。但是,如果我们检查assembly generated by 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副本(在堆栈上)。特别是它不会像语言语义所暗示的那样进行任何复制(忽略就像)。因此,gcc完全执行了您要求的优化。但是Clang并没有这样做:它在堆栈上复制了一个副本,并将其传递给foo()

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

foo/bar示例中这是可能的,因为bar以与S相同的方式将foo作为第一个参数,而bartail callfoo,这样就避免了隐式的return-address推送,否则将破坏重用stack参数的能力。

例如,如果我们仅将+ 1添加到对foo的呼叫中:

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

把戏弄糟了,因为现在bar::s的位置与位置foo的位置不同,s会期望它的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()

这并不意味着调用者int bar(S s) { s.i += 1; return foo(s); } 一定很琐碎。例如,它可以在传递之前修改其的副本:

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

...,优化将被保留:

-O2

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

内联

除此之外,这种优化发生的 main 方法是通过内联。

例如,在S处编译所有clang,gcc和MSVC don't make any copy of the S object 1 。 clang和gcc都没有真正创建对象,而是只是或多或少地直接计算了结果,甚至没有引用未使用的字段。 MSVC确实为一个副本分配了堆栈空间,但从未使用过:它仅填充foo的一个副本并从中读取,就像通过引用一样(MSVC生成的代码比其他两个编译器差很多)在这种情况下)。

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

因此,编译器进行有效的优化的窗口很小,该优化将按值传递转换为按引用传递,这是因为:

1)如果可以同时看到呼叫者和被呼叫者(在您的示例中分别为foos),则被呼叫者很可能会内联到呼叫者中如果它足够小,并且随着函数的增大和不可移植性的增加,诸如调用约定开销之类的固定成本的影响就会变得相对较小。

2)如果编译器不能同时看到调用方和被调用方 2 ,则通常必须根据平台ABI进行编译。由于编译器不知道被调用者将要做什么,因此在调用站点上没有优化调用的范围,并且在被调用者内部没有优化的范围,因为编译器必须对调用者的操作进行保守的假设。


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

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