操作系统实战45讲笔记- 07 Cache与内存:程序放在哪儿?

程序局部性原理: CPU 大多数时间在执行相同的指令或者与此相邻的指令

时间局部性VS空间局部性:
a. 时间局部性:当前访问的指令或数据,也可能在之后访问;
b. 空间局部性:当程序访问内存地址x时,可能很快会访问临近x的内存。

内存

重点需要关注的是,内存的速度还有逻辑上内存和系统的连接方式和结构。

控制内存刷新和内存读写的是内存控制器。

从逻辑上我们只需要把内存看成一个巨大的字节数组就可以,而内存地址就是这个数组的下标。

CPU 到内存的性能瓶颈

相比 CPU 的数据吞吐量,内存要慢上几个数量级。再加上多核心 CPU 同时访问内存,会导致总线争用问题,数据吞吐量会进一步下降。无论 CPU 的性能多高都没用,而内存才是决定系统整体性能的关键。

Cache

回到程序的局部性原理,用一块小而快的储存器,放在 CPU 和内存之间,就可以利用程序的局部性原理来缓解 CPU 和内存之间的性能瓶颈。这块小而快的储存器就是 Cache,即高速缓存。

Cache 中存放了内存中的一部分数据,CPU 在访问内存时要先访问 Cache,若 Cache 中有需要的数据就直接从 Cache 中取出,若没有则需要从内存中读取数据,并同时把这块数据放入 Cache 中。但是由于程序的局部性原理,在一段时间内,CPU 总是能从 Cache 中读取到自己想要的数据。

Cache 主要由高速的静态储存器、地址转换模块和 Cache 行替换模块组成。

Cache 会把自己的高速静态储存器和内存分成大小相同的行,一行大小通常为 32 字节或者 64 字节。

Cache 和内存交换数据的最小单位是一行,为方便管理,在 Cache 内部的高速储存器中,多个行又会形成一组。

除了正常的数据空间外,Cache 行中还有一些标志位,如脏位、回写位,访问位等,这些位会被 Cache 的替换模块所使用。

Cache 大致的逻辑工作流程如下:

  1. CPU 发出的地址由 Cache 的地址转换模块分成 3 段:组号,行号,行内偏移。

  2. Cache 会根据组号、行号查找高速静态储存器中对应的行。如果找到即命中,用行内偏移读取并返回数据给 CPU,否则就分配一个新行并访问内存,把内存中对应的数据加载到 Cache 行并返回给 CPU。
    写入操作则比较直接,分为回写和直通写,回写是写入对应的 Cache 行就结束了,直通写则是在写入 Cache 行的同时写入内存。

  3. 如果没有新行了,就要进入行替换逻辑,即找出一个 Cache 行写回内存,腾出空间,替换行有相关的算法,替换算法是为了让替换的代价最小化。

逻辑由Cache硬件独立实现,对软件是透明的。

Cache 带来的问题

Cache的副作用是一致性的问题。

以x86 cpu的cache结构图为例,Cache一致性问题,主要包括如下三个方面:

  1. 一个CPU核心中的指令cache和数据cache的一致性问题。(注: 第一级 Cache 是指令和数据分开的,目的是连续的指令在流水线工作时防止指令之间产生的资源冲突)

  2. 多个CPU核心各自的2级cache的一致性问题

  3. CPU的3级cache与设备内存,如DMA、网卡帧储存,显存之间的一致性问题。

对于程序代码运行而言,指令都是经过指令 Cache,而指令中涉及到的数据则会经过数据 Cache。指令是由操作码和操作数组成的,所以指令中也有数据。

典型的多核心 Cache 数据同步协议有 MESI 和 MOESI。MOESI 和 MESI 大同小异,下面我们就去研究一下 MESI 协议。

Cache 的 MESI 协议

MESI 协议定义了 4 种基本状态:M、E、S、I,即修改(Modified)、独占(Exclusive)、共享(Shared)和无效(Invalid)。

  1. M 修改(Modified):当前 Cache 的内容有效,数据已经被修改而且与内存中的数据不一致,数据只在当前 Cache 里存在。
  1. E 独占(Exclusive):当前 Cache 中的内容有效,数据与内存中的数据一致,数据只在当前 Cache 里存在;
  1. S 共享(Shared):当前 Cache 中的内容有效,Cache 中的数据与内存中的数据一致,数据在多个 CPU 核心中的 Cache 里面存在。
  1. 无效(Invalid):当前 Cache 无效。前面三幅图 Cache 中没有数据的那些,都属于这个情况。

