type
status
date
slug
summary
tags
category
icon
password

overview

《关于cpu性能》这篇文章中,笔者介绍了cpu性能的两个指标:响应时间和吞吐率;
本篇文章将着重介绍以下几点:
  1. 流水线技术
  1. 利用好流水线技术里遇到的三大冒险
  1. 乱序执行
  1. 超标量技术提高吞吐率
 

流水线技术

一条cpu指令的执行过程可以简单划分为以下三步:
  1. 取指令
  1. 指令译码
  1. 指令执行
由于取指令时需要依靠时钟信号来让pc寄存器自增,所以一条指令的完成至少需要一个时钟周期;
按照最初的设计,我们希望性能最好,于是我们需要在一个时钟周期内完成且仅完成一条指令的处理,因为每一条指令的复杂度是不一样的,而时钟周期是固定的,所以时钟周期的长度必须达到执行时间最久的那一条指令;
 
这种设计的缺陷也很明显:非常浪费时间和资源;
不论我们运行的指令复杂还是简单,都必须要等满一整个时钟周期后才能运行下一条指令,而在这期间,cpu很多资源,比如ALU,很可能长时间处于闲置状态,造成资源的浪费;
 
至此,cpu需要一种技术,能够使其尽可能地避免运行指令时时间与cpu硬件资源浪费的问题;
现代cpu往往采用流水线技术来解决这个问题;
简单来讲,流水线是一种实现多条指令重叠执行的过程,我们将一个任务拆分成几个步骤,一条流水线能够处理一条指令的各个步骤,而同一时刻,一个任务只会处于一个步骤,在第一个任务进入流水线,并从步骤1到达步骤2后,流水线处理步骤1的部分就空出来了,第二个任务就可以进入流水线进行步骤1,而不必等到第一个任务完全结束再进入;
 
具体到指令的流水线化,需要定义流水线级别数,也就是上文所说的步骤数;
不同cpu有不同的级别数,有的性能敏感分地精细,有十几层,有的则只有几层;
我们以ARMv8的子指令集—LEGv8为例,划分为五个处理步骤:
  1. 从指令寄存器中取指令
  1. 读取寄存器并通过地址译码器译码
  1. 执行指令
  1. 读写内存
  1. 写回寄存器
每一个流水线级别的操作,都需要占用一个时钟周期,一条指令最终需要流水线级别数个时钟周期;
时钟周期长度也不再需要达到一条指令运行完的时间,只需要达到最复杂的流水线级别的操作的时长即可;
与此同时,单条指令的执行时间并没有因为流水线技术而得到优化,流水线是通过增加指令的吞吐率来提高性能的;
 
此外,流水线级别数也不能过多,因为流水线深度的增加是有成本的;
每一级流水线的输出,都需要保存到额外的流水线寄存器中,虽然流水线寄存器读写速度很快,但一旦层级数目多了起来,比如达到三四十,那也会降低响应的时间,而且还会增大功耗。
 
 

流水线三大冒险

 
流水线会出现这样一种情况:在下一个时钟周期中下一条指令无法执行,我们将这种情况称为冒险(hazard);
所谓冒险,虽遇危机,但也必然有克服危机后的收获,在流水线技术中的冒险,假使我们好好解决遇到的问题,那能使cpu的吞吐率再上一个台阶;
流水线技术中会遇到三种冒险:结构冒险,数据冒险,控制冒险;
 

结构冒险

结构冒险的本质,是因硬件资源不足而造成的资源竞争问题;
在流水线中,处于不同阶段的两个指令假如都需要同样的电路资源,而这一种电路资源又恰好只有一份,这时就会发生结构冒险,使得指令无法顺利运行;
以上文划分的那五个步骤为例,第二步和第四步都需要用到地址译码器,由于只有一个地址译码器,取指令地址和取数据所在内存的地址会发生资源冲突;
 
解决办法有两个;
一个是将内存拆分为两部分,一部分放数据,一部分放指令,各自拥有各自的译码器,缺陷很明显:由于内存一分为二,没法动态分配,失去了灵活性;
还有一个,是将cpu内部的高速缓存分为指令缓存和数据缓存两部分,这样既可以解决结构冒险中的资源冲突问题,也不妨碍内存的动态分配;
前一种设计叫做哈佛架构,而后一种设计则被现代的冯·诺依曼体系结构所采用;
 

数据冒险

定义为:当一个步骤必须等待另外一个步骤完成才能进行(即存在数据依赖),此时将产生流水线的停顿,也就是数据冒险;
比如以下这段代码:
变量b的最终值依赖于变量a在执行完add 1之后的结果,这个顺序必须要保证;
 
