好的,我知道标准规定C++实现可以选择函数参数的计算顺序,但是是否有任何实现在可能影响程序的情况下实际上会“利用”这一点?
经典例子:
int i = 0;
foo(i++, i++);
注意:我不是在寻求有人告诉我不能依赖于计算顺序,我很清楚这一点。我只想知道是否有任何编译器实际上按左到右的顺序进行计算,因为我猜测如果他们这样做了,很多写得不好的代码会出问题(虽然这是正确的,但它们仍然可能会抱怨)。
好的,我知道标准规定C++实现可以选择函数参数的计算顺序,但是是否有任何实现在可能影响程序的情况下实际上会“利用”这一点?
经典例子:
int i = 0;
foo(i++, i++);
注意:我不是在寻求有人告诉我不能依赖于计算顺序,我很清楚这一点。我只想知道是否有任何编译器实际上按左到右的顺序进行计算,因为我猜测如果他们这样做了,很多写得不好的代码会出问题(虽然这是正确的,但它们仍然可能会抱怨)。
换句话说,这取决于编译器。参数的评估顺序未指定。在进入函数之前,所有参数表达式评估的副作用都会生效。后缀表达式和参数表达式列表的评估顺序未指定。
虽然不是你问题的完全复制,但我的回答(以及其他一些回答)也涵盖了你的问题。
编译器可能不仅选择从右到左计算参数,还会交错计算参数,这是出于非常好的优化原因。
标准甚至不能保证顺序执行。它只保证在函数被调用时,所有参数都已经完全评估。
是的,我见过一些版本的GCC确实这样做。对于你的示例,将调用foo(0,0),之后i将为2。(我无法给出编译器的确切版本号。这是一段时间以前的事情,但我不会惊讶再次看到这种行为。这是一种有效的调度指令的方式)
我上次看到这种差异是在2007年的x86硬件上,当时是在VS2005和GCC 3.x之间。所以这种情况(现在)很可能存在。所以我再也不依赖于评估顺序了。也许现在情况会更好些。
我希望大多数现代编译器会尝试交错计算参数的指令,因为它们必须独立并且缺乏任何相互依赖,这是C++标准的要求。这样做应该有助于保持深度流水线CPU的执行单元充分利用,从而增加吞吐量。(至少我期望一个声称是优化编译器的编译器在给定优化标志时会这样做。)
foo(i++, i++)
的行为是未定义的,因为对i
进行了两次未排序的修改,与ISO标准中的i++ + i++
没有区别;实际的实现可能在函数参数的情况下偶然执行一些一致的操作。arg1 = i++; arg2 = i++; foo(arg1,arg2)
,要么是相反的顺序,由编译器选择。C++17引入的评估顺序保证有哪些?
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
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
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
计算到仍然持有另一个指针的寄存器中。编译器似乎使用固定的评估顺序,或者至少没有充分利用他们选择的自由。pointers2
更糟糕,因为它想要在传出的++*p
参数计算时,传入的q
参数仍然在RSI中活动。GCC针对ARM的目标是对这两个函数都从左到右进行处理,更好地处理pointers2
。因此,即使在同一个编译器中,它并不总是一致的,可能是由于一些任意的内部原因。这些都不涉及任何堆栈参数,尽管历史上,GCC支持的唯一x86调用约定是纯堆栈参数的32位x86,这可能解释了为什么从右到左被固定在GCC的x86内部。(我还没有测试其他情况是否总是一致,但一些早期的答案报告说是这样。)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
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在一般情况下并不寻找这样的优化,即使在可能有更多节省的情况下也是如此(比如函数调用作为参数的情况)。
在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就不必预测是否需要进行存储转发。
@ 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)
这似乎并不比GCC的mov/load/load差多少;然后在加载结果准备好之后,进行adds/adds;store/store(或者可能是分开的,因为大多数流水线不会有2个存储单元);分支。或许在调优为OoO执行核心(如-mcpu=cortex-a76
)时,我的节省指令版本可能更值得。无论如何,如果我们需要一个mov,我认为在2个加载之后、2个存储之前进行会更好,以便在等待加载使用延迟时能够更多地完成工作。或者也可以是加载/指针复制/加载,这样我们仍然可以在只有一个加载单元的流水线上在第一个周期中进行双发射。总之,现在我们已经偏离了ARM指令调度的主题。
if (p==q) return 0;
,即使是对于x86-64的GCC,也会先进行两次加载pointers2
。(仍然会有一个额外的mov
,它本可以通过在第一次存储之后执行lea esi, [rax+1]
,而不是在之前执行add eax, 1
和在之后执行mov esi, eax
来避免。)
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中。
foo(i++, i++)
会导致未定义行为,因为在任何中间序列点之前,i
被递增了超过一次。 - Nawaz