【CPU】缓存一致性、CPU基础
PU的位宽、寻址总线、数据总线
CPU的位宽和寄存器一次能够存储的大小相关,比如一个大于4字节的数据,我们用32位的CPU显然是无法存储它的,自然就无法一次性处理它
而总线位宽和处理器的寻址能力有关,总线宽度越大,能够寻的地址就越大
CPU执行程序的一般步骤
- 第一步,CPU 读取「程序计数器」的值,这个值是指令的内存地址,然后 CPU 的「控制单元」操作「地址总线」指定需要访问的内存地址,接着通知内存设备准备数据,数据准备好后通过「数据总线」将指令数据传给 CPU,CPU 收到内存传来的数据后,将这个指令数据存入到「指令寄存器」。
- 第二步,CPU 分析「指令寄存器」中的指令,确定指令的类型和参数,如果是计算类型的指令,就把指令交给「逻辑运算单元」运算;如果是存储类型的指令,则交由「控制单元」执行;
- 第三步,CPU 执行完指令后,「程序计数器」的值自增,表示指向下一条指令。这个自增的大小,由 CPU 的位宽决定,比如 32 位的 CPU,指令是 4 个字节,需要 4 个内存地址存放,因此「程序计数器」的值会自增 4;
简单总结一下就是,一个程序执行的时候,CPU 会根据程序计数器里的内存地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。
CPU 从程序计数器读取指令、到执行、再到下一条指令,这个过程会不断循环,直到程序执行结束,这个不断循环的过程被称为 CPU 的指令周期
cache的写回机制
- 如果当发生写操作时,数据已经在 CPU Cache 里的话,则把数据更新到 CPU Cache 里,同时标记 CPU Cache 里的这个 Cache Block 为脏(Dirty)的,这个脏的标记代表这个时候,我们 CPU Cache 里面的这个 Cache Block 的数据和内存是不一致的,这种情况是不用把数据写到内存里的;
- 如果当发生写操作时,数据所对应的 Cache Block 里存放的是「别的内存地址的数据」的话,就要检查这个 Cache Block 里的数据有没有被标记为脏的,如果是脏的话,我们就要把这个 Cache Block 里的数据写回到内存,然后再把当前要写入的数据,先从内存读入到 Cache Block 里,然后再写入的数据写入到 Cache Block,最后也把它标记为脏的;如果 Cache Block 里面的数据没有被标记为脏,则就直接将数据写入到这个 Cache Block 里,然后再把这个 Cache Block 标记为脏的就好了。
可以发现写回这个方法,在把数据写入到 Cache 的时候,只有在缓存不命中,同时数据对应的 Cache 中的 Cache Block 为脏标记的情况下,才会将数据写到内存中,而在缓存命中的情况下,则在写入后 Cache 后,只需把该数据对应的 Cache Block 标记为脏即可,而不用写到内存里。
这样的好处是,如果我们大量的操作都能够命中缓存,那么大部分时间里 CPU 都不需要读写内存,自然性能相比写直达会高很多。
缓存一致性问题
问题:多核CPU在访问内存时,由于cache的写回机制,部分数据没有及时更新到内存,那么在不同线程访问同一个变量的时候就会出现读到错误的值
那么,要解决这一问题,就需要一种机制,来同步两个不同核心里面的缓存数据。要实现的这个机制的话,要保证做到下面这 2 点:
- 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(Write Propagation)
- 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串行化(Transaction Serialization)
要实现事务串行化,也就是让不同核心看到的其他核心对于代码的执行顺序是一致的
要实现事务串行化,要做到 2 点:
- CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
- 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。
问题:DMA与cache的缓存一致性问题
DMA和CPU可以异步地操作memory,解决访问冲突主要从cache与memory的两种映射机制入手
- 一致性映射,代表函数:
void *dma_alloc_coherent(struct device *dev, size_t size, dma_addr_t *handle, gfp_t gfp); void dma_free_coherent(struct device *dev, size_t size, void *cpu_addr, dma_addr_t handle); |
一般驱动使用多,申请一片uncache mem ,这样无需考虑data 一致性。代码流程:对page property,也就是是kernel页管理的页面属性设置成uncache,在缺页异常填TLB时,该属性就会写到TLB的存储属性域中。保证了dma_alloc_coherent映射的地址空间是uncached的。
dma_alloc_coherent首先对分配到的缓冲区进行cache刷新,之后将该缓冲区的页表修改为uncached,以此来保证之后DMA与CPU操作该块数据的一致性
问题:cache与外设寄存器的一致性问题
外设寄存器和CPU往往也是异步的,有时候某个外设的状态寄存器或者数据寄存器,内容已经改变了,此时如果CPU还是从cache中读值,就会出现不一致问题了
所以对于硬件和外设寄存器的地址空间,操作往往是uncached,保证CPU总是直接读写硬件寄存器。IO空间通过ioremap进行映射到内核空间。ioremap在映射寄存器地址时页表是配置为uncached的。数据不走cache,直接由地址空间中读取。保证了数据一致性。此外,在bootup的过程,在最初阶段的bootrom,系统还会关dcache,具体见笔者对于mboot源码分析的wiki
- 流式DMA映射
一致性映射会导致性能下降,因为直接ban掉了cache,另外一方面上层传下来的数据包,到了底层用的不是用dma_alloc_coherent申请的一致性内存。
那么流式映射就可以很好的解决问题
dma_addr_t dma_map_single(struct device *dev, void *cpu_addr, size_t size, enum dma_data_direction dir) void dma_unmap_single(struct device *dev, dma_addr_t handle, size_t size, enum dma_data_direction dir) void dma_sync_single_for_cpu(struct device *dev, dma_addr_t handle, size_t size, enum dma_data_direction dir) void dma_sync_single_for_device(struct device *dev, dma_addr_t handle, size_t size, enum dma_data_direction dir) int dma_map_sg(struct device *, struct scatterlist *, int , enum dma_data_direction); void dma_unmap_sg(struct device *, struct scatterlist *, int , enum dma_data_direction); |
在每发起一次DMA之前都先调用一次dma_map_sg,并指定DMA和外设之间的数据流方向,CPU是可以控制cache的,那么当指定内存流向外设时,则此时CPU把cache中的内容flush回内存,保证DMA出去的数据是最新的;当方向为外设流向内存,则相反,标记cache内容为invalid,此时CPU从内存中读取最新的值
总线嗅探与MESI协议
写传播的原则就是当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心。最常见实现的方式是总线嗅探(Bus Snooping)。
我还是以前面的 i 变量例子来说明总线嗅探的工作机制,当 A 号 CPU 核心修改了 L1 Cache 中 i 变量的值,通过总线把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的 L1 Cache 里面,如果 B 号 CPU 核心的 L1 Cache 中有该数据,那么也需要把该数据更新到自己的 L1 Cache。
可以发现,总线嗅探方法很简单, CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。
另外,总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并不能保证事务串行化。
MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:
- Modified,已修改
- Exclusive,独占
- Shared,共享
- Invalidated,已失效
这四个状态来标记 Cache Line 四个不同的状态。
「已修改」状态就是我们前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。而「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。
「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。
「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。
另外,在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。
那么,「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。
总结:CPU中的缓存数据在规则的限制下互相通信,通过不同状态的转换,保持缓存的一致性
伪共享
问题:两个核心对同一cacheline的不同变量进行修改,造成这两个变量所在的cacheline不断在共享和已修改以及已失效,于是交替发生从内存读回缓存的操作,降低了缓存的性能(甚至让cache失效)
在 Linux 内核中存在 __cacheline_aligned_in_smp
宏定义,是用于解决伪共享的问题,它会使得变量在cacheline中对齐,从而避免了同一cacheline中出现伪共享,属于空间换时间的思想
提高CPU执行效率的代码
CPU缓存层面:
- 提升数据缓存的命中率
- 提升指令缓存的命中率
核心思想都是能够一次读入更多数据或者指令进入CPU,从而减少对于内存的访问,对于数据,比如数组,尽量按照内存分布顺序进行读取,减少跳跃;
对于指令,利用分支预测工具,把更大可能执行的语句加载到内存,C语言中可以利用likely等宏
- 提升多核CPU的缓存命中率
在单核 CPU,虽然只能执行一个线程,但是操作系统给每个线程分配了一个时间片,时间片用完了,就调度下一个线程,于是各个线程就按时间片交替地占用 CPU,从宏观上看起来各个线程同时在执行。
而现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响,相反如果线程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问 内存的频率。
当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,而导致缓存命中率下降的问题,我们可以把线程绑定在某一个 CPU 核心上,这样性能可以得到非常可观的提升。
在 Linux 上提供了 sched_setaffinity
方法,来实现将线程绑定到某个 CPU 核心这一功能。
操作系统层面:
- 减少系统调用,减少内核态和用户态的切换
- 减少IO操作,减少不必要的拷贝操作
CPU 是如何执行任务的?
假设PC指向0x200,首先控制单元操纵地址总线根据PC的值寻址,接着控制单元操纵数据总线将地址所在的数据(这个数据可能是指令也可能是变量)传回指令寄存器(注意PC指针只是保存指令的地址,指令寄存器另有其器),接着根据指令的类型,将指令分配给逻辑运算单元(各种运算指令)或控制单元(存取寻址类指令)执行,这个指令寄存器以及不同指令的执行的时序以及工作原理与arm流水线架构有关,这个稍后分析