You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

106 lines
10 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 24 | 冒险和预测CPU里的“线程池”
过去两讲,我为你讲解了通过增加资源、停顿等待以及主动转发数据的方式,来解决结构冒险和数据冒险问题。对于结构冒险,由于限制来自于同一时钟周期不同的指令,要访问相同的硬件资源,解决方案是增加资源。对于数据冒险,由于限制来自于数据之间的各种依赖,我们可以提前把数据转发到下一个指令。
但是即便综合运用这三种技术我们仍然会遇到不得不停下整个流水线等待前面的指令完成的情况也就是采用流水线停顿的解决方案。比如说上一讲里最后给你的例子即使我们进行了操作数前推因为第二条加法指令依赖于第一条指令从内存中获取的数据我们还是要插入一次NOP的操作。
![](https://static001.geekbang.org/resource/image/49/2d/49f3a9b1ae2972ac5c6cfca7731bf12d.jpeg)
那这个时候你就会想了,那我们能不能让后面没有数据依赖的指令,在前面指令停顿的时候先执行呢?
答案当然是可以的。毕竟,流水线停顿的时候,对应的电路闲着也是闲着。那我们完全可以先完成后面指令的执行阶段。
## 填上空闲的NOP上菜的顺序不必是点菜的顺序
之前我为你讲解的,无论是流水线停顿,还是操作数前推,归根到底,只要前面指令的特定阶段还没有执行完成,后面的指令就会被“阻塞”住。
但是这个“阻塞”很多时候是没有必要的。因为尽管你的代码生成的指令是顺序的,但是如果后面的指令不需要依赖前面指令的执行结果,完全可以不必等待前面的指令运算完成。
比如说,下面这三行代码。
```
a = b + c
d = a * e
x = y * z
```
计算里面的 x ,却要等待 a 和 d 都计算完成,实在没啥必要。所以我们完全可以在 d 的计算等待 a 的计算的过程中,先把 x 的结果给算出来。
在流水线里,后面的指令不依赖前面的指令,那就不用等待前面的指令执行,它完全可以先执行。
![](https://static001.geekbang.org/resource/image/37/ef/37ba6c453e530660cecbbfcf56a3ecef.jpeg)
可以看到,因为第三条指令并不依赖于前两条指令的计算结果,所以在第二条指令等待第一条指令的访存和写回阶段的时候,第三条指令就已经执行完成了。
这就好比你开了一家餐馆,顾客会排队来点菜。餐馆的厨房里会有洗菜、切菜、炒菜、上菜这样的各个步骤。后厨也是按照点菜的顺序开始做菜的。但是不同的菜需要花费的时间和工序可能都有差别。有些菜做起来特别麻烦,特别慢。比如做一道佛跳墙有好几道工序。我们没有必要非要等先点的佛跳墙上菜了,再开始做后面的炒鸡蛋。只要有厨子空出来了,就可以先动手做前面的简单菜,先给客户端上去。
这样的解决方案,在计算机组成里面,被称为**乱序执行**Out-of-Order ExecutionOoOE。乱序执行最早来自于著名的IBM 360。相信你一定听说过《人月神话》这本软件工程届的经典著作它讲的就是IBM 360开发过程中的“人生体会”。而IBM 360困难的开发过程也少不了第一次引入乱序执行这个新的CPU技术。
## CPU里的“线程池”理解乱序执行
那么我们的CPU怎样才能实现乱序执行呢是不是像玩俄罗斯方块一样把后面的指令找一个前面的坑填进去就行了事情并没有这么简单。其实从今天软件开发的维度来思考乱序执行好像是在指令的执行阶段引入了一个“线程池”。我们下面就来看一看在CPU里乱序执行的过程究竟是怎样的。
使用乱序执行技术后CPU里的流水线就和我之前给你看的5级流水线不太一样了。我们一起来看一看下面这张图。
![](https://static001.geekbang.org/resource/image/15/04/153f8d5e4a4363399133e1d7d9052804.jpeg)
1.在取指令和指令译码的时候乱序执行的CPU和其他使用流水线架构的CPU是一样的。它会一级一级顺序地进行取指令和指令译码的工作。
2.在指令译码完成之后就不一样了。CPU不会直接进行指令执行而是进行一次指令分发把指令发到一个叫作保留站Reservation Stations的地方。顾名思义这个保留站就像一个火车站一样。发送到车站的指令就像是一列列的火车。
3.这些指令不会立刻执行,而要等待它们所依赖的数据,传递给它们之后才会执行。这就好像一列列的火车都要等到乘客来齐了才能出发。
4.一旦指令依赖的数据来齐了指令就可以交到后面的功能单元Function UnitFU其实就是ALU去执行了。我们有很多功能单元可以并行运行但是不同的功能单元能够支持执行的指令并不相同。就和我们的铁轨一样有些从上海北上可以到北京和哈尔滨有些是南下的可以到广州和深圳。
5.指令执行的阶段完成之后我们并不能立刻把结果写回到寄存器里面去而是把结果再存放到一个叫作重排序缓冲区Re-Order BufferROB的地方。
6.在重排序缓冲区里我们的CPU会按照取指令的顺序对指令的计算结果重新排序。只有排在前面的指令都已经完成了才会提交指令完成整个指令的运算结果。
7.实际的指令的计算结果数据并不是直接写到内存或者高速缓存里而是先写入存储缓冲区Store Buffer面最终才会写入到高速缓存和内存里。
可以看到在乱序执行的情况下只有CPU内部指令的执行层面可能是“乱序”的。只要我们能在指令的译码阶段正确地分析出指令之间的数据依赖关系这个“乱序”就只会在互相没有影响的指令之间发生。
即便指令的执行过程中是乱序的,我们在最终指令的计算结果写入到寄存器和内存之前,依然会进行一次排序,以确保所有指令在外部看来仍然是有序完成的。
有了乱序执行我们重新去执行上面的3行代码。
```
a = b + c
d = a * e
x = y * z
```
里面的 d 依赖于 a 的计算结果,不会在 a 的计算完成之前执行。但是我们的CPU并不会闲着因为 x = y \* z 的指令同样会被分发到保留站里。因为 x 所依赖的 y 和 z 的数据是准备好的, 这里的乘法运算不会等待计算 d而会先去计算 x 的值。
如果我们只有一个FU能够计算乘法那么这个FU并不会因为 d 要等待 a 的计算结果,而被闲置,而是会先被拿去计算 x。
在 x 计算完成之后d 也等来了 a 的计算结果。这个时候我们的FU就会去计算出 d 的结果。然后在重排序缓冲区里,把对应的计算结果的提交顺序,仍然设置成 a -> d -> x而计算完成的顺序是 x -> a -> d。
在这整个过程中整个计算乘法的FU都没有闲置这也意味着我们的CPU的吞吐率最大化了。
整个乱序执行技术,就好像在指令的执行阶段提供一个“线程池”。指令不再是顺序执行的,而是根据池里所拥有的资源,以及各个任务是否可以进行执行,进行动态调度。在执行完成之后,又重新把结果在一个队列里面,按照指令的分发顺序重新排序。即使内部是“乱序”的,但是在外部看起来,仍然是井井有条地顺序执行。
乱序执行极大地提高了CPU的运行效率。核心原因是现代CPU的运行速度比访问主内存的速度要快很多。如果完全采用顺序执行的方式很多时间都会浪费在前面指令等待获取内存数据的时间里。CPU不得不加入NOP操作进行空转。而现代CPU的流水线级数也已经相对比较深了到达了14级。这也意味着同一个时钟周期内并行执行的指令数是很多的。
而乱序执行以及我们后面要讲的高速缓存弥补了CPU和内存之间的性能差异。同样也充分利用了较深的流水行带来的并发性使得我们可以充分利用CPU的性能。
## 总结延伸
好了,总结一下。这一讲里,我为你介绍了乱序执行,这个解决流水线阻塞的技术方案。因为数据的依赖关系和指令先后执行的顺序问题,很多时候,流水线不得不“阻塞”在特定的指令上。即使后续别的指令,并不依赖正在执行的指令和阻塞的指令,也不能继续执行。
而乱序执行则是在指令执行的阶段通过一个类似线程池的保留站让系统自己去动态调度先执行哪些指令。这个动态调度巧妙地解决了流水线阻塞的问题。指令执行的先后顺序不再和它们在程序中的顺序有关。我们只要保证不破坏数据依赖就好了。CPU只要等到在指令结果的最终提交的阶段再通过重排序的方式确保指令“实际上”是顺序执行的。
## 推荐阅读
想要更深入地了解CPU的乱序执行的知识我们就不能局限于组成原理而要深入到体系结构中去了。你可以读一下《计算机体系结构量化研究方法》的3.4和3.5章节。
想要了解乱序执行为什么可行你可以看看Wikipedia上乱序执行所依赖的[Tomasulo算法](https://en.wikipedia.org/wiki/Tomasulo_algorithm)。这个算法也是在IBM 360时代引入的。
## 课后思考
在现代Intel的CPU的乱序执行的过程中只有指令的执行阶段是乱序的后面的内存访问和数据写回阶段都仍然是顺序的。这种保障内存数据访问顺序的模型叫作强内存模型Strong Memory Model。你能想一想我们为什么要保障内存访问的顺序呢在前后执行的指令没有相关数据依赖的情况下为什么我们仍然要求这个顺序呢
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。