1、前言

先写一个简单的测试程序并且运行:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int array[1024][1024] = {0};
int main() {
    int begintime,endtime;
    printf("start\r\n");
    begintime=clock();
    for(int i = 0 ; i < 1024 ; i++)
    {
        for(int j = 0 ; j < 1024 ; j++)
        {
            array[j][i] = 1234 ;
        }
    }
    endtime = clock();
    printf("step1 Time:%dms\n", endtime-begintime);

    begintime=clock();
    for(int i = 0 ; i < 1024 ; i++)
    {
        for(int j = 0 ; j < 1024 ; j++)
        {
            array[i][j] = 1234 ;
        }
    }
    endtime = clock();
    printf("step2 Time:%dms\n", endtime-begintime);
    return 0;
}

运行结果:

PS C:\Users\13588\Desktop\c_test> ./a.exe
start
step1 Time:20ms
step2 Time:3ms

两个程序分别是数组的行遍历和列遍历,虽然结果一致,但是可以看到行遍历比列遍历的速度快了好几倍,为什么会出现这种问题呢?

2、问题分析

为了解决这个问题,必须要了解现在计算机存储系统相关的背景知识,下图是储存金字塔,这张图很直观的描述了现代计算机系统的分级存储模型

存储金字塔.png

可以认为CPU就位于金字塔的顶点上,越靠近塔顶,离CPU越近,访问速度越快,但生产成本越高,相应的容量也就越小。在所有存储器中,寄存器直接内嵌在CPU中,访问速度最快,容量也最小,一般CPU上也就最多几十个通用寄存器供应用程序使用。反之,越往下靠近塔底,访问速度越慢,生产成本越低,相应的容量也就越大。比如图中最底部的网络存储设备,相对其他存储设备而言是访问速度最慢的,但其容量却几乎可以认为是无限制的。

那么,这种金字塔式结构中,不同层级的存储设备之间究竟是如何协调工作的呢?

用一句话概括:高一级的存储设备,往往是作为低一级存储设备的缓存来使用的

简单来说,系统运行时,为了提升数据访问效率,把程序中最近最经常访问的数据,尽可能放到访问速度更快的高一级存储器中。这样一来,每次访问数据时,从金字塔的顶端开始,都先尝试在高一级存储器中查找,如果被访问的数据存在且有效,则直接访问,否则,就逐级到更低级的存储器中去查找。
这种金字塔式的分级存储模型之所以能够以近乎完美的方式运行,实际上都是建立在现代计算机科学中的一个非常重要的理论基础之上:程序的局部性原理。
一个程序的局部性,包含两个维度:时间局部性和空间局部性。

  • 时间局部性:如果一个数据在某个时间点被CPU访问了,那么在接下来很短的一段时间内,这个数据很有可能会再次被CPU访问到。
  • 空间局部性:如果一个数据在某个时间点被CPU访问了,那么与这个数据临近的其他数据,很有可能也会很快被CPU访问到。

2.1、高速缓存cache

程序要想被CPU正常执行,必须要先被加载到内存中。但是,内存的访问速度与CPU运行速度相比,要慢好几个数量级,可以想象一下,如果CPU每次都直接从内存中存取数据,会造成大量的计算资源浪费,程序性能严重受损,假如真是这样,我们的程序运行速度将难以忍受。为了解决CPU和内存之间速度严重不匹配的问题,在CPU和内存之间设计了高速缓存,即Cache。
三级缓冲.png
目前,主流CPU一般都有三级(或更多级)的Cache,依次是L1 Cache、L2 Cache、L3 Cache。其中L1 Cache速度最快,几乎可以和内嵌在CPU中的寄存器媲美,容量也最小,而L3 Cache速度最慢(但仍然比内存快很多),但容量最大。我们可以打开windows的任务管理器查看三级缓冲:
PC三级缓冲.png
CPU读取数据时,会在L1、L2、L3Cache中逐级查找,如果找到,就从Cache直接读取,找不到再从内存读取,并且把数据存放到Cache中,以便提高下次访问的效率。在这个过程中,如果在Cache中找到所需数据,称为Cache命中(Cache Hit), 找不到称为Cache未命中(Cache Miss)。不难看出,L1 Cache命中的时候,读取数据最快,性能最好,而当L1、L2、L3 Cache全部未命中时,就必须要直接从内存中读取数据,性能最差!

