当我们编译代码并执行它时,在汇编中,我们的代码被转换,函数以非顺序方式存储。因此,每次调用函数时,处理器都需要丢弃管道中的指令。这不会影响程序的性能吗?
PS:我没有考虑在没有功能的情况下投入开发此类程序的时间。纯粹在性能水平上。编译器是否有任何方法可以解决这个问题?答案 0 :(得分:7)
因此,每次调用函数时,处理器都需要丢弃管道中的指令。
不,解码阶段后的所有内容仍然很好。 CPU知道在无条件分支之后不继续解码(如jmp
,call
或ret
)。只有已经获取但尚未解码的指令才是不应该运行的指令。在从指令解码目标地址之前,对于管道的开始没有任何用处,因此在管道中出现气泡直到目标地址已知。尽可能早地解码分支指令,从而最大限度地减少了对分支的惩罚。
在classic RISC pipeline中,阶段是IF ID EX MEM WB
(获取,解码,执行,mem,回写(结果到寄存器)。所以当ID解码分支指令时,管道抛弃了当前正在IF中获取的指令,以及当前正在ID中解码的指令(因为它是分支后的指令)。
"危害"是一个阻止稳定的指令流每个时钟通过管道的东西的术语。分支是Control Hazard。 (控制与流量控制相反,而不是数据。)
如果分支目标不在L1 I-cache中,则管道必须等待指令从内存流入,然后IF流水线阶段才能产生提取的指令。 I-cache misses总是会产生管道泡沫。对于非分支代码,预取通常会避免这种情况。
更复杂的CPU解码得足以检测分支并尽快重新引导提取以隐藏此气泡。这可能涉及一系列解码指令以隐藏获取泡泡。
此外,CPU不是实际解码以检测分支指令,而是可以针对"Branch Target Buffer"缓存检查每个指令地址。如果你受到了打击,即使你尚未对其进行解码,你也知道该指令是一个分支。 BTB还保存目标地址,因此您可以立即从那里开始提取(如果它是无条件分支,或者您的CPU支持基于分支预测的推测性执行)。
ret
实际上是更难的情况:返回地址在寄存器或堆栈中,不直接编码到指令中。它是一个无条件的间接分支。现代x86 CPU维护内部返回地址预测器堆栈,并且当您不正确地调用call / ret指令时执行非常糟糕。例如。 call label
/ label: pop ebx
对于与位置无关的32位代码非常糟糕,可以将EIP导入EBX。这将导致对调用树中的下一个约15个ret
进行错误预测。
我想我已经读过a return-address predictor stack被其他一些非x86微体系结构使用。
请参阅Agner Fog's microarchitecture pdf以了解有关x86 CPU行为的更多信息(另请参阅x86标记wiki),或阅读计算机体系结构教科书以了解simple RISC pipelines。
有关缓存和内存的更多信息(主要关注数据缓存/预取),请参阅Ulrich Drepper's What Every Programmer Should Know About Memory。
无条件分支非常便宜,比如通常情况下最差的几个周期(不包括I-cache未命中)。
函数调用的代价是当编译器无法看到目标函数的定义时,并且必须假设它在调用约定中使用了所有调用符号寄存器。 (在x86-64 SystemV中,所有浮点/向量寄存器和大约8个整数寄存器。)这需要溢出到内存或将实时数据保存在调用保留寄存器中。但这意味着该函数必须保存/恢复这些寄存器,以免打破调用者。
程序间优化让函数利用知道哪些注册其他函数实际上是什么,以及它们不是,编译器可以在同一个编译单元中做什么。甚至跨编译单元进行链接时整个程序优化。但是它不能扩展到动态链接边界,因为编译器不允许编写与同一共享库的不同编译版本相冲突的代码。
编译器是否有任何方法可以解决这个问题?
它们内联小函数,甚至只调用一次的大static
函数。
int foo(void) { return 1; }
mov eax, 1 #,
ret
int bar(int x) { return foo() + x;}
lea eax, [rdi+1] # D.2839,
ret
正如@harold指出的那样,过度使用内联可能会导致缓存未命中,因为它会大大增加您的代码大小,以至于并非所有热门代码都适合缓存。
英特尔SnB系列设计具有小巧但非常快速的uop缓存,可缓存已解码的指令。它最多只能支持1536 uops IIRC,每行6个uop。从uop缓存而不是解码器执行会将分支错误预测惩罚从19个缩短为15个循环,IIRC(类似的东西,但这些数字可能对任何特定的uarch都不正确)。与解码器相比,还有一个重要的前端吞吐量提升,尤其是对于矢量代码中常见的长指令。