最简单的方案是通过流水线冒泡,即:在译码完成之后,对该指令判断是否存在数据依赖,如果存在,则通过插入NOP操作(代表了什么都不干)的方式来达到流水线停顿,等到数据依赖问题解决了的那个时钟周期再开始正式执行(流水线是依靠时钟信号进行工作的,不可能真的停下来,所以需要依靠这个NOP操作);此方案对cpu性能消耗比较大;
 
一个更为高级的策略是操作数前推,或者叫做操作数旁路;
我们以下面两条指令为例:
第二条指令的执行依赖于t0中的值,按照之前的流水线冒泡策略,我们必须等待第一条指令的执行结果写回t0后才能运行;但事实上,第二条指令完全无需等待s2+s1的结果写回t0后再执行,s1+s2的结果可以直接传给第二条指令作为t0的输入,只要在电路中增加增加一些线路出来即可实现;这就是操作数前推的具体表现,其中增加的线路称为旁路;
通过操作数前推,可以有效减少NOP的数量,进一步提高流水线效率;
 

控制冒险

高级程序中的for循环和if语句,到了汇编代码,都会生成cmp比较指令和jmp等跳转指令,下一条指令究竟是pc寄存器中的值,还是jmp对应的地址,cpu必须等待执行完cmp指令后才知道;当决策依赖于一条指令的结果,而其他指令正在执行,这就是控制冒险;
 
为了防止流水线停顿,我们需要在cmp的结果出来之前就对其进行一个预测,这就是分支预测技术;
分支预测技术也分很多种,最简单的叫做静态分支预测;cpu认定跳转不会发生,一直按照顺序执行下去,假如预测错误,就要丢弃后面执行的指令,这个丢弃的操作是有一定的性能开销的;
高级一点的叫做动态预测;常见的一种动态预测方案叫做一级分支预测,也叫1比特饱和计数,实质为:用一个比特去记录当前分支的比较情况,用这个值,取预测下一次分支的情况,比如,这一次跳转了,那么下一次就去跳转,否则下一次继续顺序执行;还有一种叫做2比特饱和计数,引入了状态机,相当于下一次分支的预测取决于前两次的结果;关于分支预测更详细的内容,可以参考wiki
 

乱序执行

 
上文数据冒险中的操作数前推可以减少很多不必要的NOP操作,但是有些情况下,NOP是不可避免的,为了进一步提高吞吐率,cpu完全可以在NOP这个阶段去运行后面没有依赖的,可以直接运行的指令,这种技术叫做乱序执行;
以下为过程图:
notion image
 
在保留站,一个指令等待其依赖的数据也传过来之后,就可以发给功能单元FU来执行,这个阶段就是乱序执行;
所谓的FU,其实就是ALU,但不同ALU可以有不同功能,所以乱序执行阶段需要尽可能最大化利用这一点;
执行完后,还要放入重排序缓冲区,排成原先指令的顺序;
其次,指令计算的结果先提交到存储缓冲区,最后再到cpu的高速缓存或是内存;
在访问内存和写回的阶段,必须是顺序的,这是为了防止多核造成的数据不一致问题:每个cpu核都有自己的缓存和寄存器,假如写内存的顺序不一致,就会出现预期之外的错误;
 
在这一过程中,从外部看依旧是顺序的,而内部各个FU都没有闲置,cpu吞吐率进一步提提升;
 

超标量技术

 
即使上文各种cpu性能提高技术都用上,IPC(一个时钟周期能够执行的指令数)也不会超过1,因为cpu在一个时钟周期只能取得一条指令;
即然指令的执行阶段可以通过乱序执行技术来并行,那取指令和指令译码也可以考虑采用并行,即:一次性从内存取出多条指令,发给多个指令译码器进行指令译码:
这种技术,叫做多发射与超标量:
notion image
现代cpu采取这种技术,使得IPC往往可以达到2以上。
 

Conclusion

 
本文的目的为介绍cpu性能提高的常见技术;
文章从流水线的介绍讲起,到流水线的三大冒险,涉及拆分高速缓存,流水线冒泡,操作数前推,分支预测等内容,再到乱序执行和超标量技术,用并行的方式更进一步利用资源,提高cpu的吞吐率。
掌握这些内容,即可对现代cpu性能提高有一个比较宏观的了解。
 
 
认识困境,懂得醒悟 浅谈Go运行时调度器
Alex
Alex
某不知名青年|web2.5人士|喜欢猫与美少女
公告
type
status
date
slug
summary
tags
category
icon
password
有事请邮箱联系:alexwu7@outlook.com
🚀🚀🚀