Cache写机制:Write-through与Write-back

关于CPU Cache1

通常情况下,CPU从内存中直接读取数据需要几百个时钟周期,在这几百个时钟周期内,CPU除了等待什么也不能做。而引入Cache高速缓存后,当CPU需要从内存中获取数据时,Cache可以提前把CPU所需要指令和数据从内存中预取到cache缓存中,CPU直接从cache中取指令和数据。

Cache的基本原理是局部性原理,局部性原理简单说起来就是在前一段时间经常用到的代码,在将来会被再次使用的几率也很高。
Cache对CPU的意义是类似的,主存就是那个带有瓶颈的水桶,如果CPU始终要等待对内存的操作完成然后再进行下一个环节,那么在所有跟内存操作相关的指令执行时,CPU就自动降为接近内存的速度。也许你会认为很多指令是寄存器指令,内存读写在整个程序中所占的比例并不大,但是请不要忘记很恐怖的一点是,所有的指令都在内存中,也就是说每执行一条指令都要去读内存。有了这个前提,我们可以想象,在程序执行的时候,CPU会随时随刻地从内存中读取代码,整条总线会被CPU占住,这样的话总线上的其他Master(比如说DMA控制器,I2C-Slave等)就将全部受到影响,由此可见没有Cache的结果就将是灾难性的。有些Cache还会跟读写FIFO紧密配合,不过目的都是一致的,解决内存瓶颈问题。有人可能会有疑问,多数ARM7内核的芯片是没有Cache的,为什么依然能够运行得很快,这是因为IC designer在设计总线结构时动了手脚,将CPU取指的总线同其他部分总线隔离,以避免CPU对其他总线设备的干扰。

CPU Cache大多是SRAM(静态RAM),而内存大多是DRAM(动态随机存储)或者DDR(双倍动态随机存储),Cache由三级组成,一级(L1)最快,但是容量最小;三级(LLC,Last Level Cache)最慢,但是容量最大。

在多核CPU中每个核拥有独立的L1和L2两级cache,由于程序指令和程序数据的行为和热点分布差异很大,因此L1 Cache也被划分成L1i (i for instruction)和L1d (d for data)两种专门用途的缓存。为了保证所有的核看到正确的内存数据,一个核在写入L1 cache后,CPU会执行Cache一致性算法把对应的Cache Line(Cache Line是Cache与内存数据交换的最小单位)同步到其他核,这个过程并不很快,是微秒级的,相比之下写入L1 cache只需要若干纳秒。当很多线程在频繁修改某个字段时,这个字段所在的Cache Line被不停地同步到不同的核上,就像在核间弹来弹去,这个现象就叫做Cache Bouncing。

三级 Cache 由所有的核所共有,由于共享的存在,有的处理器可能会极大地占用三级Cache,导致其他处理器只能占用极小的容量,从而导致Cache不命中,性能下降。Cache-misses情况下读取一次内存大约为150个时钟周期,而Cache-hit情况下L3 cache命中读取一次内存需要40个时钟周期。

下表是常见的Arm架构中memory访问所耗费的CPU Cycle,其中iCache一般4K,Dcache一般16K。

CPU CycleRDWR
DTCM11
SDTCM55
SRAM2123
DRAM_NC706

什么是Cache Line

Cache Line可以简单的理解为CPU Cache中的最小缓存单位。目前主流的CPU Cache的Cache Line大小都是64Bytes。假设我们有一个512字节的一级缓存,那么按照64B的缓存单位大小来算,这个一级缓存所能存放的缓存个数就是512/64 = 8个。

