- 1 内存访问的排序和重新排序
- 2 访存顺序和写缓存
- 3 写缓存的flush
站在巨人的肩膀上,才能看得更远。 If I have seen further, it is by standing on the shoulders of giants. 牛顿
这是向MIPS架构移植软件的问题系列之第三篇。在前两篇文章
*《MIPS架构深入理解8-向MIPS架构移植软件之大小端问题》
*《MIPS架构深入理解9-向MIPS移植软件之Cache管理》
中,我们分别讨论了大小端模式和Cache对于移植代码的影响。那么本文,我们再从内存序理解一下对于移植代码的影响,尤指底层代码或操作系统代码。
1 内存访问的排序和重新排序
程序员往往认为他们的代码是顺序执行的:CPU执行指令,更新系统的状态,然后继续下一条指令。但是,如果允许CPU乱序执行,而不是这种串行方式执行,效率可能更高。这对于执行load和store这种存储指令尤其重要。
从CPU的角度来看,执行store操作就是发送一个write请求:给出内存地址和数据,其余的交给内存控制器完成。实际的内存和I/O设备相对较慢,等write操作完成,CPU可能已经完成了几十条甚至几百条指令。
read操作又有不同:它需要发送一个read请求,然后等待对请求的响应。当CPU需要知道内存或者设备寄存器中的内容时,没有得到请求响应前,可能啥也做不了。
如果想要追求更高的性能,就意味着我们需要让read尽可能地快,甚至不惜让write操作变得更慢。进一步考虑,我们可以让write操作排队等待,把随后的任何read操作请求提前到write请求队列之前执行。从CPU的角度来看,这是一个大优势:尽可能快地启动read操作,就越早得到read操作的响应。然后,在某个时刻把执行write操作,而且write请求队列的大小是固定的。但是,这个write操作可能需要写Cache一段时间。如果这个队列满了,可能需要停下来等待一段时间,等待所有的write完成操作。但是,这肯定要比顺序执行,效率更高。这就是现代CPU一般都具有一个write buffer的原因。
看到这儿,你可能会有一个疑问:某些程序可能会写入一个地址,然后再将其读回来,这时候会怎么样呢?如果read提前到write之前执行,我们可能从内存中读取的是旧值,从而导致程序发生故障。通常,CPU会提供额外的硬件,比较read操作的地址和write队列中的地址,如果有相同的项,就不允许这样的read操作提前到write操作之前执行。
上面的讨论没有考虑真正的并发系统,比如多核系统。并发执行的任务间共享变量,对其执行read和write操作会非常危险。比如使用共享变量进行同步和通信的时候,内存访问次序就会非常重要。这种情况下,软件一般会采用精心的设计,比如锁和信号量,进行同步操作。
但是,使用共享内存,还有一些技巧,往往效果更好,开销也更小。因为不需要使用信号量或者锁。但是,可能会被乱序执行打断。假设,我们有2个任务,如下图所示:一个读取数据结构,一个写数据结构。它们可以交替使用这个数据结构。
为了能够正确执行,我们需要知道,对于reader任务来说,当什么时候reader任务看见关键域中的值发生了更新时,能够保证其它所有的更新对reader任务可见。
当然,硬件可以实现所有的内存访问顺序问题,从而将它们对程序员不可见,但是也就放弃了解耦read和write操作带来的性能优势。MIPS架构提供了sync
指令实现这个目的,它可以确保sync指令之前的访问先于之后的执行。但是,这种保障指令有其局限性:只与内存的访问顺序有关,只能被非Cache或具有Cache一致性的内存访问的参与者看见。
对于上面的示例,为了让其在合适的系统上可靠地运行,writer任务应该在写关键域的值之前,调用一条sync指令;reader任务应该在读关键域的值之后插入一条sync指令。对于sync指令的详细使用方法,可以参考《MIPS指令集参考大全》一文。
不同的体系架构对执行顺序作出了不同的规定。一类极端情况就是,要求所有的CPU和系统设计人员努力保证一个CPU的全部读和写操作,从另一个CPU的角度看上去顺序完全相同,这叫做强序
。也有一类情况就是弱序
,比如只要求所有的写操作保持顺序不变。而MIPS架构更为激进,完全就是无序
访问内存。这就要求我们系统开发人员必须手动保证内存的访问顺序是正确的。
2 访存顺序和写缓存
前面讨论了这么多理论,接下来让我们讨论点实际的内容吧。把write操作缓存到一个队列中(也就是硬件中常常讨论的write buffer)的思想在实践中证明非常有效。因为,store指令往往是多条指令扎堆出现。比如,一个运行MIPS代码的CPU,实际上运行的store指令大约占所有指令的10%左右;但是,往往是突发式访问,比如函数的调用过程中,首先需要压栈操作一组寄存器的值。
但是,一般情况下,写缓存(英文称为write buffer
)都是硬件保证的,对于软件来说不用管理。但是,也有一些特殊的情况,程序员需要知道怎样处理:
- I/O寄存器访问的时序 这个问题,对于所有架构CPU都存在。比如,CPU发出一个store指令,更新I/O设备寄存器的值,write请求可能会在写缓存中延迟一段时间。这时候,可能会发生其它事件,比如中断。但是此时写入的值还未更新到对应的I/O设备寄存器中。这可能导致一些奇怪的行为:比如,你想禁止产生中断,但是CPU发出write操作之后,CPU还有可能会收到中断。
- read操作抢先于write操作执行 上面已经讨论过,MIPS32/64架构允许这种操作。如果想要软件更加健壮和具有可移植性,就不应该假定read和write操作顺序会被保持。如果想要保证前后两个指令周期是按照特定顺序执行,就需要插入sync指令。
- 字节汇集 有些写缓存会汇集不足WORD大小的write操作,凑成一个WORD大小的write操作,然后再执行(有些写缓存甚至会攒一个Cache行,然后再写入)。所以,为了避免对于非Cache的内存区也做相同的操作,最好的办法就是把I/O寄存器(比如,一个8位的寄存器)映射到一个单独的WORD大小的地址上。
3 写缓存的flush
通过对非Cache内存区的任意位置执行write操作,然后再read,可以清空写缓存(大部分都是这样实现的)。当然,写缓存不允许read操作发生在write之前,这样导致返回旧值。所以,必须在write和read操作之间,插入sync指令。对于兼容MIPS32/64
规范的任何系统,这应该都是有效的。
但是,有效不等于高效。通过提高内存的读写速度也可以降低整体的负荷。有些特定的系统可能会提供更快的内存或者写缓存。
任何具有回写功能的处理器或者内存接口,都引入了写缓存。只是,有的在CPU内部实现,有的在CPU外部实现。不管是在CPU内部,还是在CPU外部,麻烦是相同的。在编程的时候,一定要仔细确认你的系统中,写缓存的位置,善加利用。