编译器是否利用函数参数的不确定顺序?

61

好的,我知道标准规定C++实现可以选择函数参数的计算顺序,但是是否有任何实现在可能影响程序的情况下实际上会“利用”这一点?

经典例子:

int i = 0;
foo(i++, i++);

注意:我不是在寻求有人告诉我不能依赖于计算顺序,我很清楚这一点。我只想知道是否有任何编译器实际上按左到右的顺序进行计算,因为我猜测如果他们这样做了,很多写得不好的代码会出问题(虽然这是正确的,但它们仍然可能会抱怨)。


尝试使用不同的编译器自己动手? - strager
在为 C++ 的子集实现解释器时,我自己也问了同样的问题。哎呀。 - André Laszlo
11
请注意,foo(i++, i++)会导致未定义行为,因为在任何中间序列点之前,i被递增了超过一次。 - Nawaz
2
clang 使用从左到右的求值方式。 - Kaiserludi
在使用C++时,您应该始终使用静态代码检查器。任何值得一试的人都会指出这个错误。 - EvertW
7个回答

54
这取决于参数类型、被调用函数的调用约定、体系结构和编译器。在x86上,Pascal调用约定从左到右评估参数,而在C调用约定(__cdecl)中从右到左评估。大多数在多个平台上运行的程序都会考虑调用约定以避免出现意外情况。
如果您感兴趣,Raymond Chen的博客上有一篇不错的article。您还可以查看GCC手册的Stack and Calling部分。 编辑:只要我们把问题考虑为平台问题,我的答案就是正确的。语言标准不保证或偏好其中任何一个,并将其留作未指定。请注意措辞。它并没有说这是未定义的。在这种情况下,“未指定”意味着您无法依赖它,这是不可移植的行为。我没有C规范/草案,但它应该与我的n2798草案(C ++)类似。
在这个国际标准中,抽象机器的某些方面和操作被描述为未指定(例如,函数参数的评估顺序)。在可能的情况下,此国际标准定义了一组允许的行为。这些定义了抽象机器的非确定性方面。因此,抽象机器的实例可以针对给定的程序和输入具有多个可能的执行序列。

13
为了澄清一下,Pascal/Cdecl决定参数是从左到右还是从右到左传递,但它们仍然可以以任何顺序评估。(但由于寄存器压力,它们很可能按照它们被传递的顺序进行评估。) - j_random_hacker
9
错误:C语言中的参数是从右向左推入堆栈。但它们被求值的顺序是未定义的。 - Martin York
1
@Martin York:我什么时候说过这个顺序是明确定义的了? - dirkgently
@Martin York:我所指的是C调用约定。已编辑帖子。 - dirkgently
4
你的回答说调用规约决定了求值顺序。正如Martin York所说,这并不是正确的,它仅仅定义了它们被推到堆栈上的顺序。 - jalf
显示剩余6条评论

7
我在c++ standards中找到了答案。
第5.2.2.8段:

参数的评估顺序未指定。在进入函数之前,所有参数表达式评估的副作用都会生效。后缀表达式和参数表达式列表的评估顺序未指定。

换句话说,这取决于编译器。

5
对问题不够响应。 - ThomasMcLeod

6
所有参数都会被计算,但顺序没有定义(根据标准)。但是所有我所知道的C/C++实现都会从右到左计算函数参数。编辑:CLang是一个例外(请参见下面的评论)。
我相信从右到左的计算顺序非常早就存在了(自第一个C编译器以来)。肯定比C++发明更早,大多数C++实现将保持相同的计算顺序,因为早期的C++实现只是转换成C。
有一些技术原因要从右到左计算函数参数。在堆栈结构中,参数通常被推送到堆栈上。在C/C++中,您可以使用比实际指定的参数更多的参数调用函数--额外的参数将被忽略。如果参数从左到右计算并从左到右推送,则堆栈指针下方的堆栈插槽将保存最后一个参数,而函数无法获得任何特定参数的偏移量(因为推送的实际参数数取决于调用者)。
在从右到左的推送顺序中,堆栈指针下方的堆栈插槽将始终保存第一个参数,下一个插槽保存第二个参数等等。参数偏移量对于函数始终是确定的(可能在其他地方编写和编译为库,与调用它的地方分开)。
现在,从右到左的推送顺序并不强制要求从右到左计算顺序,但在早期编译器中,内存很少。在从右到左的计算顺序中,可以就地使用相同的堆栈(本质上,在计算参数之后--可能是表达式或函数调用!--返回值已经位于堆栈的正确位置)。在从左到右的计算中,参数值必须单独存储,然后以相反的顺序推回堆栈。