缓存设计的一个关键决定是确保每个主存块(chunk)能够存储在任何一个缓存槽里,有三种方式将缓存槽映射到主存块中:

  • 直接映射(Direct mapped cache)
    每个内存块只能映射到一个特定的缓存槽。一个简单的方案是通过块索引chunk_index映射到对应的槽位(chunk_index % cache_slots)。被映射到同一内存槽上的两个内存块是不能同时换入缓存的。直接映射是最简单的地址映射方式,它的硬件简单,成本低,地址变换速度快,而且不涉及替换算法问题。但是这种方式不够灵活,Cache的存储空间得不到充分利用,每个主存块只有一个固定位置可存放,容易产生冲突,使Cache效率下降,因此只适合大容量Cache采用。例如,如果一个程序需要重复引用主存中第0块与第16块,最好将主存第0块与第16块同时复制到Cache中,但由于它们都只能复制到Cache的第0块中去,即使Cache中别的存储空间空着也不能占用,因此这两个块会相互驱逐对方,不断地交替装入Cache中,导致命中率降低。

  • 完全关联(Fully associative cache)
    每个内存块能够被映射到任意一个Cache line。操作效果上相当于一个散列表。问题是:给定一个内存地址,要知道他是否存在于Cache中,需要遍历所有Cache Line并比较缓存内容的内存地址,而Cache的本意就是为了在尽可能少得CPU Cycle内取到数据。就像停车位可以大家随便停一样,停的时候简单,找车的时候需要一个一个停车位的找了。由于Cache比较电路的设计和实现比较困难,这种方式只适合于小容量Cache采用。

  • N路组关联(N-way set associative cache)
    组相联映射实际上是直接映射和全相联映射的折中方案,原理是将一个缓存按照N个Cache Line作为一组(set),缓存按组划为等分。主存和Cache都分组,主存中一个组内的块数与Cache中的分组数相同,组间采用直接映射,组内采用全相联映射。主存地址格式中应包含4个字段:区号、区内组号、组内块号和块内地址。而缓存中包含3个字段:组号、组内块号、块内地址。主存块存放到哪个组是固定的,至于存到该组哪一块则是灵活的。即主存的某块只能映射到Cache的特定组中的任意一块。

举个例子,4MB大小的L2缓存在我机器上是16路关联。所有64字节内存块将分割为不同组,映射到同一组的内存块将竞争L2缓存里的16路槽位。

L2缓存有65,536个Cache Line(4MB/64B),每个组需要16路Cache Line,我们将获得4096个集,Cache Line对应的物理地址凡是以256K(4096*64B)的倍数区分的,将竞争同一个Cache Line。这样一个64位系统的内存地址在4MB二级缓存中就划成了三个部分,低位6个bit(2^6=64)表示在Cache Line中的偏移量,中间12个bit(2^12=4MB/64/16)表示Cache组号(set index),剩余的高位46个bit就是内存地址的唯一id。这样的设计相较前两种设计有以下两点好处:

  1. 给定一个内存地址可以唯一对应一个set,对于set中只需遍历16个元素就可以确定对象是否在缓存中;
  2. 2^18(256K)*16(way)=4M的连续热点数据才会导致一个set内的conflict;

为什么N-Way Set Associative的Set段是从低位而不是高位开始的?

下面是一段从How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses摘录的解释:

The vast majority of accesses are close together, so moving the set index bits upwards would cause more conflict misses. You might be able to get away with a hash function that isn’t simply the least significant bits, but most proposed schemes hurt about as much as they help while adding extra complexity.

由于内存的访问通常是大片连续的,或者是因为在同一程序中而导致地址接近的(即这些内存地址的高位都是一样的)。所以如果把内存地址的高位作为set index的话,那么短时间的大量内存访问都会因为set index相同而落在同一个set index中,从而导致cache conflicts使得L2, L3 Cache的命中率低下,影响程序的整体执行效率。

了解Cache Line的概念对我们程序猿有什么帮助?

我们来看下面这个C语言中常用的循环优化例子 下面两段代码中,第一段代码在C语言中总是比第二段代码的执行速度要快。

for(int i = 0; i < n; i++) {
    for(int j = 0; j < n; j++) {
        int num;    
        //code
        arr[i][j] = num;
    }
}

for(int i = 0; i < n; i++) {
    for(int j = 0; j < n; j++) {
        int num;    
        //code
        arr[j][i] = num;
    }
}

内存访问和运行

你认为相较于循环1,循环2会运行多快?

int[] arr = new int[64 * 1024 * 1024];
 
// Loop 1
for (int i = 0; i < arr.Length; i++) 
    arr[i] *= 3;
 
