本文译自博客文章 《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