顺序访问和随机读取:
随机访问的效率当然比顺序访问低的多。
其中一个原因当然是:随机访问只会访问到其中一个float,而这导致他附近的64字节都被读取到缓存了,但实际只用到了其中4字节,之后又没用到剩下的60字节,导致浪费了94%的带宽。
虽说连续、顺序访问是最理想的,然而在使用哈希表等数据结构中,不可避免的会通过哈希函数得到随机的地址来访问,且Value类型可能小于64字节,浪费部分带宽。怎么办?
解决方法就是,把数据按64字节大小分块。随机访问时,只随机块的位置,而块的内部仍然按顺序访问
可以看到,尽管已经按照64字节读取,已经没有浪费缓存行了,但是还是比顺序访问慢了一点点 。
这是因为缓存行预取技术:吃着一碗饭的同时,先喊妈妈烧下一碗饭;
其实,当程序顺序访问 a[0], a[1] 时,CPU会智能地预测到你接下来可能会读取 a[2],于是会提前给缓存发送一个读取指令,让他读取 a[2]、a[3]。缓存在后台默默读取数据的同时,CPU自己在继续处理 a[0] 的数据。这样等 a[0], a[1] 处理完以后,缓存也刚好读取完 a[2] 了,从而CPU不用等待,就可以直接开始处理 a[2],避免等待数据的时候CPU空转浪费时间。
这种策略称之为预取(prefetch),由硬件自动识别你程序的访存规律,决定要预取的地址。一般来说只有线性的地址访问规律(包括顺序、逆序;连续、跨步)能被识别出来,而如果你的访存是随机的,那就没办法预测。遇到这种突如其来的访存时,CPU不得不空转等待数据的抵达才能继续工作,浪费了时间。
而对应的解决方法就是:用更大的分块随机访问,多弄几个缓存行
比如这一次就是一个块里64个缓存行,就可以达到预取了。
constexpr size_t n = 1 << 27; // 512MB
std::vector<float> a(n);
static uint32_t randomize(uint32_t i) {
i = (i ^ 61) ^ (i >> 16);
i *= 9;
i ^= i << 4;
i *= 0x27d4eb2d;
i ^= i >> 15;
return i;
}
void BM_ordered(benchmark::State& bm) {
for (auto _ : bm) {
#pragma omp parallel for
for (size_t i = 0; i < n; i++) {
benchmark::DoNotOptimize(a[i]);
}
benchmark::DoNotOptimize(a);
}
}
BENCHMARK(BM_ordered);
void BM_random_4B(benchmark::State& bm) {
for (auto _ : bm) {
#pragma omp parallel for
for (size_t i = 0; i < n; i++) {
size_t r = randomize(i) % n;
benchmark::DoNotOptimize(a[r]);
}
benchmark::DoNotOptimize(a);
}
}
BENCHMARK(BM_random_4B);
void BM_random_64B(benchmark::State& bm) {
for (auto _ : bm) {
#pragma omp parallel for
for (size_t i = 0; i < n / 16; i++) {
size_t r = randomize(i) % (n / 16);
for (size_t j = 0; j < 16; j++) {
benchmark::DoNotOptimize(a[r * 16 + j]);
}
}
benchmark::DoNotOptimize(a);
}
}
BENCHMARK(BM_random_64B);
void BM_random_4KB(benchmark::State& bm) {
for (auto _ : bm) {
#pragma omp parallel for
for (size_t i = 0; i < n / 1024; i++) {
size_t r = randomize(i) % (n / 1024);
for (size_t j = 0; j < 1024; j++) {
benchmark::DoNotOptimize(a[r * 1024 + j]);
}
}
benchmark::DoNotOptimize(a);
}
}
BENCHMARK(BM_random_4KB);
void BM_random_4KB_aligned(benchmark::State& bm) {
float* a = (float*)_mm_malloc(n * sizeof(float), 4096);
memset(a, 0, n * sizeof(float));
for (auto _ : bm) {
#pragma omp parallel for
for (size_t i = 0; i < n / 1024; i++) {
size_t r = randomize(i) % (n / 1024);
for (size_t j = 0; j < 1024; j++) {
benchmark::DoNotOptimize(a[r * 1024 + j]);
}
}
benchmark::DoNotOptimize(a);
}
_mm_free(a);
}
BENCHMARK(BM_random_4KB_aligned);
那为什么4KB是刚好的呢?
原来现在操作系统管理内存是用分页(page),程序的内存是一页一页贴在地址空间中的,有些地方可能不可访问,或者还没有分配,则把这个页设为不可用状态,访问他就会出错,进入内核模式。
因此硬件出于安全,预取不能跨越页边界,否则可能会触发不必要的 page fault。所以我们选用页的大小,因为本来就不能跨页顺序预取,所以被我们切断掉也无所谓。
另外,我们可以用 _mm_alloc (可以对齐到任意字节)申请起始地址对齐到页边界的一段内存,真正做到每个块内部不出现跨页现象。
/*_mm_alloc使用了处理器的高速缓存作为内存池,将较小的内存块从高速缓存中分配出去;而malloc则是直接从操作系统的堆空间中分配内存块。因此,在分配较小的内存块时,_mm_alloc的效率可能会比malloc更高。*/
void BM_random_64B_prefetch(benchmark::State &bm) {
for (auto _: bm) {
#pragma omp parallel for
for (size_t i = 0; i < n / 16; i++) {
size_t next_r = randomize(i + 64) % (n / 16);
_mm_prefetch(&a[next_r * 16], _MM_HINT_T0);
size_t r = randomize(i) % (n / 16);
for (size_t j = 0; j < 16; j++) {
benchmark::DoNotOptimize(a[r * 16 + j]);
}
}
benchmark::DoNotOptimize(a);
}
}
BENCHMARK(BM_random_64B_prefetch);
mm_prefetch对一个大小为n的数组a中的数据进行预取,在for循环中,每次取下标时都乘以16,表示第i*16 ~ (i+1)*16-1个元素,即周期为16。
在这个过程中,&_mm_prefetch(&a[next_r * 16], _MM_HINT_T0) 的作用是将a[next_r * 16]所在的cache line从内存中预加载到高速缓存或高速缓存的prefetch队列中(具体可能会根据系统和架构有所不同)。
men_bound :隐藏延迟
之前提到,1次浮点读写必须伴随着32次浮点加法的运算量,否则和只有0次加法的耗时没有任何区别,即内存带宽成唯一瓶颈的mem-bound。可是按我们理解,“1次读写+0次加法”应该会比“1次读写+8次加法”快一点点吧,因为8次加法尽管比1次读写快很多,但是毕竟还是有时间的啊,为什么会几乎没有任何区别?
这都是得益于CPU的预取机制,他能够在等待a[i+1]的内存数据抵达时,默默地做着a[i]的计算,从而只要计算的延迟小于内存的延迟,延迟就被隐藏起来了,而不必等内存抵达了再算。这就是为什么有些运算量不足32次的程序还是会无法达到mem-bound,手动预取以后才能达到,就是因为硬件预取预测失败,导致不得不等内存抵达了才能算,导致延迟隐藏失败。
成功:
失败:
为什么写入比读取慢:
按作者来说,应该是写入比读取要慢,这是因为缓存和内存通信的最小单位是缓存行:64字节。
所以当写入的力度太小的时候就会浪费带宽。
当CPU试图写入4字节时,因为剩下的60字节没有改变,缓存不知道CPU接下来会不会用到那60字节,因此他只好从内存读取完整的64字节,修改其中的4字节为CPU给的数据,之后再择机写回。
这就导致了虽然没有用到读取数据,但实际上缓存还是从内存读取了,从而浪费了2倍带宽。
但在我实际代码中显示的是写入要更快一些:
这可能是现代计算机结构对写入操作进行了优化,比如如果L2足够大的话,就会在L2里进行修改,不会再传回memory。
针对于原书中的问题,可以在写入时引入_mm_stream_si32()函数,这个函数可以代替直接赋值的操作,改为现绕开缓存,将一个4字节的写入操作挂起到临时队列,等凑满了64字节再统一写入内存,从而避免读的内存。
void BM_write_stream(benchmark::State& bm) {
for (auto _ : bm) {
#pragma omp parallel for
for (size_t i = 0; i < n; i++)
{
float value = 1;
_mm_stream_si32((int*)&a[i], *(int*)&value);
}
benchmark::DoNotOptimize(a);
}
}
BENCHMARK(BM_write_stream);
但是在这里我的电脑又出现了不对劲:
这应该是可以确定CPU自动优化了。比手动的绕过缓存写入还快。
stream 的特点:不会读到缓存里
因为 _mm_stream_si32 会绕开缓存,直接把数据写到内存,之后读取的话,反而需要等待 stream 写回执行完成,然后重新读取到缓存,反而更低效。
因此,仅当这些情况:
1.该数组只有写入,之前完全没有读取过。
2.之后没有再读取该数组的地方。
才应该用 stream 指令。
stream 的限制:最好是连续的写入
stream指令写入的地址,必须是连续的,中间不能有跨步,否则会无法合并写入。
这是因为如果存在空隙,这样挂起到临时队列的时候就没有办法合并嘛,中间隔的这一块CPU又不知道你要写进去什么,索性就放弃挂起,直接丢给缓存,这时候中间这些数据又出现了读的带宽。就不高效了。
所以为什么写入0比写入1更快:
这是因为写入0被编译器优化为memset,memset采用了stream指令。而如果写入1,就不能优化为memset指令,从而速度变慢
然而,当写入的值不是零时,编译器不能简单地将其优化为memset
。这是因为memset
函数的主要目的是将内存块设置为特定值,而不是将所有字节设置为相同的值。因此,编译器不会将写入非零值的操作优化为memset
,因为这种优化可能会导致不正确的行为。(因为默认memset会设置为0,所以编译器也默认了)。