0 前言
这其实是对参考文献的一些总结和翻译,有一些内容和原文的顺序不一致,另外就是我的翻译水平不高,一些用词可能不准确。
本来想大部分都翻译的,不过后面一些看起来有点迷糊,而且发现其实和我本意(对Cache多了解一些,优化代码)相差已经比较大了,就只翻译了前面的一部分,后面的内容都没有继续看。
1 简介
CPU的Cache是一个快速缓存,访问速度比内存要快很多,因为价格原因,无法做到很大,只能缓存部分内存数据。按功能划分,缓存可以分为指令缓存(
code cache或
instruction cache指令缓存)、数据缓存(
data cache)、TLB缓存(
translation lookaside buffer,加速虚拟地址转物理地址)。按速度划分,当前主流CPU都有二级甚至三级缓存(分别称之为
L1,L2,L3)。
大部分CPU都把指令缓存和数据缓存分开,以提高性能;也有合并到一起的,降低硬件开销。
一般只把数据缓存分级别。
例如我的一台Linux虚拟机,执行
dmesg |grep CPU
显示
Initializing CPU#0
CPU: L1 I cache: 32K, L1 D cache: 32K
CPU: L2 cache: 256K
CPU: L3 cache: 8192K
CPU: Physical Processor ID: 1
CPU: Processor Core ID: 1
说明L1 Cache共64K,其中32K是指令缓存,32K是数据缓存;L2 Cache是256K;L3 Cache是8M。
2 名词
Cache Line | 每次内存和CPU缓存之间交换数据都是固定大小,填充一个缓存管线,这个管线内部数据是连续的。 |
Cache Set | 一个或多个Cache Line组成Cache Set,也叫Cache Row |
Cache Entry | 缓存条目,包含Cache line内容(value)和对应的地址(key),可以看做是哈希表的一项。 |
Cache Hit | 缓存命中,查找的地址在Cache中 |
Cache Miss | 缓存未命中,查找的地址不在Cache中 |
Hit Rate | 命中率,Cache Hit / ( Cache Hit + Cache Miss ) |
3 未命中率(Cache Miss Rate)、延迟与等待(Stall and Wait)
一般来说,数据缓存有读写请求,而指令缓存只有读请求。
读请求未命中,会产生延迟,因为必需将数据读入,CPU才能继续工作,这时CPU处于等待状态(CPU Stall)。
如果是代码未命中,那么延迟较大,因为CPU无指令可做;如果是数据未命中,那么延迟好一点,因为可以继续执行后面无依赖代码。
有以下两种方法可以改进:
1> 乱序执行,当等待内存数据时,看后面指令是否有独立的可以执行的。
2> 超线程,当核心发现要等待时,立即切换到核心中另一个线程执行(超线程技术确保了这种切换延迟很小)。
写请求未命中,不会有多少延迟,因为这时CPU可以继续计算,而copy数据到内存相对于CPU来说可看成一个后台任务,写请求可以用队列先存起来。
为了降低未命中率,前人做了很多研究,其中Mark Hill将未命中的原因分为3类:
Compulsory Miss(Cold Miss) | 第一次引用数据,必需从内存中读取,这时Cache大小和结合性都没有关系,prefetch有一定的帮助 |
Capacity Miss | 仅仅是因为Cache大小限制导致的Miss,和结合性还有block size没有关系。 |
Conflict Miss | 又分为mapping miss,即从内存到Cache之间的映射,内存比Cache大,总会有冲突;replacement miss,如果选取合适的替换策略,可以降低这种冲突。 |
该文章贴了一个图,表明Cache的大小在64K以后,未命中率的下降就不那么明显了。
4 替换策略和写策略
替换策略:当缓存未命中,则需要将新的内容读入缓存,替换掉原有内容,如果有多个Cache Line可供选择,那么选择哪个?
一般采取LRU(least recently used)。
写策略:
1> Write-through:每次写请求直接写回内存。
2> Write-back(copy-back):只有当缓存被替换时才写回内存。
3> 混用策略:当写请求积累到一定程度,一起写回内存,这样可以优化总线使用。
当内存中的数据更新时,可能缓存没有更新,例如DMA(direct memory access,这一般在高级语言中没有,需要使用汇编或intrinsic函数)或多核处理器,每个核心有自己的Cache),需要通过一个信息交互,告知这个数据已经被修改了(dirty),需要重新载入。通信协议叫做缓存一致性协议(cache coherent protocols)。
5 Cache Entry结构
一个Cache Entry大概是这样构成的
tag | data block | flag bits |
tag:包含部分内存地址
data block:就是一个Cache Line的内容
flag bits:一般包含数据是否有效(valid bit)和数据是否被写(dirty bit),指令缓存因为只读,只需要valid bit,但是不知道具体实现是怎样的。
一个有效内存地址可以分为
tag | index | block offset |
index:表示装入Cache Set的索引号
block offset:表示这个地址在数据块(data block)中的偏移量
举例来说:
如果L1 Cache大小为8k,每个Cache Line是64 bytes,4个Cache Line组成一个Cache Set。
那么总共就有8k / 64 bytes = 128个Cache Line,每4个组成一组,那就有128 / 4 = 32个Cache Set
所以block offset占6个bit(2^6=64),index占5个bit(2^5=32),tag占21个bit(32 - 5 - 6)
6 数据的映射(结合性Associativity)
当数据不在Cache中时,需要从内存中加载到Cache,但是加载到哪个Cache Line,这是有区别的。
fully associative:内存的数据可以载入到任何一个Cache Line,相当于所有的Cache Line都在一个Cache Set
direct-mapped associative:任何一个内存数据只能载入到某个特定的Cache Line,每个Cache Line自己就是一个Cache Set
2-way set associative:任何内存数据可以载入到某2个特定的Cache Line中的任意一个,就是2个Cache Line 组成一个Cache Set。类似的有4-way,8-way等等。
这是一种权衡,如果一个Cache Set有10个Cache Line,那么查询数据是否在缓存中,最坏情况就要查询10次才能确定。也就是说Cache Set越大,搜索越慢,但是同时,也可以减少未命中率(例如冲突时,需要频繁替换和从内存加载数据)。
根据经验,从direct-map到2-way,或者从2-way到4-way,和将Cache大小翻番后获得的Cache命中率提高差不多;4-way以上获得的命中率提高效果就有限了,往往是为了其它原因才那么设计。
猜测执行:如果是direct-mapped Cache,CPU在计算好地址以后,直接去对应的Cache Entry拿数据,就开始后续计算,而同时,另外的逻辑去执行地址检测,查看Cache Entry中是否是需要的地址,这是因为CPU有pipeline功能。类似地,其它类型的Cache也可以这样执行,只不过需要猜测取一个Set中的哪个Entry,这是由tag的一个子集,称作hint,作为一个提示,来猜测的。
其它的映射策略:
2-way skewed:既然用了偏这个词(skew),就是说Entry的使用是不等价的,每个Set中的0号Entry作为direct-map方法来用,1号Entry则用另一个Hash方法来用。优点是当用户用一个病态读取路径来读取数据,也不会造成太多的未命中。缺点是计算Hash耗费时间,并且替换策略比较麻烦,因为在一个Set中的地址是混杂的。
Pseudo-associative:包括hash-rehash Cache和column-associative Cache,这个没看懂,介绍也不多。
7 地址转换(Address translation)
当前一般进程中使用的都是虚拟地址(virtual address),虚拟地址转换到物理地址(physical address),一般是通过MMU器件(memory management unit)来完成的,而操作系统的page映射表可以部分放在缓存里,称作TLB(translation lookaside buffer)。硬件上面加Cache,最开始既不是给指令用,也不是给数据用,而是为了TLB使用。
地址转换有三个特征:
延迟:从虚拟地址到物理地址的获得需要的时钟数
别名:多个虚拟地址可以对应一个物理地址
粒度:地址一般都切分成页,例如4G地址切分成4K的页,一共有
1048576页,每次交换处理单位都是页。