探索内存带宽

  本文译自博客文章 《Achieving maximum memory bandwidth

  原文作者 Alex Reece 。


探索内存带宽

TL;DR: 使用 Non-temporal 指令或者优化后的一种字符串指令来跑满带宽。

内存带宽是什么

  当分析计算机程序性能的时候,了解程序所涉及的硬件是很重要的。对于一个内存系统来说有两个很重要的指标:

  • 内存延迟(从指令请求的到达到其数据准备完毕的一段时间)
  • 内存带宽(在一定时间内所能传输的数据量)

  内存的理论带宽很容易求得,比如说我的笔记本有两条DDR3 SDRAM以1600MHz的频率运行着,每条内存连接着64bit宽的总线,于是理论内存带宽就是25.6GB/s。(译者注:$1600\times 10^6 \times 2\times 64 / 8 = 25.6\times 10^9B/s$,式中的2是双通道的,有说法是DDR的两倍已经算在了1600MHz的频率中)
  这就表示不论我如何优化我的程序,在1s内我顶多只能传输25.6GB的数据。而且现实中也难以达到理论值。



内存带宽的测量

  为了测量内存带宽,我写了一个简单的评测。对于每个函数来说,我在内存中开了一个很大的数组,并且通过数据量除以运行时间来计算出带宽。
  比如说如果一个函数花了120ms在内存中取得1GB的数据,就计算带宽为8.33GB/s。为了减少测时的开销误差,我取几次重复实验中的最小时间,测试程序可以在Github上找到。

首次尝试

  一开始,我写了一个简单的C程序为数组中的每一个元素赋值:

void write_memory_loop(void* array, size_t size) {
  size_t* carray = (size_t*) array;
  size_t i;
  for (i = 0; i < size / sizeof(size_t); i++) {
    carray[i] = 1;
  }
}

  这段代码会生成如下的汇编指令:

0000000100000ac0 <_write_memory_loop>:
   100000ac0:   48 c1 ee 03             shr    $0x3,%rsi
   100000ac4:   48 8d 04 f7             lea    (%rdi,%rsi,8),%rax
   100000ac8:   48 85 f6                test   %rsi,%rsi
   100000acb:   74 13                   je     100000ae0 <_write_memory_loop+0x20>
   100000acd:   0f 1f 00                nopl   (%rax)
   100000ad0:   48 c7 07 01 00 00 00    movq   $0x1,(%rdi)
   100000ad7:   48 83 c7 08             add    $0x8,%rdi
   100000adb:   48 39 c7                cmp    %rax,%rdi
   100000ade:   75 f0                   jne    100000ad0 <_write_memory_loop+0x10>
   100000ae0:   f3 c3                   repz retq 

  程序运行的结果为9.23 GiB/s,然而这并不是所期待的带宽值(而我的最终目标是23.8 GiB/s



试试SIMD指令

  我的第一个尝试就是借助SIMD指令(单指令多数据流方式)来更多地获取到内存数据。一个现代CPU是十分复杂的,其内部含有多个ALU(算逻运算器),这使得CPU可以在一次操作中可以同时处理多块数据。

  我将会用这种操作来处理更多的数据,来使程序获得更多带宽。因为我电脑的CPU支持AVX指令集,于是我的每条指令可以一口气处理256位的数据。

#include <immintrin.h>
void write_memory_avx(void* array, size_t size) {
  __m256i* varray = (__m256i*) array;

  __m256i vals = _mm256_set1_epi32(1);
  size_t i;
  for (i = 0; i < size / sizeof(__m256i); i++) {
    _mm256_store_si256(&varray[i], vals);  // This will generate the vmovaps instruction.
  }
}

  但是就结果而言,这段程序的内存带宽为9.01 GiB/s,并没有什么提升。

  为什么我的带宽总是稍微少于理论带宽的一半呢?

  主要原因是因为内存在总线上的传输工作会在CPU的Cache里就完成了,Cache也往往要比一个256位的数据要大。为了写一个256位的数据,Cache要首先从内存里读入整个Cache行,再对其进行写入。
  这意味着我的这个仅有写操作的程序,会实际上进行两次访存(其中需要一次来读入缓存)。



试试 Non-temporal 指令

  如What every programmer should know about memory第47页所示:

These non-temporal write operations do not read a cache line and then modify it; instead, the new content is directly written to memory.

  于是我们就可以用这种方法来避免写内存时的读入的现象了:

void write_memory_nontemporal_avx(void* array, size_t size) {
  __m256i* varray = (__m256i*) array;

  __m256i vals = _mm256_set1_epi32(1);
  size_t i;
  for (i = 0; i < size / sizeof(__m256); i++) {
    _mm256_stream_si256(&varray[i], vals);  // This generates the vmovntps instruction.
  }
}

  然而程序的带宽仅为12.65 GiB/s,该结果与memset带宽12.84 GiB/s的速度相仿。



试试一种循环的字符串指令

  使用一种rep指令前缀的效果会循环执行一种特殊的字符串指令。比如说rep stosq就会循环把一个字写入到数组中。
  对于现代CPU来说,这也同样有用,我在研究了C语言内联汇编的语法后,写了这样一个函数:

void write_memory_rep_stosq(void* buffer, size_t size) {
  asm("cld\n"
      "rep stosq"
      : : "D" (buffer), "c" (size / 8), "a" (0) );
}

  这个程序的带宽为20.60 GiB/s,已经接近了理论带宽25.6 GiB/s了。但是我对于之前的 Non-temporal 指令为何相去甚远仍然没有头绪。



试试多核

  实际上使用一个核心来获取最大内存带宽确实够呛,于是我借助了OpenMP来运行基于多线程的 Non-temporal 指令测试。
  测时的时候,我令所有线程都准备完毕后开始计时,当所有线程都完毕后结束计时。于是在测时代码中,我添加了内存屏障:

#pragma omp parallel  // Set OMP_NUM_THREADS to the number of physical cores.
{
#pragma omp barrier  // Wait for all threads to be ready before starting the timer.

#pragma omp master  // Start the timer on only one thread.
start_time = monotonic_seconds();

// The code we want to time.

#pragma omp barrier  // Wait for all threads to finish before ending the timer.

#pragma omp master  // End the timer.
end_time = monotonic_seconds();
}

  这次我获得了非常理想的结果,接近了目标结果23.8 GiB/s

write_memory_avx_omp:  9.68 GiB/s
write_memory_nontemporal_avx_omp: 22.15 GiB/s
write_memory_memset_omp: 22.15 GiB/s
write_memory_rep_stosq_omp: 21.24 GiB/s

-------------本文结束-------------
0%