为什么这个延迟循环在多次迭代后没有休眠,却开始运行得更快?

74

考虑:

#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毫秒,但之后成本降低到0.2毫秒。

取消注释usleep时,延迟循环对所有运行需要0.4毫秒,从未加速。为什么?

该代码使用g++ -O0(无优化)编译,因此延迟循环不会被优化掉。它在Intel(R) Core(TM) i3-3220 CPU @ 3.30 GHz上运行,使用3.13.0-32-generic Ubuntu 14.04.1 LTS (Trusty Tahr)。


你应该检查 usleep() 的结果,因为它可能会被中断或者由于参数无效而不起作用。这将使任何时间计算变得不可靠。 - John3136
@John3136:usleep在时间窗口之外。他正在计时一个忙循环,要么是连续的,要么是间隔1毫秒的睡眠。 - Peter Cordes
1
为了进行基准测试,您应该至少使用gcc -O2或(由于您的代码是C++)g++ -O2进行编译。 - Basile Starynkevitch
1
如果你睡眠1000微秒,我期望循环至少需要1毫秒。你是如何测量0.4毫秒的? - Adrian McCarthy
2
@AdrianMcCarthy:usleep时间窗口之外 - Peter Cordes
显示剩余2条评论
2个回答

128

经过26次迭代后,由于您的进程连续几次使用了其完整的时间片, Linux将CPU加速到最大时钟速度。

如果您使用性能计数器而不是挂钟时间进行检查,则会看到延迟循环的核心时钟周期保持不变,证实这只是DVFS的效果(所有现代CPU都使用DVFS以在大多数时间以更节能的频率和电压运行)。

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

如果您在带Turbo的Intel CPU上运行一段时间,当热限制要求时钟速度降至最大持续频率以下,您可能会看到每次迭代的时间再次略微增加。 (有关Turbo允许CPU以比高功率工作负载所能承受的更快的速度运行的更多信息,请参见为什么我的CPU无法在HPC中保持峰值性能。)
介绍一个usleep函数,可以防止Linux的CPU频率调节器提升时钟速度,因为即使在最低频率下,该进程也不能产生100%的负载。(即内核的启发式算法决定CPU对正在运行的工作负载足够快。)

其他理论的评论:

关于David提出的一个潜在的从usleep切换上下文可能会污染缓存的理论:总体而言,这不是一个坏主意,但它并不能帮助解释这段代码。

对于这个实验来说,缓存/ TLB污染根本不重要。在时间窗口内,除了堆栈结束外,基本上没有东西接触到内存。大部分时间都花费在一个微小的循环(1行指令缓存),只接触堆栈内存中的一个int。任何usleep期间可能发生的缓存污染都只占此代码时间的一小部分(真正的代码将会有所不同)!

更详细地说,在x86中:

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

run函数可能与main不在同一缓存行中(因为gcc将main标记为“冷”,所以它会得到较少的优化并与其他冷函数/数据放置在一起)。我们可以预期出现一两个指令缓存未命中。虽然它们可能仍在同一页的4k页面中,但main将在进入程序的定时区域之前触发潜在的TLB未命中。

gcc -O0会将OP的代码编译成类似于这样(Godbolt Compiler explorer)的形式:在堆栈上保留循环计数器的内存。

空循环将循环计数器保留在堆栈内存中,因此在典型的Intel x86 CPU上,由于与内存目标(读取-修改-写入)相关的存储转发延迟,循环每6个周期运行一次,在OP的IvyBridge CPU上,100k iterations * 6 cycles/iteration为600k个周期,这占主导地位,最多可能会有几个缓存未命中(对于代码获取未命中,每个未命中大约需要200个周期,这会阻止进一步指令的发出)。

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

即使循环计数器保存在寄存器中,100k个周期也是很多的。


我将N的值增加了100倍,并使用cpufreq-info命令,但当代码运行时,我发现CPU仍在最低频率下工作。 - phyxnj
@phyxnj:如果取消注释usleep,对我来说它会在N=10000000时加速。(我使用grep MHz /proc/cpuinfo,因为我从未安装cpufreq-utils在这台机器上)。实际上,我刚刚发现cpupower frequency-info显示了cpufreq-info所做的事情,只针对一个核心。 - Peter Cordes
@phyxnj:你确定你在查看所有核心而不是只有一个核心吗?cpupower似乎默认只显示第0个核心。 - Peter Cordes
“grep MHz /proc/cpuinfo” 显示 CPU 频率确实增加了。 “cpufreq-info” 可能监视 CPU 的任意一个核心。我认为你是对的,这可能是问题的原因。 - phyxnj
1
@phyxnj:这不是随机的,核心数量会在输出中打印出来。例如:http://www.thinkwiki.org/wiki/How_to_use_cpufrequtils。它几乎肯定默认只使用0号核心。唯一不可预测的是你的进程将在哪个核心上运行。 - Peter Cordes

3

usleep 的调用可能会导致上下文切换,也可能不会。如果发生上下文切换,它所需的时间会比没有上下文切换时长。


1
usleep主动放弃CPU,因此一定会进行上下文切换(即使系统处于空闲状态),不是吗? - rakib_
1
@rakib 如果没有需要切换的上下文或时间间隔太短,则不会进行上下文切换。当你谈论少于10毫秒左右时,操作系统可能决定不进行上下文切换。 - David Schwartz
@rakib:肯定有一个切换到内核模式然后返回的开关。在恢复调用usleep的进程之前,可能没有切换到另一个进程,因此对缓存/ TLB /分支预测器的污染可能很小。 - Peter Cordes
2
@rakib 然后 schedule 计算出距离它下一步操作还有多长时间,然后决定如何等待,可能会安排其他任务,可能使用硬件计时器,也可能不使用。 - David Schwartz
1
@rakib:如果在usleep的调用者返回之前CPU上没有运行重要任务,一些人可能会说没有进行上下文切换,即使硬件短暂地进入了睡眠状态(使用hlt指令)。在这种情况下,肯定存在最小化的缓存/TLB污染,而且我记得没有TLB失效。(我忘记内核模式下的页表是如何工作的了,但我认为不必在每个系统调用时都清除整个TLB)。 - Peter Cordes
显示剩余11条评论

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