我们代码中的函数让它变慢?

Functions in our code make it slower?

当我们编译代码并执行它时,在我们的代码被转换的汇编中,函数以非顺序方式存储。所以每次调用一个函数,处理器都需要把流水线中的指令扔掉。这不会影响程序的性能吗?

PS:我没有考虑开发这种没有功能的程序所投入的时间。纯粹看性能水平。编译器有什么方法可以减少它吗?

So every time a function is called, the processor needs to throw away the instructions in the pipeline.

没有,解码阶段之后的一切都还不错。 CPU 知道在无条件分支后不继续解码(如 jmpcallret)。只有已经获取但尚未解码的指令才是不应该 运行 的指令。在从指令中解码出目标地址之前,流水线的开头没有任何用处可做,因此在知道目标地址之前,流水线中会出现气泡。尽早解码分支指令,从而最大限度地减少采用分支的惩罚。

classic RISC pipeline中,阶段是IF ID EX MEM WB(fetch, decode, execute, mem, write-back(results to registers)。所以当ID解码分支指令时,流水线抛出去掉当前在 IF 中获取的指令,以及当前在 ID 中解码的指令(因为它是分支之后的指令)。

"Hazard" 是指阻止稳定的指令流以每个时钟一条的速度通过管道的事物的术语。分支是Control Hazard。 (在流量控制中进行控制,而不是数据。)

如果分支目标不在 L1 I-cache 中,流水线将必须等待指令从内存流入,然后 IF 流水线级才能产生获取的指令。 I-cache misses 始终创建管道气泡。对于非分支代码,预取通常会避免这种情况。


更复杂 CPUs 解码足够远以检测分支并尽快重新引导提取以隐藏此气泡。这可能涉及用于隐藏提取气泡的解码指令队列。

此外,CPU 可以根据 "Branch Target Buffer" 缓存检查每个指令地址,而不是实际解码以检测分支指令。如果你命中了,你就知道指令是一个分支,即使你还没有解码它。 BTB 还保存目标地址,因此您可以立即从那里开始获取(如果它是无条件分支或您的 CPU 支持基于分支预测的推测执行)。


ret 实际上是更难的情况:return 地址在寄存器或堆栈中,而不是直接编码到指令中。这是一个无条件的 indirect 分支。现代 x86 CPUs 维护一个内部 return-address predictor stack,当你不匹配 call/ret 指令时表现非常糟糕。例如。 call label / label: pop ebx 对于位置无关的 32 位代码将 EIP 放入 EBX 是很糟糕的。这将导致对接下来的 15 个左右 ret 调用树的错误预测。

我想我已经读到 a return-address predictor stack 被其他一些非 x86 微体系结构使用。

参见 Agner Fog's microarchitecture pdf to learn more about how x86 CPUs behave (also see the tag wiki), or read a computer architecture textbook to learn about simple RISC pipelines

有关缓存和内存(主要集中在数据缓存/预取)的更多信息,请参阅Ulrich Drepper's What Every Programmer Should Know About Memory


无条件分支非常便宜,最坏的情况下通常是几个周期(不包括 I-cache 未命中)。

函数调用的最大代价是当编译器看不到目标函数的定义时,不得不假设它破坏了调用约定中所有被调用破坏的寄存器。 (在 x86-64 SystemV 中,所有 float/vector 寄存器,以及大约 8 个整数寄存器。)这需要溢出到内存或将实时数据保存在调用保留寄存器中。但这意味着函数必须 save/restore 那些注册才不会破坏调用者。

编译器可以在同一个编译单元中执行过程间优化,让函数利用知道哪些寄存器实际上破坏了其他函数,哪些没有破坏。甚至跨编译单元进行 link 次全程序优化。但它不能跨越动态-linking 边界,因为不允许编译器生成会破坏同一共享库的不同编译版本的代码。


Are there any ways in which compilers deal with this to reduce it?

它们内联小函数,甚至是只调用一次的大 static 函数。

e.g.

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 微指令 IIRC,每行 6 微指令。从 uop 缓存而不是从解码器执行将分支预测错误的惩罚从 19 缩短到 15 个周期,IIRC(类似的东西,但这些数字对于任何特定的 uarch 实际上可能不正确)。与解码器相比,前端吞吐量也有显着提升,尤其是。对于矢量代码中常见的长指令。