【问题标题】:Why does code run slower in linear fashion than in a loop?为什么代码以线性方式运行比循环慢?
【发布时间】:2014-03-28 15:12:15
【问题描述】:

因此,我正在测量一些延迟,即在我的机器上执行添加指令以获取 CPI 估计值所需的时间。我首先编写了一个实现串行添加的线性版本(交错以利用管道)。然后我采用相同的代码并将添加的内容包装在一个循环中并重新评估它。我了解循环级并行性的影响,但我不明白它比仍然应该实现 DLP 的串行版本更快。我想可能是因为循环展开版本通过寄存器重命名更多地利用了管道,所以有更高的 IPC,但我也尝试增加线性版本的交错,但并没有真正提高性能。我认为分支错误预测会导致循环版本变慢,但事实并非如此。有什么想法吗?

#include <time.h>
#include <stdio.h>

#define ONE asm volatile( "add $20, %eax; add $10, %ecx");
#define FIVE ONE ONE ONE ONE ONE
#define TWOFIVE FIVE FIVE FIVE FIVE FIVE
#define HUNDO TWOFIVE TWOFIVE TWOFIVE TWOFIVE
#define THOUSAND  HUNDO HUNDO HUNDO HUNDO HUNDO HUNDO HUNDO HUNDO HUNDO HUNDO
#define TENTHOUSAND THOUSAND THOUSAND THOUSAND THOUSAND THOUSAND THOUSAND THOUSAND THOUSAND THOUSAND THOUSAND
#define HUNDREDK  TENTHOUSAND TENTHOUSAND TENTHOUSAND TENTHOUSAND TENTHOUSAND TENTHOUSAND TENTHOUSAND TENTHOUSAND TENTHOUSAND TENTHOUSAND
#define MILLION  HUNDREDK HUNDREDK HUNDREDK HUNDREDK HUNDREDK HUNDREDK HUNDREDK HUNDREDK HUNDREDK HUNDREDK

static __inline__ unsigned long long rdtsc(void){
    unsigned end, start;
    __asm__ __volatile__("rdtsc" : "=a"(start), "=d"(end));
    return ((unsigned long long)start) | (((unsigned long long)end)<<32);
}
int main(){
    double CPI = 0;
    long long start, end;
    long long clocks;
    int i;
    start=rdtsc();
    for(i=0; i < 10000; i++){
        HUNDREDK
    }
    end=rdtsc();
    //calculate the time elapsed in ns per access
    clocks = end-start;
    CPI = clocks/(double)(200000*10000); //divide by Number of instructions * loop

    printf("Cycles Per Instruction %lf, Clocks %Ld\n", CPI, clocks);
}

两者之间的差异非常显着。线性版本的 IPC 约为 0.2,循环版本的 IPC 约为 4。是的,我记得在评估两者时更改了除以的指令数量 :)

也许我这样做的方式有些混乱,因为文件大小不是问题。我只是删除循环。两者处理不同数量的指令,但我也更改了最后除以的值。以相同的编译大小结束。

更新: 感谢您的回复。有几个问题。第一个是我进行测量的方式,一个版本的 IF 时间在循环中被摊销,而另一个则没有。我运行了更多代码,并且循环级并行性的指令交错在循环中比在串行版本中更大。串行版本仍然有一些写入后写入依赖项,这些依赖项没有被重命名并导致管道停止。

【问题讨论】:

  • “有和没有循环”到底是什么意思?
  • 当你在没有循环的情况下运行时,你记得要从 CPI 计算中删除 *10000,不是吗? =)
  • 如果我删除循环并线性运行代码,即手动展开它,那么它会慢得多。 @Arkku 是的,我做到了:)
  • 在任何情况下,如果您将循环扩展为接近相同数量的add 操作,您的代码将变得如此之大,以至于通过缓存移动代码数据将占主导地位.我猜是代码大小与指令数量的问题。
  • @jeremycole,我在所有缓存和不同级别的指令/文件大小中测量了这一点,它仍然非常一致。

标签: c++ c performance architecture


【解决方案1】:

我的猜测是,因为您展开了如此大量的迭代,所以代码非常大。不断将新的命令页面加载到缓存中的开销远高于迭代变量的开销。

就分支错误预测而言,循环实际上应该很少。它将预测最常使用的分支,即正确 9999/10000 次。分支预测其实很好。

【讨论】:

  • 是的,基本上展开循环是有限制的。如果迭代次数相当少,但展开这么大的循环肯定会更慢,这会有所帮助。
  • 我已经尝试过使用不同数量的指令并让它们跨越所有不同的缓存。另外,两者的代码大小也接近一致。
  • 这不是一个合理的比较,是吗?将同一事物循环 10k 次与一次将在所有 10k 循环中分摊加载代码的成本...
  • 嗯,是的,实际上我认为更好的比较可能是像 200 unrolled vs 2000 unrolled,两者都在一个循环中——展开量的数量级差异,但可能仍然适合缓存。 @jeremycole 是正确的,10K 循环确实包括 10K 倍的任何加载代码开销,并且使用 200K 展开指令肯定会有非零开销。
  • 另一种证明缓存负载占主导地位的方法。将对HUNDREDK 的调用移动到函数中。在启动计时器之前调用该函数一次(代码将被缓存),然后仅对第二次调用计时。那看起来好多了。 (在我的测试中从 CPI ~4.9 下降到 CPI ~1.2。)
【解决方案2】:

更可能的原因是MILLION 情况下的 L3 缓存而不是HUNDREDK 情况下的 L1/L2 缓存。

ONE 的大小介于 6 到 8 个字节之间 (source) - 抱歉不准确;组装不是很好,但对于粗略计算来说已经足够了。

考虑到这一点,并假设三个字节的最佳情况(两个adds 总共六个字节):

  • HUNDREDK ~ 600 KB
  • MILLION ~ 6 MB

假设一个 64 KB 的 L1 缓存和 256 KB 的 L2 缓存 (source),MILLION 代码一直溢出到 L3(CPU 内核之外),而 HUNDREDK 大部分是预取的在 L1 和 L2 中(在 CPU 内核中) - source for prefetching

【讨论】:

  • ONE的大小为6字节:83 c0 1483 c1 0a
  • 我不认为这是问题所在。我已经考虑了可执行文件的大小,它们是从同一个缓存中提取的。
  • 整个可执行文件的大小可能不是一个好的起点。与展开的代码相比,循环的大小是最重要的部分。有人在第一个答案的 cmets 中提到过(有多少代码 &lt;iostream&gt; 正在添加到可执行文件中)。
  • @the downvoter:很高兴能解释一下你为什么认为这不正常。
  • @PandaRaid 您可以做的一项快速测试是使用适合 L1 缓存的较小展开大小;它应该比 HUNDREDK 案例还要快。
猜你喜欢
  • 1970-01-01
  • 2013-08-08
  • 1970-01-01
  • 2011-09-18
  • 2017-12-17
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-11-09
相关资源
最近更新 更多