// Loop 2
for (int i = 0; i < arr.Length; i += 16) 
    arr[i] *= 3;

第一个循环将数组的每个值乘3,第二个循环将每16个值乘3,第二个循环只做了第一个约6%的工作,但在现代机器上,两者几乎运行相同时间:在我机器上分别是80毫秒和78毫秒。

两个循环花费相同时间的原因跟内存有关。循环执行时间长短由数组的内存访问次数决定的,而非整型数的乘法运算次数。你会发现硬件对这两个循环的主存访问次数是相同的。背后的原因是今天的CPU不再是按字节访问内存,而是以64字节为单位的块(chunk)拿取,称为一个Cache Line。当你读一个特定的内存地址,整个缓存行将从主存换入缓存,并且访问同一个缓存行内的其它值的开销是很小的。由于16个整型数占用64字节(一个缓存行),For循环步长在1到16之间必定接触到相同数目的缓存行:即数组中所有的缓存行。

指令级别并发

下面两个循环中你以为哪个较快?

int steps = 256 * 1024 * 1024;
int[] a = new int[2];
 
// Loop 1
for (int i=0; i<steps; i++) { a[0]++; a[0]++; }
 
// Loop 2
for (int i=0; i<steps; i++) { a[0]++; a[1]++; }

结果是第二个循环约比第一个快一倍,至少在我测试的机器上。为什么呢?这跟两个循环体内的操作指令依赖性有关。

第一个循环体内,操作做是相互依赖的(译者注:下一次依赖于前一次):但第二个循环中,就没有时序的依赖性了。由于现代处理器中对不同部分指令拥有一点并发性,使得CPU在同一时刻可以访问L1缓存的两处内存位置,或者执行两次简单算术操作。在第一个循环中,处理器无法发掘这种指令级别的并发性,但第二个循环中就可以。

缓存行的伪共享(false-sharing)

在多核机器上,缓存遇到了另一个问题:一致性。不同的处理器拥有完全或部分分离的缓存。在我的机器上,L1缓存是分离的(这很普遍),而我有两对处理器,每一对共享一个L2缓存。这随着具体情况而不同,如果一个现代多核机器上拥有多级缓存,那么快速小型的缓存将被处理器独占。

当一个处理器改变了属于它自己缓存中的一个值,其它处理器就再也无法使用它自己原来的值,因为其对应的内存位置将被刷新到所有缓存。而且由于缓存操作是以缓存行而不是字节为粒度,所有缓存中整个缓存行将被刷新!

为证明这个问题,考虑如下例子:

private static int[] s_counter = new int[1024];

private void UpdateCounter(int position)
{
    for (int j = 0; j < 100000000; j++)
    {
        s_counter[position] = s_counter[position] + 3;
    }
}

在我的四核机上,如果我通过四个线程传入参数0、1、2、3,并调用UpdateCounter,所有线程将花费4.3秒。另一方面,如果我传入16、32、48、64,整个操作进花费0.28秒!

为何会这样?第一个例子中的四个值很可能在同一个缓存行里,每次一个处理器增加计数,这四个计数所在的缓存行将被刷新,而其它处理器在下一次访问它们各自的计数(注意数组是private属性,每个线程独占)将失去命中(miss)一个缓存。这种多线程行为有效地禁止了缓存功能,削弱了程序性能。

P.S.个人感悟——局部性原理和流水线并发

