高速缓存

延迟

  • 从书的p416了解到,2015年,SRAM的访问时间只需1.3ns,同时期,cpu主频为3Ghz,时钟周期约为0.33ns,因此SRAM访问一次大概需要4个时钟周期,而这,也差不多就是L1的访问延迟,L2大约10个时钟周期内,L3大约50个时钟周期内,这些数据可以在p425的表格和段落中找到,和博文中的数据基本能对得上
  • 倒也不用刨根问底,费劲心思地去弄明白L1,L2,L3的访问延迟到底是多少ns,多少时钟周期,怎么样能充分地利用L1,L2,L3才是重要的事

布局

高速缓存是和cpu封装在一起的,其布局方式如下所示

cpu
+-------------------------------------------------+
| core 0                    core N                |
| +-------------------+     +-------------------+ |
| | +------+ +------+ |     | +------+ +------+ | |
| | | L1-d | | L1-i | |     | | L1-d | | L1-i | | |
| | +------+ +------+ |     | +------+ +------+ | |
| |     |       |     | ... |     |       |     | |
| | +---------------+ |     | +---------------+ | |
| | |       L2      | |     | |       L2      | | |
| | +---------------+ |     | +---------------+ | |
| +-------------------+     +-------------------+ |
|          |                          |           |
| +---------------------------------------------+ |
| |                      L3                     | |
| +---------------------------------------------+ |
+-------------------------------------------------+
  • 每个核都有自己的L1和L2,L3是所有核共享的
  • L1又分为只保存数据的L1-d和只保存指令的L1-i

大小

搜集了一些cpu的高速缓存信息,整理为如下表格

