在L1缓存中缓存2KB数据时内存带宽崩溃的原因

时间:2018-12-12 21:04:56

标签: c++ linux performance x86-64 intel

在一个自我教育的项目中,我借助以下代码来测量内存的带宽(在这里,换句话说,整个代码位于问题的末尾):

unsigned int doit(const std::vector<unsigned int> &mem){
   const size_t BLOCK_SIZE=16;
   size_t n = mem.size();
   unsigned int result=0;
   for(size_t i=0;i<n;i+=BLOCK_SIZE){           
             result+=mem[i];
   }
   return result;
}

//... initialize mem, result and so on
int NITER = 200; 
//... measure time of
   for(int i=0;i<NITER;i++)
       resul+=doit(mem)
选择

BLOCK_SIZE的方式是,每添加一个整数,就会提取整个64字节的缓存行。我的机器(Intel-Broadwell)每增加一个整数大约需要0.35纳秒,所以上面的代码可能会使带宽高达182GB / s饱和(此值只是一个上限,可能已经过高了,重要的是不同大小的带宽比例)。该代码使用g++-O3进行编译。

根据向量的大小,我可以观察到L1(*)-,L2-,L3-缓存和RAM存储器的预期带宽:

enter image description here

但是,我真的很难解释一个影响:L1缓存的测量带宽在2 kB左右的崩溃,此处分辨率更高:

enter image description here

我可以在我可以访问的所有计算机(具有Intel-Broadwell和Intel-Haswell处理器)上重现结果。

我的问题:内存大小约为2 KB导致性能崩溃的原因是什么?

(*)我希望我能正确理解,对于L1缓存,不是64字节,而是每次传输仅读取4个字节(没有其他更快的缓存必须填充缓存行),因此绘制的带宽因为L1只是上限,而不是badwidth本身。

编辑:选择内部for循环中的步长为

  • 8(而不是16)崩溃发生了1KB
  • 4个(而不是16个)崩溃发生了0.5KB

即当内部循环包含约31-35步/读数时。这意味着崩溃不是由于内存大小,而是由于内部循环中的步数。

可以用@user10605163's great answer中所示的分支遗漏来解释。


列出要复制的结果

bandwidth.cpp

#include <vector>
#include <chrono>
#include <iostream>
#include <algorithm>


//returns minimal time needed for one execution in seconds:
template<typename Fun>
double timeit(Fun&& stmt, int repeat, int number)
{  
   std::vector<double> times;
   for(int i=0;i<repeat;i++){
       auto begin = std::chrono::high_resolution_clock::now();
       for(int i=0;i<number;i++){
          stmt();
       }
       auto end = std::chrono::high_resolution_clock::now();
       double time = std::chrono::duration_cast<std::chrono::nanoseconds>(end-begin).count()/1e9/number;
       times.push_back(time);
   }
   return *std::min_element(times.begin(), times.end());
}


const int NITER=200;
const int NTRIES=5;
const size_t BLOCK_SIZE=16;


struct Worker{
   std::vector<unsigned int> &mem;
   size_t n;
   unsigned int result;
   void operator()(){
        for(size_t i=0;i<n;i+=BLOCK_SIZE){           
             result+=mem[i];
        }
   }

   Worker(std::vector<unsigned int> &mem_):
       mem(mem_), n(mem.size()), result(1)
   {}
};

double PREVENT_OPTIMIZATION=0.0;


double get_size_in_kB(int SIZE){
   return SIZE*sizeof(int)/(1024.0);
}

double get_speed_in_GB_per_sec(int SIZE){
   std::vector<unsigned int> vals(SIZE, 42);
   Worker worker(vals);
   double time=timeit(worker, NTRIES, NITER);
   PREVENT_OPTIMIZATION+=worker.result;
   return get_size_in_kB(SIZE)/(1024*1024)/time;
}


int main(){

   int size=BLOCK_SIZE*16;
   std::cout<<"size(kB),bandwidth(GB/s)\n";
   while(size<10e3){
       std::cout<<get_size_in_kB(size)<<","<<get_speed_in_GB_per_sec(size)<<"\n";
       size=(static_cast<int>(size+BLOCK_SIZE)/BLOCK_SIZE)*BLOCK_SIZE;
   }

   //ensure that nothing is optimized away:
   std::cerr<<"Sum: "<<PREVENT_OPTIMIZATION<<"\n";
}

create_report.py

import sys
import pandas as pd
import matplotlib.pyplot as plt

input_file=sys.argv[1]
output_file=input_file[0:-3]+'png'
data=pd.read_csv(input_file)

labels=list(data)    
plt.plot(data[labels[0]], data[labels[1]], label="my laptop")
plt.xlabel(labels[0])
plt.ylabel(labels[1])   
plt.savefig(output_file)
plt.close()

构建/运行/创建报告

>>> g++ -O3 -std=c++11 bandwidth.cpp -o bandwidth
>>> ./bandwidth > report.txt
>>> python create_report.py report.txt
# image is in report.png

2 个答案:

答案 0 :(得分:18)

我稍稍更改了值:NITER = 100000NTRIES=1,以获得较小的噪声结果。

我现在没有Broadwell可用,但是我在Coffee-Lake上尝试了您的代码,但性能有所下降,不是2KB,而是4.5KB。此外,我发现吞吐量略高于2KB的行为不稳定。

图中的蓝线对应于您的测量(左轴):

这里的红线是perf stat -e branch-instructions,branch-misses的结果,给出了未被正确预测的分支比例(百分比,右轴)。如您所见,两者之间存在明显的反相关性。

查看更详细的perf报告,我发现基本上所有这些分支错误预测都发生在Worker::operator()的最内部循环中。如果循环分支的采用/未采用模式变得太长,则分支预测器将无法对其进行跟踪,因此内部循环的出口分支将被错误预测,从而导致吞吐量急剧下降。随着迭代次数的进一步增加,这种单一错误预测的影响将变得不那么重要,从而导致吞吐量恢复缓慢。

有关下降之前不稳定行为的更多信息,请参见下面@PeterCordes的评论。

无论如何,避免分支预测错误的最佳方法是避免分支,因此我手动展开了Worker::operator()中的循环,例如:

void operator()(){
    for(size_t i=0;i+3*BLOCK_SIZE<n;i+=BLOCK_SIZE*4){
         result+=mem[i];
         result+=mem[i+BLOCK_SIZE];
         result+=mem[i+2*BLOCK_SIZE];
         result+=mem[i+3*BLOCK_SIZE];
    }
}

展开2、3、4、6或8个迭代将得出以下结果。请注意,我没有校正向量末尾由于展开而被忽略的块。因此,蓝线中的周期性峰值应忽略不计,周期性模式的下界基线是实际带宽。

enter image description here enter image description here enter image description here enter image description here enter image description here

您可以看到分支错误预测的比例并没有真正改变,但是由于分支总数因展开迭代的次数而减少,因此它们将不再对性能产生重大影响。

还有另一个好处是,如果展开循环,则处理器可以更自由地无序进行计算。

如果应该将其实际应用,我建议尝试给热循环一个编译时固定的迭代次数或一定的除数保证,以便(也许有一些额外的提示)编译器可以决定展开的最佳迭代次数。

答案 1 :(得分:2)

可能无关,但是您的Linux机器可能会以CPU频率运行。我知道Ubuntu 18具有在功率和性能之间取得平衡的gouverner。您还希望发挥过程亲和力,以确保它在运行时不会迁移到其他内核。