STREAM Benchmark - 访存性能的底层逻辑分析
针对 STREAM 测试题的研究与分析
1. 引言
STREAM benchmark 是目前衡量高性能计算(HPC)系统可持续内存带宽的事实标准;在 CPU 研发团队中,OS Booting 后第一个展开的测试题就是 STREAM,主要用于 Core 的访存性能评估。
首先明确 STREAM 测试在做什么:
STREAM 主要测量四种典型的内存操作向量内核 kernel,每个内核对硬件的要求略有不同 

2. 主要参数释义
1. STREAM_ARRAY_SIZE:表明测试所需的元素个数
#ifndef STREAM_ARRAY_SIZE # define STREAM_ARRAY_SIZE 10000000 #endif
由于 STREAM 测试的是访存带宽,因此所有读写必须落到最终存储体,也就是 DDR 上,(考虑到软件进行一致性维护额外引入的复杂度,一般情况下不会 Disable Cache),所以必须让测试的数据集大于最后一层 Cache 的容量大小。这样最终测试结果可以排除掉 Cache 的访问速率模糊了最终计算结果。
计算方式如下:
![]()
假设 L3Cache 为 20M,依据作者注释的要求,三个数组要求三倍的大小,再考虑到 prefetch 的影响,作者额外引入了一个 0.8 倍的因子(现代 CPU 非常聪明,它会预测你下一步要读什么并提前存入缓存。如果数组只比缓存大一点,prefetch 算法可能依然能让大部分数据留在缓存里)。所以最终测试的大小必须要是 3.8 倍 L3Cache 的容量;

