课程主页:https://courses.cs.washington.edu/courses/cse351/16sp/

课程资料:

实验部分:https://github.com/vuquangtrong/HW-SW_Interface_Course

实验说明:https://courses.cs.washington.edu/courses/cse351/13sp/lab-0.html

课件:http://academictorrents.com/details/b63a566df824b39740eb9754e4fe4c0140306f4b

课程视频:https://www.bilibili.com/video/BV1Zt411s7Gg?from=search&seid=8781593976070799647

这次回顾第七部分,这部分介绍了内存和缓存。

第7节:内存和缓存

缓存基础知识

执行时间和SIZE的关系
int array[SIZE];  
int A = 0;  

for (int i = 0 ; i < 200000 ; ++ i) {         
	for (int j = 0 ; j < SIZE ; ++ j) {                
		A += array[j];         
	}  
}

之所以产生上述现象是由于缓存的原因,后续会详细介绍。

问题:处理器-内存瓶颈

处理器等待内存的时间过久:

解决方法是增加缓存:

缓存
  • 英文定义:存放粮食、武器和/或财宝的隐藏空间。
  • CSE定义:访问时间很短的计算机内存,用于存储频繁或最近使用的指令或数据(i-cache、d-cache)
    • 更普遍地, 用于优化具有不同特性(网络接口缓存、I/O缓存等)的系统元素之间的数据传输。
一般缓存机制

Hit

请求的数据在缓存中:

Miss

请求的数据不在缓存中,所以要将内存中的数据替换缓存中的数据:

总结

局部原则

为什么缓存会起作用
  • 局部性原则:程序倾向于引用邻近于其他最近引用过的数据项和指令。
  • 时间局部性:
    • 最近引用的项可能会在不久的将来再次引用
  • 空间局部性:
    • 地址接近的项往往会在比较接近的时间内被引用
局部性例子
sum = 0;
for (i = 0; i < n; i++)
	sum += a[i];
return sum;
  • 数组$a$有很好的空间局部性,$\text{sum}$有很好的时间局部性

内存层次结构

缓存不命中的代价

比较99%命中率和97%命中率的平均读取时间:

  • 97%:$1+0.03\times 100=4$
  • 99%:$1+0.01\times 100=2$

这说明99%的命中率比97%命中率好两倍,所以我们关注不命中率。

缓存性能度量
  • 不命中率
    • 在缓存中找不到的内存引用的比例(丢失/访问)$\text{= 1 – hit rate}$
    • 典型数字(百分比):
      • L1为3-10%
      • 对于L2来说可能很小(例如<1%),具体取决于大小等。
  • 命中时间
    • 从高速缓存传送一个字到CPU所需时间
      • 包括确定行是否在缓存中的时间
    • 典型数字:
      • L1需要4个时钟周期
      • L2需要10个时钟周期
  • 不命中处罚
    • 因为错过而需要额外的时间
    • 主存通常需要50-200个周期(趋势:正在增加!)
内存层次结构
  • 硬件和软件系统的一些基本和持久特性:
    • 更快的存储技术几乎总是每字节成本更高,容量更低
    • 内存技术速度之间的差距正在扩大
      • 适用于:寄存器$\leftrightarrow$高速缓存、高速缓存$\leftrightarrow$DRAM,DRAM$\leftrightarrow$磁盘等。
    • 写得好的程序往往显示出良好的局部性
  • 这些特性彼此完美互补
  • 提出了一种组织内存和存储系统的方法,称为内存层次结构
  • 内存层次结构的基本思想:
    • 每个级别$k$用作以下较大、较慢的级别$k+1$的高速缓存。
  • 为什么内存层次结构工作?
    • 由于局部性,程序访问级别$k$的数据的频率比访问级别$k+1$的数据的频率高。
    • 因此,级别$k+1$的存储更慢,从而更大且每比特便宜。
  • 想法:内存层次结构创建了一个庞大的存储池,其成本与最底层的廉价存储相同,但以最上层的快速存储速度向程序提供数据。
例子

英特尔i7内存层次结构

缓存组织

高速缓存$(S,E,B)$的通用组织

高速缓存读取

利用上图的结构进行高速缓存读取,索引组判断属于哪个组,块偏移确定了$B$个字节的数据块中的字偏移,标记位确定标记。

例子

考虑$E=2$的情形:

首先通过$0…01$确定是第二个缓存,然后比较tag:

