0%

超标量处理器中访存指令的处理逻辑

load/store指令被取入处理器内部后,进过各级流水线的处理,最终会到达LSU部件(load store unit),
之前的各级流水只是对load/store指令做一些通用的处理,比如,decode,rename等。

load/store指令到达LSU后,理论上看如果没有地址上的依赖,计算出访问地址以及数据寄存器
ready后应该立即投入执行。展开看看load/store存在的地址上的依赖,地址依赖和指令间的
寄存器依赖比较类似,不同的是RAW/WAR/WAW都是真依赖,比如,WAR对相同地址先读后写,
后面的store先执行逻辑一定错了。

所以这里的问题就是,严格按照地址依赖关系把load/store投入执行,处理器性能就会低,
load/store在没有计算出访问地址之前投机执行,就需要冲突检测逻辑以及出现冲突时flush
流水线。现在的CPU一般会按顺序执行store,提前投机执行load,因为load是把数据取入CPU,
处于依赖链条的前端,load取到数据后,后续依赖它的指令就可以继续投机执行。

我们依照顺序执行store、提前投机执行load的基本逻辑,看看应该如何处理如上的各种冲突。
首先因为store是顺序执行的,就不会有WAW的冲突了。

针对WAR违例的处理。如下x1/x3的寄存器里的值相同,处理器先投机执行了store,因为处理
器里的指令要顺序提交,所以指令B写出的值需要先保存在处理器内部buffer里,一般load指
令在取入数据时会先搜索如上的处理器内部buffer,但是这里因为store指令比load新,所以
load不能用处理器内部buffer(store buffer)里的值,load需要从cache里取数据进来。

1
2
3
4
5
6
7
8
9
  A:  load x0, (x1)	
...
B: store x2, (x3)

+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ old
| | | | | | | | | | | | | | | | | | | | |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
^ ^ ^ ^ \-------------/
allocate B A commit retired

这么看,处理器只要对LSU里的load/store做编号,load取数据的时候不用store buffer里
比它年轻(标号比load大的store)的store指令的输出就好。

针对RAW违例的处理。如下x1/x3的寄存器里的值相同,B处的load指令先投机执行了,B后依赖
B的指令也可以投机执行,当A处的store指令执行时,需要检查是否有访问相同地址的更年轻
的load指令已经执行,如果有,那么就检测到了RAW违例。发生RAW违例时,load指令以及比
它年轻的指令的运算都可能使用错误的数据,所以都要被flush掉。

1
2
3
4
5
6
7
8
9
10
11
  A:  store x2, (x3)
...
B: load x0, (x1)
C: add x4, x0, x5

flush <--------------+
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ old
| | | | | | | | | | | | | | | | | | | | |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
^ ^ ^ ^ ^ \-------------/
allocate C B A commit retired

allocate到B的指令可以立即做flush也可以等到B提交的时候再做flush,其中的不同在于,
flush指令不只是释放ROB的资源,被flush指令在CPU里各个部件上占用的资源都要释放,立即
做flush可以及时释放错误占用的硬件资源,但是需要增加额外的检测电路,从CPU的各个部件
里把flush指令挑出来,等到B提交时,比B老的指令都已经退休,流水线里都是需要被flush
掉的指令(检测到RAW违例要停止fetch指令),flush逻辑实现会比较简单, 但是因为处理器一
直在等B到提交状态,性能就会比较差。

从上面的分析得到,我们想象的LSU里至少需要有:load queue、store queue、store buffer,
其中load queue是一个无序的容器,store queue和store buffer是FIFO。

对于一个store指令,其投机执行时得到的要素必须是先缓存到CPU内部buffer里的,我们就
先叫这个内部buffer为store buffer,所谓store指令投机执行的要素是指store的地址和数据。
当store指令处于待提交状态时,store可以把数据写到store地址对应的存储单元上,就是把
数据写入cache或者内存,理论上只有这个写操作生效,store指令才可以retired,处理器才
能继续提交后续的指令,但是写cache或内存相比处理器执行指令是一个慢速操作,处理器不
能一直等着这个动作完成,所以这里还要有一个缓存,CPU把commit的store按顺序放入这个
缓存,这个缓存里的store操作本质上已经是构架状态,必须成功完成,CPU认为放入这个缓存
的store指令已经retire,于是CPU继续顺序提交ROB上的后续待提交指令,可以把后面这个缓存
成为store buffer 2。

需要注意的是如上提到的两个store buffer逻辑上是独立的,实现中可以合并在一个buffer。
这个在《超标量处理器设计》中有提及,就是给store buffer上的每个位置设置三种状态:
(1) no-complete store指令进入store buffer,地址和数据还在计算中;(2) complete store
指令的地址和数据都准备好了;(3) retired store已经被提交处于retired状态。需要注意的
是1/2还是处理器内部状态,处于1/2的store是可能被flush掉的,3已经是CPU外部状态,而且
如上所述,必须成功写入存储器。

