Loading

STREAM Benchmark - 访存性能的底层逻辑分析

针对 STREAM 测试题的研究与分析

1. 引言

STREAM benchmark 是目前衡量高性能计算(HPC)系统可持续内存带宽的事实标准;在 CPU 研发团队中,OS Booting 后第一个展开的测试题就是 STREAM,主要用于 Core 的访存性能评估。

首先明确 STREAM 测试在做什么:

STREAM 主要测量四种典型的内存操作向量内核 kernel,每个内核对硬件的要求略有不同  image

d98bd2bf-c4d6-42c6-ba83-6a8a8f714424

2. 主要参数释义

1. STREAM_ARRAY_SIZE:表明测试所需的元素个数

 

#ifndef STREAM_ARRAY_SIZE
#   define STREAM_ARRAY_SIZE 10000000
#endif

 

由于 STREAM 测试的是访存带宽,因此所有读写必须落到最终存储体,也就是 DDR 上,(考虑到软件进行一致性维护额外引入的复杂度,一般情况下不会 Disable Cache),所以必须让测试的数据集大于最后一层 Cache 的容量大小。这样最终测试结果可以排除掉 Cache 的访问速率模糊了最终计算结果。

计算方式如下:

image

假设 L3Cache 为 20M,依据作者注释的要求,三个数组要求三倍的大小,再考虑到 prefetch 的影响,作者额外引入了一个 0.8 倍的因子(现代 CPU 非常聪明,它会预测你下一步要读什么并提前存入缓存。如果数组只比缓存大一点,prefetch 算法可能依然能让大部分数据留在缓存里)。所以最终测试的大小必须要是 3.8 倍 L3Cache 的容量;

image

76Mbytes 一共有 79,691,776 个 bytes,这里单位字节数是 8 字节/元素,因为现代处理器多为 64bit 系统,且测试是基于精度更高、更符合现代科学计算需求的 double 类型,源码中标识如下:

#ifndef STREAM_TYPE
#define STREAM_TYPE double
#endif

默认情况下测试基于 10000000 个元素来进行测试,但如今高性能处理器 L3 一般都大于 20M,真实场景下依据下述公式计算:

image

计算得到实际需求 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 包括如下的几类影响:

  1. 内存页表填充 Page Faults: 当启动程序并声明了较大容量的数组时,操作系统通常只是分配了“虚拟地址空间”,并没有立刻在 DDR 里准备好这些空间。当 Core 第一次尝试读写这些数组时,会触发大量的缺页中断(Page Faults)。操作系统必须停下当前业务,去物理内存里寻找空闲页,更新页表(TLB),然后才允许 Core 访问。因此当第二轮运行时,物理内存已经映射完毕,Core 可以全速直接访问 DDR。第一次运行的时间里包含了“操作系统分配内存”的时间,不属于“内存带宽”的范畴。

  2. Cache 控制器开销: Core 中有 l2 Cache,一般在 dram 访问前也会经过 SLC / L3 Cache,当读写请求第一次经过 Cache 时,一定会触发 miss;而当第一轮执行完毕,Cache 此时已经充满数据,后续的访问会产生 replacement,将数据刷回 DDR,上述描述中的 L2->L3->DDR 替换 flow 也会占据一部分访存带宽,而在第二轮开始后,Cache 控制器会遵照特定规律的模式进行 line fill 和 replacement,使得测试误差尽可能的稳定;

  3. I-Cache 与分支预测的预热:测试时 kernel 的指令本身需要从 DDR load 到 I-cache 以进行后续的测试;另一方面,Core 中的 branch predictor 需要从一系列程序指令的执行中学习这一段代码的跳转逻辑,以达到更高的分支命中;第一次迭代时 Core 需要 Load 和 Learn。

  4. 动态频率调节 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 库来实现多线程并发访问:

  1. #pragma omp parallel 通知编译器创建一个线程组,接下来的代码块 {} 全员并行执行
  2. #pragma omp master 通知编译器:虽然上面开了多个线程,但 {} 里的活儿只让“主线程”执行。omp_get_num_threads() 调用 OpenMP 函数,获取当前线程池里的线程总数。
  3. 准备一个计数器,初始为 0
  4. #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. 主测试程序

测试主程序比较简单:

  1. 首先记录每轮测试的起始时间
  2. 计算完一轮后更新测试所用时间
  3. 总结阶段,排除第一轮测试,得到每一种 kernel 计算的平均时间,最小时间和最大时间
  4. 输出相关信息,最大带宽,平均时间,最小时间和最大时间
/* --- 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;

这里计算最大带宽所用到的公式是:

image

即每一种 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,就会报错

具体分析本文暂不展开。

参考来源:

  1. https://github.com/jeffhammond/STREAM
  2. https://www.cs.virginia.edu/stream/ref.html
  3. https://www.amd.com/en/developer/zen-software-studio/applications/spack/stream-benchmark.html

 

欢迎关注微信公众号:

847f1ec2cb60af55d142eb003c9e9dfe

 

posted @ 2026-04-22 21:21  0x40  阅读(132)  评论(0)    收藏  举报