访存优化_2、预取与直写

顺序访问和随机读取:

 随机访问的效率当然比顺序访问低的多。

其中一个原因当然是:随机访问只会访问到其中一个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,所以编译器也默认了)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
性能的提高一直是计算机研究人员孜孜不倦追求的目标。随着大规模集成电 路的发展,处理器的计算能力飞速提高。计算机性能提高的瓶颈由计算转变为存 储。存储性能是程序访存特征和特定的存储结构共同作用的结果。程序访存特征 的研究一直伴随着处理器的发展,为存储结构提供发展方向。 针对Cache结构不区分程序数据和主存物理实现一维连续的特征,本文选取 具有典型访存特征的应用程序,通过分析访存特征给出合理的存储优化方案: 1) 在共享Cache存储体系结构中,利用Simics+GEMS体系结构模拟器,分析 基于PostgreSQL数据库在线事务处理不同数据集的访存特征,建立数据分类模型, 将数据集划分为放弃型、保护型和自由竞争型三类;然后提出一种软件协同的半 透明共享Cache结构区分对待三类数据集,实验结果证明Cache失效率最高下降 率为12%。 2) 针对矩阵行列访问二维连续的特征和DRAM存储一维连续的特性,提出一 种针对行列交替访问优化方案——窗口访问,并证明了最优窗口原理,利用可 重构实验平台实现窗口访问存储控制器,实验证明矩阵行列交替访问的存储性能 提高可达73.6%,一维FFT并行算法性能可提高45.1%。 3) 基于窗口访问原理,指导CPU和GPU上矩阵数据的布局,优化矩阵行列 交替访问程序,实验证明CPU中矩阵行列交替访问的存储性能最大提高58.4%, 并实现窗口访问和FFTW结合的一维FFT并行算法,与FFTW相比计算性能提高 可达7%;分析CUDA编程框架和相应GPU的结构特点,将窗口原理应用到GPU 中以warp为单位的访存过程上,实验表明矩阵行列交替访问的存储性能提高了1 倍。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值