为什么此延迟环路在无睡眠的几次迭代后开始运行得更快?


73

考虑:

#include <time.h>
#include <unistd.h>
#include <iostream>
using namespace std;

const int times = 1000;
const int N = 100000;

void run() {
  for (int j = 0; j < N; j++) {
  }
}

int main() {
  clock_t main_start = clock();
  for (int i = 0; i < times; i++) {
    clock_t start = clock();
    run();
    cout << "cost: " << (clock() - start) / 1000.0 << " ms." << endl;
    //usleep(1000);
  }
  cout << "total cost: " << (clock() - main_start) / 1000.0 << " ms." << endl;
}

这是示例代码。在时序循环的前26次迭代中,该run函数的成本约为0.4 ms,但随后成本降低为0.2 ms。

如果不加usleep注释,则延迟环路将花费0.4 ms进行所有运行,而不会加速。为什么?

该代码是使用g++ -O0(无需优化)编译的,因此不会优化延迟循环。它运行在3.30 GHz的Intel®CoreTM i3-3220 CPU上,具有3.13.0-32通用的Ubuntu 14.04.1 LTS(Trusty Tahr)。


您可能应该检查的结果,usleep()因为它可能被中断,或者因为您的参数无效而可能什么也不做。这将使任何计时都不可靠。
John3136 '16

@ John3136:休眠处于定时窗口之外。他正在安排一个繁忙的循环,以背靠背或间隔1毫秒的睡眠时间进行。
彼得·科德斯

1
为了进行基准测试,至少应使用gcc -O2或编译(因为您的代码是C ++)g++ -O2
Basile Starynkevitch 2016年

1
如果您睡眠1000微秒,我希望循环至少需要1毫秒。您如何测量0.4毫秒?
阿德里安·麦卡锡

2
@AdrianMcCarthy:这usleep在计时窗口之外
彼得·科德斯

Answers:


125

经过26次迭代后,Linux将CPU提升到最大时钟速度,因为您的进程连续两次使用其全部时间片

如果您使用性能计数器而不是挂钟时间进行检查,您会发现每个延迟环的核心时钟周期保持恒定,从而确认这只是DVFS的作用(所有现代CPU都使用DVFS以更高的能耗运行-大部分时间都是有效的频率和电压)。

如果您在Skylake上进行了测试,并且内核支持新的电源管理模式(硬件完全控制时钟速度),则加速会更快。

如果您将它在带有TurboIntel CPU上运行一段时间,则可能会发现,一旦散热限制要求时钟速度降低到最大持续频率,每次迭代的时间会再次稍微增加。(有关Turbo的更多信息,请参见为什么我的CPU无法在HPC中保持峰值性能,更多有关Turbo使CPU运行超过其在高功率工作负载下可以承受的速度的信息。)


引入ausleep可以防止Linux的CPU频率调节器提高时钟速度,因为即使在最低频率下,该过程也不会产生100%的负载。(即,内核的启发式方法决定CPU的运行速度足以满足其上正在运行的工作负载。)



对其他理论的评论

回复:David提出的从潜在的上下文切换usleep可能会污染高速缓存的理论:一般来说,这并不是一个坏主意,但这无助于解释这段代码。

缓存/ TLB污染对于该实验根本不重要。除了堆栈的末尾,时序窗口内基本上没有其他东西可以接触内存。大部分时间都花在一个很小的循环(1行指令高速缓存)中,该循环仅接触int堆栈存储器之一。usleep此代码期间任何潜在的缓存污染都只占该代码时间的一小部分(实际代码会有所不同)!

对于x86更详细:

对其clock()自身的调用可能会丢失高速缓存,但是代码获取高速缓存未命中会延迟开始时间的测量,而不是成为测量结果的一部分。clock()几乎永远不会延迟对的第二次调用,因为它在缓存中仍然很热。

run函数可能位于与之不同的缓存行中main(因为gcc标记main为“冷”,因此它的优化程度较低,并与其他冷函数/数据一起放置)。我们可以预期会有一两个指令高速缓存未命中。但是,它们可能仍在同一4k页面中,因此main在进入程序的定时区域之前会触发潜在的TLB丢失。

gcc -O0将把OP的代码编译成这样的东西(Godbolt编译器浏览器):将循环计数器保存在堆栈的内存中。

空循环将循环计数器保持在堆栈存储器中,因此,在典型的Intel x86 CPU上,循环的运行是OP的IvyBridge CPU上每约6个周期执行一次迭代,这要归功于add存储目标的一部分的存储转发延迟(读取-modify-write)。 100k iterations * 6 cycles/iteration周期为60万个周期,最多可控制几个高速缓存未命中(每个200个周期用于代码获取未命中,这会阻止进一步的指令发出,直到它们被解决为止)。

乱序执行和存储转发应在访问堆栈时(作为call指令的一部分)在大多数情况下隐藏潜在的高速缓存未命中。

即使将循环计数器保存在寄存器中,也要花费10万个周期。


我将值增加了N100x并使用cpufreq-info命令,我发现在代码运行时,cpu仍在最小频率上工作。
phyxnj

@phyxnj:有usleep没有评论?对我来说N = 10000000。(我使用,grep MHz /proc/cpuinfo因为我从没到过在这台机器上安装cpufreq-utils的机会)。实际上,我只是发现它cpupower frequency-info显示了一个核心的cpufreq-info所做的事情。
彼得·科德斯

@phyxnj:您确定要查看所有内核,而不仅仅是一个内核吗? cpupower似乎默认为核心只是0
彼得·科德斯

grep MHz /proc/cpuinfo显示CPU频率确实增加了。cpufreq-info也许监视CPU的随机核心。我认为您是对的,也许这就是问题的原因。
phyxnj

1
@phyxnj:这不是随机的,核心编号会在输出中打印出来。例如thinkwiki.org/wiki/How_to_use_cpufrequtils。几乎可以肯定它仅默认为核心0。唯一无法预测的是您的进程将在哪个核心上运行。
彼得·科德斯

3

调用usleep可能会或可能不会导致上下文切换。如果是这样,它将比不这样做需要更长的时间。


1
usleep自愿放弃CPU,因此应该确定有一个上下文切换(即使系统处于空闲状态),不是吗?
rakib_

1
@rakib如果没有什么可切换上下文的,或者时间间隔太短,则不会。当您谈论的时间少于10毫秒左右时,操作系统可能会决定不进行上下文切换。
David Schwartz

@rakib:肯定会切换到内核模式并返回。在恢复称为的进程之前,可能不会切换到其他进程usleep,因此缓存/ TLB /分支预测变量的污染可能很小。
彼得·科德斯

2
@rakib然后schedule找出要执行下一件事情的时间,然后决定如何等待,可能要安排其他时间,可能要使用硬件计时器,可能不需要。
David Schwartz

1
@rakib:如果在返回给的调用者之前,CPU上没有任何重要的运行usleep,即使硬件在hlt短时间内进入休眠状态(带有指令),也有人会说没有上下文切换。在这种情况下,缓存/ TLB污染绝对是最小的,并且IIRC没有TLB无效。(我确实忘记了内核模式的页表是如何工作的,但我认为不必在每次系统调用时都浪费掉整个TLB)。
彼得·科德斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.