6

点击这里阅读

虽然不是你问题的完全复制,但我的回答(以及其他一些回答)也涵盖了你的问题。

编译器可能不仅选择从右到左计算参数,还会交错计算参数,这是出于非常好的优化原因。

标准甚至不能保证顺序执行。它保证在函数被调用时,所有参数都已经完全评估。

是的,我见过一些版本的GCC确实这样做。对于你的示例,将调用foo(0,0),之后i将为2。(我无法给出编译器的确切版本号。这是一段时间以前的事情,但我不会惊讶再次看到这种行为。这是一种有效的调度指令的方式)


公共子表达式消除是优化重排序的主要原因。请记住,参数传递和参数评估可以不同,尽管许多编译器将它们视为相同。 - plinth

2

我上次看到这种差异是在2007年的x86硬件上,当时是在VS2005和GCC 3.x之间。所以这种情况(现在)很可能存在。所以我再也不依赖于评估顺序了。也许现在情况会更好些。


哦?知道这个消息真是太好了。谢谢。目前我使用的编译器只有VC 08和ICC 11。我应该开始在GCC中测试我的代码。 - RaptorFactor
2
ICC尽可能与VS兼容,英特尔的人真是好人 :) - Robert Gould
1
VS也不能保证评估顺序。他们以前改变过评估函数参数的方式,而且没有理由不再这样做。这不是你应该依赖的东西。 - jalf

2

我希望大多数现代编译器会尝试交错计算参数的指令,因为它们必须独立并且缺乏任何相互依赖,这是C++标准的要求。这样做应该有助于保持深度流水线CPU的执行单元充分利用,从而增加吞吐量。(至少我期望一个声称是优化编译器的编译器在给定优化标志时会这样做。)


2
简而言之,实际上,GCC和clang在处理未排序或不确定排序的表达式时,并不会根据产生更高效的汇编代码来选择评估顺序。即使对于一些没有副作用的表达式也是如此。
首先,在C++14及之前的版本中,foo(i++, i++)的行为是未定义的,因为对i进行了两次未排序的修改,与ISO标准中的i++ + i++没有区别;实际的实现可能在函数参数的情况下偶然执行一些一致的操作。
C++17将函数参数从未排序改为不确定排序,所以它要么是arg1 = i++; arg2 = i++; foo(arg1,arg2),要么是相反的顺序,由编译器选择。C++17引入的评估顺序保证有哪些?
GCC和clang在未排序或不确定排序的情况下,根据评估顺序选择进行优化时会出现严重问题,甚至会错过一些非常基本的东西,比如在调用之间保存3个单独的局部变量与仅保存a+b+c结果以供return baz(a+b+c, foo(x));使用;请参见Godbolt链接中的quux,其中包含此问题的其他部分的代码。(而且这甚至不是一个正确性问题,只是在抽象机器中以任何方式都会得到相同的结果的实现细节。)

GCC bug#70408 在某些情况下,重复使用相同的保留调用寄存器可以生成更小的代码展示了一种没有未定义行为的无序评估情况,如果选择不同的评估顺序,编译器可以更智能地保存/恢复较少的寄存器。根据Andrew Pinski的回复,教导GCC考虑不同的评估顺序可能非常困难,至少对于函数调用而言。

// __attribute__((const)) //  optional: promises no side-effects and not even reading anything except args
int foo(int);  // not inlineable

int bar(int a) {
  return foo(a+2) + 5 * foo (a);
}