2.2、Cache Line

Cache Line 是 Cache和内存之间进行数据传输的最小单位。根据上文讲解的程序的局部性原理,如果一个数据被CPU访问了,那么这个数据相邻的其他数据也很快会被访问到。因此,为了提高内存数据的读取效率,并且最大化利用CPU资源,数据在Cache和内存之间传输时,不是一个字节一个字节进行传输的,而是以缓存行(Cache Line)为单位进行传输的。不同CPU的Cache line大小可能不同,典型的CPU Cache line大小是64个字节。

2.3、Cache Line 实例讲解

在一个Cache Line大小为64字节的机器上,定义个数组:

int a[16] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};

我们假设数组a的起始地址是Cache Line对齐的,可以简单理解为a的地址能被64整除。假设,数组a还从来没有被访问过,如果此时需要访问a中的一个元素a[5],如:

int x = a[5];

由于在此之前数组a没有被访问过,所以理论上讲,数组a应该只存在于内存中,并未被加载到Cache中。因此,此时CPU在Cache中找不到a[5],触发Cache Miss,然后需要从内存中读取数据,并更加载到Cache中。前面提到,Cache和内存之间是以Cache Line为单位进行数据传输的,因此,这里会把一个Cache line大小(64字节)的数据从内存读取出来加载到Cache中。由于a的起始地址恰巧是Cache line对齐的,所以CPU会把整个数组(64个字节,刚好一个Cache Line)都加载到Cache中。紧接着,如果再访问数组a的元素,如:

int y = a[10];

此时,整个数组都在Cache中,所以CPU在访问时,触发Cache Hit,直接从Cache读取数据即可,不需要再从内存中读取。

2.4、按行、列访问的真正差异 - Cache

C语言的数组,所有元素是存放在地址连续的内存中的,此外,C语言的多维数组,是按行进行存储的。

按行对数组进行访问,也就是从数组起始地址开始,一直连续访问到数组的最后一个元素的地址处。第一次访问一个Cache Line的首个元素时,触发Cache Miss,与该元素临近的几个元素会组成一个Cache Line,被一起加载到Cache中。如此,在访问下一个元素的时候,就会Cache Hit,直接从Cache读取数据即可。
按列对数组进行访问,因此并不是按照连续地址进行访问的,而是每次间隔1024 4个字节进行访问。第一次访问一个Cache Line的首个元素时,假设地址为x,尽管该元素临近的一个Cache Line大小的元素也会被一起加载进Cache中,但是程序接下来访问的并不是临近的元素,而是地址为x + 1024 4处的元素,因此会再次触发Cache Miss。而当程序回过头来访问x + 4地址处的元素时,这个Cache Line可能已经被其他数据冲刷掉了。因为,尽管Cache会尽量缓存最近访问过的数据,但毕竟大小有限,当Cache被占满时,一些旧的数据就会被冲刷替换掉。
可以看出,无论是时间局部性还是空间局部性,行遍历都要比列遍历好很多!相比行遍历,列遍历会触发大量的Cache Miss,这也是为什么列遍历的性能会如此之差!

3、cache在MCU上的应用

大概十年前,常用的微控制器的主频一般为几十 MHz,时至今日,上百 MHz 主频的 MCU 已经很常见了。比如,采用 ST 最新40nm工艺的 STM32H7 已经可以跑到 400 MHz 了,而一旦 ARM Cortex M7 发展到 28nm 技术,频率将达到 800 MHz。但仔细想想,虽然微控制器的频率大幅提高了,可是一般作为主存储器使用的动态存储器(DRAM),其存储周期仅为几十ns。那么,如果指令和数据都存放在主存储中,主存储器的速度将会严重制约整个系统的性能。高性能的微控制器会在主存储器和 CPU 之间增加高速缓冲存储器(Cache),目的是提高对存储器的平均访问速度,从而提高存储系统的性能。目前STM32F7及以上的高性能MCU就搭载了Icache与Dcache。

文章目录