Cache相关知识整理

高速缓存的基本原理

参考资料:

在物理结构上,Cache由SRAM构成,SRAM比DRAM的速度快一一些,但是造价会更高。

高速缓存的结构能够用(S, E, B, m)这一个四元组来描述:

  • S : 该Cache有S个组
  • E : 每个组有E个缓存行
  • B : 每个缓存行存储B个字节的内存拷贝,这个实际上就是对内存的拷贝
  • m : 表示机器的地址位数,而S和B又将m地址分成3部分
    • 标记, t个位
    • 组索引, s个位
    • 快偏移, b个位

组索引,确定某个内存块存放于Cache的哪个组;标记用来确定这个内存块在这个组的哪个缓存行;块偏移则用来确定内存地址在这个缓存行的偏移地址。

这三个值唯一地标识了存储在高速缓存行中的块。

image-20230511203831491

给定一个内存地址,处理器按照如下步骤找到Cache中的某个内存块:

  1. 根据内存地址的组索引位定位到Cache的某个组
  2. 依次遍历这个组的缓存行,对比内存地址的标记位和缓存行中的标记位,只有当该缓存行的标记位和内存地址的标记位相同且设置了有效位的情况时,才表示处理器找到了这个缓存行
  3. 根据内存地址的快索引,找出该内存地址在该缓存行的字偏移

根据每个组中的行数E的不同,可以将Cache分成三种不同的类型

  • E = 1,即每组只包含一个行。称为直接映射高速缓存
  • E = 高速缓冲的所有行数。称为全相联高速缓存
  • E > 1 ,但不包括所有行数。称为组相联高速缓存

对于直接映射高速缓存,只需要通过组索引确定组号即可,不需要再次比对标记位。缺点是,对于映射到同一个组的内存行,只能存放在一个缓冲行,因此在某些情况下,会发生“抖动”(即反复地驱逐、加载相同的缓冲行),缓冲命中率极低

对于全相联高速缓存,每个内存行都能够存放于所有的缓冲行中,此时没有组索引,需要比对所有缓冲行的标记位。因此构造一个又大又快的全相联高速缓存是非常困难的,而且很昂贵。

对于组相联高速缓存,内存行被分配到某个组中,这个内存行可以占据这个组中的任意一个缓冲行。因此相比于直接映射高速缓存,能够有效抑制“抖动”现象;相比于全相联高速缓存,制作成本有所下降。

intel core i的高速缓存层次结构(图源CSAPP)如下:

image-20230511212535106

每个核有两个L1 cache,一个d-cache存放数据缓存,一个i-cache存放代码缓存, L3缓存则是所有核都共享。

其中L1缓存的大小为32KB,组数为64,相联度为8(即每个组有8个缓存行),块大小为64B。

常见的利用Cache进行优化的技巧

将那些经常一起获取的变量放得近一些

“近一些”是指,尽量放在同一个缓存行上。

下面是linux 2.4内核中的struct page结构,也能看到作者注释中写出了他对缓存行的考虑:

/*
 * Try to keep the most commonly accessed fields in single cache lines
 * here (16 bytes or greater).  This ordering should be particularly
 * beneficial on 32-bit processors.
 *
 */
typedef struct page {
	struct list_head list; 
    // 
	struct address_space *mapping;
	unsigned long index;    
    // 
	struct page *next_hash;
	atomic_t count;
	unsigned long flags;	
	struct list_head lru;
	unsigned long age;
	wait_queue_head_t wait;
	struct page **pprev_hash;
	struct buffer_head * buffers;
	void *virtual; /* non-NULL if kmapped */
	struct zone_struct *zone;
} mem_map_t;

其中的

struct address_space *mapping;
unsigned long index;  

这两个数据结构,mapping指向某个inode的address_space结构,它本身是一个基数树结构,而凭借index变量,可以快速定位基数树中的节点,因此这两个变量是经常被一起用的,这里将它们放入同一个缓存行,那么在使用mapping的同时,index变量也已经被加载到缓存行中了,节省了一次缓冲行加载操作,而且也减少了很多潜在的剔除、和重加载操作。

再比如taskt_struct结构,这个结构体很大,如果不加以位置限制,那么内核的操作过程中可能发生多次CacheMiss。

随便看一下其中比较靠前的属性:

struct task_struct {int prio, static_prio, normal_prio;
	unsigned int rt_priority;
	const struct sched_class *sched_class;
	struct sched_entity se;
	struct sched_rt_entity rt;