# first function args goes in RDI
foo_gcc:  # current GCC13.2 is the same as GCC6 when I reported the missed-optimization
    pushq   %rbp
    pushq   %rbx               # save two call-preserved regs
    movl    %edi, %ebx
    leal    2(%rdi), %edi        # lea is worse than  add $2, %edi; separate missed opt
    subq    $8, %rsp           # and adjust the stack again for alignment
    call    foo                # foo(a+2)
    movl    %ebx, %edi
    movl    %eax, %ebp
    call    foo                # foo(a)
    addq    $8, %rsp
    leal    (%rax,%rax,4), %eax   # eax = 4*eax + eax = 5*eax
    popq    %rbx
    addl    %ebp, %eax
    popq    %rbp
    ret

vs. 手动操作,按照不同的顺序进行调用,并在复制过程中使用LEA进行加法操作。(在某些CPU上,例如Intel在Ice Lake之前,将LEA指令加载到相同的寄存器中比使用add指令的立即数略差,但是当它能够节省一条或多条指令时,即使只是一条mov指令,使用LEA指令仍然是值得的。)
foo_handwritten:
    push    %rbx
    lea     2(%rdi), %ebx          # stash ebx=a+2
    call    foo                    # foo(a)
    mov     %ebx, %edi
    lea     (%rax,%rax,4), %ebx    # reuse ebx to stash 5*foo(a)
    call    foo                    # foo(a+2)
    add     %ebx, %eax
    pop     %rbx
    ret

如果我们只看函数参数的顺序,包括可能的副作用,要么在不同的变量上避免未定义行为,要么在C++17中使它们具有不确定的顺序,我们可以得到没有未定义行为的代码,这样我们就可以进行有意义的讨论。
foo(++*p, ++*q)这样的东西很有意思:在C++17之前,编译器可以假设int *p,*q不会别名,因为这会导致未定义行为,来自未排序的副作用。因此,例如,它可以在任何增量和存储之前都执行两次加载1。(在为顺序机器调度指令以更好地隐藏加载延迟时尤其有用。)但看起来编译器实际上并没有这样做。
在具有寄存器参数的调用约定中,出站参数需要以与接收入站参数时相同的寄存器结束。(对于堆栈参数也是如此,但在这种情况下,您必须在使用之前加载到寄存器中,然后在尾调用跳转之前覆盖传入的参数,以及存储到指向的对象。)
这样的函数需要一些寄存器复制指令,因为它不能只是将*p加载到RDI寄存器(例如在x86-64上),之后还需要保留指针以存储递增后的值。但它也需要将递增后的值放回最初保存指针的寄存器中。
int baz(int,int);      // not visible to the compiler for inlining

int pointers(int *p, int *q){   // in RDI, RSI in the x86-64 System V calling convention
    return baz(++*p, ++*q);     // int args in EDI, ESI, the low 32 bits of RDI,RSI
}

这个问题是对称的;没有任何一种评估顺序有优势。每个解引用的值都必须落在保存它的指针的寄存器中,而不是不同的参数。通过baz(++*q, ++*p)使它们相反可以节省一条指令,但它仍然是对称的,所以无论我们首先评估哪个,我们想要评估的寄存器都被占用。
我们可以添加一个虚拟参数,这样传入和传出的参数只在一个寄存器中重叠,并且按顺序排列,这样两种情况都不需要将整数放在保存指向它的指针的相同寄存器中。(因为这将需要复制指针或者在存储之后从其他地方加载并复制整数。)
// test cases that allow more efficient asm with one eval order than the other
int pointers2(int dummy1, int *q, int *p){  // q then p
    return baz(++*q, ++*p);                 // q then p - 2nd arg clashes with incoming
}

int pointers3(int *p, int dummy1, int *q){  // p then q
    return baz(++*q, ++*p);                 // q then p - 1st arg clashes with incoming
}

