缓存性能的优化方法

时间:2023-02-02 05:56:17

基于 《计算机体系结构:量化研究方法》2.2 缓存性能的10种高级优化方法

因为CPU和主存的性能差距越来越大,所以在CPU和主存之间增加了多级高速缓存,如L1 cache、L2 cache,来提升性能。

缓存设计时需要考虑以下度量

  • 命中时间:在缓存中找到指定的数据的时间。
  • 缺失率:当缓存中未找到指定数据的概率。
  • 缺失代价:当缓存未命中恢复到缓存命中的代价
  • 功耗
  • 缓存带宽

可以通过一些方法来针对这些度量进行优化,将这10种方法分为5类

  • 缩短命中时间:小而简单的第一级缓存和路预测。这两种技术通常还能降低功耗。
  • 增加缓存带宽:流水化缓存、多组缓存和无阻塞缓存。这些方法对功耗有不确定影响
  • 降低缺失代价:关键字优化,合写缓冲区。这两种优化方法对功耗影响很小
  • 降低缺失率:编译器优化。该方法可以降低功耗
  • 通过并行降低缺失代价或缺失率:硬件预取和编译器预取。这些方法会增加功耗,主要是因为提前取出了未用到的数据。

小而简单的第一级缓存

大容量的L1 cache会影响时钟频率,因此L1 cache的容量涨幅很小,甚至根本没有增长。L1 cache大小有限,就无法和主存地址一一映射,因此必然需要一个地址映射方式,即将主存地址转换为cache的地址,而映射方式的选择会影响命中时间。

了解到以下几种映射方式

  • 全相联:主存的任一地址能映射到任意cache地址。cache利用率高,但是查找时需要和全部cache内容比较,命中时间长。这相当于是在一个数组中读写数据,写入时可以写到任意空位,这也导致读取时需要遍历整个数组。
  • 直接相联:主存的地址映射到固定的cache地址。cache利用率低且冲突率高,但命中时间短。这相当于是通过取模运算在数组中读写数据,只需要一次计算。热点数据可能映射到一个位置,就会出现反复替换的情况。
  • 组相联:将cache分组,每个组包含多个cacheline,组间直接相联,组内全相联。组内全相连意味着当多个热点数据映射到一个组时,就有多个位置可以存储,减少了替换的次数。当组内只有一个cacheline时,就相当于是直接相联。

cacheline是cache的最小单位,一般为64字节。上述的地址只能是cacheline对齐的地址。

常见的是组相联

路预测

对需要访问的cacheline做预测,在下一次缓存访问时优先尝试这些cacheline。因为在组内使用全相联,读取时需要比较组内所有cacheline,通过预测的方式,可以直接先检查部分cacheline,如果预测正确就减少了查找时间。

实现缓存访问的流水化

将缓存访问细粒度拆分为多个步骤,就能采用流水线模式了,这使缓存访问的实际延迟分散到多个时钟周期,从而缩短时钟周期时间、提高缓存带宽,但会减缓命中速度。
流水线深度增加会增加预测错误分支的代价,延长从发送装载指令到使用数据的时钟周期数,但这更便于使用高相联度的缓存。

采用无阻塞缓存

只理解到一点,不能因为缓存未命中而导致CPU停顿,对于支持乱序执行的CPU,其可以继续从指令缓存中提取执行其他不相关的指令。

采用多种缓存

将缓存划分为几个相互独立支持同时访问的缓存组,提高并行度。

关键字优先和提前重启动

缓存加载数据的最小单位是cacheline,但是CPU需要的数据可能只是其中的一个字节,因此

  • 关键字优先:优先加载CPU所需的数据,当加载完CPU所需的数据时可以直接发给CPU,缓存开始加载其他数据。
  • 提前重启动:按照正常顺序加载数据,但是当加载完CPU所需的数据时直接发给CPU,让CPU继续执行,而缓存继续加载剩余的数据

合并写缓存区

如果缓存区已经存在数据,当CPU再次写入数据时,检查这些数据是否属于同一个cacheline,如果是则可以合并写入
多字写入通常快于每次一个字的写入操作。

采用编译器优化以降低缺失率

循环交换

通过循环交换,使内循环满足空间局部性,提高了cache命中率,达到性能提升的目的。
比如下面代码的两个循环,Loop 1的耗时远小于Loop 2的耗时。

#include <stdio.h>
#include <array>
#include <time.h>

const int I = 50000;
const int J = 1000;

int main()
{
    std::array<int, J>* a = new std::array<int, J>[I];
    clock_t s = clock();
    // Loop 1
    for( int i = 0; i < I; ++i ){
        for( int j = 0; j < J; ++j ){
            a[i][j] = 2 * a[i][j];
        }
    }
    clock_t e = clock();
    printf( "loop 1 cost %u\n", e - s );
    s = clock();
    // Loop 2
    for( int j = 0; j < J; ++j ){
        for( int i = 0; i < I; ++i ){
            a[i][j] = 2 * a[i][j];
        }
    }
    e = clock();
    printf( "loop 2 cost %u\n", e - s );
    return 0;
}

根据二维数组的内存结构,我们可知Loop 1的内循环是在访问连续内存

分块

部分代码是无法通过循环交换来优化的,循环交换仅适用于内存是按行连续或按列连续的场景。如果在循环中行列都会变更,此时可以使用分块进行优化。
分块利用了时间局部性和空间局部性,即在让循环在固定分块中进行,反复使用指令和数据,避免频繁更新cache,提高cache命中率。但这种方式,我感觉实现起来比较麻烦,需要兼容不同底层,应该不太会用。

对指令和数据进行硬件预取

在取指令和数据时,预取下一批指令和数据,因为局部性原理,这一般能降低缺失代价和缺失率,但是有时会降低性能。

用编译器控制预期

由编译器来判断,预取那些指令和数据

  • 寄存器预取:将数据值载入到一个寄存器中。
  • 缓存预取:仅将数据载入缓存中。

循环是重要的预取优化目标,因为其本身就很适合预取。发出预取指令会增加指令开销,因此编译器必须非常小心。

使用www.onlinegdb.com编译执行示例代码
吐槽一下公司竟然还在用10年前的编译器