Cache硬件会监控所有CPU上cache的操作,根据相应操作使Cache里的数据行在上面这些状态之间切换。Cache 硬件通过这些状态的变化,就能安全地控制各 Cache 间、各 Cache 与内存之间的数据一致性了。

开启 Cache

x86 CPU 上默认是关闭 Cache 的,需要在 CPU 初始化时将其开启。只需要将 CR0 寄存器中 CD、NW 位同时清 0 即可。CD=1 时表示 Cache 关闭,NW=1 时 CPU 不维护内存数据一致性。

mov eax, cr0 #开启 CACHE    
btr eax,29 #CR0.NW=0
btr eax,30 #CR0.CD=0
mov cr0, eax

获取内存视图

物理地址空间中可能有空洞,有 ROM,有内存,有显存,有 I/O 寄存器,获取内存有多大没用,关键是要获取哪些物理地址空间是可以读写的内存。

x86 平台上,通过 BIOS 提供的实模式下中断服务来获取内存的地址空间。这个中断服务是 int 15h(https://wiki.osdev.org/Detecting_Memory_(x86)#BIOS_Function:INT_0x15.2C_EAX.3D_0xE820)。


_getmemmap:
  xor ebx,ebx #ebx设为0
  mov edi,E80MAP_ADR   #edi设为存放输出结果的1MB内的物理内存地址 [ EDI称为目的变址寄存器,通常存放处理后的数据的内存地址 ]
loop:
  mov eax,0e820h       #eax必须为0e820h
  mov ecx,20           #输出结果数据项的大小为20字节:8字节内存基地址,8字节内存长度,4字节内存类型
  mov edx,0534d4150h   #edx必须为0534d4150h
  int 15h              #执行中断
  jc error             #如果flags寄存器的C位置1,则表示出错
  add edi,20           #更新下一次输出结果的地址
  cmp ebx,0            #如ebx为0,则表示循环迭代结束
  jne loop             #还有结果项,继续迭代
    ret
error:                #出错处理

上面的代码是在迭代中执行中断,每次中断都输出一个 20 字节大小数据项,最后会形成一个该数据项(结构体)的数组,可以用 C 语言结构表示,如下。


#define RAM_USABLE 1 //可用内存
#define RAM_RESERV 2 //保留内存不可使用
#define RAM_ACPIREC 3 //ACPI表相关的
#define RAM_ACPINVS 4 //ACPI NVS空间
#define RAM_AREACON 5 //包含坏内存
typedef struct s_e820{
    u64_t saddr;    /* 内存开始地址 */
    u64_t lsize;    /* 内存大小 */
    u32_t type;    /* 内存类型 */
}e820map_t;

总结

思考题:

如何写出提高 Cache 命中率的代码?

参考答案:

1、遵从80-20法则,程序80%的时间在运行20%或更少的代码,针对热代码进行优化,才容易产出效果;
2、遵从数据访问的局部性法则,按数据存放顺序访问内存效率远高于乱序访问内存效率,也就是尽量帮助CPU做好数据Cache的预测工作。同样根据Cache大小,做好数据结构的优化工作,进行数据压缩或数据填充,也是提升Cache效率的好方式;
3、遵从指令访问的局部性法则,减少跳转指令,同样是尽量帮助CPU做好数据Cache的预测工作;现代CPU都有一些预测功能【如分支预测】,利用好CPU的这些功能,也会提升Cache命中率;
4、避免计算线程在多个核心之间漂移,避免缓存重复加载,可以绑定核心【物理核即可,不用到逻辑核】,提高效率;
5、去除伪共享缓存:在多核环境下,减少多个核心对同一区域内存的读写并发操作,减少内存失效的情况的发生;
6、合理提高进程优先级,减少进程间切换,可以变相提供Cache提速的效果
7、关闭Swap,可以变相提供内存提速、Cache提速的效果;
8、使用Intel自家的编译器,开启性能优化,很多时候可以提速运算效率;
9、使用C语言,而不是更高级的语言,很多时候可以提速运算效率;
10、直接使用昂贵的寄存器作为变量,可以变相提供加速效果;

参考

操作系统实战45讲

posted on 2022-10-09 15:03  miyan  阅读(249)  评论(0编辑  收藏  举报