一、CPU对内存的硬件支持
该图是CPU的总架构图,CPU有两个内存控制器(IMC),每个内存控制器上都有一个DDR PHY,DDR PHY是连接DDR内存条和内存控制器的桥梁。
每个DDR PHY有三个DDR4通道,每个通道有两个内存插槽,可以连接2条DIMM。DIMM是现代最常用的内存模块规格,即双列直插式内存模块。
二、内存硬件内部结构
内存条上每个内存颗粒叫一个Chip,Rank指的是同一组Chip的集合。同一个Rank的Chip并行工作,每个Chip的比特位共同组成64比特的数据。
假设某条内存正面有字符:16 GB 2R×8
2R表示该内存有2个Rank,×8表示每个每个内存颗粒的位宽是8比特,由于CPU每次要同时读写64比特,所以:
- 位宽为4比特的颗粒,需要16个Chip组成一个Rank
- 位宽为8比特的颗粒,需要8个Chip组成一个Rank
- 位宽为16比特的颗粒,需要4个Chip组成一个Rank
每个Chip内部是由一层层Bank组成的,Bank内部是电容的二维行列矩阵结构。
矩阵由多个方块元素组成,这个方块是最小的内存管理单位,叫内存颗粒位宽。
三、内存IO原理
(1)内存延迟
内存IO工作过程中,有几个比较重要的参数:
- CL:发送一个列地址到内存与数据开始响应之间的周期数。
- tRCD:打开一行内存并访问其中的列的最小周期数。
- tRP:发出预充电命令和打开下一行之间所需的最小时钟周期数。
- tRAS:行活动命令与发出充电命令之间的最小时钟周期数。
(2)内存IO过程
加入进程要读取内存地址0x0000的一个字节的数据,需要经过如下步骤:
- 内存控制器对每个Chip中指定行地址进行充电,需要tRP个时钟周期。
- 内存控制器向每个Chip发出打开一行内存的命令,需要tRCD个时钟周期。
- 内存控制器接着发送列地址,CL个时钟周期后,Chip就把数据聚合好了。
如果两次内存IO,行地址是同一行,那么第二次访问的时候就不需要第一二步了,Chip缓存中的数据可以直接使用。
如果行列地址变了,就是随机IO;如果行地址没变,列地址变了,就是顺序IO。在内存硬件中,随机IO比顺序IO速度慢。
四、存储性能测试
(1)延时测试
- 看各缓存大小对性能的影响
- 看访问随机性对性能的影响
测试原理就是定义数组,然后对数组进行访问,统计耗时情况。
测试步骤:
-
申请64MB内存,并进行初始化
// 内存测试区域从 2 KB 开始,最大到 64 MB #define MINBYTES (1 << 11) // 2 KB #define MAXBYTES (1 << 26) // 64 MB // 循环步长从1 到 64 字节 #define MAXSTRIDE 64 #define MAXELEMS MAXBYTES/sizeof(double) double data[MAXELEMS]; // init_data 初始化要访问的内存数据 void init_data(double *data, int n) { int i; for (i = 0; i < n; i++) { data[i] = i; } } int main() { init_data(data, MAXELEMS); }
-
数组从2KB开始,每次测试都翻倍,知道64MB。刚开始的数组在L1还能装的下,随着数组变大,逐渐到L2、L3。
for (size = MINBYTES; size <= MAXBYTES; size <<= 1) { printf("%.2f\t", get_seque_access_result(size, stride, 1)); }
-
访问顺序上,以步长为1对数组进行循环遍历。
// 内存按照一定的步长进行顺序访问 void seque_access(int elems, int stride) /* The test function */ { int i; double result = 0.0; volatile double sink; for (i = 0; i < elems; i += stride) { result += data[i]; } //这一行是为了避免编译器把循环给优化掉了 sink = result; }
-
将遍历步长设置为变量,每次都翻倍,1、2、…、32、64,。为了对数据访问局部性造成一定破坏。
for (stride = 1; stride <= MAXSTRIDE; stride=stride*2) { ... }
-
还定义了一种随机访问的方式,使用提前随机生成的数组下标。这样就彻底的破坏了局部性。
// 提前把要进行随机访问的数组下标准备好,用于随机访问测试 void create_rand_array(int max, int count, int* pArr) { int i; for (i = 0; i < count; i ++,pArr++) { int rd = rand(); int randRet = (long int)rd * max / RAND_MAX; *pArr = randRet; } return; }
-
测试的主要过程
// 运行内存访问延时测试 void run_delay_testing(){ ... // 多次实验,进行内存顺序访问延时评估 // 外层循环控制步长依次从 1 到 64,目的是不同的顺序步长的访问效果差异 // 内存循环控制数据大小依次从 2KB 开始到 64MB,目的是要保证数据大小依次超过 L1、L2、L3 for (stride = 1; stride <= MAXSTRIDE; stride=stride*2) { ... for (size = MINBYTES; size <= MAXBYTES; size <<= 1) { ... } } // 多次实验,进行内存随机访问延时评估 for (size = MINBYTES; size <= MAXBYTES; size <<= 1) { ... } }
运行结果:
可以得出以下结论:
- 在数组比较小的时候,访问延迟都很低。
- **即使是比较大的数组,如果用局部性非常好的方式来访问,延时也很低。**因为步长为1的情况下遍历数组,大部分的数据会在缓存中命中。
- **数组比较大,顺序访问步长也比较长,破坏了局部性,延时就会上涨。**步长较长时,提前缓存的64位CacheLine缓存就容易命中失败。之中情况下CPU缓存命中失败,就会到内存中的rowbuffer的数组中可能有数据,这就只需要CL个周期的延时。
- **内存随机访问性能很差。**这时连内存中的rowbuffer的数组也没有用了,需要开始真正的内存访问。
(2)带宽测试
带宽是单位时间内可以从存储中读取多大的数据。
带宽计算方式为:
width = size(MB)/time(s)
带宽的性能和延时测试一样,受内存数组大小和局部性访问是否好的影响。
带宽测试的结果和延时测试的结果基本是一样的,数组比较小,局部性访问比较好的时候,带宽就很高,因为这是大部分是在CPU的高速缓存中完成。