最后$100$确定偏移:

再来看一个具体例子:

如果读取的缓存中没有内容或者有效位为0,则记为miss,否则为hit。

常规缓存概念:缓存未命中的类型
  • 冷未命中
    • 由于缓存为空而发生。
  • 冲突未命中
    • 大多数高速缓存将级别$k + 1$的块限制为级别$k$的块位置的一小部分子集。
      • 例如。 第$k + 1$级的块$i$必须放置在第$k$级的块($i \mod 4$)中。
    • 当级别$k$高速缓存足够大,但是多个数据对象都映射到同一级别$k$块时,就会发生冲突未命中。
      • 例如。 每次引用块0、8、0、8、0、8,…都会未命中。
  • 容量未命中
    • 当活动高速缓存块的集合(工作集)大于高速缓存时发生。
  • 存在多个数据副本:
    • L1,L2,L3,主存,磁盘
  • write-hit怎么办?
    • Write-through(立即写入内存)
    • Write-back(将写操作推迟到内存中,直到替换行为止)
      • 需要一个dirty bit(行是否与内存不同)
  • 发生写缺失该怎么办?
    • write-miss(加载到缓存中,更新行在缓存中)
      • 如果有更多写入该位置的信息,则很好
    • No-write-allocate(直接写入内存,不加载到缓存中)
  • 典型
    • Write-through + No-write-allocate
    • Write-back + Write-allocate

考虑缓存的程序优化

内存层次结构优化
  • 编写具有局部性的代码
    • 空间局部性:连续访问数据
    • 时间局部性:确保访问同一数据的时间不相隔太远
  • 如何实现?
    • 算法选择合理
    • 循环变换
矩阵乘法
/* ijk */
for (i=0; i<n; i++)  {
  for (j=0; j<n; j++) {
    sum = 0.0;
    for (k=0; k<n; k++) 
      sum += a[i][k] * b[k][j];
    c[i][j] = sum;
  }
} 

注意按这个方法写效率较低,从内存布局即可看出:

更换循环次序,一共可以得到6种循环,效率如下:

这里解释下为什么kij的效率较高,首先看代码:

/* kij */
for (k=0; k<n; k++) {
  for (i=0; i<n; i++) {
    r = a[i][k];
    for (j=0; j<n; j++)
      c[i][j] += r * b[k][j];   
  }
}

接着看示意图:

说明该代码的空间局部性较好。

高速缓存未命中的分析
  • 假设:
    • 矩阵元素是双精度
    • 高速缓存块 = 8 doubles
    • 缓存大小$C << n$(比$n$小得多)
  • 第一次迭代:
    • $n / 8 + n = 9n / 8$次未命中
  • 总未命中次数:
    • $9n / 8 n^2 =(9/8) n^3$

分块矩阵乘法
c = (double *) calloc(sizeof(double), n*n);

/* Multiply n x n matrices a and b  */
void mmm(double *a, double *b, double *c, int n) {
    int i, j, k;
    for (i = 0; i < n; i+=B)
		for (j = 0; j < n; j+=B)
             for (k = 0; k < n; k+=B)
		 /* B x B mini matrix multiplications */
                  for (i1 = i; i1 < i+B; i++)
                      for (j1 = j; j1 < j+B; j++)
                          for (k1 = k; k1 < k+B; k++)
	                      	c[i1*n+j1] += a[i1*n + k1]*b[k1*n + j1];
}

图示如下:

未命中的分析
  • 假设:
    • 高速缓存块 = 8 doubles
    • 缓存大小$C << n$(比$n$小得多)
    • 三个分块可以放入缓存,$3B^2<C$
  • 第一次(块)迭代:
    • 每个方块$B\times B/ 8=B^2/8$次未命中
    • 总共:$2n / B * B^2 / 8$ = $nB/4$(忽略矩阵c)
  • 总共未命中:
    • $nB/4 * (n/B)^2 = n^3/(4B)$

在缓存中的示意图:

分块总结
  • 无分块:$(9 / 8)^{*} n^{3}$
  • 分块:$1 /(4 B)^{*} n^{3}$
  • 建议选择最大的$B$,使得$3B^2<C$
  • 产生巨大差异的原因:
    • 矩阵乘法具有固有的时间局部性:
      • 输入数据:$3n^2$,计算$2n^3$
      • 每个数组元素使用$O(n)$次!
    • 但是程序必须正确编写