程序的运行存在时间和空间上的局部性,前者是指只要内存中的值被换入缓存,今后一段时间内会被多次引用,后者是指该内存附近的值也被换入缓存。如果在编程中特别注意运用局部性原理,就会获得性能上的回报。

  • 比如C语言中应该尽量减少静态变量的引用,这是因为静态变量存储在全局数据段,在一个被反复调用的函数体内,引用该变量需要对缓存多次换入换出,而如果是分配在堆栈上的局部变量,函数每次调用CPU只要从缓存中就能找到它了,因为堆栈的重复利用率高。

  • 再比如循环体内的代码要尽量精简,因为代码是放在指令缓存里的,而指令缓存都是一级缓存,只有几K字节大小,如果对某段代码需要多次读取,而这段代码又跨越一个L1缓存大小,那么缓存优势将荡然无存。

  • 关于CPU的流水线(pipeline)并发性简单说说,Intel Pentium处理器有两条流水线U和V,每条流水线可各自独立地读写缓存,所以可以在一个时钟周期内同时执行两条指令。但这两条流水线不是对等的,U流水线可以处理所有指令集,V流水线只能处理简单指令。

    CPU指令通常被分为四类,第一类是常用的简单指令,像mov, nop, push, pop, add, sub, and, or, xor, inc, dec, cmp, lea,可以在任意一条流水线执行,只要相互之间不存在依赖性,完全可以做到指令并发。

    第二类指令需要同别的流水线配合,像一些进位和移位操作,这类指令如果在U流水线中,那么别的指令可以在V流水线并发运行,如果在V流水线中,那么U流水线是暂停的。

    第三类指令是一些跳转指令,如cmp,call以及条件分支,它们同第二类相反,当工作在V流水线时才能通U流水线协作,否则只能独占CPU。

    第四类指令是其它复杂的指令,一般不常用,因为它们都只能独占CPU。

    如果是汇编级别编程,要达到指令级别并发,必须要注重指令之间的配对。尽量使用第一类指令,避免第四类,还要在顺序上减少上下文依赖。2


Cache写机制:Write-through与Write-back3

当CPU采用高速缓存时,对于Write-hit而言,它的写内存操作有两种模式:

  1. Write-through(直写模式):在数据更新时,同时写入缓存Cache和后端存储。此模式的优点是操作简单;缺点是因为数据修改需要同时写入内存,总线工作繁忙,内存的带宽被大大占用,因此运行速度会受到影响数据。假设一段程序在频繁地修改一个局部变量,局部变量生存周期很短,而且其他进程/线程也用不到它,CPU依然会频繁地在Cache和内存之间交换数据,造成不必要的带宽损失。

  2. Write-back(回写模式):在数据更新时只写入缓存Cache,而不是立即写入内存。只在数据被替换出缓存时,被修改的缓存数据才会被写到后端存储。此模式的优点是数据写入速度快,因为不需要写存储;缺点是一旦更新后的数据未被写入存储时出现系统掉电的情况,数据将无法找回。对一cache行的多次写命中都在cache中快速完成修改,只是需被替换时才写回速度较慢的主存,减少了访问主的次数从而提高了效率。为支持这种策略,每个cache行必须配置一个修改位,以反映此行是否被CPU修改过。

Write-misses写缺失的处理方式:

  1. Write allocate方式先将写入位置的数据读入缓存Cache,然后采用write-hit(缓存命中写入)操作。这种方式下,写缺失操作与读缺失操作类似。

  2. No-write allocate方式并不将写入位置的数据读入缓存Cache,而是直接将数据写入存储。这种方式下,只有读操作会被缓存。


无论是Write-through还是Write-back都可以使用写缺失的两种方式之一。只是通常Write-back采用Write allocate方式,而Write-through采用No-write allocate方式(此时相当于只有读Cache);因为多次写入同一缓存时,Write allocate配合Write-back可以提升性能,而Write allocate对于Write-through则没有帮助。在此之外,还有一些特殊的Cache写机制:

  • Write–once策略主要用于某些处理器的片内cache,其写命中和写未命中的处理与Write-back with allocate基本相同,只是第一次写命中时要将Cache数据同时写入主存。采用Write–once策略,在第一次片内cache写命中时,CPU启动一个存储写周期,其它cache监听(snoop)到此主存块地址及写信号后,即可把它们各自保存可能有的该块内存地址的拷贝及时作废(无效处理)。尔后若有对片内cache此行的再次或多次写命中,则按Write-back处理,在CPU内部完成,无需再送出信号了。

  • uncacheable内存是一部分特殊的内存,比如PCI设备的I/O空间通过MMIO方式被映射成内存来访问。这种内存是不能缓存在Cache中的,因为设备驱动在修改这种内存时,总是期望这种改变能够尽快通过总线写回到设备内部,从而驱动设备做出相应的动作。如果放Cache中,硬件就无法收到指令。