cpu 高速缓存 组数 相联度 块大小 总大小 备注
Core i5-3337U L1-d 64 8 64B 2x32KiB
双核四线程 L1-i 64 8 64B 2x32KiB
1.8Ghz L2 512 8 64B 2x256KiB
- L3 4096 12 64B 3MiB
Core i5-1155G7 L1-d 64 12 64B 4x48KiB
四核八线程 L1-i 64 8 64B 4x32KiB
2.5Ghz L2 1024 20 64B 4x1280KiB
- L3 16384 8 64B 8MiB
Xeon E5-2609 v4 L1-d 64 8 64B 8x32KiB 这个网站的数据可以作为参考
八核八线程 L1-i 64 8 64B 8x32KiB
1.7Ghz L2 512 8 64B 8x256KiB
- L3 16384 20 64B 20MiB
Xeon E5-2623 v4 L1-d 64 8 64B 4x32KiB 这个网站的数据可以作为参考
四核八线程 L1-i 64 8 64B 4x32KiB
2.6Ghz L2 512 8 64B 4x256KiB
- L3 8192 20 64B 10MiB
Xeon E5-2680 v4 L1-d 64 8 64B 14x32KiB 这个网站的数据可以作为参考
十四核二十八线程 L1-i 64 8 64B 14x32KiB
2.4Ghz L2 512 8 64B 14x256KiB
- L3 35840 16 64B 35MiB
Xeon Bronze 3106 L1-d 64 8 64B 8x32KiB 这个网站的数据可以作为参考
八核八线程 L1-i 64 8 64B 8x32KiB
1.7Ghz L2 1024 16 64B 8x1MiB
- L3 16384 11 64B 11MiB
Xeon Silver 4210R L1-d 64 8 64B 10x32KiB 本身是虚拟机,getconf和系统文件的值对不上,机器上查出来的值和搜索引擎更是对不上
十核二十线程 L1-i 64 8 64B 10x32KiB
2.4Ghz L2 10x1MiB
- L3 14080KiB
Xeon Silver 4215R L1-d 64 8 64B 8x32KiB 和Xeon Bronze 3106的值一模一样哦
八核十六线程 L1-i 64 8 64B 8x32KiB
3.2Ghz L2 1024 16 64B 8x1MiB
- L3 16384 11 64B 11MiB
  • 以上数据综合对比了以下几个信息来源
    • 工具cpu-z
    • 命令lscpu
    • 命令getconf -a | grep "CACHE"
    • 文件/proc/cpuinfo
    • 文件/sys/devices/system/cpu/cpu0/cache/*/*
    • 搜索引擎

结构

虽然说高速缓存有L1,L2,L3这么些层,但是其数据的组织结构都是一样的,如下所示

cache
+------------------------------------------+
|       +--------------------------------+ |
|       |        +---------------------+ | |
|       | line 0 | valid | tag | block | | |
|       |        +---------------------+ | |
|       |                  .             | |
| set 0 |                  .             | |
|       |                  .             | |
|       |        +---------------------+ | |
|       | line N | valid | tag | block | | |
|       |        +---------------------+ | |
|       +--------------------------------+ |
|                       .                  |
|                       .                  |
|                       .                  |
|       +--------------------------------+ |
|       |        +---------------------+ | |
|       | line 0 | valid | tag | block | | |
|       |        +---------------------+ | |
|       |                  .             | |
| set N |                  .             | |
|       |                  .             | |
|       |        +---------------------+ | |
|       | line N | valid | tag | block | | |
|       |        +---------------------+ | |
|       +--------------------------------+ |
+------------------------------------------+
  • 缓存包含若干个组(set)
  • 每个组包含若干个行(line)
  • 每一行包含这么几个字段
    • 有效位(valid):一个bit,指明这个行是否包含有意义的信息
    • 标记位(tag):多个bit,唯一地标识存储在这一行里的数据块
    • 数据块(block):缓存的数据

  • 每个组只包含一行的高速缓存称为直接映射高速缓存
  • 每个组有N(N>1)行的高速缓存称为N路组相联高速缓存
  • 一个组包含所有行的高速缓存称为全相联高速缓存

高速缓存更新的最小单位是块

访问

只需检查地址中的位,就能知道高速缓存是否缓存了想要的数据,因此,一个n位的地址被分成了三个字段,如下所示

n-1                    0
+----------------------+
| tag | index | offset |
+----------------------+
  • 标记位(tag):用于与高速缓存中的tag进行匹配
  • 索引(index):用于索引高速缓存的组
  • 偏移(offset):用于指示高速缓存中数据块的字偏移量

因此,高速缓存想要确定一个请求是否命中,只需以下三步

  1. 组选择:根据地址中间的几位(index),找到对应的组
  2. 行匹配:找到组之后遍历每一行,遍历的时候先看下有效位是否置位,置位的话再看下tag是否匹配
  3. 字偏移:匹配到具体的行之后根据偏移(offset)取出相应的字

高速缓存用来选择组和标识行的机制极其简单,硬件必须在几个纳秒的时间完成这些工作

答疑

  • 前面提到的组,行,标记位,块大小等等,它们的数量或大小都是任意的吗?

    1. 当然不是,假设地址总共有m位,其中tag有t位,index有s位,offset有b位,则,tag的位数对应了高速缓存中tag的位数,\(2^s\)大小的组数对应了高速缓存中的组数,\(2^b\)大小的字节数对应了高速缓存中的块大小
    2. 地址的位数肯定是固定,L1,L2,L3的块大小也基本都是64B,即offset基本不会变,但L1,L2,L3的组数好像都不一样,所以,在对不同的层进行访问时,tag和index的位数可能还都不一样得嘞
    3. 一个地址的中间几位决定了它将被缓存在哪个组,那么,必然存在,每隔\(2^{s+b}\)字节的内存,会存放在同一个组中,如果非常不巧,你操作的两组内存就是需要缓存在同一组中,且行的数量也满足不了操作的两组内存的大小(书p429的例子更极端,每个组只有一行,即直接映射高速缓存),就会导致缓存冲突,此时,即使程序有良好的空间局部性,高速缓存空间也够,但高速缓存还是在反复地加载和驱逐相同的高速缓存块,术语抖动(thrash)用来描述这种情况
  • 高速缓存没命中怎么办?

    1. 那当然是从内存中把包含这个字的块加载过来喽
    2. 加载过来的时候如果组里面有空行那就直接放进去就好了,如果没有空行那就得替换掉某一行了,可能会使用最不常使用(Least-Frequently-Used,LFU),最近最少使用(Least-Recently-Used,LRU)等策略来判断具体替换哪一行
    3. 虽然高速缓存更新的最小单位是块,但cpu还有一种叫预加载(Prefetching)的技术,使得一次可能更新更多的数据

更新

我们不光从高速缓存中读,也会往里写,进行数据更新,写有两种方式

  • 直写(write-through):马上更新到紧接着的低一层的副本中
  • 写回(write-back):尽可能推迟更新,比如要驱逐这个更新过的块时才更新到紧接着的低一层的副本中

一般用的肯定是写回策略


要写的那块内存如果在高速缓存中,那就是写命中(write hit),如果没命中,那就又分成两种情况了

  • 写分配(write-allocate):把那块内存加载到高速缓存,然后进行更新
  • 非写分配(non-write-allocate):避开高速缓存,直接把字更新到内存

直写高速缓存通常是非写分配的,写回高速缓存通常是写分配的


同步

书上没有介绍多核之间缓存一致性等相关内容,以下为查阅资料后自己的理解

  • 块大小的内存一路加载到L1,cpu才能访问,每个核又都有自己的L1,如果有多个核访问同一块大小的内存,那么,这个块在多个核的L1就肯定都会有一个副本
  • 多个核可能访问的是这个块的不同的字,例如,核0访问字0,核1访问字1,然而,缓存更新的最小单位是块,所以核之间进行数据同步的最小单位应该也是块
  • 就是说,只要其中一个核更新了这个块的数据,其他几个访问这个块的核也必须同步地更新这个块的数据

了解到的有Directory协议Snoopy协议MESI状态机及其变种MOESIMESIF,这么些个解决多核之间缓存一致性问题的设计,数据频繁地在多个核之间进行同步,肯定也会损失一些性能,所以尽量不要多线程地更新块大小的内存?

实践

例1

该程序用不同的步长和不同的访问次数尝试验证大步长访问可能导致的缓存冲突

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/time.h>

#define STEP_MAX  32 * 1024
#define COUNT_MAX 20
#define CIRCLE_MAX 10 * 1000 * 1000

int* data = NULL;
const int dataSize = STEP_MAX * COUNT_MAX;

int main(int argc, char* argv[])
{
    data = (int*)malloc(dataSize * sizeof(int));

    int count = 0;
    int i = 0;
    int j = 0;
    int k = 0;
    int l = 0;
    int m = 0;
    int result[COUNT_MAX][6] = {0};
    struct timeval timestampStart;
    struct timeval timestampEnd;

    for (i = 0; i < COUNT_MAX; i++, m = 0)
    {
        for (j = 1023; j < STEP_MAX; )//此次跳跃的步长
        {
            // 每次访问都重置数据内容
            srand(time(NULL));
            for (k = 0; k < dataSize; k++)
            {
                data[k] = rand();
            }

            gettimeofday(&timestampStart, NULL);

            for (k = 0; k < CIRCLE_MAX; k++)//重复次数
            {
                for (l = 0; l < i; l++)//总共要跳跃几次
                {
                    count += data[l * j];
                }
            }

            gettimeofday(&timestampEnd, NULL);

            printf("count=%d, step=%d, m=%d, delta sec=%ld, usec=%ld\n",
                   i + 1,
                   j,
                   m,
                   timestampEnd.tv_sec - timestampStart.tv_sec,
                   timestampEnd.tv_usec - timestampStart.tv_usec);

            result[i][m] = timestampEnd.tv_usec - timestampStart.tv_usec;

            m++;
            j = (1 << (m + 10)) - 1;

            sleep(5);
        }
    }

    printf("-|");
    for (i = 0; i < 6; i++)
    {
        printf("%d|", 1 << (i + 10));
    }
    printf("\n");

    for (i = 0; i < COUNT_MAX; i++)
    {
        printf("%d|", i + 1);
        for (j = 0; j < 6; j++)
        {
            printf("%d|", result[i][j]);
        }
        printf("\n");
    }

    return 0;
}

最终得到了如下的结果,横轴是步长,纵轴是访问次数,时间单位是微秒

- 1024 2048 4096 8192 16384 32768
1 8172 10002 8779 8980 8334 10833
2 15844 15849 15813 15833 15710 15835
3 27121 27349 27663 27347 27248 28037
4 40592 40698 40643 40492 40555 40801
5 54777 54510 54435 54720 54620 -
6 68392 68434 68819 68737 73400 72319
7 82326 82483 - 82309 88323 86804
8 96395 96760 97184 96517 103672 101047
9 110706 110623 111138 110722 120002 115904
10 125181 - 125114 125835 135281 130511
11 139639 - 139536 139197 151250 148204
12 154150 - 153986 168941 169224 166367
13 - 168756 168577 177494 186343 182848
14 - 184668 183677 193554 - 198631
15 198517 207602 199160 209848 219260 215120
16 214364 - 213509 229545 237940 -
17 230105 229864 230555 - 266303 248097
18 - 243905 244135 262743 - 275074
19 288215 - 288427 323662 - 323618
20 309008 - 318165 340938 - 341031

时间的增长似乎比较得平稳,没有发现什么猛然增长的地方,其实,步长从几十,几百到几兆,几十兆都试过,其结果也是如此,可能还有哪里没考虑到吧

例2

该程序用于遍历一个二维数组,分别用了逐行遍历和逐列遍历

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <sys/time.h>

# define ROW 1024 * 1024
# define COL 64

int main(int argc, char* argv[])
{
    int i = 0;
    int j = 0;
    int** matrix = (int**)malloc(ROW * sizeof(int*));
    for (i = 0; i < ROW; i++)
    {
        matrix[i] = (int*)malloc(COL * sizeof(int));

        for (j = 0; j < COL; j++)
        {
            matrix[i][j] = rand();
        }
    }

    struct timeval timestampStart;
    gettimeofday(&timestampStart, NULL);

    int count = 0;
    // 逐行遍历
    for (i = 0 ; i < ROW; i++)
    {
        for (j = 0; j < COL; j++)
        {
            count += matrix[i][j];
        }
    }

    // 逐列遍历
    for (i = 0 ; i < COL; i++)
    {
        for (j = 0; j < ROW; j++)
        {
            count += matrix[j][i];
        }
    }

    struct timeval timestampEnd;
    gettimeofday(&timestampEnd, NULL);

    printf("delta sec=%ld, usec=%ld\n",
           timestampEnd.tv_sec - timestampStart.tv_sec,
           timestampEnd.tv_usec - timestampStart.tv_usec);

    return 0;
}

逐行遍历耗时108ms左右,逐列遍历耗时446ms左右,4倍的时间差距,可见,逐列遍历对高速缓存的运作方式并不友好

例3

该程序用于统计一个数组内偶数的个数,并通过多线程拆分任务

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <sys/time.h>

#define THREAD_NUM 2
#define DATA_SIZE 16 * 1024 * 1024

int* data = NULL;
int result[THREAD_NUM] = {0};//收集结果的数组
const int chunkSize = DATA_SIZE / THREAD_NUM;

void* threadFunc(void* arg)
{
    int id = *((int*)arg);
    int start = id * chunkSize;
    int end = start + chunkSize;

    for (int i = start; i < end; ++i)
    {
        if ((data[i] & 0x01) == 0)
        {
            ++result[id];
        }
    }
}

int main(int argc, char* argv[])
{
    data = (int*)malloc(DATA_SIZE * sizeof(int));
    int i = 0;
    for (i = 0 ; i < DATA_SIZE; i++)
    {
        data[i] = rand();
    }

    struct timeval timestampStart;
    gettimeofday(&timestampStart, NULL);

    pthread_t pid[THREAD_NUM];
    int arg[THREAD_NUM];
    for (i = 0; i < THREAD_NUM; i++)
    {
        arg[i] = i;
        pthread_create(&pid[i], NULL, threadFunc, &arg[i]);
    }

    for (i = 0; i < THREAD_NUM; i++)
    {
        pthread_join(pid[i], NULL);
    }

    struct timeval timestampEnd;
    gettimeofday(&timestampEnd, NULL);

    for (i = 0; i < THREAD_NUM; i++)
    {
        printf("id=%d, result=%d\n", i, result[i]);
    }

    printf("delta sec=%ld, usec=%ld\n",
           timestampEnd.tv_sec - timestampStart.tv_sec,
           timestampEnd.tv_usec - timestampStart.tv_usec);

    return 0;
}

线程数取2时耗时150ms左右,取4时耗时160ms左右,怎么线程数越多还跑得越慢了?原因就在于存放结果的result数组,它的大小意味着它肯定是放在高速缓存的某一个块里的,然后又有多个线程需要对它进行写操作,上面的代码意味着这个块的数据需要不断地在多个核之间进行同步,最终导致了这个奇怪的现象

知道原因就好办了,线程函数里面用临时变量存储数据,最后放到result即可

void* threadFunc(void* arg)
{
    int id = *((int*)arg);
    int count = 0;
    int start = id * chunkSize;
    int end = start + chunkSize;

    for (int i = start; i < end; ++i)
    {
        if ((data[i] & 0x01) == 0)
        {
            ++count;
        }
    }

    result[id] = count;
}

改进后,线程数取2时耗时47ms左右,取4时耗时35ms左右

参考

《深入理解计算机系统》原书第3版
与程序员相关的CPU缓存知识
多核Cache一致性

posted @ 2023-06-04 22:23  start-from-ling  阅读(232)  评论(0)    收藏  举报