单个sqrt()的运行速度如何比放入for循环时慢两倍

Sto*_*one 4 c assembly gcc x86-64 compiler-optimization

我正在进行一项实验,分析在C代码中计算单个sqrt所需的时间.我有两个策略.

一种是直接测量单个sqrt调用,另一种是在for循环中多次执行sqrt,然后计算平均值.C代码非常简单,如下所示:

long long readTSC(void);

int main(int argc, char** argv)
{
    int    n = atoi(argv[1]);
    //v is input of sqrt() making sure compiler won't 
    //precompute the result of sqrt(v) if v is constant
    double v = atof(argv[2]);. 
    long long tm;            //track CPU clock cycles
    double x;                //result of sqrt()
    //-- strategy I ---
    tm = readTSC();     //A function that uses rdtsc instruction to get the number of clock cycles from Intel CPU
    x  = sqrt(v);
    tm = readTSC() - tm;
    printf("x=%15.6\n",x);   //make sure compiler won't optimize out the above sqrt()
    printf("%lld clocks\n",tm);
    double sum = 0.0;
    int i;

    //-- strategy II --
    tm = readTSC();
    for ( i = 0; i < n; i++ )
        sum += sqrt((double) i);
    tm = readTSC() - tm;

    printf("%lld clocks\n",tm);
    printf("%15.6e\n",sum);
    return 0;
}

long long readTSC(void)
{
 /* read the time stamp counter on Intel x86 chips */
  union { long long complete; unsigned int part[2]; } ticks;
  __asm__ ("rdtsc; mov %%eax,%0;mov %%edx,%1"
        : "=mr" (ticks.part[0]),
          "=mr" (ticks.part[1])
        : /* no inputs */
        : "eax", "edx");
  return ticks.complete;
}
Run Code Online (Sandbox Code Playgroud)

在运行代码之前,我预计策略I的时序结果可能略小于策略II中的时序结果,因为策略II还计算for循环和加法总和所产生的开销.

我在没有O3优化的情况下使用以下命令在Intel Xeon E5-2680 2.7GHz机器上编译我的代码.

gcc -o timing -lm timing.c
Run Code Online (Sandbox Code Playgroud)

然而,结果显示策略I需要大约40个时钟周期,而策略II平均需要21.8个时钟周期,几乎是前一个时钟周期的一半.

供您参考,我还粘贴了下面的相关汇编代码和一些注释.基于时序结果,我发现每个迭代执行两个sqrt().但我很难从汇编代码中看出CPU如何实际并行执行两个sqrt()的调用?

call    atof
cvtsi2ss    %eax, %xmm0
movss   %xmm0, -36(%rbp)

//-- timing single sqrt ---
call    readTSC
movq    %rax, -32(%rbp)
movss   -36(%rbp), %xmm1
cvtps2pd    %xmm1, %xmm1
//--- sqrtsd instruction
sqrtsd  %xmm1, %xmm0
ucomisd %xmm0, %xmm0
jp  .L8
je  .L4
.L8:
movapd  %xmm1, %xmm0
//--- C function call sqrt()
call    sqrt
.L4:
movsd   %xmm0, -72(%rbp)
movq    -72(%rbp), %rax
movq    %rax, -24(%rbp)
call    readTSC
//-- end of timing single sqrt ---

subq    -32(%rbp), %rax
movq    %rax, -32(%rbp)
movl    $.LC0, %eax
movsd   -24(%rbp), %xmm0
movq    %rax, %rdi
movl    $1, %eax
call    printf
movl    $.LC1, %eax
movq    -32(%rbp), %rdx
movq    %rdx, %rsi
movq    %rax, %rdi
movl    $0, %eax
call    printf
movl    $0, %eax
movq    %rax, -16(%rbp)

call    readTSC
//-- start of for loop----
movq    %rax, -32(%rbp)
movl    $0, -4(%rbp)
jmp .L5
.L6:
//(double) i
cvtsi2sd    -4(%rbp), %xmm0
//-- C function call sqrt()
call    sqrt
movsd   -16(%rbp), %xmm1
//add sqrt(i) to sum (%xmm0)
addsd   %xmm1, %xmm0
movsd   %xmm0, -16(%rbp)
//i++
addl    $1, -4(%rbp)
.L5:
movl    -4(%rbp), %eax
//check i<n
cmpl    -40(%rbp), %eax
jl  .L6
//-- end of for loop--
//you can skip the rest of the part.
call    readTSC
subq    -32(%rbp), %rax
movq    %rax, -32(%rbp)
movl    $.LC1, %eax
movq    -32(%rbp), %rdx
movq    %rdx, %rsi
movq    %rax, %rdi
movl    $0, %eax
call    printf
movl    $.LC3, %eax
movsd   -16(%rbp), %xmm0
movq    %rax, %rdi
movl    $1, %eax
call    printf
Run Code Online (Sandbox Code Playgroud)

Hri*_*iev 7

E5-2680是Sandy Bridge CPU,延迟和倒数吞吐量SQRTSD均为10到21个周期/ instr.所以在循环与否,你应该测量接近观察到的21.8周期的东西.sqrtGLIBC中的函数只是检查参数的符号,并安排非负分支通过分支预测进行推测性执行,而分支预测又是一个调用__ieee754_sqrt,它本身就是x86-64系统发出的简单内联汇编程序sqrtsd %xmm0, %xmm0.

CPU使用寄存器重命名来处理数据依赖性.因此,它可以sqrtsd %xmm0, %xmm0在管道中的不同执行阶段具有两个副本.由于sqrt不需要立即执行结果,因此可以sqrt在处理时执行其他指令,这就是为什么平均仅测量21.8个周期的原因.

至于第一种情况下的较大值,RDTSC不具有单个循环的分辨率.它有一定的延迟,所以你基本上是测量T_code_block + T_rdtsc_latency.在第二种情况下,对迭代进行平均得出:

(T_code_block * n_iters + T_rdtsc_latency) / n_iters =
= T_code_block + (T_rdtsc_latency / n_iters)
Run Code Online (Sandbox Code Playgroud)

对于大型n_iters,第二项消失,您可以非常精确地测量单次迭代.


在进行基准测试时必须非常小心RDTSC.TSC它本身以参考时钟速度在现代CPU上打勾.如果环路运行的时间足够长,它可能会触发内核时钟升压模式,CPU将运行得更快,因此一个内核时钟周期将对应少于一个参考时钟周期.结果,看起来在增强区域中执行的指令在标称时钟频率的区域中执行指令所花费的周期较少.

此外,在执行周期精确测量时,始终使用taskset实用程序或sched_setaffinity(2)系统调用将进程固定到单个CPU内核.操作系统调度程序通常会围绕不同的核心移动进程,以使它们保持同等负载,这是一个昂贵的过程.在执行几个指令的小区域期间可能发生这种情况的概率非常低,但对于长循环而言,这个概率要高得多.对多次迭代求平均可以降低迁移的严重性,但仍然会得到偏差的结果.将流程固定到单个核心可以完全避免这种情况.