存储器:层次结构、局部性
- 简单介绍计算机存储器的分成存储结构、访问速度和大小;
- 第一小节是对"局部性"的理解基础;
局部性重要点:
- 通过数据在内存中的顺序,以步长为1的方式读取数据;
- 读入了一个对象,就应该尽可能多的使用:
- 同一个内存位置要多次引用;
- 对此对象其他内存位置的使用要集中在读后后的时间内使用;
- 编译器将局部变量放在寄存器上,因此对局部变量的引用非常快(时间局部性);
- 将注意力放在常用核心函数内循环的优化上,他们是最长被执行的代码,减少内循环的缓存不命中。
局部性/缓存命中(对比VM页命中)是本节最重要的概念.
一.存储器的层次结构
1.1 层次、访问时间、块大小
一个典型的存储器层次结构分为五个部分:
- 硬盘的替代产品是SSD速度极快:
- CUP对于各个层次的存储器的访问时间:寄存器1个时钟周期,高速缓存cache/SRAM几个时钟周期,内存DRAM几百个时钟周期,硬盘访问的时钟周期是内存的十万倍,也就是打给千万个时钟周期;
- 各个层次存储器总大小(本机为例):L1_cache:32KB4,L2_cache:2562KB,L3_cache:6MB,内存12GB,SSD128GB,SATA500GB;
- 相邻层次的存储器使用大小相同的块传输数据,块大小的设定一般是层次越靠上块越小:寄存器与L1_cache之间1字(本机64位4字节),SRAM之间及与DRAM之间是几十个字节,而内存DRAM和硬盘之间可能达到几百KB或MB量级。
1.2 各层次存储器简介,详情看书
1.2.1 SRAM、DRAM和CUP对内存的访问
cache使用SRAM,即静态随机访问存储器,每位bit使用六个晶体管保存。具有双稳态特性:只要有电就会永远保持它的值,对光电干扰不敏感。
内存使用DRAM,每位对应一个电容的充电,使用一个晶体管。对光电干扰敏感,电容电压被扰乱后就不能恢复了。
SRAM和DRAm特性对比:
- 六个晶体管,因此SRAM功耗高,密集度低;
CUP访问主存
数据流在CUP和主存之间传送时通过总线事务bus transaction完成的,分为读事务和写事务,前者是从内存获取数据。
总线bus是一组并行的导线,能携带地址、数据和控制信号,地址和数据共享一组导线。控制线携带的信号会同步事务,表示出当前被执行的事务类型:
- 当前事务是读还是写;
- 读事务的话是到主存还是到磁盘控制器;
- 总线上是地址还是数据项。
总线结构示意图:
读事务示例
执行movq A,%rax
,即将地址为A的数据放进寄存器%rax中时,加载步骤如下(主要部件有系统总线、IO桥、内存总线):
- 将(虚拟地址)A放到系统总线上,IO桥将总线上的信号传递给内存总线,图a;
- 主存高指导内存总线上的地址信号,通过页表读取对应地址的数据,然后将数据写到内存总线上,IO桥将内存信号翻译成系统总线信号,然后沿着系统总线传递,图b;
- CUP感觉到系统总线上的数据,从总线上读取数据。
1.2.2硬盘和SSD
硬盘和SSD固态硬盘属于非易失性存储器,即断电不丢失数据。
磁盘结构如下:
盘面——磁道——扇区。对磁盘的访问时间由三部分组成,寻道时间seek time、旋转时间rotational和传送时间access time:
- 寻道时间seek time:传动臂将读写头定位到包含目标扇区的磁道上,寻道时间是毫秒级的;
- 旋转时间rotational time:驱动器等待目标扇区的第一个位旋转到读写头下,旋转时间也是毫秒级的(7200转/分);
- 传送时间,与SSD几乎相同。
硬盘读写的时间几乎全部花在寻道和旋转时间上,对其访问时10ms级别的。而对SRAM和DRAm的访问都是10nm和100ns级别的,比硬盘块百倍和千倍——不是10万倍。
CUP对SSD的访问不是机械驱动的,没有寻道和旋转时间,因此随机访问时间比旋转硬盘要快。
二.局部性
储存器和虚拟内存中最重要的概念,与编程息息相关。
2.1时间局部性和空间局部性介绍
局部性分为时间局部性temporal(means relating to time) locality和空间局部性spatial(空间的) locality:
- 时间局部性:被引用过一次的内存位置可能在短时间内被多次引用;
- 空间局部性:某个内存位置被引用了一次,则短时间内其附近的内存位置被程序引用。
局部性在硬件层面、操作系统和应用程序中的应用:
- 计算机引用小儿快的cache保存最近使用的指令和数据项;
- 操作系统中的虚拟内存系统使用内存作为虚拟地址空间最近被引用块的高速缓存;
- Web浏览器将最近使用的文档放在本地磁盘上。
2.2怎样提高时间局部性和空间局部性
一个连续的向量,每个K个元素进行访问,我们就成为步长为K的引用模式stride-k reference pattern,步长越长,空间局部性越差,因此我们应该按照数据在内存中的存储位置,按照步长为1的模式读取数据;
取指令的局部性/编写高速缓存友好的代码:
指令不同于数据,是不能被修改的,cache friendly的代码编写的基本方法:
- 核心函数(常用函数)的循环应该运行的块,针对其进行优化,其他可以忽略;
- 尽量减少核心循环内部的缓存不命中数量;
- 对局部变量的反复引用是好的,因为他们缓存在寄存器中(时间局部性);
- 一旦存储器读取了一个对象,就尽可能多的引用他。
加一个简单实例代码,对数组的行优先和列优先访问,速度差10倍以上:
public class LocarityDemo {
static int var=0;
static void func(int i){
var=i;
}
public static void main(String[] args) {
Random ran=new Random();
//二维数据,分别进行优先访问和列优先访问
int arr[][]=new int[1024][(int)(Integer.MAX_VALUE/Math.pow(2,15))];
//数组赋值
for (int i = 0; i < 1024; i++) {
for (int j = 0; j < (int)(Integer.MAX_VALUE/Math.pow(2,15)); j++) {
arr[i][j]=ran.nextInt();
}
}
//行优先
Long t1=System.nanoTime();
for (int i = 0; i < 1024; i++) {
for (int j = 0; j < (int)(Integer.MAX_VALUE/Math.pow(2,15)); j++) {
func(arr[i][j]);
}
}
//列优先
Long t2=System.nanoTime();
for (int j = 0; j < (int)(Integer.MAX_VALUE/Math.pow(2,15)); j++) {
for (int i = 0; i < 1024; i++) {
func(arr[i][j]);
}
}
Long t3=System.nanoTime();
System.out.println(t2-t1);
System.out.println(t3-t2);
}
}
//output:
100615290
1634671351