使用这种设置,一个评估顺序可以避免需要任何额外的寄存器复制指令。另一个评估顺序不能,因为它想要将++*q++*p计算到仍然持有另一个指针的寄存器中。编译器似乎使用固定的评估顺序,或者至少没有充分利用他们选择的自由。
在x86-64上的GCC从右到左进行,使得pointers2更糟糕,因为它想要在传出的++*p参数计算时,传入的q参数仍然在RSI中活动。GCC针对ARM的目标是对这两个函数都从左到右进行处理,更好地处理pointers2。因此,即使在同一个编译器中,它并不总是一致的,可能是由于一些任意的内部原因。这些都不涉及任何堆栈参数,尽管历史上,GCC支持的唯一x86调用约定是纯堆栈参数的32位x86,这可能解释了为什么从右到左被固定在GCC的x86内部。(我还没有测试其他情况是否总是一致,但一些早期的答案报告说是这样。)
Clang for x86-64在处理过程中是从左到右的(就像ARM GCC一样),所以pointers2避免了任何多余的指令;当它准备计算++*p时,RSI不再需要。
# Actual GCC13 code-gen for the good version
# p in RDI, q in RDX
pointers3(int*, int, int*):
        mov     eax, DWORD PTR [rdi]          # *p
        lea     esi, [rax+1]                  # 1 + *p   But LEA saves the day
        mov     DWORD PTR [rdi], esi          # ++*p

        mov     eax, DWORD PTR [rdx]          # *q 
        lea     edi, [rax+1]                  # eval into EDI, the first arg
        mov     DWORD PTR [rdx], edi

        jmp     baz(int, int)                 # tailcall

它本来可以在一开始就加载到ESI中,并使用add而不是lea来进行复制+加法2。但没有浪费mov指令;GCC的从右到左的评估顺序对这个情况恰好很好。不像其他函数:
# Actual GCC13 code-gen for the sub-optimal version
# q in RSI, p in RDX
pointers2(int, int*, int*):
        mov     ecx, DWORD PTR [rdx]     # load *p
        mov     rax, rsi               ##### copy q to RAX, this is the insn we could avoid
        lea     esi, [rcx+1]             # eval ++*p into outgoing 2nd arg reg
        mov     DWORD PTR [rdx], esi     # and store it back to *p

        mov     ecx, DWORD PTR [rax]     # using the copy of q
        lea     edi, [rcx+1]             # eval ++*q into 1st arg
        mov     DWORD PTR [rax], edi

        jmp     baz(int, int)            # tailcall

选择对参数进行评估的另一种顺序,可以避免像pointers3那样使用mov rax, rsi指令,从而节省代码大小,并让乱序执行看得更远一点(因为它不会占用ROB = 重排序缓冲区中的一个条目)。
# Hand-written version that GCC *could* have made
#  with different semantics if p==q.  Or equivalent with __restrict
# q in RSI, p in RDX
pointers2(int, int*, int*):
        mov     edi, [rsi]
        add     edi, 1                # eval ++*q into 1st arg
        mov     [rsi], edi

        mov     esi, [rdx]
        add     esi, 1                # eval ++*p into outgoing 2nd arg
        mov     [rdx], esi

        jmp     baz(int, int)         # tailcall

DWORD PTR被其他操作数被32位寄存器隐含,所以我更倾向于省略它。这只是示例之间的格式差异,关键的区别是这个总共有7条指令,而不是8条,代码大小也减小了3个字节。我使用的指令在所有CPU上都至少同样廉价。)
一般来说,一个mov在整个程序中并不重要,但编译器仍然不应该浪费指令。这个例子足以得出结论,GCC和clang在一般情况下并不寻找这样的优化,即使在可能有更多节省的情况下也是如此(比如函数调用作为参数的情况)。

脚注1:

在C++17中,p==q的情况将避免未定义行为,但编译器对先执行哪个增量的选择将影响函数参数。因此,除非你知道它们不相等,否则不应该编写像baz(++*p, ++*q)这样的代码。

你甚至可以通过int *__restrict p, int *__restrict q向编译器做出这个承诺,或者如果它们是不同的类型,则可以使用默认的-fstrict-aliasing,GCC和clang将推断它们不会别名。例如:int *p, long long *q