76Mbytes 一共有 79,691,776 个 bytes,这里单位字节数是 8 字节/元素,因为现代处理器多为 64bit 系统,且测试是基于精度更高、更符合现代科学计算需求的 double 类型,源码中标识如下:
#ifndef STREAM_TYPE #define STREAM_TYPE double #endif
默认情况下测试基于 10000000 个元素来进行测试,但如今高性能处理器 L3 一般都大于 20M,真实场景下依据下述公式计算:
![]()
计算得到实际需求 size 后,只需要指定宏 -DSTREAM_ARRAY_SIZE=NEW_VALUE 重新编译即可
gcc -O -DSTREAM_ARRAY_SIZE=100000000 stream.c -o stream.100M
2. NTIMES:表明每种 kernel 测试的总次数
#ifdef NTIMES #if NTIMES<=1 # define NTIMES 10 #endif #endif #ifndef NTIMES # define NTIMES 10 #endif
测试最少也会进行 2 轮,因为要排除第一轮中系统测试的 Overhead,最终返回默认 10 次计算中的最优计算结果,系统 Overhead 包括如下的几类影响:
-
内存页表填充 Page Faults: 当启动程序并声明了较大容量的数组时,操作系统通常只是分配了“虚拟地址空间”,并没有立刻在 DDR 里准备好这些空间。当 Core 第一次尝试读写这些数组时,会触发大量的缺页中断(Page Faults)。操作系统必须停下当前业务,去物理内存里寻找空闲页,更新页表(TLB),然后才允许 Core 访问。因此当第二轮运行时,物理内存已经映射完毕,Core 可以全速直接访问 DDR。第一次运行的时间里包含了“操作系统分配内存”的时间,不属于“内存带宽”的范畴。
-
Cache 控制器开销: Core 中有 l2 Cache,一般在 dram 访问前也会经过 SLC / L3 Cache,当读写请求第一次经过 Cache 时,一定会触发 miss;而当第一轮执行完毕,Cache 此时已经充满数据,后续的访问会产生 replacement,将数据刷回 DDR,上述描述中的 L2->L3->DDR 替换 flow 也会占据一部分访存带宽,而在第二轮开始后,Cache 控制器会遵照特定规律的模式进行 line fill 和 replacement,使得测试误差尽可能的稳定;
-
I-Cache 与分支预测的预热:测试时 kernel 的指令本身需要从 DDR load 到 I-cache 以进行后续的测试;另一方面,Core 中的 branch predictor 需要从一系列程序指令的执行中学习这一段代码的跳转逻辑,以达到更高的分支命中;第一次迭代时 Core 需要 Load 和 Learn。
-
动态频率调节 CPU Throttling:CPU 系统在执行第一轮测试前可能是 IDLE 态,此时整个系统处于低功耗,低频率状态;第二次迭代时系统已经拉升到标准运行频率。
3. 多线程与并行访问
现代 CPU 都是基于分布式内存系统,128GB 的 DDR 空间可能是分配到 4 个 32GB 的 DDR 控制器下, 单个 Core 的 Outstanding 是有限的,因此一个 Core 的 in-flight 请求很难打满整个内存系统的带宽,在 HPC 中基于多核并发访问在一定程度上是可以提升带宽结果的。
#ifdef _OPENMP
printf(HLINE);
#pragma omp parallel
{
#pragma omp master
{
k = omp_get_num_threads();
printf ("Number of Threads requested = %i\n",k);
}
}
#endif
#ifdef _OPENMP
k = 0;
#pragma omp parallel
#pragma omp atomic
k++;
printf ("Number of Threads counted = %i\n",k);
#endif
STREAM 中借助 OpenMP 库来实现多线程并发访问:
#pragma omp parallel通知编译器创建一个线程组,接下来的代码块 {} 全员并行执行#pragma omp master通知编译器:虽然上面开了多个线程,但 {} 里的活儿只让“主线程”执行。omp_get_num_threads()调用 OpenMP 函数,获取当前线程池里的线程总数。- 准备一个计数器,初始为 0
#pragma omp atomic通知编译器:执行原子操作,对每一个实际参与的线程自加,打印最终的线程数。
OPENMP 基于 Runtime Control 机制来获取线程数,权力被交给了运行环境
当运行编译好的 STREAM 程序时,线程数的确定遵循以下优先级(从高到低):
a. 环境变量 OMP_NUM_THREADS
可以在启动程序前手动指定
export OMP_NUM_THREADS=16 # 设置为 16 线程 ./stream
b. 操作系统的默认配置
如果没有设置环境变量,OpenMP 库通常会调用硬件接口,默认开启等同于 CPU 逻辑核心数的线程。如果你的 CPU 是 8 核 16 线程,它通常会默认启动 16 个线程
4. 测试时间标准
测试所需的系统时钟是非常重要的,如果运行的计时器精度太低,比如只有 10ms,但是整个测试只跑了 5ms,那么计算结果将会是 0 或者误差极大。
/* Get initial value for system clock. */
#pragma omp parallel for
for (j=0; j<STREAM_ARRAY_SIZE; j++) {
a[j] = 1.0;
b[j] = 2.0;
c[j] = 0.0;
}
printf(HLINE);
if ( (quantum = checktick()) >= 1)
printf("Your clock granularity/precision appears to be "
"%d microseconds.\n", quantum);
else {
printf("Your clock granularity appears to be "
"less than one microsecond.\n");
quantum = 1;
}
t = mysecond();
#pragma omp parallel for
for (j = 0; j < STREAM_ARRAY_SIZE; j++)
a[j] = 2.0E0 * a[j];
t = 1.0E6 * (mysecond() - t);
printf("Each test below will take on the order"
" of %d microseconds.\n", (int) t );
printf(" (= %d clock ticks)\n", (int) (t/quantum) );
printf("Increase the size of the arrays if this shows that\n");
printf("you are not getting at least 20 clock ticks per test.\n");
printf(HLINE);
printf("WARNING -- The above is only a rough guideline.\n");
printf("For best results, please be sure you know the\n");
printf("precision of your system timer.\n");
printf(HLINE);
源码通过 checktick 函数获取了最小时间颗粒度,如果返回值是 1 说明最小可感知时间粒度是 1ms,而如果系统时钟精度比 1ms 还小,也会被近似为 1 计算。
另一方面,上述代码跑了一个最简单的 SCALE 操作,计算了开始时间,执行完循环后计算耗时,并乘以 1.0E6 转换成微秒,并打印输出出来,如果 (t/quantum) 比 20 小就说明在测试运行期间,系统时钟一共跳动(Tick)了不到 20 次。
以 20 次为基准,说明测试持续的时间足够长,足以让计时器的随机误差稀释到 以下, 这样测得的带宽数据才是稳定、科学的。
5. 主测试程序
测试主程序比较简单:
- 首先记录每轮测试的起始时间
- 计算完一轮后更新测试所用时间
- 总结阶段,排除第一轮测试,得到每一种 kernel 计算的平均时间,最小时间和最大时间
- 输出相关信息,最大带宽,平均时间,最小时间和最大时间
/* --- MAIN LOOP --- repeat test cases NTIMES times --- */
scalar = 3.0;
for (k=0; k<NTIMES; k++)
{
times[0][k] = mysecond();
#pragma omp parallel for
for (j=0; j<STREAM_ARRAY_SIZE; j++)
c[j] = a[j];
#endif
times[0][k] = mysecond() - times[0][k];
times[1][k] = mysecond();
#pragma omp parallel for
for (j=0; j<STREAM_ARRAY_SIZE; j++)
b[j] = scalar*c[j];
#endif
times[1][k] = mysecond() - times[1][k];
times[2][k] = mysecond();
#pragma omp parallel for
for (j=0; j<STREAM_ARRAY_SIZE; j++)
c[j] = a[j]+b[j];
#endif
times[2][k] = mysecond() - times[2][k];
times[3][k] = mysecond();
#pragma omp parallel for
for (j=0; j<STREAM_ARRAY_SIZE; j++)
a[j] = b[j]+scalar*c[j];
#endif
times[3][k] = mysecond() - times[3][k];
}
/* --- SUMMARY --- */
for (k=1; k<NTIMES; k++) /* note -- skip first iteration */
{
for (j=0; j<4; j++)
{
avgtime[j] = avgtime[j] + times[j][k];
mintime[j] = MIN(mintime[j], times[j][k]);
maxtime[j] = MAX(maxtime[j], times[j][k]);
}
}
printf("Function Best Rate MB/s Avg time Min time Max time\n");
for (j=0; j<4; j++) {
avgtime[j] = avgtime[j]/(double)(NTIMES-1);
printf("%s%12.1f %11.6f %11.6f %11.6f\n", label[j],
1.0E-06 * bytes[j]/mintime[j],
avgtime[j],
mintime[j],
maxtime[j]);
}
printf(HLINE);
/* --- Check Results --- */
checkSTREAMresults();
printf(HLINE);
return0;
这里计算最大带宽所用到的公式是:
![]()
即每一种 kernel 测试所有字节数(MB) 最小耗费时间(s)
前者在源码中定义如下:这里前两种 kernel 计算涉及 1 读 1 写,因此系数为 2,后两种 kernel 计算是 2 读 1 写,因此系数为 3。
static double bytes[4] = {
2 * sizeof(STREAM_TYPE) * STREAM_ARRAY_SIZE,
2 * sizeof(STREAM_TYPE) * STREAM_ARRAY_SIZE,
3 * sizeof(STREAM_TYPE) * STREAM_ARRAY_SIZE,
3 * sizeof(STREAM_TYPE) * STREAM_ARRAY_SIZE
};
最后一步是结果校验,由于 STREAM 运行了大量的浮点数运算,为了防止硬件故障、编译器优化过度(导致计算结果错误)或者超频不稳导致的数据位翻转,程序会:
- 根据数学公式计算出预期的 a[j], b[j], c[j] 最终值
- 将实际数组里的值与预期值对比
- 如果误差超过了 epsilon,就会报错
具体分析本文暂不展开。
参考来源:
- https://github.com/jeffhammond/STREAM
- https://www.cs.virginia.edu/stream/ref.html
- https://www.amd.com/en/developer/zen-software-studio/applications/spack/stream-benchmark.html
欢迎关注微信公众号:


浙公网安备 33010602011771号