内存带宽测试
常见的内存带宽测试有STREAM、babel-stream或者lmbench,可以学习它们是怎么写的。这里采用类似STREAM的方式(即通过四种kernel:copy, scale, add, triad来测试)。
多次测试(NTIMES
=20),去掉前3次的值,取最小时间。计时函数采用clock_gettime
获得纳秒级别的精度。
#include <time.h> // For struct timespec, clock_gettime, CLOCK_MONOTONIC
double wall_time()
{
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return 1. * t.tv_sec + 1.e-9 * t.tv_nsec;
}
copy和scale的读写内存大小分别为2 * sizeof(DATA_TYPE) * ARRAY_SIZE
字节,add和triad的为3 * sizeof(DATA_TYPE) * ARRAY_SIZE
字节.
计算内存带宽。
for (itest = 0; itest < NTIMES; itest++) {
times[itest][0] = wall_time();
GPTLstart("copy");
for (i = 0; i < ARRAY_SIZE; i++)
c[i] = a[i];
GPTLstop("copy");
times[itest][0] = wall_time() - times[itest][0];
times[itest][1] = wall_time();
GPTLstart("scale");
for (i = 0; i < ARRAY_SIZE; i++)
b[i] = scalar * c[i];
GPTLstop("scale");
times[itest][1] = wall_time() - times[itest][1];
times[itest][2] = wall_time();
GPTLstart("add");
for (i = 0; i < ARRAY_SIZE; i++)
c[i] = a[i] + b[i];
GPTLstop("add");
times[itest][2] = wall_time() - times[itest][2];
times[itest][3] = wall_time();
GPTLstart("triad");
for (i = 0; i < ARRAY_SIZE; i++)
a[i] = b[i] + scalar * c[i];
GPTLstop("triad");
times[itest][3] = wall_time() - times[itest][3];
}
查看STREAM官方的说明,在做内存带宽测试时,需要注意所使用的每个数组的大小,至少要是CPU的L3 Cache的4倍。以保证每次访存没有被cache兜住,都要从内存中读。
另外在写的时候,发现如果四个kernel内的变量依赖不妥当设置的话(例如,每次都是从数组a
中读,向数组c
中写),则会得到快得飞起的时间和极大的内存带宽。这是不合理的,是因为开启-O3
的编译选项时,编译器激进优化掉了中间所有的读写操作。所以最好设置成,每一个kernel的读,恰好是前一个kernel的写。
测试选取课题组的机器,单节点内有两块Intel Xeon Gold 6132 CPU。每块CPU有14个计算核心,L1D、L2和L3 cache分别为32KB、1024KB和19712KB(单CPU内14个核心共用L3)。根据网上公开的参数资料,该款CPU的最大理论内存带宽为:
根据L3 Cache的大小,设置数组大小为100000000,double的数据类型,每个数组占用762.9 MB。使用单核的测试结果如下:
用满节点内两个CPU的28个计算核心,将每个核的结果累加得到总的带宽:
为了确定测试的可靠性,利用GPTL和PAPI库在每次调用kernel的位置插装,统计PMU事件(PAPI_L1_DCM
,PAPI_L1_ICM
,PAPI_L2_TCM
,PAPI_L3_TCM
分别对应L1D、L1I、L2、L3的缺失次数):
从事件统计结果来看,L1DCM≈L2_TCM=L3_TCM,可见几乎所有的访存在三级Cache系统里都是缺失的。
Cache带宽测试
为了一次性把三级Cache的带宽都测试出来,想到了之前看《Computer System:A Programmer’s Perspective》时看到的Memory Mount的测试方法,通过使用不同大小的工作集、不同长短的遍历步长,统计对应的读带宽情况,然后得到以(size, stride)
二维自变量、带宽因变量的类似山脉的图。这样可一目了然整个Cache系统的读带宽情况。
前面所用的类似STREAM的测试,是读、写混合,四种kernel的设置也更符合实际应用场景。而此处的Memory Mount则是将读、写分开测,测试方法参考了Test-Driving Intel Xeon Phi这篇文章,写法也比较简单。
读带宽测试kernel:
void test_read(int elems, int stride) {
// unroll
int i;
int stride2 = 2 * stride,
stride3 = 3 * stride,
stride4 = 4 * stride,
stride5 = 5 * stride,
stride6 = 6 * stride;
// stride7 = 7 * stride;
// stride8 = 8 * stride;
DATA_TYPE acc0 = 0, acc1 = 0, acc2 = 0, acc3 = 0, acc4 = 0, acc5 = 0;//, acc6 = 0;//, acc7 = 0;
volatile DATA_TYPE sink;
int limit = elems - stride6;
for (i = 0; i < limit; i += stride6) {
acc0 += data[i];
acc1 += data[i+stride];
acc2 += data[i+stride2];
acc3 += data[i+stride3];
acc4 += data[i+stride4];
acc5 += data[i+stride5];
// acc6 += data[i+stride6];
// acc7 += data[i+stride7];
}
for (; i < elems; i += stride)
acc0 += data[i];
sink = (acc0 + acc1) + (acc2 + acc3) + (acc4 + acc5);// + (acc6);// + acc7);
}
需要注意的是,同样为了避免编译器过度优化导致根本没有执行核心代码,将最后的读的结果赋给了一个volatile
型变量。在不做循环展开时,发现测出来的带宽相当小,甚至写带宽还比读带宽高出将近一倍,因此将循环展开,每次在循环体内执行多条访存语句。一方面减少了循环指令数的执行开销;一方面也可能是类似这篇文章分析prefetch效果的原因时所说的,经典的tomasulo算法的流水线CPU,其取指部件按指令顺序将指令放入保留站(RS)中并设置依赖关系,当CPU在RS中看到了指令且相应功能部件还有空余时,那么就能执行该条指令。所以这里的循环展开相当于缩减了(彼此间无依赖的)访存指令的间隔,使CPU尽快地发起访存指令,更高效地利用部件。实验发现循环展开6次效果最好(检查了4,6,8三种),可能也对应于前述的该款CPU的内存控制器有6通道。
写带宽测试kernel的写法是类似的:
void test_write(int elems, int stride) {
// unroll
int i;
int stride2 = 2 * stride,
stride3 = 3 * stride,
stride4 = 4 * stride;
// stride5 = 5 * stride,
// stride6 = 6 * stride;
DATA_TYPE scalar = 3.0;
int limit = elems - stride4;
for (i = 0; i < limit; i += stride4) {
data[i] = scalar;
data[i+stride ] = scalar;
data[i+stride2] = scalar;
data[i+stride3] = scalar;
// data[i+stride4] = scalar;
// data[i+stride5] = scalar;
}
for (; i < elems; i += stride)
data[i] = scalar;
}
测试的工作集大小范围从最小的16KB(L1D Cache的一半大小),以2的幂次往上增加,直到最大的512MB(远大于L3 Cache 19.25MB)。每个工作集大小,均采用从1到12变化的步长进行测试。工作集越小,时间局部性越好;步长越小,空间局部性越好。
下图为的单核的Memory Mount图,左图为读带宽,右图为写带宽。
从图中可见,垂直于sizes轴的有三道山脊,分别对应于L1D cache,L2 cache和L3 cache的大小,它们将山划分为四个区域,分别对应工作集完全落在L1D cache,L2 cache,L3 cache和主存内所获得的带宽。在每个区域内,都是一个带宽逐渐下降的斜坡,代表随着步长的变化,空间局部性下降。沿着strides
=1获得剖面图如下,更清晰地看出这个差异:
山脊的位置,即读带宽骤跌的位置,32KB,1024KB均符合CPU的实际cache大小。注意此时一颗CPU内仅有一个进程,其能使用所有的L3 cache(为19.25MB),故第三次的读带宽骤跌发生在工作集大小为16MB和32MB之间。因此可知在以1为步长读取时,在这个场景下,L1D cache的带宽为37.1GB/s,L2的带宽为29.3GB/s,L3的带宽为21.5GB/s,主存的带宽为10.7GB/s左右。主存的值与之前的读写混合的测试结果在一个量级上。在其它步长处做剖面图,也能得到类似趋势,但整个图的绝对值会降低一些。 那么有一个问题,谁才是各级Cache及主存的 (尽可能)真实的 读带宽呢?
确实在不同的计算场景下(如之前的四个读写混合的kernel),会得到不同的带宽值。个人猜测,如果希望每次访问都是从特定的目标处(e.g., L1D,L2或L3)取值,那么应该使每次访问在目标处之上的所有cache中都miss掉,而在目标处命中。例如,Cache系统的块大小为64字节(对应8个double),如果以步长为1进行读取,则每次miss之后,L1会从下面的cache中调上一个块,所以接下来有7次L1 hit,这对于测量L2的读带宽显然是有“欺骗性”的。因此理论上,应将步长设置至少为8时,配合上合适的工作集大小(完全放入L2 cache内),测得的才是真正的L2的带宽。实际上,步长大于8时,也并不是就在一定步长范围内的读带宽基本不变,这可能是不同级cache之间使用的块大小不一样所致。所以以下增大步长范围来观察。
事实上,之前的以strides
=1得到的带宽剖面图中,可能含有由于硬件预取(自动识别顺序的、步长为1的引用模式)带来的“水分”。虽然它看起来没有CSAPP书中展示的Intel Core i7系统的在strides
=1时那么平坦。
L1带宽
L1之上再无cache,因此取16KB的工作集大小即可。如下图所示,其读带宽随步长增加而一直降低,这点似乎无法解释。
L2带宽
对于L2,工作集大小为64KB,128KB,256KB和512KB时均能放入L2中,又不至于被L1容纳。下图可见步长大于8时,读带宽基本不变,大致为16.6GB/s。
L3带宽
对于L3,工作集大小为64KB,128KB,256KB和512KB时均能放入L3中,又不至于被L2容纳。下图可见步长大于8时,读带宽基本不变,大致为2.5GB/s。但对于2MB的工作集,在步长变大到12以上时的反常情况也是无法解释的。
主存带宽
对于主存,工作集大小为32MB,64MB,128MB,256MB,512MB时均不至于被L3容纳。下图可见步长大于16时,读带宽基本不变,大致为1GB/s。对于较小的工作集,在步长变大到12以上时的反常情况也无法解释。
以上都是对纯读的带宽的分析,对另一半图的纯写的带宽的分析同理,不再赘述。
下图为单节点内用满28个计算核心的Memory Mount图(总的累计的带宽),左图为读带宽,右图为写带宽。
值得一提的是,由于14个核心共用一个CPU内的L3 cache,平均每核只有1.375MB(比每核单独使用的L2的1024KB大不了多少),因此读带宽的骤跌的位置在512KB到2MB之间。
同时从内存山的图中可以看到,当用满核心时,落在主存区域的以步长为1的读获得的带宽在205GB/s左右,这与之前看到的Intel Xeon Gold 6132的单CPU的理论内存带宽119.21GB/s的指标很接近了(单节点内两颗CPU)。