	unsigned int policy;
	cpumask_t cpus_allowed;
	// ... 
}

看到关于CPU调度方面的属性全都集中在了一起。

避免false sharing

上一节我们减小变量的大小来将尽可能多的变量放在 一个cacheline上,这一节我们要做的刚好相反,加上一些padding使得变量独占一个cache line从而避免false sharing,这可能在多线程编程中有实际应用。

假设有这样一个结构:

struct test {
    int a ;
    int b ;
}
struct test struct_test_var{0,0};

用两个线程分别去操作该结构变量的a、b

void thread_A() {
    for (int i = 0; i < 100000000; i++) {
        struct_test_var.a++;
    }
}

void thread_B() {
    for (int i = 0; i < 100000000; i++) {
        struct_test_var.b++;
    }
}
int main() {
    thread tA(thread_A);
    thread tB(thread_B);

    tA.join();
    tB.join();
    return 0;
}

这里的两个线程看似操作了不相关的两个变量,但false sharing的问题将严重降低程序的运行效率。

false sharing是指,两个线程在两个CPU上执行的时候,分别会将包含a、b的缓冲行加载到各自的Cache Line中,虽然CPU处理的是不同的变量,但是只要一个CPU对其CacheLine修改了其中一个变量,就会使得另一个CPU的CacheLine全部失效,这样CPU只好加载新的有效数据到CacheLine中。这是硬件保证缓冲一致性的重要机制,但在这里却使得CacheLine中的数据不断地加载、失效,严重降低了程序的执行效率。

一个简单的解决方法就是在变量a与b之间加上额外的字节,使得a、b分别处于两个不同的缓冲行上:

struct a{
    int a;
    char paddings[60];
    int b;
};

在Linux上可以使用 time 命令观察程序修改前后的实际执行时间,一般来说,修改后的程序执行时间将显著下降。

在我的实验机器上得出如下结果, 虚拟机配置为4核,4GB:

image-20230627112403606

可以看到加上padding后,运行效率明显提升。

控制class\struct结构的大小

假设缓冲行的大小为64字节,那么尽量使得自定义class的大小要么能够整除64,要么是64的倍数。

这与处理器的默认内存对齐有着相同的缘由,因为处理器获取内存不是一个字节一个字节地存取的,高速缓存也不是一个字节一个字节地缓冲内存,它是以块为单位,比如以64字节为一块加载到缓冲行。

那么使得class的大小要么能够整除64,要么是64的倍数,能够保证对象跨缓冲行的行数最小。

比如对象的大小为32,一个缓冲行能够完整地存放两个对象:

image-20230511222014462

但如果一个对象的大小为48字节,一个缓冲行只能存放4/3个对象:

image-20230511222330889

那么如果我们要获取第二个对象,那么处理器需要加载两个缓冲行,也会增加很多潜在的剔除、和重加载操作,影响程序运行效率。

矩阵乘法

假设有m×r的矩阵A和r×n的矩阵B,对它们做乘法得到m×n的矩阵C

C代码如下:

void multiply_matrices(int A[m][r], int B[r][n], int C[m][n]) 
{ 
    int i, j, k; 
    for (i = 0; i < m; i++) { 
        for (j = 0; j < n; j++) { 
            for (k = 0; k < r; k++) {
                C[i][j] += A[i][k] * B[k][j]; 
        } 
    } 
} 

根据线性代数的知识,这是最简单的实现也符合我们直觉的实现方式。

程序对A、B两个矩阵的遍历模式如下,A是按行遍历,B是按列遍历:

image-20230511224337144

但是处理器是按行存放举矩阵的:

image-20230511224350623

那么这就导致上方的程序的最后一个循环内,每次存取B矩阵的一个元素,都可能引起一个CacheMiss,因为存取B的前后两个元素大概率不在同一个缓存行。

有什么方法优化吗?改变对B矩阵的存取模式即可,即也按行存取B矩阵即可。对程序的改动也比较简单,只要交换里面的两个的循环次序即可

void multiply_matrices(int A[m][r], int B[r][n], int C[m][n]) 
{ 
    int i, j, k; 
    for (i = 0; i < m; i++) { 
        for (k = 0; k < r; k++) { // 交换里面两个循环的次序
            for (j = 0; j < n; j++) { 
                C[i][j] += A[i][k] * B[k][j]; 
        } 
    } 
} 
posted @ 2023-05-11 22:52  别杀那头猪  阅读(252)  评论(0编辑  收藏  举报