http://www.cnblogs.com/lzhu/p/7071488.html

常见的动态RAM的共同特点是都靠电容存储电荷的原理来寄存信息,电容上的电荷一般只能维持1~2ms,因此即使电源不掉电,信息也会自动消失,所以必须在2ms内对其所有存储单元恢复一次原状态,称为刷新,刷新是一行一行进行的。又因为内存就一套地址译码和片选装置,刷新与存取有相似的过程,它要选中一行,这期间片选线、地址线、地址译码器全被占用着。所以刷新与存取不能并行。同理,刷新操作之间也不能并行,意味着一次只能刷一行。

假设刷新1行的时间为0.5μs(刷新时间是等于存取周期的。因为刷新的过程与一次存取相同,只是没有在总线上输入输出。顺便说一下存取周期>真正用于存取的时间,因为存取周期内、存取操作结束后仍然需要一些时间来更改状态。——对于SRAM也是这样,对于DRAM更是如此)。
并假设按存储单元(1B/单元)分为64行64列。
(64×64个单元×1B/单元 = 2^12个单元×1B/单元 = 4KB内存 )。

(1).集中刷新
指在规定的一个刷新周期内,对所有存储单元集中一段时间逐行进行刷新。(一般是刷新周期的最后一段时间)
例如:对6464的矩阵刷新,存取周期是0.5us,刷新周期为2ms(占4000个存取周期)。
则集中刷新共需0.5
64=32us(占64个存取周期),在这段时间内存只用来刷新,阻塞一切存取操作,其余3968个存取周期用来读/写或维持信息。
这64个存取周期称为“死时间”,所占的比率64/4000*100%=1.6%称为死时间率。
这种方式的优点是速度高,缺点是死时间长。

(2).分散刷新
指对每行存储单元的刷新分散到每个存取周期内完成。其中,把机器的存取周期分成两段,前半段用来读/写或维持信息,后半段用来刷新。
例如:对6464的矩阵刷新,存取周期是0.5us,则读写周为0.5us。
刷新周期为:64
1us=64us。<2ms , 在2ms丢失电荷前就会及时补充。
优点是没有死时间了,缺点是速度慢。

(3).异步刷新
指不规定一个固定的刷新周期,将每一行分来来看,只要在2ms内对这一行刷新一遍就行。
例如:对64*64的矩阵刷新,存取周期为0.5us。
要使每行能在2ms内刷新一次,即每隔 (2ms/64us) 刷新一行,也就是对这一行来说,下一次对它进行刷新的间隔,期间要经过64次内存刷新周期才又轮得到它。
每行刷新的时间仍为0.5us,刷新一行只停止一个存取周期,但对每行来说,刷新间隔在2ms以内,死时间缩短为0.5us。


DMA传送不经过CPU的控制,假如硬盘的数据不能经过DMA控制器读到内存,那么每完成一次将硬盘的数据读出来,再存放到内存的操作,都要通过CPU运行几条读写指令来完成,这时CPU就做不了别的事了,如果有DMA控制器,则这个过程不需要CPU的参与,只需要占用总线就可以了。CPU还可以去完成别的运算。

Burst操作还是要通过CPU的参与的,与单独的一次读写操作相比,burst只需要提供一个其实地址就行了,以后的地址依次加1,而非burst操作每次都要给出地址,以及需要中间的一些应答、等待状态等等。如果是对地址连续的读取,burst效率高得多,但如果地址是跳跃的,则无法采用burst操作

single传输:

Burst传输:如果采用Burst 8 trans,则CPU可能一次从DDR读取数据到ARM内部寄存器R3R10,然后再将R3R10的数据写入到SRAM中

参考数据:single传输速率16MB/s,Burst传输数据160MB/s,DMA传输速率240MB/s,由于DMA配置阶段耗时2205ns,所以当需要传输的数据量大于1080Bytes后,DMA的速度大于Burst;当数据量大于36Bytes时,DMA速度大于Single。

