Cache for MIPS
没有Cache的MIPSCPU不能称为真正的RISC。可能这样说不公平。但为了一些特殊的目的,你可以设计一个含有小而紧密内存的MIPSCPU,而这些内存只需要固定个数的流水线步骤(最好是一个)就可以被访问到。但绝大部分MIPS CPU都是含有cache的。
这一章将介绍MIPS的cache怎样工作和软件应该怎么做才能使它可以被使用而且是可靠的。MIPSCPU重新启动后,cache的状态是不确定的,所以软件必须非常小心。你有一些线索知道cache的大小(如果你直接知道cache的大小后去初始化,这是一个不好的软件习惯。)。对于诊断程序员,我们将讨论怎样测试cache和获取特殊入口。
对于实时应用程序的程序员,希望在CPU运行时能够正确地控制cache。我们也将讨论怎么做,虽然我对使用一些窍门方式有怀疑。
当然这些也随着MIPSCPU的发展而进步。对于早期的32位MIPS处理器,初始化cache或者使其无效,首先让cache进入一种特殊的状态,然后通过普通的读写操作来完成。对于后来的处理器,一些特殊的指令被定义出来做这些相关的操作。
4.1 cache和cache的管理
cache的工作就是将内存中的一部分数据在cache中保留一个备份,使这些数据能一个固定的极短的时间内被快速的存取并返回给CPU,这样能保证流水线的连续运行。
绝大部分MIPSCPU针对指令和数据有其各自的cache(分别称为Icache和Dcache),这样读一条指令和一个数据的读操作或者写操作就能同时发生。
老的CPU家族(象x86)为了保证被写入CPU的代码的一致性,所以没有cache。现在的x86芯片拥有更灵活的硬件设计,从而保证软件没有必要从更本上了解cache(如果你正在装一台机器跑MS/DOS,它将在本质上提供一致性)。
但因为MIPS机器有各自的cache,所以就没有必要那么灵活。cache对于应用程序来说必须是透明的,除了除了能感觉到运行速度的增加。但对于系统程序或者驱动程序,拥有cache的MIPSCPU并没有尝试cache对它们也是透明的。cache仅仅使CPU跑得更快,而不能给系统程序员有所帮助。在象Unix一类的操作系统中,操作系统能对应用程序完全隐藏cache,当然对于更多不能的胜任的操作系统,其也能很好的隐藏大部分cache的处理,但你可能必须知道在什么时候需要调用适当的子程序来对cache做一些必要操作。
4.2 cache怎样工作 从概念上讲,cache是一个相连内存(associative memory),当数据被写入时用数据的一部分作为关键字来标志的一块存储区域。在cache中,关键字是整个内存的地址。提供一个相同的关键字给相连内存,你将得到相同的数据。一个真实的相连内存在存入条目时,将完全按照它们的关键字,除非它已经满了。然而,由于需要这个当前的关键字必须和所有被存的关键字同时比较,因此任何大小的真实相连内存不是效率低或速度慢,或者就是两者都有。
怎样我们才能设计有用的高速缓存,使其不仅效率高而且速度快呢?图4.1展示了一种最简单高速缓存的基本设计方案,直接映射(direct-mapped)高速缓存。它被1992年以前的MIPSCPU广泛使用。
直接映射cache由许多块简单的高速缓存排列构成(通常每一块称之为一line),通过地址低位在整个范围内做索引。cache的每一条line都包含一个字或者几个字的数据和一个标签(tag)区域,tag记录着数据所在内存的地址。
当一个读操作时,每一条line都可以被访问到,tag将和内存地址的高位做比较;如果匹配的话,我们知道是找到正确的数据了,这被称之为命中(hit)。如果在这一块中有超过一个字的数据,对应的那个字的数据通过地址的最低几位来选择出来。
如果tag没有匹配,这称之为没有命中(miss),那么数据需要从内存中读入,然后复制到cache对应的line中。这对应line中原来的数据将会被抛弃,如果CPU又需要被抛弃的数据时,需要再次从内存中取得。
这样的直接映射cache有一个特征,就是对于任何一个内存地址,在高速缓存中只有唯一的一条line可以用来保存其数据。这样有好处也有坏处。好处就是这样的架构简单,可以使CPU跑得更快。但简单也有其不好的一面:如果你的程序要不停地交替使用两个数据,而它们刚好要对应高速缓存中的同一块(可能是它们对应内存地址的低位刚好一样),这样这两个数据就会不停的将对方替换出高速缓存,以至高速缓存的效率被彻底的降下来。
而真正的相连内存将不会遇到这样的折腾,但对于任何合理大小,它将是难以想象的复杂、昂贵和速度缓慢。 折衷的办法就是使用two-way set-associative cache,其实就是两个direct-mapped cache并联,在它们中同时匹配内存位置。如图4.2。这时对应一个地址将有两次机会命中。Four-way set-associative cache (就是有四个直接映射的子高速缓存)在cache的设计中也是很平常的。但是这是有惩罚的。一个set-associate cache比起直接映射cache来需要更多的总线连接,所以cache太大以至于很难在一块芯片上构造直接映射。
不过也有巧妙的地方,由于直接映射cache对于你需要的数据只有唯一的候选者,所以把一些东西放到tag匹配前运行是可能的(只要CPU不做和着个数据有关的操作)。这样可以提高每一个时钟利用率。
由于当运行一段时间后cache会被装满,所以当再次存放从内存读来的数据时,就会抛弃一些cache内原有的数据。如果你知道这些数据在cache和内存中是一致的,那么你可以直接把cache中的备份抛弃;但如果cache中的数据更新的话,你就需要首先把这些数据存回到内存中。
这就给我们带来一个问题,cache怎样处理写操作?
4.3 Write-Through Caches in Early MIPS CPUs
CPU不能仅仅是读数据(就象上面的讨论),它们也要写数据。由于cache只是将主存中的一部分数据做一个备份,所以有一个显而易见的方法来处理CPU的写操作,被称之为Write-Through cache。 对于Write-Through cache,写操作时CPU总是将数据直接写到主存中去;如果对应主存位置的数据在cache中有一个备份,那么cache中的那个备份也要被更新。如果我们总是这样做的,那么cache中的任何数据将和主存中的保持一致,所以只要我们需要我们就可以抛弃任何一条cahce line的数据,并且除了消耗时间不会丢失任何东西。
当然这也是有危险的,当我们让处理器等待写操作结束时,处理器的运行速度将彻底的降下来,不过我们能修复这个问题。可以将要写入主存的数据及其地址先保存在另一边,然后有主存控制器自己取得这些数据并完成写操作。这个临时保存写操作内容的地方被称之为写操作缓冲区 (write buffer),它是先入先出的(FIFO)。 早期的MIPS CPU有一个直接映射的write-through cache和一个写操作缓冲区,还有一个R3000的激发设置。它在同一芯片上构造cache控制器,但需要额外的高速存贮器芯片来存贮tag和数据。只有CPU跑一些特殊的程序很平均地产生的写操作,主存系统在这种工作方式下才能很好的消化这些写操作并工作的很好。
但CPU运行速度的增长比存贮器块得多。某些时候当32位的MIPS让位给64位R4000后,MIPS的速度就已经超过存贮器系统可以合理消化所有写操作的临界点了。
4.4 Write-Bach Cache in Recent MIPS CPUs 早期的MIPS CPU 使用简单的write-through cache。后来的MIPS CPU由于速度太快而不能适用这种方法,它们会陷入存储系统的写操作中,速度慢得像爬行。
解决的方法就是把要写的数据保留在cache中。要写的数据只写到cache中,并且对应的那条cahce line要做一个标记,使我们肯定不会忘记在某个时候把它回写到内存中(一条line需要回写,称之为dirty)。 Write-back cache还可以分成几种不同的子处理方式。如果当前cache中没有要写地址所对应的数据,我们可以直接写到主存中而不管cache,或者可以用特殊的方式把数据读入cache,然后再直接写cache,后面这种方式被称之为写分配(write allocate)。用一种自私的观点来看一个程序运行在一个CPU上,写分配(write-allocate)看起来象浪费时间;但是它可以使整个系统的设计变得简单,因为在程序运行时读写内存都读或者写都是以一条cache line大小为单位的块进行操作。 从MIPS R4000 开始,MIPS CPU在芯片内拥有cache,而且都支持write-through和write-allocate两种工作模式,line的大小也是支持16byte和32byte两种。
MIPS cache的这些工作模式可以被应用到使用sillicon Graphics设计R4000和其他大型CPU,其他计算机系统也因为多处理器系统而被这些cache工作模式影响到。
4.5 Cache设计的其他选择 在上个世纪八十和九十年代针对怎样设计cache,做了很多工作和研究。所以下面还有许多其它的设计选择。
Physically addressed/virtually addressed:
当CPU在运行成熟的操作系统时,数据和指令在程序中的地址(程序地址或虚拟地址)会被转换成系统内存使用的物理地址。
如果cache纯粹地在物理地址方式下工作,将很容易被管理(我们将在后面讨论为什么)。但合法的虚拟地址可以让cache更早地开始查询匹配工作,这样可以使系统跑的稍微块一点。
但虚拟地址有什么问题呢?它们不是唯一的;当许多不同的程序在CPU不同的地址空间中运行,它们可能会共享同样的虚拟地址而使用不同的数据。当我们切换不同的地址空间时,每次都需要重新初始化cache;这种方式在很多年前被使用,可以作为针对非常小的cache的一种合理解决方法。但针对大的cahce这种方式不仅可笑而且效率低下,我们需要一块区域来辨别cache tag中的地址空间,以至我们不被它们混淆。
这儿还有其它关于虚拟地址更细致的问题:相同的物理地址可以在不同的任务中被不同的虚拟地址描述。这就会导致相同物理地址的内容会被映射到不同的cache条目中(因为它们对应不同的虚拟地址,所以会被不同的索引所选中)。这样的情况必须被操作系统的内存管理所避免掉。详细的情况将在4.14.2节介绍。
从R4000起,MIPS的主cache都使用虚拟地址索引,从而提供快速的cache索引。但对于作为标记符来标记每一个cache-line,物理地址比虚拟地址更好。物理地址是唯一的而且效率更高,因为这样的设计显示出CPU在做cache索引的同时可以把虚拟地址转换成物理地址。
line大小的选择(Choice of line size):
line的大小是对应每一个tag可以存贮多少字的数据。早期的MIPS的cache对应一个tag只能存贮一个字的数据。但对应一个tag能存贮多个字的数据更好,尤其是内存系统支持快速的burst read。现代的MIPS cache趋向于使用四个或者八个字大小的line,并且更大的第二层和第三层cache使用更大的line。 当cache miss发生时,整个一条line的数据都要从内存中获得。但很可能会取来几line的数据;一个字的cache line的MIPS CPU经常是一次就取多个字的数据。
分开/统一(Split/unified):
MIPS的主cache总是分成I-cache和D-cache,取指令时察看I-cache,读写数据时察看D-cache。(顺便说一下,如果你想执行CPU刚刚拷贝到内存的代码,你必须不仅仅要是D-cache一部分无效使这些代码数据在D-cache中不再存在,而且还要保证它们被装入I-cache)
但是不在同一块芯片上的第二层cache很少也按这种方式来分成两块。这样就没有什么真的优势可言了。除非你能针对两种cache提供分开的数据总线,但这又会需要太多的管脚。
4.6 Cache管理(Magaging Caches)
Cache系统在系统软件的帮助下,必须保证任何应用程序数据的一致性,和它们在没有cache的系统下一样,尤其是DMA I/O控制器(直接从内存中取得数据)取得程序认为已经写过的数据。 对于CISC CPU,通常都不需要系统软件对cache的帮助;因为它会花费额外的内存空间、silicon area、时钟周期来使得cache变得真正的透明。
在系统启动的时候MIPS CPU需要初始化它的cache;这是一个十分复杂的过程,下面有关于它的几点建议。但当系统启动后运行到三种情况CPU必须加以干涉。
.在DMA设备从内存取数据之前:
如果一个设备从内存中取得数据,它必须取得正确的数据。如果D-cache是write-back,并且程序已经写了一些数据,那么很可能其中一些正确的数据还保留在D-cache中而没有写回到主存中去。CPU当然不可能看到这个问题;如果CPU需要这些数据,它会从cache中得到正确的数据。
所以在DMA设备开始从内存中读数据前,任何一个将被读数据如果还保留在D-cache中,必须被写回到内存中。
. DMA设备写数据到内存:
如果一个设备要将数据存贮到内存中,要使cache中任何对应将要写入内存位置的line都无效化,这是非常重要的。否则,CPU读这些位置的数据,将得到错误的数据。cache应该在数据通过DMA写入内存之前将对应的cache line无效化。
. 拷贝指令:
当CPU自己为了后面的执行而写一部分指令到内存中,你首先必须保证这些指令会被回写到内存中,其次保证I-cache中对应这些指令的line会被无效化。在MIPS CPU中,D-cache和I-cache是没有任何联系的。(当CPU自己写指令到内存中时,这时候指令是被当作数据写的,很可能只被写到cache中,所以我们必须保证这些指令都会被回写到内存中;为什么要使I-cache无效化,这和数据通过DMA直接写入内存中要无效cache一样的原因。)
如果你的软件需要解决这些问题,就需要针对cache line的两个独特的操作。
第一个操作被称之为回写操作。CPU必须能够针对地址在cache中查找对应的cache line。如果找到,并且对应line是dirty,就需要把这条line的数据写回到内存中。
CPU增加了其他不同层次的cache(速度和大小),来减少miss的处理。所以设计者可以使内层的cache机构简单,从而使它能在很高的时钟频率上作查询。这样很显然越往内层的cache就会越小。从1998年开始,许多高速的cpu都在同一块芯片上采用第二级cache,主cache的大小变小,双重16K的主cache受到青睐。
不在同一块芯片上的cache通常都是直接映射的,因为组相连的cache系统需要更多的总线从而需要更多的管脚来连接。这还是一个值得研究的领域;MIPS R10000采用只有一个数据总线的二路组相连cache,如果命中的不是希望的那一组,通过一段延时后在返回数据来实现(两个组共用一个数据总线)。
在cache的发展过程中,产生了两类主要的软件接口来针对cache。从软件的观点来看,一类是建立在以R3000为代表的32位MIPS CPU的基础上;另一类是建立在以R4000为代表的64位MIPS CPU上的。R3000这一类型的MIPS CPU的cache是write-through,直接映射的,物理地址为索引。cache访问的最小单位是一个字,所以写一个字节(或者是写小于一个字)的操作必须被特殊的处理。在读写这一类数据是cache管理采用特殊的模式。
为什么不通过硬件来管理cache?
通过硬件来管理cache通常被称为“爱管闲事”。当另一个cpu或者是DMA设备访问内存时,被访问地址对应的内容对于cache来说是可以看到的。
4.7 第二层和第三层cache
在大型的系统中,通常需要一个嵌套的多层cache。一个小而快的主cache最接近cpu。访问主cache出现miss时,不是直接从内存中查找而是从第二层cache中查找。第二层cache在速度和大小上是介于主cache和内存之间。cache层次的数目可以通过内存速度和cpu最快访问速度比较来决定;由于cpu速度发展比内存的发展快得多,在过去的12年里桌上型电脑系统从没有cache发展到有两层cache。九十年代后期的最快cpu速度大约可以达到500MHz,拥有三层cache。
4.8 MIPS CPU cache的构造
通过观察cache采用模式和层次的发展(看表4.1),我们可以将MIPS CPU分成两类,古老的和现代的。
当时钟的速度变得越快,我们就能看到越多得cache构造,因为设计者为了应付CPU跑得速度比内存系统越来越快。为了保证运行的顺畅,cache必须提高运行速度,保证提供数据的速度比外围得存贮器要快,同时也要保证尽可能多命中。相比较R4000类型的CPU,主cache是write back类型,是write allocate ,virtually indexed,physically tagged, 二路或四路组相连的cache。
许多R4x00和其后续cpu在同一块上拥有第二层cache的控制器,1998年出现了这样的第一块cpu。
由于两种产生的不同,我们将分两节来详细介绍。
注意!一些系统的第二层cache不是由mips cpu内部的硬件来控制的,而是建立在内存的总线上。对于这类cache的软件接口将具有系统特殊性,和象这章介绍的由cpu内部控制的cache的软件接口相比,可能有很大的不同。
4.9 Programming R3000-Style Caches MIPS R2000打破了芯片内cache控制器的基础,将cache额外的分成I-cache和D-cache。这是一个后见之明,不会让人感到惊讶,就是这样一个先驱者的冒险导致了后面很多事端。cache有一个特殊的软件访问缺点。
为了节省芯片管脚,cache将不能拥有不同的闸门来执行字节、半个字和其他小于一个字单位的写操作。所以在R2000系列中对cache执行一个小于字单位的写操作时,会回写到主存中,并将cache中这个字所在的Line无效化。这样针对cache管理,提供了一个使cache无效的方法:只用写一个字节就行了。
你可以看到支持这些简化的观点。R2000设计者提出理由小于字的操作通常用于字符操作,字符操作总是由库函数提供,而这些库函数用整个字的操作来重写。这些假设总是被认为对对错错,或者半对半错。
直到认识到不是所有系统都能用相同的函数库,而且每个字节写操作都使所在cache无效也不是一个好主意,这些争论才没有继续下去。因为这是不能被容忍的,所以出现了一个很大的改动,R3000系列的cpu通过一个RMW(read-modify-write)序列来执行小于字单位的写操作。这个RMW出现在所以的32位的mips cpu中,并增加了一个 时钟周期来作为这样一个写操作的延时。
这样cache无效的机制被带入困境;R2000因为它的奇怪习惯而有一个优点,可以通过字节的写操作来使cache无效化。而R3000 cache 需要用一个叫isolation的模式来挽救,原来这种模式只是用于cache诊断的。RMW队列因为这种模式而受到压制,在那种状态下小于一个字单位的写操作还是会让该字所处的line无效化。这是不幸的但不是悲惨(灾难)的,对于一些运行着的系统做一些事有着更有益的地方。显著的就是当cache在isolation模式时的时候,cache将没有读写操作,任何读写操作将直接和内存打交道。
4.9.1 Using Cache Isolation and Swapping
所有的R3000系列cpu的cache都是write-through模式的,这就是说cache中不会拥有比内存中更新的数据。也就是说cache中的数据从来都不需要回写到内存,所以我们只需要能使D-cache和I-cache无效就行了。
只需要不同的cache操作按照内存顺序来做cahce的管理,并且cache的管理没有必要通过特殊的内存地址空间。所以这儿有一个状态寄存器有一个SR位能够使D-cache关闭isolation模式;在这种模式下读写操作只影响着cache,读还是会命中但不管tag是不是相等。当D-cache处于isolation模式时,小于一个字单位的写操作会使对应cache Line被无效化。
CAUTION!!!
当D-cache处于isolation模式,任何读写操做不会受其对应地址或TLB条目的影响而按照非cache的情况操作。这样的结果就是cache管理程序必须保证有些数据是不可以被访问的;如果你能通过你的编译器做到很好的控制,并且能过保证所有你用的变量都保存在寄存器中,你才能在很高级别的语言中写它们。还必须保证运行这些程序时屏蔽中断。
I-cache在通常运行模式下也是完全不可访问的。所以CPU提供了另一种模式,cache交换(swapped),通过设置状态寄存器的SwC位;这时D-cache可以担当I-cache,I-cache可以担当D-cache。当cache是交换模式时,isolated的I-cache条目可以被读、写和无效化。
D-cache可以完美的充当I-cache使用(可能I-cache也可以通过初始化使之象D-cache一样工作),但I-cache不能完全的充当D-cache。这也是靠不住的,当cache是交换模式时有用,isolation却没有用。
如果你需要使用交换的I-cache来存储字单位的数据(和以前一样小于字单位的数据写操作会使该数据对应的line被无效化),你必须保证在返回到正常模式时对应的cache line必须被无效化。
4.9.2 Initializing and Sizing 初始化和判断大小
当机器启动时cache的状态是不确定的,所以这时读cache结果也是不可预知的。你也应该认识到机器重起后状态寄存器的SwC位和IsC位也是不确定的,所以在对cache读写前(即使在非cache的情况)启动软件最好能将这些状态设为可知的。
不同的MIPS CPU,cache有不同的大小。为了保证你软件的可移植性,最好能在初始化的时候计算出D-cache和I-cache的大小。这样比直接配置一个给定的值好。
下面将介绍怎样获得cache大小的值: a. Isolated cache,让I-cache处于交换模式。 b. 在R3000系列CPU中,cache的大小可能是256K,128K,64K,32K,16K,8K,4K ,2K,1K和0.5K(K等于1024,单位是字节)。将这些可能的值n(上面那些值中的一个)写到物理地址等于它们本身的地方(有大到小)。最简单产生物理地址是用Kseg0段地址(n+0x80000000)。因为cache地址是重叠循环的,那么如果n是cache大小的倍数,那么它就会被后面小的值所覆盖。
c. 所以读物理地址零(也就是0x80000000),就能得到cache大小的值。
初始化cache,你必须保证每一个cache条目都被无效化,而且正确对应一个内存位置,所含的之值也是正确的: a. 检查状态寄存器SR的PZ位是不是位零(为1的话,关闭奇偶位,对于同一个芯片上的 cache这不是一个好主意)。 b. isolated D-cache,并使它和I-cache交换。 c. 对于cache的每一个字,先写一个字的值(使cache的每条line的tag、数据、和奇偶位都正确),然后再写一个字节(使每条line都无效)。
不过要注意当对于每条line有四个字的I-cache,这样做效率就很低;因为只要写一个字节就足够使每条line无效了。当然除非你要经常调用这个使cache无效程序,否则这个问题是不会表现的很明显。不过如果你想根据实际情况来优化cache无效化程序,就需要在启动的时候确定cache的结构。
4.9.3 cache无效化(Invalidation) 使cache无效,按照下面的流程: a. 计算出你使cache失效所需的地址范围。使用超过cache大小的范围是浪费时间。 b. 使D-cache孤立。一旦被孤立后你就不能读写内存,所以你必须花费所有的代价来防止异常的产生。关闭所有的中断并保证后面的程序都不会导致内存访问异常。
c. 如果你还想使I-cache失效,使cache处于交换模式。 d. 在刚才计算出的地址范围内针对每一条line写一个字节的内容。
e. 关闭cache的交换和孤立模式。
通常你应该在I-cache打开的模式下运行使cache失效的程序。这听起来是混乱和危险的,但事实上你没有必要花费额外的步骤去跑cache。一个使cache失效的程序在cache关闭的情况下运行要慢4到10倍。
当你的CPU去设IsC位时,本质上必须关闭所有的中断,因为这时是不能访问内存的。
4.9.4 测试和探察
在测试、调试或profiling时,画一个cache条目的示意图是很有帮助的。 你不能直接的读tag的值,但对于合法的line有详尽的方法得到:
a. Isolate the cache. b. 通过每条line的起始地址从每条line里取得(低位地址匹配,高位地址包括你系统的内存的物理地址范围)。每一次读取都要参考状态寄存器的CM位,只有该位为零时,取得的tag值才是正确的。
这需要很多个计算机的周期,不过对于在20MHz的处理器,1K的D-cache对应4MB的物理内存,做整个查询就只需要几秒钟。
4.10 Programming R4000-Style Caches R4000修改了早期cpu cache不合适的地方。但R4000成功的地方就在于cache有多种工作模式(write-back , write-allocate),以及拥有更长的line。因为有write-back工作模式,当被cpu写时每一条line都需要一个状态位来标志这条line为dirty(因此来表示很内存中的数据不同)。
对于这类cache,我们需要invalidate和write-back操作:而且还必须保证任何cpu写到cache中的数据必须被回写到内存中。
对于诊断和维护的目的,tag将更容易的被读写;R4000增加了一对寄存器TagLo和TagHi用来在cache tag和系统管理软件之间中转数据。对于R4000没有直接方式读取cache line内的数据,当然你还是可以通过cache 命中的方式来访问数据。CPU可以通过执行cache指令来从cache tag内取数据到32-bit的TagLo和TagHi寄存器中,或者是将这些寄存器的内容写到cache tag。图4.3显示这些寄存器的详细内容。
cache的地址tag存有除了用来查询cache index的其他所有位;因此主cache tag的长度会因为最大物理地址(R4x000是36bit)和用来索引cache位数不同而不同。13bit用来作为最初R4000的8KB大小主cache的索引,从此就再也没有小于这个位数。这样tag长度就有23bit,并且TagLo是24bit;在目前的cpu内TagHi总是零。这对于最小cache大小或是可以支持的最大物理地址是很重要的。对于R4000,现在TagHi是多余的;把它设为零并忘了它。
所以TagLo寄存器内包含对应cache line的tag的所有位。TagLo(Pstate)还包含状态位。在绝大多数情况下(多处理器)这将变得非常复杂,但对所有cache的管理和初始化它足够表明当Pstate为零是一个合法的值来对应一条无效的cache entry。 这个区域被后来的cpu占用,用来储存第二层cache的状态信息,但这成了一个惯例值为零是安全和合适的对于初始化。
最后,TagLo(p)是一个奇偶位,设一为整个cache tag偶校验。为全零的TagLo表明正确地偶校验。一些cpu忽视这个bit,而不去检查它,而且也没有危害。
4.10.1 CacheERR, ERR, and ErrorEPC Register:Cache ErrorHandling
CPU的cache是内存系统的至关重要的一部分,对于高效的实用或正确系统可以发现用额外的位来表明存储在这儿的数据的完整性是值得的。
内存系统的校验将首尾相接理想化地被执行下去;当数据一被产生或被传入系统校验位就会被计算,随着数据被存放,并在数据被使用前被检查。That way the check catches faults not just in the memory array but in the complex buses and gizmos that data passes through on its way to the CPU and back. 这样检查的方法将不在内存队列中抓住错误而在总线上,并通过这样的方式转送到cpu和返回。
因为这个原因,R4x000地CPU(设计应用于大型计算机)在cache里提供错误校验。和主存系统一样,你既可使用简单的奇偶校验或者使用错误纠正码(ECC)。
奇偶校验是简单的使用一个额外的bit来对应内存中每一个byte。一个奇偶错误可以告诉系统这个数据是不可靠的,并允许有些控制停止来代替creeping随机错误。奇偶校验的一个至关重要的任务就是系统开发的过程中提供巨大的帮助,因为它不能明确的指出由于内存数据完整性导致的问题。
但一个byte的废物将有百分之五十的机会有一个正确地奇偶校验,并且在72位的数据总线上的随机垃圾256次中将有一次没有被发现。一些系统可能会好一点。
错误纠错码计算起来将更加复杂,因为对于一个64位的数据将有8个bit的校验位。这将是十分的彻底,一个bit的错误将被唯一的指出并纠正,任何两个bit的错误也不会被忽视。在非常大的内存队列中ECC将从本质上排除随机错误。
因为纠错码一次可以检查整个64位的数据,所以使用纠错码的内存不能进行小于一个字的数据的写操作,被选中的小于一个字的数据必须被并入新的数据并重新计算纠错码。MIPS cpu在cache关闭的情况下需要内存系统能进行小于一个字数据的写操作,这将使事情变得复杂。内存系统硬件必须将一个小于一个字数据的写操作转变为先读然后合并,接着重新计算,最后在写入的操作序列。
对于简单的系统一般的选择是奇偶校验位,而不是其他的。让采用校验方式变成可选择的这是很有意义的,这样在设计研发过程中将有利于诊断,而在成为产品时却不用付出相应的代价。
无论检查机制是运行在内存系统中还是在R4x00的cache中,cpu将提供一个字节对应的奇偶校验位,或是对应64位的8bit纠错码,或者就是干脆没有保护。
当支持错误检测时,数据的检测位在cache填充时通常是直接通过系统接口到cache内存放而不被检查。只有在数据被使用时才检查,这样可以保证任何cache奇偶异常都被转交给引起它的指令,而不是转交给使用共同的cache line。当作一个退化的情况,一个在非cache的取指错误会被标志成cache奇偶错误,这种情况会使你很混乱。
注意,系统接口标志进来的数据为没有合法的检查位是有可能的。在这种情况下,cpu会为它内部的cache重新产生检查位。
如果一个错误发生了,cpu会产生一个特殊的错误陷阱。这个vector会直达一个非cache的位置(如果cache内是错误的数据,它会很愚蠢地去执行cache内地代码)。如果系统采用ECC,当写操作时硬件会产生纠错位,当错误时会检查得到。硬件并不知道怎么纠错;这将是软件的工作。
ERR寄存器(如图4.4)的格式如下: a. ER/ED/ET/EE/EB: 这些位将区别是什么cache(主cache 或第二层cache,指令cache还是数据cache)发生了错误,或是它在系统接口之外。 b. PidX: 给出错误位置的cache index。你可以取得这儿的内容用于index类型的cache操作;它将得到正确的line,而不管cache是直接映射还是组相连的。
当错误发生了,ErrorEPC寄存器指向发生错误的指令位置。ERR寄存器保存着ECC位,你需要用它来纠正可以改正的错误,但在这儿我们将不再讲如何做--因为这需要很大的篇幅,你将需要密切联系处理器手册。你将可以得到一些简单的针对mips运算规则的代码。
4.10.2 The Cache Instruction cache 指令
Cache指令有着和MIPS存储指令类似的格式(拥有通常寄存器再加上十六位有符号的偏移地址),但表示数据寄存器的值会被译成选择区表示是什么cache指令。这儿没有标准的名字来表示这些cache操作;在这儿我就武断地使用来自SDE-MIPS算法库的名字,这些名字依次是基于SCI/MIPS的一个头文件(include files)。选择区不是完全的位编码,但是几乎是;看表4.2。 Cache选择区可以让你做下面这些选择: a. 那类cache: 选择是icache还是dcache,是主cache还是第二层cache。因为没有多余的位存在,所以还没有提供的三层cache的选择。但这儿我要提醒你这是和CPU密切有关的,在R4000之后的64位CPU提供对R4000兼容性是非常有帮助的。
b. How cache is addressed:有两种不同的类型。如果是命中方式,你需要提供正常的程序地址(虚拟地址),其必须被转化。如果提供的地址确实在cache中,则该操作会对应相应的cache line被完成;如果不再cache中,那就什么也不用做。 另一种是index方式。地址低位用来直接选中某一个cache line,而不管这条line现在有什么内容。这显现出cache内部组织的没有原则性。
cache的维护通常是需要命中方式,而初始化时就需要index方式。 c. 回写(write back):如果对应的line是dirty,就将数据回写到内存中去;如果不是,就像是一条nop指令。 d. invalidate(使无效):将这条line标志为无效,使其的数据不能在被使用。同时做回写和无效是可能的;但这不是自动的,如果你需要你可以使一条dirty的line无效。所以一些应用程序可能会丢数据。
e. Load/store tags:这些操作是将对应line内的tag内容存到TagLo和TagHi寄存器中或从这两个寄存器读到对应line的tag内。
存储tag使用比较过时的方式(TagLo和TagHi寄存器要预先设为零),是cache初始化的一部分。 f. Fill:这是仅仅为I-cache设计的,这个操作通过特殊的内存地址来填充一条cache line。对于填充dcache是没有必要的,因为当cache打开的时候读取没有命中时就会达到同样的效果。 g. Create data:这类操作是能够让用户能以很高的速度来写内存的排列,而避免任何的cache重新填充。除非你能保证在数据被使用或被回写到内存中之前覆盖这些所有的数据。
这个特性对于初始化和诊断很有帮助(你将在后面初始化第二层cache的代码例子中看到,并知道如何清除第二层cache的数据)。
4.10.3 计算cache的大写和决定怎样配置 对于R4x00 CPU(和绝大多数后面的CPU)主cache大小和line的大小会同过CP0的Config寄存器可靠的给出。
但要得出你的cache是直接映射的还是组相连的却是相当的困难。但对于正在运行的cache这就不难测试出来了,你只要参考两个不可能同时出现在直接映射的cache中的地址,然后使用index类的操作去检查看它们是不是同时在cache内存在;当然如果你还没有初始化cache,这是没有用的。幸运的是你可以只写同一个程序来初始化直接映射cache或是组相连cache。
4.10.4 初始化程序 这儿将介绍一个很好的方法:
1.开辟一些内存对应任意的数据,但如果你的系统使用奇偶校验码或是纠错码,你必须保证这是正确的,并用这些数据填充cache。(在算法库程序中我们保留至少32K的系统内存一直到cache初始化的时候;只要在cache关闭的时候去写这些内存,就能得到正确的奇偶码。)还需要一个足够的空间来初始化的二层cache;我们将采用迂回的方式来处理。
2.将TagLo寄存器设为零,这样能保证对应line有效的那一位没有被置起来并且tag的奇偶码是一致的。 TagLo寄存器被cache Store_Tag指令使用,强制使对应的line无效和清除tag的奇偶码。 3.屏蔽中断,不然会有一些意外发生。
4.先初始化Icache,然后是Dcache。下面是初始化Icache的C代码。(你必须相信像Index_Store_Tag_I()这样的函数或是宏能作底层的操作;它们或是琐碎的汇编代码子函数,能够运行在相应指令的机器上,或是对应GUN C用户通过宏调用一个C嵌入汇编。)
for (addr = KSEG0; addr < KSEG0 + size; addr += lnsize) { /* clear tag to invalidate */ Index_Store_Tag_I(addr);
/* fill so data field parity is correct */ Fill _I (addr); /* invalidate again – prudent but not strictly necessay */ Index_Store_Tag_I();
}
5.Dcache的初始化相对来说要复杂一些,因为没有对应Dcache的Index_Fill_D操作;我们只能通过从cache读取数从而依靠通常的没有命中过程来达到目的。依次当cache填充指令对应index操作时,读取工程会依靠内存地址通过tag来命中一条line。你必须非常小心tag;对应two-way的cache,用初始化Icache的循环来初始化Dcache会将Dcache的一半初始化两次,因为清除PTagLo会重新设置用来决定下一次没有命中时是那一组cache Line的位。下面是正确的方法。
/* clear all tags */ for (addr = KSEG0; addr <KSEG0 + size; addr += lnsize) Index_Store_Tag_D (addr); /* load from each line (in cached space) */ for (addr = KSEG0; addr < KSEG0 + size; addr += lnsize) junk = *addr; /* clear all tags */ for (addr = KSEG0; addr < KSEG0 + size; addr += lnsize) Index_Store_Tag_D (addr);
4.10.5 Invalidating or Writing Back a Region of Memory in the Cache
对于对应一些I/O空间的程序或物理地址的范围,用于无效或回写的参数是不变的。
你几乎总是用命中类型的cache指令来使cache内需要的位置来使其无效或回写。如果你需要将内存一个巨大范围使其无效或回写,使用index类型的指令来使整个cache无效或回写会比较快,虽然这是一个最优化的方法,但你很可能会忽视。
我们有足够的理由这样做:
PI_cache_invalidate(void *buf, int nbytes) {
char *s; for (s = (char*)buf; s < buf + nbytes; s += lnsize)
Hit_Invalidate_I (s); }
注意这儿没有必要产生特殊的地址,只要buf是程序地址就足够了,但像下面的例子,如果p是物理地址,你就必须将其加上一个常量转化成kseg0范围内的地址。
PI_cache_invalidate (p + 0x80000000, nbytes);
4.11 cache 效率
从九十年代早期cache设计在cpu同一块芯片上,高速cpu的性能很大程度上是有他们的cache系统性能决定的。现在许多的系统(尤其是嵌入式系统,需要节约cache的大小和内存的性能),CPU有50-60%的时间时在等待cache的再次填充。就这点来说将CPU性能翻倍将增加应用程序15-25%的性能。
cache性能取决于系统在等待cache再次填充的总时间。你可以将之归结为两个参数产生的结果:
a.cache没有命中的几率:是CPU(取指令或数据存取)在cache中没有命中,从而需要内存中取的比例。
b.cache没有命中需要替换造成的延时:这个延时是从内存中取数替换后到CPU的流水线继续下去的时间。
当然这没有必要很好的测量。举个例子,x86的CPU的寄存器个数很少,所以同样的程序编译给x86使用会比给MIPS使用多很多的数据存取。当然x86使用堆栈来代替寄存器给这些额外存取使用;而这堆栈位置将是内存中使用非常频繁的区域,对应cache的话使用效率会非常高。通过大块特殊的程序,我们就能获得cache没有命中的次数。
上面提到的意见将给下面指出的几种提高系统运行速度的显而易见的方法很有帮助。
a. 减少cache没有命中的几率:
1.让cache变得更大。这是最有效的,当然也算是最昂贵的。在1996年,64K的cache会占据高速嵌入CPU一半的硅面积甚至超过,所以要使cache的大小翻倍,你只有等待摩尔理论发明在相同的面积上做更多的门。
2.增加cache的组相连。值得增加到四组,再增加下去提高就几乎看不到了。
3.增加另外层次的cache。当然这将使计算变得更加复杂。除了多义子系统的复杂外,第二层cache的非命中率会被抑制很多;主cache已经可以撇去CPU重复访问数据行为的cream。为了使其物有所值,第二层cache必须比主cache大得多(一般来说是八倍或者更大),并且第二层cache的访问时间必须比内存快(两倍或者更快)。
4.优化你的程序从而减少非命中率。如果工作处于实践阶段,这就很难说清楚了。对于小的程序优化就比较简单,但对于琐碎的程序就很费力了。但迄今为止还没有人做出一个工具可以优化任何程序。看4.12节。
b. 减少cache替换的处罚:
1.加快CPU获得第一个字的数据。DRAM内存系统需要必须做许多发动工作,才能提供数据很快。使内存和CPU之间更加紧密,减短它们之间的路径,这样数据在它们之间来回才会更快。
注意这是唯一可以在便宜的系统中应用,并且效果不错。不过荒谬的是它很少被注意到,也许是因为它需要在CPU接口和内存系统设计之间考虑更多的综合因素。当CPU设计者在设计芯片的接口时不情愿处理这些问题,很可能因为他们的工作已经够复杂的了!
2.增加内存burst的宽度。这是传统上通常被应用的,是昂贵的技术,用两个或者更多的内存系统来交替存储数据;当初始化完后你就可以交替着从每一个内存系统中取数据,这样带宽好像翻倍了。第一个这样内存技术的应用是,是在1996年出现的同步DRAM(SDRAM)。SDRAM修改了DRAM的接口,提供更大的带宽。
c. 尽早的重新起动CPU:这个简单的方法是在排列cache替换的数据时,从CPU没有命中的数据开始,当数据一到就马上重新启动CPU。cache的替换可以和CPU运行并行。MIPS CPU从R4x000开始就应用这项技术,用一个子缓冲区来存放cache要替换进来的数据,并能那个字的数据是最先需要的。但只有R4600和其派生出来的CPU才体现出这项技术的好处。
激进的方式是让CPU绕过取数操作继续执行下去;取数的操作由总线接口控制器控制,CPU继续运行直到它需要的数据被存放到相应的寄存器重。这被称之为没有阻碍取数,从R10000和RM7000开始被应用。
更激进的方法是,可以执行任何后续代码,只要它不依赖于还没有取来的数据,R10000就可以不按照指令的顺序来执行。这类CPU应用这项技术非常彻底,不仅仅是应用到取数还应用到计算和跳转指令。
4.12 修改软件使Influence Cache效率更高
很多时候我们都在程序访问可知地方的基础上工作,并且我们是公平的在不强制的工作方式上操作。对于绝大多数目的我们同样可以假设,访问是恰当的随机分布式。对于工作站就必须能够支持执行足够的应用程序,这是一个公平的假设。但当一个嵌入式系统运行一个简单的应用程序,没有命中的清况好像是因为特殊的程序经过特殊的编译造成的。这是很有诱惑的,如果我们能是应用程序代码能在系统的方式下提高cache的效率。为了了解这是怎样处理的,你将cache没有命中分类,按照它们产生的原因:
a. 第一次访问:所以任何数据必须从内存中读入。 b. 替换:cache的大小是有限的,所以当你的程序没有运行多久就会出现cache没有命中,需要替换掉一部分合法的数据。当程序运行时,cache就会不停的替换掉数据然后在取。你可以通过使用大的cache和减小程序的大小来使替换和没有命中减到最少(其实就是程序大小和cache大小的比例)。
c. 从实际来讲,cache通常没有超过四路组相连的,所以对于任何程序地址在cache中最多有四个位置可以存放;对应直接映射就只有一个,二路组相连的cache就有两个(和组相连比较thrashing会丢失减少速度的可能;但绝大多数研究者建议一个四路组相连的cache在way的选择上几乎不回丢失性能)。
如果你的程序会非常频繁的使用n段空间的数据,而这n段空间的地址的低位又非常接近,那么它们就会使用相同的line。如果n大于cache的路数,那么cache的没有命中也会非常频繁,因为每一段空间的数据会不停的将cache内的其他空间的数据挤出去。
明白上面的知识,那么怎样针对程序做变化使它针对cache运行的更好? a. 使程序更小:如果你能做到的话,这是个很好的主意。你可以使用适当的编译器优化(外来的优化通常是程序更大)。 b. 让程序中经常执行的那部分更小:访问密度在一个程序中不总是平均分布的。通常会有相当一部分代码几乎不被用到(错误处理,不明系统的管理),或者只被用到一次(初始化代码)。如果你能将这些很少用到的代码剥离出来,对于剩余的程序你就能得到一个很好的cache命中率。
资格访问的方式有利于将一些频繁使用的程序区分出来,并固定放在内存的某一位子,以减少要运行时的放置。这样至少这些经常使用的程序不会因为cache的位置而相互碰撞。
c. 强迫一些重要的代码或数据常驻cache:一些机器的能允许一部分cache保护它们所拥有的数据不被替换。这部分代码一般是中断处理或是其他自关重要的软件中确定要执行的。这些代码或数据一般被保留在二路组相连cache的其中一组中(这样当系统重其后,cache就像是一个直接映射的cache).
我很怀疑这个方法的生存能力,我也不知道有那些研究支持它的有效性。系统重起后的损失很可能大于执行那些保留代码的获利。cache上锁很可能就像一个不确定的市场工具来限死顾客对cache启发式特性的渴望。这个渴望是可以理解的,随着程序越快越复杂越大,但cache毕竟只是影响结果的一部分因数。
d. 安排程序避免碰撞:上面提到的让程序的执行部分变的更小,这对于我来说太难维护所以不是个好的主意。而且对于组相连的cache(尤其是两路的)使这个方式更加没有意义。
e. 让那些很少用到的数据和代码不经过cache:这看起来很有吸引力,让cache只给那些重要的代码或数据服务,排除那些只用一次或很少使用的代码和数据。
但这几乎总是一个错误。如果数据真的很少使用,那么它们不可能一开始就在cache中。因为cache取数时总是以一条line的长度4或16字为单位,所以即使是传送只使用一次的数据也能有很高的速度;burst替换比一个字的访问几乎不多花时间,并且能给你免费提供另外的3或5个字的数据。
简而言之,我们将介绍下面的内容作为一个起点(除非你已经有很多实践和很深的想法,你才可以放弃)。开始我们先认为除了I/O寄存器cache都是打开的,并且很少使用远程内存。在你试着做预测前,先搞明白cache对你的应用程序有什么启发。第二在硬件上排除任何问题。也没有任何软件辅助收回因为高的cache替换率和小的内存带宽而损失的性能。尝试从新组织软件而降低cache的非命中率,而不能增加其长度和使其变复杂,但也要明白一开始收获是很小并且来之不易。也试着在硬件上做优化。
4.13 Write Buffers and When You Need to Worry
通常使用write-though cache的32位MIPS CPU,其每一个写操作都会立刻直接写到主存中,如果CPU要等待每一个写操作完成才能继续,这将是是一很大的性能瓶颈。
C语言程序编译给MIPS使用,平均有10%的指令是存储指令;但这些操作可能可以趋向于合并为burst,举个例子在一个函数开始时的现场保护(保存一些寄存器)。DRAM内存通常有这样的特征,一组中第一个写的通常会花费很长时间(这些CPU一般是5至10个时钟周期),而第二个和后边的就会相应很快。
如果CPU简单地等待每一个写操作完成,这对性能地打击很大。所以通常会提供一个回写地缓冲区,先入先出地存入要写地数据和地址。
使用write-through cache的32位MIPS CPU很倚重回写缓冲区。在这些CPU中,在CPU的时钟频率达到40MHz时能缓冲四次的队列将很难提供很好的缓冲。 后来的CPU(有write-back cache)的缓冲区能直接保存需要回写的line,并且提高非cache写的时间。
许多回写缓冲区操作的时间对于软件来说是透明的。但有时候编程人员要注意下面的情况: a. I/O寄存器访问的时间:这对所有MIPS CPU都有影响。当你执行一个向I/O寄存器的写操作,这会有一个不能确定的延时。和I/O系统之间的其他通讯可能会很快,举个例子在你告诉设备不要再产生中断后,你还是可能会看到一个活跃的中断。在其他例子中,如果I/O寄存器在一个写操作后需要一定时间来恢复,那么在你开始计算这个延时前,你必须保证回写缓冲区是空的。这儿你必须保证CPU等待直到回写缓冲区腾空。定义子程序来做这项工作是个好习惯;子程序叫wbflush(),这是个传统。看后面的4.13.1节,如何实现。
上面描述了在任何MIPS R4x000(MIPSIII ISA)上可能发生的。还针对整个IDT R3051家族,和绝大多数流行的嵌入式CPU。但在一些早期的32位系统中,更怪的事可能发生: a. 读操作赶上写操作:当一条取数指令(非cache或是没有命中cache)执行时,回写缓冲区不是空的。CPU需要选择:是等写操作完成还是将内存接口给取操作使用?让取操作先做将提高效率,因为CPU需要等待直到取的数据到来。这是一个好的机会,写操作被压倒,但它后面还是可以和CPU并行。
最初的R3000硬件将这个选择留给系统硬件来决定。从IDT开始的绝大多数MIPSI CPU不允许读操作压倒写操作,写操作有没有条件的优先权。绝大多数MIPSIII CPU不允许读操作被压倒,但软件也没有必要对此进行过多的考虑。看8.4.9节关于sync指令的描述。 如果你确认你的MIPS I CPU没有无条件的写的优先权,那么当你在处理I/O寄存器时,必要的地址检测也不能帮到你;因为早期的一个向不同地址的写操作还没有完成,那么这时候的取操作就会产生错误。在这中情况下你就需要调用wbflush()。
b. 字节合并:当缓冲区注意到一些部分字的写操作是写向相同的字地址,缓冲区会将这些写操作合并成一个简单的写操作。这并没有被所有的R3051家族的CPU所采用,因为它可能在对I/O寄存器的写操作时产生错误。
如果将你的I/O寄存器映射成每个寄存器对应一个独立的字地址,这就不是一个坏注意了。但你不可能总这样做。
4.13.1 执行wbfush
除非你的CPU是上面提到的特殊类型的一种,你能保证在执行针对任何地址的取操作时,回写缓冲区是空的(这会暂停CPU直到写操作完成,取操作也完成)。但这样效率不高;你可以通过使用最快的内存来最小限度来克服一些。
对于那些从来都不想考虑这些的人,一个写操作后面紧跟着一个针对相同地址的取操作(如果你是运行在MIPS III或其后面的CPU,需要在两个指令间加入sync指令),需要先清空回写缓冲区(很难看到如果CPU没有这个动作也能执行正确)。
一些系统通过硬件信号来指明FIFO是不是空的,连到输入接口让CPU能立刻得知。但不是到目前为止的CPU都这样做。
CAUTION!一些系统通常在CPU外面也有写操作的缓冲区。任何总线或内存接口自夸的写加速也会出现相同的特征。写缓冲区在CPU外和在CPU内一样,也会给你带来相同的问题。
4.14 其他的关于MIPS CPU 虽然你可能永远也不用知道这些,但我们还是有许多理由要谈到这些。
4.14.1 多处理器的cache特征
这个书的讨论将仅仅针对单CPU的系统。感兴趣的可以读相应的文档(Sweazey和Smith著,1986)。
4.14.2 Cache Aliases
这个问题只会影响这样一类的cache,用于产生index的地址和存放在tag内的地址不一样。在R4000类型CPU的主cache中,index是由虚拟地址长生而tag内存放的是物理地址。这对性能很有好处,因为cache查询和地址转化可以并行,但这也可能导致aliases。
绝大多数这些CPU可以按照4KB一页的大小来转化地址,而cache大小是8KB或者更大。两个不同的虚拟地址可以映射成对应一个物理地址,而且这是两个连续页,我们可以假设它们开始地址分别是0KB和4KB。如果程序访问地址0KB,那么数据会被读入到index为0的cache中。我们在假设要地址4KB来访问相同的数据,cache就又会从内存中取数并保存到cache中,但这次index却为4KB。这时在cache中针对相同数据有两个备份,一个被改变了,另一个却不会受到影响。这就是cache的alias。
MIPS第二层cache是物理地址来产生index和被存放到tag中,所以它们不会产生上面的问题。
不过,避免这个问题比改正它更容易。如果任意两个不同的虚拟地址能产生相同的index,那么这个问题就不会出现了。对于4KB一页,只要保证用来产生index的最低12位地址一致;只要保证不同虚拟地址对应的物理地址页大小等于主cache大小的模。如果你能保证虚拟地址是64KB的倍数,这就不可能产生这个问题,你也不会遇到麻烦。
这一章将介绍MIPS的cache怎样工作和软件应该怎么做才能使它可以被使用而且是可靠的。MIPSCPU重新启动后,cache的状态是不确定的,所以软件必须非常小心。你有一些线索知道cache的大小(如果你直接知道cache的大小后去初始化,这是一个不好的软件习惯。)。对于诊断程序员,我们将讨论怎样测试cache和获取特殊入口。
对于实时应用程序的程序员,希望在CPU运行时能够正确地控制cache。我们也将讨论怎么做,虽然我对使用一些窍门方式有怀疑。
当然这些也随着MIPSCPU的发展而进步。对于早期的32位MIPS处理器,初始化cache或者使其无效,首先让cache进入一种特殊的状态,然后通过普通的读写操作来完成。对于后来的处理器,一些特殊的指令被定义出来做这些相关的操作。
4.1 cache和cache的管理
cache的工作就是将内存中的一部分数据在cache中保留一个备份,使这些数据能一个固定的极短的时间内被快速的存取并返回给CPU,这样能保证流水线的连续运行。
绝大部分MIPSCPU针对指令和数据有其各自的cache(分别称为Icache和Dcache),这样读一条指令和一个数据的读操作或者写操作就能同时发生。
老的CPU家族(象x86)为了保证被写入CPU的代码的一致性,所以没有cache。现在的x86芯片拥有更灵活的硬件设计,从而保证软件没有必要从更本上了解cache(如果你正在装一台机器跑MS/DOS,它将在本质上提供一致性)。
但因为MIPS机器有各自的cache,所以就没有必要那么灵活。cache对于应用程序来说必须是透明的,除了除了能感觉到运行速度的增加。但对于系统程序或者驱动程序,拥有cache的MIPSCPU并没有尝试cache对它们也是透明的。cache仅仅使CPU跑得更快,而不能给系统程序员有所帮助。在象Unix一类的操作系统中,操作系统能对应用程序完全隐藏cache,当然对于更多不能的胜任的操作系统,其也能很好的隐藏大部分cache的处理,但你可能必须知道在什么时候需要调用适当的子程序来对cache做一些必要操作。
4.2 cache怎样工作 从概念上讲,cache是一个相连内存(associative memory),当数据被写入时用数据的一部分作为关键字来标志的一块存储区域。在cache中,关键字是整个内存的地址。提供一个相同的关键字给相连内存,你将得到相同的数据。一个真实的相连内存在存入条目时,将完全按照它们的关键字,除非它已经满了。然而,由于需要这个当前的关键字必须和所有被存的关键字同时比较,因此任何大小的真实相连内存不是效率低或速度慢,或者就是两者都有。
怎样我们才能设计有用的高速缓存,使其不仅效率高而且速度快呢?图4.1展示了一种最简单高速缓存的基本设计方案,直接映射(direct-mapped)高速缓存。它被1992年以前的MIPSCPU广泛使用。
直接映射cache由许多块简单的高速缓存排列构成(通常每一块称之为一line),通过地址低位在整个范围内做索引。cache的每一条line都包含一个字或者几个字的数据和一个标签(tag)区域,tag记录着数据所在内存的地址。
当一个读操作时,每一条line都可以被访问到,tag将和内存地址的高位做比较;如果匹配的话,我们知道是找到正确的数据了,这被称之为命中(hit)。如果在这一块中有超过一个字的数据,对应的那个字的数据通过地址的最低几位来选择出来。
如果tag没有匹配,这称之为没有命中(miss),那么数据需要从内存中读入,然后复制到cache对应的line中。这对应line中原来的数据将会被抛弃,如果CPU又需要被抛弃的数据时,需要再次从内存中取得。
这样的直接映射cache有一个特征,就是对于任何一个内存地址,在高速缓存中只有唯一的一条line可以用来保存其数据。这样有好处也有坏处。好处就是这样的架构简单,可以使CPU跑得更快。但简单也有其不好的一面:如果你的程序要不停地交替使用两个数据,而它们刚好要对应高速缓存中的同一块(可能是它们对应内存地址的低位刚好一样),这样这两个数据就会不停的将对方替换出高速缓存,以至高速缓存的效率被彻底的降下来。
而真正的相连内存将不会遇到这样的折腾,但对于任何合理大小,它将是难以想象的复杂、昂贵和速度缓慢。 折衷的办法就是使用two-way set-associative cache,其实就是两个direct-mapped cache并联,在它们中同时匹配内存位置。如图4.2。这时对应一个地址将有两次机会命中。Four-way set-associative cache (就是有四个直接映射的子高速缓存)在cache的设计中也是很平常的。但是这是有惩罚的。一个set-associate cache比起直接映射cache来需要更多的总线连接,所以cache太大以至于很难在一块芯片上构造直接映射。
不过也有巧妙的地方,由于直接映射cache对于你需要的数据只有唯一的候选者,所以把一些东西放到tag匹配前运行是可能的(只要CPU不做和着个数据有关的操作)。这样可以提高每一个时钟利用率。
由于当运行一段时间后cache会被装满,所以当再次存放从内存读来的数据时,就会抛弃一些cache内原有的数据。如果你知道这些数据在cache和内存中是一致的,那么你可以直接把cache中的备份抛弃;但如果cache中的数据更新的话,你就需要首先把这些数据存回到内存中。
这就给我们带来一个问题,cache怎样处理写操作?
4.3 Write-Through Caches in Early MIPS CPUs
CPU不能仅仅是读数据(就象上面的讨论),它们也要写数据。由于cache只是将主存中的一部分数据做一个备份,所以有一个显而易见的方法来处理CPU的写操作,被称之为Write-Through cache。 对于Write-Through cache,写操作时CPU总是将数据直接写到主存中去;如果对应主存位置的数据在cache中有一个备份,那么cache中的那个备份也要被更新。如果我们总是这样做的,那么cache中的任何数据将和主存中的保持一致,所以只要我们需要我们就可以抛弃任何一条cahce line的数据,并且除了消耗时间不会丢失任何东西。
当然这也是有危险的,当我们让处理器等待写操作结束时,处理器的运行速度将彻底的降下来,不过我们能修复这个问题。可以将要写入主存的数据及其地址先保存在另一边,然后有主存控制器自己取得这些数据并完成写操作。这个临时保存写操作内容的地方被称之为写操作缓冲区 (write buffer),它是先入先出的(FIFO)。 早期的MIPS CPU有一个直接映射的write-through cache和一个写操作缓冲区,还有一个R3000的激发设置。它在同一芯片上构造cache控制器,但需要额外的高速存贮器芯片来存贮tag和数据。只有CPU跑一些特殊的程序很平均地产生的写操作,主存系统在这种工作方式下才能很好的消化这些写操作并工作的很好。
但CPU运行速度的增长比存贮器块得多。某些时候当32位的MIPS让位给64位R4000后,MIPS的速度就已经超过存贮器系统可以合理消化所有写操作的临界点了。
4.4 Write-Bach Cache in Recent MIPS CPUs 早期的MIPS CPU 使用简单的write-through cache。后来的MIPS CPU由于速度太快而不能适用这种方法,它们会陷入存储系统的写操作中,速度慢得像爬行。
解决的方法就是把要写的数据保留在cache中。要写的数据只写到cache中,并且对应的那条cahce line要做一个标记,使我们肯定不会忘记在某个时候把它回写到内存中(一条line需要回写,称之为dirty)。 Write-back cache还可以分成几种不同的子处理方式。如果当前cache中没有要写地址所对应的数据,我们可以直接写到主存中而不管cache,或者可以用特殊的方式把数据读入cache,然后再直接写cache,后面这种方式被称之为写分配(write allocate)。用一种自私的观点来看一个程序运行在一个CPU上,写分配(write-allocate)看起来象浪费时间;但是它可以使整个系统的设计变得简单,因为在程序运行时读写内存都读或者写都是以一条cache line大小为单位的块进行操作。 从MIPS R4000 开始,MIPS CPU在芯片内拥有cache,而且都支持write-through和write-allocate两种工作模式,line的大小也是支持16byte和32byte两种。
MIPS cache的这些工作模式可以被应用到使用sillicon Graphics设计R4000和其他大型CPU,其他计算机系统也因为多处理器系统而被这些cache工作模式影响到。
4.5 Cache设计的其他选择 在上个世纪八十和九十年代针对怎样设计cache,做了很多工作和研究。所以下面还有许多其它的设计选择。
Physically addressed/virtually addressed:
当CPU在运行成熟的操作系统时,数据和指令在程序中的地址(程序地址或虚拟地址)会被转换成系统内存使用的物理地址。
如果cache纯粹地在物理地址方式下工作,将很容易被管理(我们将在后面讨论为什么)。但合法的虚拟地址可以让cache更早地开始查询匹配工作,这样可以使系统跑的稍微块一点。
但虚拟地址有什么问题呢?它们不是唯一的;当许多不同的程序在CPU不同的地址空间中运行,它们可能会共享同样的虚拟地址而使用不同的数据。当我们切换不同的地址空间时,每次都需要重新初始化cache;这种方式在很多年前被使用,可以作为针对非常小的cache的一种合理解决方法。但针对大的cahce这种方式不仅可笑而且效率低下,我们需要一块区域来辨别cache tag中的地址空间,以至我们不被它们混淆。
这儿还有其它关于虚拟地址更细致的问题:相同的物理地址可以在不同的任务中被不同的虚拟地址描述。这就会导致相同物理地址的内容会被映射到不同的cache条目中(因为它们对应不同的虚拟地址,所以会被不同的索引所选中)。这样的情况必须被操作系统的内存管理所避免掉。详细的情况将在4.14.2节介绍。
从R4000起,MIPS的主cache都使用虚拟地址索引,从而提供快速的cache索引。但对于作为标记符来标记每一个cache-line,物理地址比虚拟地址更好。物理地址是唯一的而且效率更高,因为这样的设计显示出CPU在做cache索引的同时可以把虚拟地址转换成物理地址。
line大小的选择(Choice of line size):
line的大小是对应每一个tag可以存贮多少字的数据。早期的MIPS的cache对应一个tag只能存贮一个字的数据。但对应一个tag能存贮多个字的数据更好,尤其是内存系统支持快速的burst read。现代的MIPS cache趋向于使用四个或者八个字大小的line,并且更大的第二层和第三层cache使用更大的line。 当cache miss发生时,整个一条line的数据都要从内存中获得。但很可能会取来几line的数据;一个字的cache line的MIPS CPU经常是一次就取多个字的数据。
分开/统一(Split/unified):
MIPS的主cache总是分成I-cache和D-cache,取指令时察看I-cache,读写数据时察看D-cache。(顺便说一下,如果你想执行CPU刚刚拷贝到内存的代码,你必须不仅仅要是D-cache一部分无效使这些代码数据在D-cache中不再存在,而且还要保证它们被装入I-cache)
但是不在同一块芯片上的第二层cache很少也按这种方式来分成两块。这样就没有什么真的优势可言了。除非你能针对两种cache提供分开的数据总线,但这又会需要太多的管脚。
4.6 Cache管理(Magaging Caches)
Cache系统在系统软件的帮助下,必须保证任何应用程序数据的一致性,和它们在没有cache的系统下一样,尤其是DMA I/O控制器(直接从内存中取得数据)取得程序认为已经写过的数据。 对于CISC CPU,通常都不需要系统软件对cache的帮助;因为它会花费额外的内存空间、silicon area、时钟周期来使得cache变得真正的透明。
在系统启动的时候MIPS CPU需要初始化它的cache;这是一个十分复杂的过程,下面有关于它的几点建议。但当系统启动后运行到三种情况CPU必须加以干涉。
.在DMA设备从内存取数据之前:
如果一个设备从内存中取得数据,它必须取得正确的数据。如果D-cache是write-back,并且程序已经写了一些数据,那么很可能其中一些正确的数据还保留在D-cache中而没有写回到主存中去。CPU当然不可能看到这个问题;如果CPU需要这些数据,它会从cache中得到正确的数据。
所以在DMA设备开始从内存中读数据前,任何一个将被读数据如果还保留在D-cache中,必须被写回到内存中。
. DMA设备写数据到内存:
如果一个设备要将数据存贮到内存中,要使cache中任何对应将要写入内存位置的line都无效化,这是非常重要的。否则,CPU读这些位置的数据,将得到错误的数据。cache应该在数据通过DMA写入内存之前将对应的cache line无效化。
. 拷贝指令:
当CPU自己为了后面的执行而写一部分指令到内存中,你首先必须保证这些指令会被回写到内存中,其次保证I-cache中对应这些指令的line会被无效化。在MIPS CPU中,D-cache和I-cache是没有任何联系的。(当CPU自己写指令到内存中时,这时候指令是被当作数据写的,很可能只被写到cache中,所以我们必须保证这些指令都会被回写到内存中;为什么要使I-cache无效化,这和数据通过DMA直接写入内存中要无效cache一样的原因。)
如果你的软件需要解决这些问题,就需要针对cache line的两个独特的操作。
第一个操作被称之为回写操作。CPU必须能够针对地址在cache中查找对应的cache line。如果找到,并且对应line是dirty,就需要把这条line的数据写回到内存中。
CPU增加了其他不同层次的cache(速度和大小),来减少miss的处理。所以设计者可以使内层的cache机构简单,从而使它能在很高的时钟频率上作查询。这样很显然越往内层的cache就会越小。从1998年开始,许多高速的cpu都在同一块芯片上采用第二级cache,主cache的大小变小,双重16K的主cache受到青睐。
不在同一块芯片上的cache通常都是直接映射的,因为组相连的cache系统需要更多的总线从而需要更多的管脚来连接。这还是一个值得研究的领域;MIPS R10000采用只有一个数据总线的二路组相连cache,如果命中的不是希望的那一组,通过一段延时后在返回数据来实现(两个组共用一个数据总线)。
在cache的发展过程中,产生了两类主要的软件接口来针对cache。从软件的观点来看,一类是建立在以R3000为代表的32位MIPS CPU的基础上;另一类是建立在以R4000为代表的64位MIPS CPU上的。R3000这一类型的MIPS CPU的cache是write-through,直接映射的,物理地址为索引。cache访问的最小单位是一个字,所以写一个字节(或者是写小于一个字)的操作必须被特殊的处理。在读写这一类数据是cache管理采用特殊的模式。
为什么不通过硬件来管理cache?
通过硬件来管理cache通常被称为“爱管闲事”。当另一个cpu或者是DMA设备访问内存时,被访问地址对应的内容对于cache来说是可以看到的。
4.7 第二层和第三层cache
在大型的系统中,通常需要一个嵌套的多层cache。一个小而快的主cache最接近cpu。访问主cache出现miss时,不是直接从内存中查找而是从第二层cache中查找。第二层cache在速度和大小上是介于主cache和内存之间。cache层次的数目可以通过内存速度和cpu最快访问速度比较来决定;由于cpu速度发展比内存的发展快得多,在过去的12年里桌上型电脑系统从没有cache发展到有两层cache。九十年代后期的最快cpu速度大约可以达到500MHz,拥有三层cache。
4.8 MIPS CPU cache的构造
通过观察cache采用模式和层次的发展(看表4.1),我们可以将MIPS CPU分成两类,古老的和现代的。
当时钟的速度变得越快,我们就能看到越多得cache构造,因为设计者为了应付CPU跑得速度比内存系统越来越快。为了保证运行的顺畅,cache必须提高运行速度,保证提供数据的速度比外围得存贮器要快,同时也要保证尽可能多命中。相比较R4000类型的CPU,主cache是write back类型,是write allocate ,virtually indexed,physically tagged, 二路或四路组相连的cache。
许多R4x00和其后续cpu在同一块上拥有第二层cache的控制器,1998年出现了这样的第一块cpu。
由于两种产生的不同,我们将分两节来详细介绍。
注意!一些系统的第二层cache不是由mips cpu内部的硬件来控制的,而是建立在内存的总线上。对于这类cache的软件接口将具有系统特殊性,和象这章介绍的由cpu内部控制的cache的软件接口相比,可能有很大的不同。
4.9 Programming R3000-Style Caches MIPS R2000打破了芯片内cache控制器的基础,将cache额外的分成I-cache和D-cache。这是一个后见之明,不会让人感到惊讶,就是这样一个先驱者的冒险导致了后面很多事端。cache有一个特殊的软件访问缺点。
为了节省芯片管脚,cache将不能拥有不同的闸门来执行字节、半个字和其他小于一个字单位的写操作。所以在R2000系列中对cache执行一个小于字单位的写操作时,会回写到主存中,并将cache中这个字所在的Line无效化。这样针对cache管理,提供了一个使cache无效的方法:只用写一个字节就行了。
你可以看到支持这些简化的观点。R2000设计者提出理由小于字的操作通常用于字符操作,字符操作总是由库函数提供,而这些库函数用整个字的操作来重写。这些假设总是被认为对对错错,或者半对半错。
直到认识到不是所有系统都能用相同的函数库,而且每个字节写操作都使所在cache无效也不是一个好主意,这些争论才没有继续下去。因为这是不能被容忍的,所以出现了一个很大的改动,R3000系列的cpu通过一个RMW(read-modify-write)序列来执行小于字单位的写操作。这个RMW出现在所以的32位的mips cpu中,并增加了一个 时钟周期来作为这样一个写操作的延时。
这样cache无效的机制被带入困境;R2000因为它的奇怪习惯而有一个优点,可以通过字节的写操作来使cache无效化。而R3000 cache 需要用一个叫isolation的模式来挽救,原来这种模式只是用于cache诊断的。RMW队列因为这种模式而受到压制,在那种状态下小于一个字单位的写操作还是会让该字所处的line无效化。这是不幸的但不是悲惨(灾难)的,对于一些运行着的系统做一些事有着更有益的地方。显著的就是当cache在isolation模式时的时候,cache将没有读写操作,任何读写操作将直接和内存打交道。
4.9.1 Using Cache Isolation and Swapping
所有的R3000系列cpu的cache都是write-through模式的,这就是说cache中不会拥有比内存中更新的数据。也就是说cache中的数据从来都不需要回写到内存,所以我们只需要能使D-cache和I-cache无效就行了。
只需要不同的cache操作按照内存顺序来做cahce的管理,并且cache的管理没有必要通过特殊的内存地址空间。所以这儿有一个状态寄存器有一个SR位能够使D-cache关闭isolation模式;在这种模式下读写操作只影响着cache,读还是会命中但不管tag是不是相等。当D-cache处于isolation模式时,小于一个字单位的写操作会使对应cache Line被无效化。
CAUTION!!!
当D-cache处于isolation模式,任何读写操做不会受其对应地址或TLB条目的影响而按照非cache的情况操作。这样的结果就是cache管理程序必须保证有些数据是不可以被访问的;如果你能通过你的编译器做到很好的控制,并且能过保证所有你用的变量都保存在寄存器中,你才能在很高级别的语言中写它们。还必须保证运行这些程序时屏蔽中断。
I-cache在通常运行模式下也是完全不可访问的。所以CPU提供了另一种模式,cache交换(swapped),通过设置状态寄存器的SwC位;这时D-cache可以担当I-cache,I-cache可以担当D-cache。当cache是交换模式时,isolated的I-cache条目可以被读、写和无效化。
D-cache可以完美的充当I-cache使用(可能I-cache也可以通过初始化使之象D-cache一样工作),但I-cache不能完全的充当D-cache。这也是靠不住的,当cache是交换模式时有用,isolation却没有用。
如果你需要使用交换的I-cache来存储字单位的数据(和以前一样小于字单位的数据写操作会使该数据对应的line被无效化),你必须保证在返回到正常模式时对应的cache line必须被无效化。
4.9.2 Initializing and Sizing 初始化和判断大小
当机器启动时cache的状态是不确定的,所以这时读cache结果也是不可预知的。你也应该认识到机器重起后状态寄存器的SwC位和IsC位也是不确定的,所以在对cache读写前(即使在非cache的情况)启动软件最好能将这些状态设为可知的。
不同的MIPS CPU,cache有不同的大小。为了保证你软件的可移植性,最好能在初始化的时候计算出D-cache和I-cache的大小。这样比直接配置一个给定的值好。
下面将介绍怎样获得cache大小的值: a. Isolated cache,让I-cache处于交换模式。 b. 在R3000系列CPU中,cache的大小可能是256K,128K,64K,32K,16K,8K,4K ,2K,1K和0.5K(K等于1024,单位是字节)。将这些可能的值n(上面那些值中的一个)写到物理地址等于它们本身的地方(有大到小)。最简单产生物理地址是用Kseg0段地址(n+0x80000000)。因为cache地址是重叠循环的,那么如果n是cache大小的倍数,那么它就会被后面小的值所覆盖。
c. 所以读物理地址零(也就是0x80000000),就能得到cache大小的值。
初始化cache,你必须保证每一个cache条目都被无效化,而且正确对应一个内存位置,所含的之值也是正确的: a. 检查状态寄存器SR的PZ位是不是位零(为1的话,关闭奇偶位,对于同一个芯片上的 cache这不是一个好主意)。 b. isolated D-cache,并使它和I-cache交换。 c. 对于cache的每一个字,先写一个字的值(使cache的每条line的tag、数据、和奇偶位都正确),然后再写一个字节(使每条line都无效)。
不过要注意当对于每条line有四个字的I-cache,这样做效率就很低;因为只要写一个字节就足够使每条line无效了。当然除非你要经常调用这个使cache无效程序,否则这个问题是不会表现的很明显。不过如果你想根据实际情况来优化cache无效化程序,就需要在启动的时候确定cache的结构。
4.9.3 cache无效化(Invalidation) 使cache无效,按照下面的流程: a. 计算出你使cache失效所需的地址范围。使用超过cache大小的范围是浪费时间。 b. 使D-cache孤立。一旦被孤立后你就不能读写内存,所以你必须花费所有的代价来防止异常的产生。关闭所有的中断并保证后面的程序都不会导致内存访问异常。
c. 如果你还想使I-cache失效,使cache处于交换模式。 d. 在刚才计算出的地址范围内针对每一条line写一个字节的内容。
e. 关闭cache的交换和孤立模式。
通常你应该在I-cache打开的模式下运行使cache失效的程序。这听起来是混乱和危险的,但事实上你没有必要花费额外的步骤去跑cache。一个使cache失效的程序在cache关闭的情况下运行要慢4到10倍。
当你的CPU去设IsC位时,本质上必须关闭所有的中断,因为这时是不能访问内存的。
4.9.4 测试和探察
在测试、调试或profiling时,画一个cache条目的示意图是很有帮助的。 你不能直接的读tag的值,但对于合法的line有详尽的方法得到:
a. Isolate the cache. b. 通过每条line的起始地址从每条line里取得(低位地址匹配,高位地址包括你系统的内存的物理地址范围)。每一次读取都要参考状态寄存器的CM位,只有该位为零时,取得的tag值才是正确的。
这需要很多个计算机的周期,不过对于在20MHz的处理器,1K的D-cache对应4MB的物理内存,做整个查询就只需要几秒钟。
4.10 Programming R4000-Style Caches R4000修改了早期cpu cache不合适的地方。但R4000成功的地方就在于cache有多种工作模式(write-back , write-allocate),以及拥有更长的line。因为有write-back工作模式,当被cpu写时每一条line都需要一个状态位来标志这条line为dirty(因此来表示很内存中的数据不同)。
对于这类cache,我们需要invalidate和write-back操作:而且还必须保证任何cpu写到cache中的数据必须被回写到内存中。
对于诊断和维护的目的,tag将更容易的被读写;R4000增加了一对寄存器TagLo和TagHi用来在cache tag和系统管理软件之间中转数据。对于R4000没有直接方式读取cache line内的数据,当然你还是可以通过cache 命中的方式来访问数据。CPU可以通过执行cache指令来从cache tag内取数据到32-bit的TagLo和TagHi寄存器中,或者是将这些寄存器的内容写到cache tag。图4.3显示这些寄存器的详细内容。
cache的地址tag存有除了用来查询cache index的其他所有位;因此主cache tag的长度会因为最大物理地址(R4x000是36bit)和用来索引cache位数不同而不同。13bit用来作为最初R4000的8KB大小主cache的索引,从此就再也没有小于这个位数。这样tag长度就有23bit,并且TagLo是24bit;在目前的cpu内TagHi总是零。这对于最小cache大小或是可以支持的最大物理地址是很重要的。对于R4000,现在TagHi是多余的;把它设为零并忘了它。
所以TagLo寄存器内包含对应cache line的tag的所有位。TagLo(Pstate)还包含状态位。在绝大多数情况下(多处理器)这将变得非常复杂,但对所有cache的管理和初始化它足够表明当Pstate为零是一个合法的值来对应一条无效的cache entry。 这个区域被后来的cpu占用,用来储存第二层cache的状态信息,但这成了一个惯例值为零是安全和合适的对于初始化。
最后,TagLo(p)是一个奇偶位,设一为整个cache tag偶校验。为全零的TagLo表明正确地偶校验。一些cpu忽视这个bit,而不去检查它,而且也没有危害。
4.10.1 CacheERR, ERR, and ErrorEPC Register:Cache ErrorHandling
CPU的cache是内存系统的至关重要的一部分,对于高效的实用或正确系统可以发现用额外的位来表明存储在这儿的数据的完整性是值得的。
内存系统的校验将首尾相接理想化地被执行下去;当数据一被产生或被传入系统校验位就会被计算,随着数据被存放,并在数据被使用前被检查。That way the check catches faults not just in the memory array but in the complex buses and gizmos that data passes through on its way to the CPU and back. 这样检查的方法将不在内存队列中抓住错误而在总线上,并通过这样的方式转送到cpu和返回。
因为这个原因,R4x000地CPU(设计应用于大型计算机)在cache里提供错误校验。和主存系统一样,你既可使用简单的奇偶校验或者使用错误纠正码(ECC)。
奇偶校验是简单的使用一个额外的bit来对应内存中每一个byte。一个奇偶错误可以告诉系统这个数据是不可靠的,并允许有些控制停止来代替creeping随机错误。奇偶校验的一个至关重要的任务就是系统开发的过程中提供巨大的帮助,因为它不能明确的指出由于内存数据完整性导致的问题。
但一个byte的废物将有百分之五十的机会有一个正确地奇偶校验,并且在72位的数据总线上的随机垃圾256次中将有一次没有被发现。一些系统可能会好一点。
错误纠错码计算起来将更加复杂,因为对于一个64位的数据将有8个bit的校验位。这将是十分的彻底,一个bit的错误将被唯一的指出并纠正,任何两个bit的错误也不会被忽视。在非常大的内存队列中ECC将从本质上排除随机错误。
因为纠错码一次可以检查整个64位的数据,所以使用纠错码的内存不能进行小于一个字的数据的写操作,被选中的小于一个字的数据必须被并入新的数据并重新计算纠错码。MIPS cpu在cache关闭的情况下需要内存系统能进行小于一个字数据的写操作,这将使事情变得复杂。内存系统硬件必须将一个小于一个字数据的写操作转变为先读然后合并,接着重新计算,最后在写入的操作序列。
对于简单的系统一般的选择是奇偶校验位,而不是其他的。让采用校验方式变成可选择的这是很有意义的,这样在设计研发过程中将有利于诊断,而在成为产品时却不用付出相应的代价。
无论检查机制是运行在内存系统中还是在R4x00的cache中,cpu将提供一个字节对应的奇偶校验位,或是对应64位的8bit纠错码,或者就是干脆没有保护。
当支持错误检测时,数据的检测位在cache填充时通常是直接通过系统接口到cache内存放而不被检查。只有在数据被使用时才检查,这样可以保证任何cache奇偶异常都被转交给引起它的指令,而不是转交给使用共同的cache line。当作一个退化的情况,一个在非cache的取指错误会被标志成cache奇偶错误,这种情况会使你很混乱。
注意,系统接口标志进来的数据为没有合法的检查位是有可能的。在这种情况下,cpu会为它内部的cache重新产生检查位。
如果一个错误发生了,cpu会产生一个特殊的错误陷阱。这个vector会直达一个非cache的位置(如果cache内是错误的数据,它会很愚蠢地去执行cache内地代码)。如果系统采用ECC,当写操作时硬件会产生纠错位,当错误时会检查得到。硬件并不知道怎么纠错;这将是软件的工作。
ERR寄存器(如图4.4)的格式如下: a. ER/ED/ET/EE/EB: 这些位将区别是什么cache(主cache 或第二层cache,指令cache还是数据cache)发生了错误,或是它在系统接口之外。 b. PidX: 给出错误位置的cache index。你可以取得这儿的内容用于index类型的cache操作;它将得到正确的line,而不管cache是直接映射还是组相连的。
当错误发生了,ErrorEPC寄存器指向发生错误的指令位置。ERR寄存器保存着ECC位,你需要用它来纠正可以改正的错误,但在这儿我们将不再讲如何做--因为这需要很大的篇幅,你将需要密切联系处理器手册。你将可以得到一些简单的针对mips运算规则的代码。
4.10.2 The Cache Instruction cache 指令
Cache指令有着和MIPS存储指令类似的格式(拥有通常寄存器再加上十六位有符号的偏移地址),但表示数据寄存器的值会被译成选择区表示是什么cache指令。这儿没有标准的名字来表示这些cache操作;在这儿我就武断地使用来自SDE-MIPS算法库的名字,这些名字依次是基于SCI/MIPS的一个头文件(include files)。选择区不是完全的位编码,但是几乎是;看表4.2。 Cache选择区可以让你做下面这些选择: a. 那类cache: 选择是icache还是dcache,是主cache还是第二层cache。因为没有多余的位存在,所以还没有提供的三层cache的选择。但这儿我要提醒你这是和CPU密切有关的,在R4000之后的64位CPU提供对R4000兼容性是非常有帮助的。
b. How cache is addressed:有两种不同的类型。如果是命中方式,你需要提供正常的程序地址(虚拟地址),其必须被转化。如果提供的地址确实在cache中,则该操作会对应相应的cache line被完成;如果不再cache中,那就什么也不用做。 另一种是index方式。地址低位用来直接选中某一个cache line,而不管这条line现在有什么内容。这显现出cache内部组织的没有原则性。
cache的维护通常是需要命中方式,而初始化时就需要index方式。 c. 回写(write back):如果对应的line是dirty,就将数据回写到内存中去;如果不是,就像是一条nop指令。 d. invalidate(使无效):将这条line标志为无效,使其的数据不能在被使用。同时做回写和无效是可能的;但这不是自动的,如果你需要你可以使一条dirty的line无效。所以一些应用程序可能会丢数据。
e. Load/store tags:这些操作是将对应line内的tag内容存到TagLo和TagHi寄存器中或从这两个寄存器读到对应line的tag内。
存储tag使用比较过时的方式(TagLo和TagHi寄存器要预先设为零),是cache初始化的一部分。 f. Fill:这是仅仅为I-cache设计的,这个操作通过特殊的内存地址来填充一条cache line。对于填充dcache是没有必要的,因为当cache打开的时候读取没有命中时就会达到同样的效果。 g. Create data:这类操作是能够让用户能以很高的速度来写内存的排列,而避免任何的cache重新填充。除非你能保证在数据被使用或被回写到内存中之前覆盖这些所有的数据。
这个特性对于初始化和诊断很有帮助(你将在后面初始化第二层cache的代码例子中看到,并知道如何清除第二层cache的数据)。
4.10.3 计算cache的大写和决定怎样配置 对于R4x00 CPU(和绝大多数后面的CPU)主cache大小和line的大小会同过CP0的Config寄存器可靠的给出。
但要得出你的cache是直接映射的还是组相连的却是相当的困难。但对于正在运行的cache这就不难测试出来了,你只要参考两个不可能同时出现在直接映射的cache中的地址,然后使用index类的操作去检查看它们是不是同时在cache内存在;当然如果你还没有初始化cache,这是没有用的。幸运的是你可以只写同一个程序来初始化直接映射cache或是组相连cache。
4.10.4 初始化程序 这儿将介绍一个很好的方法:
1.开辟一些内存对应任意的数据,但如果你的系统使用奇偶校验码或是纠错码,你必须保证这是正确的,并用这些数据填充cache。(在算法库程序中我们保留至少32K的系统内存一直到cache初始化的时候;只要在cache关闭的时候去写这些内存,就能得到正确的奇偶码。)还需要一个足够的空间来初始化的二层cache;我们将采用迂回的方式来处理。
2.将TagLo寄存器设为零,这样能保证对应line有效的那一位没有被置起来并且tag的奇偶码是一致的。 TagLo寄存器被cache Store_Tag指令使用,强制使对应的line无效和清除tag的奇偶码。 3.屏蔽中断,不然会有一些意外发生。
4.先初始化Icache,然后是Dcache。下面是初始化Icache的C代码。(你必须相信像Index_Store_Tag_I()这样的函数或是宏能作底层的操作;它们或是琐碎的汇编代码子函数,能够运行在相应指令的机器上,或是对应GUN C用户通过宏调用一个C嵌入汇编。)
for (addr = KSEG0; addr < KSEG0 + size; addr += lnsize) { /* clear tag to invalidate */ Index_Store_Tag_I(addr);
/* fill so data field parity is correct */ Fill _I (addr); /* invalidate again – prudent but not strictly necessay */ Index_Store_Tag_I();
}
5.Dcache的初始化相对来说要复杂一些,因为没有对应Dcache的Index_Fill_D操作;我们只能通过从cache读取数从而依靠通常的没有命中过程来达到目的。依次当cache填充指令对应index操作时,读取工程会依靠内存地址通过tag来命中一条line。你必须非常小心tag;对应two-way的cache,用初始化Icache的循环来初始化Dcache会将Dcache的一半初始化两次,因为清除PTagLo会重新设置用来决定下一次没有命中时是那一组cache Line的位。下面是正确的方法。
/* clear all tags */ for (addr = KSEG0; addr <KSEG0 + size; addr += lnsize) Index_Store_Tag_D (addr); /* load from each line (in cached space) */ for (addr = KSEG0; addr < KSEG0 + size; addr += lnsize) junk = *addr; /* clear all tags */ for (addr = KSEG0; addr < KSEG0 + size; addr += lnsize) Index_Store_Tag_D (addr);
4.10.5 Invalidating or Writing Back a Region of Memory in the Cache
对于对应一些I/O空间的程序或物理地址的范围,用于无效或回写的参数是不变的。
你几乎总是用命中类型的cache指令来使cache内需要的位置来使其无效或回写。如果你需要将内存一个巨大范围使其无效或回写,使用index类型的指令来使整个cache无效或回写会比较快,虽然这是一个最优化的方法,但你很可能会忽视。
我们有足够的理由这样做:
PI_cache_invalidate(void *buf, int nbytes) {
char *s; for (s = (char*)buf; s < buf + nbytes; s += lnsize)
Hit_Invalidate_I (s); }
注意这儿没有必要产生特殊的地址,只要buf是程序地址就足够了,但像下面的例子,如果p是物理地址,你就必须将其加上一个常量转化成kseg0范围内的地址。
PI_cache_invalidate (p + 0x80000000, nbytes);
4.11 cache 效率
从九十年代早期cache设计在cpu同一块芯片上,高速cpu的性能很大程度上是有他们的cache系统性能决定的。现在许多的系统(尤其是嵌入式系统,需要节约cache的大小和内存的性能),CPU有50-60%的时间时在等待cache的再次填充。就这点来说将CPU性能翻倍将增加应用程序15-25%的性能。
cache性能取决于系统在等待cache再次填充的总时间。你可以将之归结为两个参数产生的结果:
a.cache没有命中的几率:是CPU(取指令或数据存取)在cache中没有命中,从而需要内存中取的比例。
b.cache没有命中需要替换造成的延时:这个延时是从内存中取数替换后到CPU的流水线继续下去的时间。
当然这没有必要很好的测量。举个例子,x86的CPU的寄存器个数很少,所以同样的程序编译给x86使用会比给MIPS使用多很多的数据存取。当然x86使用堆栈来代替寄存器给这些额外存取使用;而这堆栈位置将是内存中使用非常频繁的区域,对应cache的话使用效率会非常高。通过大块特殊的程序,我们就能获得cache没有命中的次数。
上面提到的意见将给下面指出的几种提高系统运行速度的显而易见的方法很有帮助。
a. 减少cache没有命中的几率:
1.让cache变得更大。这是最有效的,当然也算是最昂贵的。在1996年,64K的cache会占据高速嵌入CPU一半的硅面积甚至超过,所以要使cache的大小翻倍,你只有等待摩尔理论发明在相同的面积上做更多的门。
2.增加cache的组相连。值得增加到四组,再增加下去提高就几乎看不到了。
3.增加另外层次的cache。当然这将使计算变得更加复杂。除了多义子系统的复杂外,第二层cache的非命中率会被抑制很多;主cache已经可以撇去CPU重复访问数据行为的cream。为了使其物有所值,第二层cache必须比主cache大得多(一般来说是八倍或者更大),并且第二层cache的访问时间必须比内存快(两倍或者更快)。
4.优化你的程序从而减少非命中率。如果工作处于实践阶段,这就很难说清楚了。对于小的程序优化就比较简单,但对于琐碎的程序就很费力了。但迄今为止还没有人做出一个工具可以优化任何程序。看4.12节。
b. 减少cache替换的处罚:
1.加快CPU获得第一个字的数据。DRAM内存系统需要必须做许多发动工作,才能提供数据很快。使内存和CPU之间更加紧密,减短它们之间的路径,这样数据在它们之间来回才会更快。
注意这是唯一可以在便宜的系统中应用,并且效果不错。不过荒谬的是它很少被注意到,也许是因为它需要在CPU接口和内存系统设计之间考虑更多的综合因素。当CPU设计者在设计芯片的接口时不情愿处理这些问题,很可能因为他们的工作已经够复杂的了!
2.增加内存burst的宽度。这是传统上通常被应用的,是昂贵的技术,用两个或者更多的内存系统来交替存储数据;当初始化完后你就可以交替着从每一个内存系统中取数据,这样带宽好像翻倍了。第一个这样内存技术的应用是,是在1996年出现的同步DRAM(SDRAM)。SDRAM修改了DRAM的接口,提供更大的带宽。
c. 尽早的重新起动CPU:这个简单的方法是在排列cache替换的数据时,从CPU没有命中的数据开始,当数据一到就马上重新启动CPU。cache的替换可以和CPU运行并行。MIPS CPU从R4x000开始就应用这项技术,用一个子缓冲区来存放cache要替换进来的数据,并能那个字的数据是最先需要的。但只有R4600和其派生出来的CPU才体现出这项技术的好处。
激进的方式是让CPU绕过取数操作继续执行下去;取数的操作由总线接口控制器控制,CPU继续运行直到它需要的数据被存放到相应的寄存器重。这被称之为没有阻碍取数,从R10000和RM7000开始被应用。
更激进的方法是,可以执行任何后续代码,只要它不依赖于还没有取来的数据,R10000就可以不按照指令的顺序来执行。这类CPU应用这项技术非常彻底,不仅仅是应用到取数还应用到计算和跳转指令。
4.12 修改软件使Influence Cache效率更高
很多时候我们都在程序访问可知地方的基础上工作,并且我们是公平的在不强制的工作方式上操作。对于绝大多数目的我们同样可以假设,访问是恰当的随机分布式。对于工作站就必须能够支持执行足够的应用程序,这是一个公平的假设。但当一个嵌入式系统运行一个简单的应用程序,没有命中的清况好像是因为特殊的程序经过特殊的编译造成的。这是很有诱惑的,如果我们能是应用程序代码能在系统的方式下提高cache的效率。为了了解这是怎样处理的,你将cache没有命中分类,按照它们产生的原因:
a. 第一次访问:所以任何数据必须从内存中读入。 b. 替换:cache的大小是有限的,所以当你的程序没有运行多久就会出现cache没有命中,需要替换掉一部分合法的数据。当程序运行时,cache就会不停的替换掉数据然后在取。你可以通过使用大的cache和减小程序的大小来使替换和没有命中减到最少(其实就是程序大小和cache大小的比例)。
c. 从实际来讲,cache通常没有超过四路组相连的,所以对于任何程序地址在cache中最多有四个位置可以存放;对应直接映射就只有一个,二路组相连的cache就有两个(和组相连比较thrashing会丢失减少速度的可能;但绝大多数研究者建议一个四路组相连的cache在way的选择上几乎不回丢失性能)。
如果你的程序会非常频繁的使用n段空间的数据,而这n段空间的地址的低位又非常接近,那么它们就会使用相同的line。如果n大于cache的路数,那么cache的没有命中也会非常频繁,因为每一段空间的数据会不停的将cache内的其他空间的数据挤出去。
明白上面的知识,那么怎样针对程序做变化使它针对cache运行的更好? a. 使程序更小:如果你能做到的话,这是个很好的主意。你可以使用适当的编译器优化(外来的优化通常是程序更大)。 b. 让程序中经常执行的那部分更小:访问密度在一个程序中不总是平均分布的。通常会有相当一部分代码几乎不被用到(错误处理,不明系统的管理),或者只被用到一次(初始化代码)。如果你能将这些很少用到的代码剥离出来,对于剩余的程序你就能得到一个很好的cache命中率。
资格访问的方式有利于将一些频繁使用的程序区分出来,并固定放在内存的某一位子,以减少要运行时的放置。这样至少这些经常使用的程序不会因为cache的位置而相互碰撞。
c. 强迫一些重要的代码或数据常驻cache:一些机器的能允许一部分cache保护它们所拥有的数据不被替换。这部分代码一般是中断处理或是其他自关重要的软件中确定要执行的。这些代码或数据一般被保留在二路组相连cache的其中一组中(这样当系统重其后,cache就像是一个直接映射的cache).
我很怀疑这个方法的生存能力,我也不知道有那些研究支持它的有效性。系统重起后的损失很可能大于执行那些保留代码的获利。cache上锁很可能就像一个不确定的市场工具来限死顾客对cache启发式特性的渴望。这个渴望是可以理解的,随着程序越快越复杂越大,但cache毕竟只是影响结果的一部分因数。
d. 安排程序避免碰撞:上面提到的让程序的执行部分变的更小,这对于我来说太难维护所以不是个好的主意。而且对于组相连的cache(尤其是两路的)使这个方式更加没有意义。
e. 让那些很少用到的数据和代码不经过cache:这看起来很有吸引力,让cache只给那些重要的代码或数据服务,排除那些只用一次或很少使用的代码和数据。
但这几乎总是一个错误。如果数据真的很少使用,那么它们不可能一开始就在cache中。因为cache取数时总是以一条line的长度4或16字为单位,所以即使是传送只使用一次的数据也能有很高的速度;burst替换比一个字的访问几乎不多花时间,并且能给你免费提供另外的3或5个字的数据。
简而言之,我们将介绍下面的内容作为一个起点(除非你已经有很多实践和很深的想法,你才可以放弃)。开始我们先认为除了I/O寄存器cache都是打开的,并且很少使用远程内存。在你试着做预测前,先搞明白cache对你的应用程序有什么启发。第二在硬件上排除任何问题。也没有任何软件辅助收回因为高的cache替换率和小的内存带宽而损失的性能。尝试从新组织软件而降低cache的非命中率,而不能增加其长度和使其变复杂,但也要明白一开始收获是很小并且来之不易。也试着在硬件上做优化。
4.13 Write Buffers and When You Need to Worry
通常使用write-though cache的32位MIPS CPU,其每一个写操作都会立刻直接写到主存中,如果CPU要等待每一个写操作完成才能继续,这将是是一很大的性能瓶颈。
C语言程序编译给MIPS使用,平均有10%的指令是存储指令;但这些操作可能可以趋向于合并为burst,举个例子在一个函数开始时的现场保护(保存一些寄存器)。DRAM内存通常有这样的特征,一组中第一个写的通常会花费很长时间(这些CPU一般是5至10个时钟周期),而第二个和后边的就会相应很快。
如果CPU简单地等待每一个写操作完成,这对性能地打击很大。所以通常会提供一个回写地缓冲区,先入先出地存入要写地数据和地址。
使用write-through cache的32位MIPS CPU很倚重回写缓冲区。在这些CPU中,在CPU的时钟频率达到40MHz时能缓冲四次的队列将很难提供很好的缓冲。 后来的CPU(有write-back cache)的缓冲区能直接保存需要回写的line,并且提高非cache写的时间。
许多回写缓冲区操作的时间对于软件来说是透明的。但有时候编程人员要注意下面的情况: a. I/O寄存器访问的时间:这对所有MIPS CPU都有影响。当你执行一个向I/O寄存器的写操作,这会有一个不能确定的延时。和I/O系统之间的其他通讯可能会很快,举个例子在你告诉设备不要再产生中断后,你还是可能会看到一个活跃的中断。在其他例子中,如果I/O寄存器在一个写操作后需要一定时间来恢复,那么在你开始计算这个延时前,你必须保证回写缓冲区是空的。这儿你必须保证CPU等待直到回写缓冲区腾空。定义子程序来做这项工作是个好习惯;子程序叫wbflush(),这是个传统。看后面的4.13.1节,如何实现。
上面描述了在任何MIPS R4x000(MIPSIII ISA)上可能发生的。还针对整个IDT R3051家族,和绝大多数流行的嵌入式CPU。但在一些早期的32位系统中,更怪的事可能发生: a. 读操作赶上写操作:当一条取数指令(非cache或是没有命中cache)执行时,回写缓冲区不是空的。CPU需要选择:是等写操作完成还是将内存接口给取操作使用?让取操作先做将提高效率,因为CPU需要等待直到取的数据到来。这是一个好的机会,写操作被压倒,但它后面还是可以和CPU并行。
最初的R3000硬件将这个选择留给系统硬件来决定。从IDT开始的绝大多数MIPSI CPU不允许读操作压倒写操作,写操作有没有条件的优先权。绝大多数MIPSIII CPU不允许读操作被压倒,但软件也没有必要对此进行过多的考虑。看8.4.9节关于sync指令的描述。 如果你确认你的MIPS I CPU没有无条件的写的优先权,那么当你在处理I/O寄存器时,必要的地址检测也不能帮到你;因为早期的一个向不同地址的写操作还没有完成,那么这时候的取操作就会产生错误。在这中情况下你就需要调用wbflush()。
b. 字节合并:当缓冲区注意到一些部分字的写操作是写向相同的字地址,缓冲区会将这些写操作合并成一个简单的写操作。这并没有被所有的R3051家族的CPU所采用,因为它可能在对I/O寄存器的写操作时产生错误。
如果将你的I/O寄存器映射成每个寄存器对应一个独立的字地址,这就不是一个坏注意了。但你不可能总这样做。
4.13.1 执行wbfush
除非你的CPU是上面提到的特殊类型的一种,你能保证在执行针对任何地址的取操作时,回写缓冲区是空的(这会暂停CPU直到写操作完成,取操作也完成)。但这样效率不高;你可以通过使用最快的内存来最小限度来克服一些。
对于那些从来都不想考虑这些的人,一个写操作后面紧跟着一个针对相同地址的取操作(如果你是运行在MIPS III或其后面的CPU,需要在两个指令间加入sync指令),需要先清空回写缓冲区(很难看到如果CPU没有这个动作也能执行正确)。
一些系统通过硬件信号来指明FIFO是不是空的,连到输入接口让CPU能立刻得知。但不是到目前为止的CPU都这样做。
CAUTION!一些系统通常在CPU外面也有写操作的缓冲区。任何总线或内存接口自夸的写加速也会出现相同的特征。写缓冲区在CPU外和在CPU内一样,也会给你带来相同的问题。
4.14 其他的关于MIPS CPU 虽然你可能永远也不用知道这些,但我们还是有许多理由要谈到这些。
4.14.1 多处理器的cache特征
这个书的讨论将仅仅针对单CPU的系统。感兴趣的可以读相应的文档(Sweazey和Smith著,1986)。
4.14.2 Cache Aliases
这个问题只会影响这样一类的cache,用于产生index的地址和存放在tag内的地址不一样。在R4000类型CPU的主cache中,index是由虚拟地址长生而tag内存放的是物理地址。这对性能很有好处,因为cache查询和地址转化可以并行,但这也可能导致aliases。
绝大多数这些CPU可以按照4KB一页的大小来转化地址,而cache大小是8KB或者更大。两个不同的虚拟地址可以映射成对应一个物理地址,而且这是两个连续页,我们可以假设它们开始地址分别是0KB和4KB。如果程序访问地址0KB,那么数据会被读入到index为0的cache中。我们在假设要地址4KB来访问相同的数据,cache就又会从内存中取数并保存到cache中,但这次index却为4KB。这时在cache中针对相同数据有两个备份,一个被改变了,另一个却不会受到影响。这就是cache的alias。
MIPS第二层cache是物理地址来产生index和被存放到tag中,所以它们不会产生上面的问题。
不过,避免这个问题比改正它更容易。如果任意两个不同的虚拟地址能产生相同的index,那么这个问题就不会出现了。对于4KB一页,只要保证用来产生index的最低12位地址一致;只要保证不同虚拟地址对应的物理地址页大小等于主cache大小的模。如果你能保证虚拟地址是64KB的倍数,这就不可能产生这个问题,你也不会遇到麻烦。