最近阅读了一些关于CPU的资料,自感收获颇丰。本文算是读后感,整理出来和大家分享。
CPU Pipeline
严格讲我不是CS专业,不清楚CS本科是否需要学习CPU架构。或者说,在这个软件高度集成的时代,软件工程师有必要掌握这些细节吗?我的答案是:学以致用的角度,不需要;如果你专注于性能优化,则有借鉴意义。
上图是一段简单的汇编以及个人注释,主要看气质。第一,所有的代码编译为汇编指令(Instruction),然后通过CPU执行,比如cmp(对比),add(加法),mulsd(浮点乘法)等。第二,在这个过程中,通过对应的寄存器,比如eax,xmm,将变量传递给CPU,寄存器有不同的类型,数目有限,通过mov指令,填补指令间的裂缝。
编译成汇编码后,运行阶段CPU会“逐条”(未必)执行每一条指令并返回结果,不同指令需要消耗不同的cycles,这个过程称之为Latency。下图是AMD K7中MOV指令对应的消耗:
CPU需要四个阶段来翻译每一条指令:
- Fetch 从内存中获取该指令
- Decode 解码
- Execute 执行指令
- Writeback 将结果写回
上图是执行一条指令的完整过程,CPI(Cycles Per Instruction) = 4。实际上,CPU也是流水线作业,同一时间执行多个指令。当这条指令将要进入execute阶段时,下一条指令也准备进入decode阶段,再下一条也开始fetch阶段,这样,单个指令花费的时间不变,但总体看,CPI = 1,性能提升了4倍,如下图所示:
人性是贪婪的,在这个基础上又不断优化,就是下面要介绍的Superpipelining & Superscalar。
不同指令的复杂度是不同的,但瓶颈总是那个最慢的指令。打个比方,马路上都是小汽车,路况流畅,突然来了一大卡,则容易阻滞。Superpipelining的思路是:以Logic gate(逻辑运算的基本组件)为最小单位,将复杂指令(大卡)拆分。结果是,CPU流水线变长(Deeper Pipeline),拆分后的指令可以达到CPI=1,且每秒能运行更多的Cycles。
Intel Pentium 4采用这种技术,将流水线扩展到31个stages,主频高达5k MHz。但在实际应用中,性能并没有太多提升,而Athlon XP和Motorola G4的性能反而更佳,因此Intel缩小到20 stages。这可以归咎于理论和现实的差距,首先,流水线变深是一种面向未来的设计方案,短时间内很难提升CPU的主频,因此性能提升有限;其次,流水线过长,会增大指令间的依赖关系,导致预判准确率下降(下面会提到)。
这样,流水线的Execute阶段实际上是功能单元的集合,各单元只负责自己的业务。这时自然会想到,是否可以并行执行指令,进而显著提升性能。但要做到这点,就要提高fetch和decode的能力,才能提供足够多的执行任务(execution resources)。实际上,Pentium 4和Athlon XP可以每个周期解码2.5个指令。而这种提供多个流水线的方式,就是superscalar。如下图,CPI = 1/3。
同样,这种策略也会有一些实际问题,比如内存存取数据性能的不足,无法获取足够多的数据,造成浪费,并行逻辑的复杂度,也容易造成预判准确率降低。
实际上还有一种VLIW(very long instruction word),如上所示,而实际中是对这几种策略的融合。
Dependencies& Prediction
按照上面的思路,扩展多条流水线,增加每条流水线的深度,不就可以提升性能吗?但实际应用则是另一个层面了。我们看下面这段代码:
a = b * c;
d = a 1;
很简单的两行代码,第二行指令依赖第一行指令的结果。因此,处理器会挂起第二行指令,直到变量a的结果可用。这么宝贵的时间,白白浪费多么可惜,于是CPU决定调整程序的运行顺序,在挂起时找到其他合适指令来填补这些流水线bubbles。在技术上分为static(编译器)和dynamic(处理器)两种形式。
程序员应该了解编译器常见的优化选项,这个好处是编译阶段,可以提供足够多的资源和时间,编译器可以针对整段代码认真分析。但这种方案不可能做到准确的预测未来。
另外一种则是运行时的优化策略,称为OOO(out of order execution)。要做到运行时的乱序,要有能力记录指令间的依赖关系。为了简化变量间的依赖关系,一个有效的办法就是对变量重命名(Register Renaming)。这样,指令间的变量都对应独立的寄存器,进而实现并行化。这种策略的好处是动态的,同样的程序,不需重编译,自动新CPU的性能提升,但提高的CPU的逻辑复杂度。
我们再看另一种逻辑判断:
if(a>0){
...
}else{
...
}
在这,有两个branch,我们无法预知应该执行哪一个,但为了保证流水线的效率,CPU决定提前猜一个branch,猜对了就继续,猜错了就要flush,之前的准备白费。所以,问题的关键就是提高预测的准确度。
同样,这种预测分为static编译器和dynamic运行时两类。比如if逻辑则预判进入第一个分支forward,而while则预判是返回到循环体backward,但这种static预测通常只对循环较为有效。另一种则是运行时策略,比如记录上一次执行的分支,或者引用计数,作为预判的依据等,因此,需要占据一点芯片资源,随着流水线深度的加大,mispredict的可能性也加大。据统计,Pentium Pro/II/III中,30%的性能都浪费在预测失败上。因此,现代处理器分配更多的硬件资源来进行分支预测,比如不同分支间的关联,历史记录,多分支预测等,但即便如此,准确度只能达到95%。
另一种方案是提供条件判断指令(predicated instruction),在此就不介绍了。
Parallelism
讲到这,就是我自认为最有意思的讨论,提升CPU性能有两个思路,提高计算能力,让它能搬更多的砖,或者让它变得更聪明,提高效率。
我们不妨看看Intel的发展历程。早期因为CISC指令过于复杂,只能让CPU更聪明,后来,出于和AMD的竞争的需要,则专注于计算速度。同时,在IA-64上进行了大量编译器优化策略。面对IA-64的失败,Pentium 4过于追求速度带来的能耗,发热问题,以及AMD在低主频下良好的性能,Intel又重拾智慧路线。另外,在这个过程中,Intel在运行时期间对X86的指令进行简化,分解为RISC风格的微指令,称为μops。
可见,大家都属于局部激进,整体中庸的发展模式。因为大家心里都明白,在理论上,每一种方式都有一堵无法逾越的墙。
- Thepower wall 目前,运算速度提升30%,则需要两倍的电压和发热,并且这种设计思路无法满足移动设备,也不可能长久
- TheIPL wall 目前多数应用并没有很好的并行化设计(指令级别)
- Thememory wall 内存和CPU在性能上的差距拉大,供不应求,后面会提到。
老一点的程序员应该都读过一篇文章《The Free Lunch Is Over》,不妨重读一下,里面有一句话“Chipdesigners are under so much pressure to deliver ever-faster CPUs that they’llrisk changing the meaning of your program, and possibly break it, in order tomake it run faster”。或者可以说,工程师在这三面墙上取得了巨大的进展,软件不做任何改进就可以等到性能的提升,但现在接近临界点了。面对窘境,Intel率先在Pentium 4支持SMT(Simultaneous multi-threading)技术,开启了多线程,多核方面的尝试。
事情的发展并非一帆风顺,多线程的价值是建立在一种假设之上:当前有很多应用程序在运行,或者一个程序内,有很多线程同时运行。然后,并不是所有场景都能符合如上的假设,比如数据库,三维图形渲染或科学运算,对并行运算有较高的要求,能很好的发挥多线程的优势(其实,也受限于带宽),但其他应用,比如浏览器,并不需要多线程或没有多线程的设计,则无法体现其价值。同时,因为是在一个Core中模拟多线程,线程切换的状态管理也是一种成本,因此,早期的SMT表现很诡异,性能跟具体程序有强连接。有时候,仿佛是两个高效的Core,有时候,就好比两个慵懒的Core,有时候,还不如一个Core实在。SMT在P4的速度的提升在-10%到30%之间,差强人意。在不断的探索后,目前Intel的Core i系列的设计方式是2^n个Cores每个Core支持两线程。在这个过程中,我们意识到,不同的应用程序,对多核和多线程的依赖程度是不一样的,因此,可以针对不同的应用场景,推出适合的CPU方案。
这样就有一个权衡:典型的SMT设计,要支持Superscalar,支持OOO,设计复杂度的提高,自然会占用更多的空间,因此,一个聪明的Core所占用的空间,可以装得下6个简单的Core(领导曾对我说:一个老员工的工资可以招三个应届生)。如上图,同样10亿个晶体管,左侧是Intel酷睿4核,共8个线程,后者是Sun的UltraSPARC,16核共128个线程。索尼PS3主机在设计上大胆创新,一个聪明的Core配N个简单的Cores,但产生很多兼容问题;再比如针对移动环境低能耗,便携和低性能,实现了CPU,GPU,网卡,IO等一体化的芯片设计。
经历了Instruction,Thread并行化后,还有Data并行化的方式,这也是本次我的一大收获:SIMD(single instruction, multiple data),也称为向量化处理。
对于C 程序员,不妨了解一下,目前Intel和Github上都有一些资料和开源库,可以学习参考,如果精力允许,不妨测试一下性能提升是否显著,特别是结合OpenMP等多线程机制,可以考虑对部分函数进行vectorization改造。
Memory Cache
上面基本上涵盖了CPU的主要知识点,还留下一个小尾巴,就是缓存。从处理器的角度而言,Cache就是处理器和内存之间一块空间小,但速度快的内存。因为人们发现程序在存取内存时,具有重复性(循环利用)和连续性(内存相邻),于是通过一种策略,将“更可能选中”的数据放在缓存中,从来缓解Memory Wall造成的Bubbles。
C 程序员不妨看一下《STL源码剖析》,里面也提到了STL的内存池概念,三级缓存的方式设计内存池。比如你看书,会把最近看的,重要的书放在桌子上,唾手可得;另外一些还不错的书放在书架上,挪一下屁股就可以找到;其他书放在箱子里,就需要折腾一翻。
一分钱一分货,给你这些空间了,如何有效的利用缓存,提高命中率,就是一件严肃的问题了。如下是时间成本的数学公式,而我们能优化的空间就是让tpp三变量尽可能小:
先从最简单的direct map,也称为One-way set associative。首先,我们看一下缓存存储方式,类似hashtable:
如上,根据内存地址的后三位,来指定映射关系,这样,缓存中有8个block,内存中平均4个block对应Cache中的一个block。
这样,就有一个问题,我只知道后三位,那如何知道当前缓存的数据对应的是四个block中的哪一个呢?这里引入一个tag概念,对应00,01,10,11四种可能,在查找时,如下图所示,判断Address的低位找到对应的Index,根据tag和高位判断是否命中。
该设计的好处是直接定位,缺点是易冲突。而且以动态的眼光来看,CPU寻址的速度会越来越快,这种设计的优势就越来越次要,而内存会越来越大,hash冲突的概率会越来越明显。这样,在Cache空间不变的情况下,如何降低冲突的可能?这里就要介绍一下目前主流的缓存设计方案:setassociative。
如上,思路就是扁平化,比如单路下的冲突属于设计问题,没有缓冲空间;而在这种情况下,N way就说明冲突的数据还有N个block并存,比如采用Least-Recently used(LRU)来提高命中率。
总结
坦白说,本文所有的思路和要点都不是我的,但每个字已融入我的血液中。而让我欣慰的是,能够把CPU的这些看似破碎的知识点连贯起来,从中窥探CPU发展的来龙去脉;再者结合软件开发中的一些经历,对一些问题的理解更深刻了,比如SIMD和Memory Cache。写到这,我很满足。
纵观CPU的发展,我觉得高速公路的例子形象。早期潜力大,我们优化路况,让每一辆车可以更快的到达目的地。当这一方式接近极限或应用成本无法接受时,我们开始扩宽路面,这样,每辆车的时间不会减少,但可以承载更多的车。其实,很多事物,甚至个人的发展,也都有共性。
同样的工资,你需要的是一个大学教授,还是三个搬砖工?当你的领导已经在考虑这个问题时,我只想说,你走你的路 直到我们无法接触。