dma有burst、burst size、transfer的概念:

burst:

dma实际上是一次一次的申请总线,把要传的数据总量分成一个一个小的数据块。比如要传64个字节,那么dma内部可能分为2次,一次传64/2=32个字节,这个2(a)次呢,就叫做burst。这个burst是可以设置的。这32个字节又可以分为32位 8或者16位16来传输。

transfer size:

就是数据宽度,比如8位、32位,一般跟外设的FIFO相同。

burst size:

就是一次传几个 transfer size.

配置数据宽度为32位。一次传8个32位=32个字节。

那么如果总长度为128字节,那么实际dma设置的长度为 128/32 = 4.


内存带宽计算公式:带宽=内存核心频率×内存总线位数×倍增系数。
先容我从DDR的技术说起,DDR采用时钟脉冲上升、下降沿各传一次数据,1个时钟信号可以传输2倍于SDRAM的数据,所以又称为双倍速率SDRAM。它的倍增系数就是2。
DDR2仍然采用时钟脉冲上升、下降支各传一次数据的技术(不是传2次),但是一次预读4bit数据,是DDR一次预读2bit的2倍,因此,它的倍增系数是2X2=4。
DDR3作为DDR2的升级版,最重要的改变是一次预读8bit,是DDR2的2倍,DDR的4倍,所以,它的倍增系数是2X2X2=8。

DDR SDRAM是Double Data Rate SDRAM的缩写,是双倍速率同步动态随机存储器的意思。
SDRAM在一个时钟周期内只传输一次数据,它是在时钟的上升期进行数据传输;而DDR内存则是一个时钟周期内传输两次次数据,它能够在时钟的上升期和下降期各传输一次数据,因此称为双倍速率同步动态随机存储器。

与DDR相比,DDR2最主要的改进是在内存模块速度相同的情况下,可以提供相当于DDR内存两倍的带宽。这主要是通过在每个设备上高效率使用两个DRAM核心来实现的。作为对比,在每个设备上DDR内存只能够使用一个DRAM核心。技术上讲,DDR2内存上仍然只有一个DRAM核心,但是它可以并行存取,在每个时钟周期处理多达4bit的数据

需要补充的一点是,内存有三种不同的频率指标,它们分别是核心频率、时钟频率和有效数据传输频率。
核心频率即为内存Cell阵列(Memory Cell Array)的工作频率,它是内存的真实运行频率;
时钟频率即I/O Buffer(输入/输出缓存)的传输频率;
有效数据传输频率则是指数据传送的频率。
DDR3内存一次从存储单元预取8Bit的数据,在I/OBuffer(输入/输出缓存)上升和下降中同时传输,因此有效的数据传输频率达到了存储单元核心频率的8倍。同时DDR3内存的时钟频率提高到了存储单元核心的4倍。也就是说DDR3-800内存的核心频率只有100MHz,其I/O频率为400MHz,有效数据传输频率则为800MHz。
从SDRAM-DDR时代,数据总线位宽时钟没有改变,都为64bit,但是采用双通道技术,可以获得64X2=128bit的位宽。
下面计算一条标称DDR3 1066的内存条在默认频率下的带宽:
1066是指有效数据传输频率,除以8才是核心频率。一条内存只用采用单通道模式,位宽为64bit。
所以内存带宽=(1066/8)×64×8=68224Mbit。
由此可知,如果内存工作在标称频率的时候,可以直接用标称频率×位宽,简化公式。 再根据8bit(位)=1Byte(字节),得68224/8=8528MByte=8.328125GB。
再以两条标称1066超频到1200的DDR3内存,组成双通道后的带宽:超频到1200后,内存核心频率应为1200/8=150MHz,而双通道的位宽=128bit:带宽=150×128×8=153600Mbit=18.75GB4


  1. 关于CPU Cache – 程序猿需要知道的那些事. ↩︎

  2. 7个示例科普CPU CACHE ↩︎

  3. Cache写机制:Write-through与Write-back. ↩︎

  4. DDR3 内存带宽计算 ↩︎

  • 7
    点赞
  • 65
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值