这里的新问题就是怎么保存3一定可以成功写入存储器,纵然CPU在把一个store变成3状态时,
可以确保TLB命中(包括做Page Table Walk之后重新fill TLB的情况),但是当处于3状态的store
在store buffer里排队时,VA->PA的映射关系也可能发生变换,随后的store访问就会出错。
这么看起来,当页表发生变换后,使用TLB无效化指令做同步时,TLB无效化指令要flush这里
store buffer里处于3状态的对应store操作。

多核系统下的LSU。Paul很出名的文章《Memory Barriers: a Hardware View for Software Hackers》
里指出之所以存在barrier是CPU微架构上引入store buffer和invalid queue导致的,我们看看
能不能把这里的store buffer和LSU里的store buffer的概念整合到一起理解。

其实,我们这里讨论的store buffer和文章里讨论的store buffer逻辑上是一致的,commit
store进store buffer的原因都是防止核被挂住,在多核情况下,一个store操作还需要和其它
核做通信,如果一直等其它核的响应,虽然可以保证数据的一致性,但是牺牲了本核的性能。

注意这里的关键点是store buffer里处于3状态的store操作如何写存储器,如果对于不同地址
的store操作依然是顺序的,就不会出问题,如果对于不同地址的store是可以乱序的,就会
出现文章里错误执行的行为。只不过文章里,对于可以直接写cache的操作就直接写cache了,
这个和所有commit store都进store buffer,store buffer里可以乱序是一样的行为。

我们把barrier的逻辑也尝试整合进来,为了简化理解,我们这里认为如上1和2的store被放
在核内的store queue里,如上的3状态的store会被发到核外的store buffer上。可以看见
对于store barrier,其实现逻辑可能就是当CPU检测到store barrier时,停止向store buffer
里发请求,并且持续等待到store buffer里的请求都完成,store barrier这个指令才能提交,
store barrier提交时,恢复core向store buffer的请求通路。所谓store buffer里请求都完成,
在多核系统下,是指完成了对应的MESI协议的多核一致性协议操作,比如,如果要对一个
share状态的cache line写,这个请求完成指当前core向一同share这个cache line的core发
cache invalid消息,并接收到所有应答。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+-------------+     +-------------+     +-------------+
| core 0 | | core 1 | | core 2 |
| | | | | |
| store queue | | store queue | | store queue |
+-------------+ +-------------+ +-------------+
v v v
+-------------+ +-------------+ +-------------+
| store buffer| | store buffer| | store buffer|
| A B C | | | | |
+-------------+ +-------------+ +-------------+
\
-- invalid -------->---->-------
\ \
cache lines cache lines cache lines
<---- ack -- /
<---------- ack ----------------

如上图,core 0上如果有一个store barrier,core0关闭store queue到store buffer的通路,
并把store buffer里已经存在的store请求完成。注意这里的完成是指其他core上也看到一致
的数据,那么对于store barrier隔开的两个store请求,他们在cache系统上生效确实保证了
先后。

但是,这里需要注意的是,store barrier保证前后两个store的顺序,但是并不保证其他core
上看到的顺序,这里关键之处是其他core上的指令执行是乱序的。

1
2
3
4
5
core 0:                  core 1:

store X1, [addr1] load X3, [addr2]
store barrier
store X2, [addr2] load X4, [addr1]

store barrier虽然可以保证core 0上两个store的顺序,但是core1上第二条load可能早就
乱序执行把地址addr1上的数据load到X4里了,当core1从addr2上确实load到core0写如入
的X2时,第二条load指令上X4是addr1上的旧值。

所以,如上经典的MP场景下,为了保证信息传递的正确性,要在core1的两个load之间加load
barrier,本质上就是不使用投机得到的load结果,重新执行load barrier之后的load指令。

考虑把invalid queue的逻辑也一起整合进来。实际上,从上面的分析中可以看见,就算没有
所谓的invalid queue,core对不同地址的乱序load请求就会导致乱序行为发生。invalid
queue的意思是,CPU里的一个core(B)收到另一个core(A)发给它的一个invalid cache请求,
core B自己还有一堆事要做,于是core B把这个invalid cache 请求放到自己的invalid
queue里,回响应给core A,随后core B再去做invalid queue中的请求。可以看到,invalid
queue的存在会使得投机执行的load可能拿到“更加错误”的值,本质上invalid queue和load
queue是正交的概念。

所以,当有invalid queue存在的时候,load barrier除了重新执行barrier后的load指令,
还要在执行之前把invalid queue里的请求清空,这样load barrier后的load指令得到的一定
是正确的值。

综合起来看,程序员从微架构的角度做出的理解只是辅助罢了,从memory order的定义出发
的理解才是编程的依据。

LSU和MMU的关系。load/store通过LSU进行访存操作,当系统里开启虚拟地址时,load/store
指令使用的地址是虚拟地址,这时就需要先通过MMU把虚拟地址翻译为物理地址,随后使用物理
地址访问内存。这个过程中TLB作为地址翻译的cache,可以大大加速地址翻译效率。当MMU无法
把虚拟地址翻译为物理地址时,对应的load/store需要报异常。

LSU和cache的关系。cache作为存储系统的最前端直接和LSU做交互,当store的值缓存在store
buffer里时,外部不可见(逻辑上看,如上状态3的store操作已经外部可感知),当写入cache
时,外部可见,CPU无法撤销。