__restrict对于错过优化的测试用例没有帮助,尽管GCC会在ARM上始终在存储之前执行两次加载,或者只在x86-64上针对pointer2执行。ARM核心上的有序执行很常见,因此隐藏加载延迟更有用。即使在x86-64上,如果可以等到加载之后再进行存储,对于具有未知地址的存储(因为乱序执行还没有准备好地址)来说也更好,这样CPU就不必预测是否需要进行存储转发。

GCC的ARM代码生成可能会更好,如果第一条指令是第一次加载而不是将指针复制到另一个寄存器;使用`-mcpu=cortex-a53`和`-mcpu=cortex-a76`会导致不同的调优和指令调度选择。此外,在非别名情况下,两个版本的函数都会多出一条`mov`指令。如果无法避免,在顺序执行的核心上避免加载延迟瓶颈可能是值得的,但我不确定是否是必要的。例如:
这部分涉及到ARM指令调度的细节,有点离题
@ hand-written for the version with  __restrict  pointers
@ GCC13.2  starts with  mov r3, r0  then 2 loads / 2 adds / 2 stores
pointers3(int*, int, int*):
        ldr     r1, [r0]
        ldr     r3, [r2]       @ Eventually want it in R0, but load elsewhere
        adds    r1, r1, #1     @ first use is 2 insns after the load, same as GCC
        str     r1, [r0]       @ store right after add; GCC left a gap
        adds    r0, r3, #1     @ adds into a different low reg is still a 16-bit instruction with small-enough immediate
        str     r0, [r2]
        b       baz(int, int)

这对于顺序执行的ARM来说可能还不错。第二个LDR和第一个ADDS可以在同一个周期内通过流水线,因为它们是独立的。但是,除非内存阶段在执行阶段之后,并且超标量CPU支持转发,否则STR不能在与产生其输入的ADDS相同的周期内进行。 (据我所知,P5 Pentium不支持这一点。)
然后,下一个周期可以进行第一个STR和第二个ADDS,然后进行第二个STR和B尾调用跳转。

这似乎并不比GCC的mov/load/load差多少;然后在加载结果准备好之后,进行adds/adds;store/store(或者可能是分开的,因为大多数流水线不会有2个存储单元);分支。或许在调优为OoO执行核心(如-mcpu=cortex-a76)时,我的节省指令版本可能更值得。无论如何,如果我们需要一个mov,我认为在2个加载之后、2个存储之前进行会更好,以便在等待加载使用延迟时能够更多地完成工作。或者也可以是加载/指针复制/加载,这样我们仍然可以在只有一个加载单元的流水线上在第一个周期中进行双发射。总之,现在我们已经偏离了ARM指令调度的主题。

有趣的事实:这个调度模式与RISC-V中的clang使用的模式相同:load/load / add / store / add / store。对于其他ISA的clang仍然采用从左到右的评估顺序。(至少在寄存器中;我没有检查过有这么多参数需要几个堆栈参数。希望它首先评估堆栈参数,这样它就有多余的寄存器来评估其他寄存器参数。或者至少按照这种方式调度指令,如果它们是独立的。)
如果你在函数中加入if (p==q) return 0;,即使是对于x86-64的GCC,也会先进行两次加载pointers2。(仍然会有一个额外的mov,它本可以通过在第一次存储之后执行lea esi, [rax+1],而不是在之前执行add eax, 1和在之后执行mov esi, eax来避免。)

脚注2:GCC在可以使用add的地方使用了LEA

return baz(*q <<= 7, *p *= 4);Godbolt)中,GCC使用lea esi, [0+rax*4],这是7个字节(opcode + modrm + SIB + disp32=0),比起一开始就加载到ESI中的shl esi, 2多了4个字节。

但它确实直接加载到EDI中,所以可以使用sal edi, 7。而对于*q *= 12385, ++*p,它确实使用了内存源imul-immediate来加载和乘法运算到EDI中。

我猜它的成本模型错误地认为,如果LEA可能存在,那么它的成本就和其他任何操作一样便宜,所以寄存器分配不会费力将输入放入正确的寄存器中。然而,对该启发式方法的简单更改可能会导致其他情况下的更糟糕的代码,比如在某些情况下不使用LEA,尽管它可能会节省指令,这取决于实际的启发式方法是什么。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接