开发过程中咱们多少都会关注服务的性能,然而性能优化是绝对比拟艰难,往往须要多轮优化、测试,属于费时费力,有时候还未必有好的成果。然而如果有较好的性能优化办法领导、工具辅助剖析能够帮忙咱们疾速发现性能瓶颈所在,针对性地进行优化,能够事倍功半。
性能优化的难点在于找出要害的性能瓶颈点,如果不借助一些工具辅助定位这些瓶颈是十分艰难的,例如:c++程序通常大家可能都会借助perf /bcc这些工具来寻找存在性能瓶颈的中央。性能呈现瓶颈的起因很多比方 CPU、内存、磁盘、架构等。本文就仅仅是针对CPU调优进行调优,即如何榨干CPU的性能,将CPU吞吐最大化。(实际上CPU出厂的时候就曾经决定了它的性能,咱们须要做的就是让CPU尽可能做有用功),所以针对CPU利用率优化,实际上就是找出咱们写的不够好的代码进行优化。
一、示例
先敬上代码:
#include <stdlib.h> #define CACHE_LINE __attribute__((aligned(64))) struct S1 { int r1; int r2; int r3; S1 ():r1 (1), r2 (2), r3 (3){} } CACHE_LINE; void add(const S1 smember[],int members,long &total) { int idx = members; do { total += smember[idx].r1; total += smember[idx].r2; total += smember[idx].r3; }while(--idx); } int main (int argc, char *argv[]) { const int SIZE = 204800; S1 *smember = (S1 *) malloc (sizeof (S1) * SIZE); long total = 0L; int loop = 10000; while (--loop) { // 不便比照测试 add(smember,SIZE,total); } return 0; }
注:代码逻辑比较简单就是做一个累加操作,仅仅是为了演示。
编译+运行:
g++ cache_line.cpp -o cache_line ; task_set -c 1 ./cache_line
下图是示例cache\_line在CPU 1外围上运行,CPU利用率达到99.7%,此时CPU基本上是满载的,那么咱们如何晓得这个cpu运行cache\_line 服务过程中是否做的都是有用功,是否还有优化空间?
有的同学可能说,能够用perf 进行剖析寻找热点函数。的确是能够应用perf,然而perf只能晓得某个函数是热点(或者是某些汇编指令),然而没法确认引起热点的是CPU中的哪些操作存在瓶颈,比方取指令、解码、.....
如果你还在为判断是CPU哪些操作导致服务性能瓶颈而手足无措,那么这篇文章将会你给你授道解惑。本文次要通过介绍自顶向下分析方法(TMAM)方法论来疾速、精准定位CPU性能瓶颈以及相干的优化倡议,帮忙大家晋升服务性能。为了让大家更好的了解本文介绍的办法,须要筹备些常识。
二、CPU 流水线介绍
(图片起源:intel 官网文档)
古代的计算机个别都是冯诺依曼计算机模型都有5个外围的组件:运算、存储、管制、输出、输入。本文介绍的办法与CPU无关,CPU执行过程中波及到取指令、解码、执行、回写这几个最根底的阶段。最早的CPU执行过程中是一个指令依照以上步骤顺次执行完之后,能力轮到第二条指令即指令串行执行,很显然这种形式对CPU各个硬件单元利用率是非常低的,为了进步CPU的性能,Intel引入了多级流水、乱序执行等技术晋升性能。个别intel cpu是5级流水线,也就是同一个cycle 能够解决5个不同操作,一些新型CPU中流水线多达15级,下图展现了一个5级流水线的状态,在7个CPU指令周期中指令1,2,3曾经执行实现,而指令4,5也在执行中,这也是为什么CPU要进行指令解码的目标:将指令操作不同资源的操作分解成不同的微指令(uops),比方ADD eax,[mem1] 就能够解码成两条微指令,一条是从内存[mem1]加载数据到长期寄存器,另外一条就是执行运算,这样就能够在加载数据的时候运算单元能够执行另外一条指令的运算uops,多个不同的资源单元能够并行工作。
(图片起源:intel 官网文档)
CPU外部还有很多种资源比方TLB、ALU、L1Cache、register、port、BTB等而且各个资源的执行速度各不相同,有的速度快、有的速度慢,彼此之间又存在依赖关系,因而在程序运行过程中CPU不同的资源会呈现各种各样的束缚,本文使用TMAM更加主观的剖析程序运行过程中哪些外在CPU资源呈现瓶颈。
三、自顶向下剖析(TMAM)
TMAM 即 Top-down Micro-architecture Analysis Methodology自顶向下的微架构分析方法。这是Intel CPU 工程师演绎总结用于优化CPU性能的方法论。TMAM 实践根底就是将各类CPU各类微指令进行归类从大的方面先确认可能呈现的瓶颈,再进一步下钻剖析找到瓶颈点,该办法也合乎咱们人类的思维,从宏观再到细节,过早的关注细节,往往须要破费更多的工夫。这套方法论的劣势在于:
- 即便没有硬件相干的常识也可能基于CPU的个性优化程序
- 系统性的打消咱们对程序性能瓶颈的猜想:分支预测成功率低?CPU缓存命中率低?内存瓶颈?
- 疾速的辨认出在多核乱序CPU中瓶颈点
TMAM 评估各个指标过程中采纳两种度量形式一种是cpu时钟周期(cycle[6]),另外一种是CPU pipeline slot[4]。该办法中假设每个CPU 内核每个周期pipeline都是4个slot即CPU流水线宽是4。下图展现了各个时钟周期四个slot的不同状态,留神只有Clockticks 4 ,cycle 利用率才是100%,其余的都是cycle stall(进展、气泡)。
(图片起源:intel 官网文档)
3.1 根底分类
(图片来源于:intel 文档)
TMAM将各种CPU资源进行分类,通过不同的分类来辨认应用这些资源的过程中存在瓶颈,先从大的方向确认大抵的瓶颈所在,而后再进行深入分析,找到对应的瓶颈点各个击破。在TMAM中最顶层将CPU的资源操作分为四大类,接下来介绍下这几类的含意。
3.1.1 Retiring
Retiring示意运行无效的uOps 的pipeline slot,即这些uOps[3]最终会退出(留神一个微指令最终后果要么被抛弃、要么退出将后果回写到register),它能够用于评估程序对CPU的绝对比拟实在的有效率。现实状况下,所有流水线slot都应该是"Retiring"。100% 的Retiring意味着每个周期的 uOps Retiring数将达到最大化,极致的Retiring能够减少每个周期的指令吞吐数(IPC)。须要留神的是,Retiring这一分类的占比高并不意味着没有优化的空间。例如retiring中Microcode assists的类别实际上是对性能有损耗的,咱们须要防止这类操作。
3.1.2 Bad Speculation
Bad Speculation示意谬误预测导致节约pipeline 资源,包含因为提交最终不会retired的 uOps 以及局部slots是因为从先前的谬误预测中复原而被阻塞的。因为预测谬误分支而节约的工作被归类为"谬误预测"类别。例如:if、switch、while、for等都可能会产生bad speculation。
3.1.3 Front-End-Boun
Front-End 职责:
- 取指令
- 将指令进行解码成微指令
- 将指令分发给Back-End,每个周期最多散发4条微指令
Front-End Bound示意解决其的Front-End 的一部分slots没法交付足够的指令给Back-End。Front-End 作为处理器的第一个局部其外围职责就是获取Back-End 所需的指令。在Front-End 中由预测器预测下一个须要获取的地址,而后从内存子系统中获取对应的缓存行,在转换成对应的指令,最初解码成uOps(微指令)。Front-End Bound 意味着,会导致局部slot 即便Back-End 没有阻塞也会被闲置。例如因为指令cache misses引起的阻塞是能够归类为Front-End Bound。内存排序
3.1.4 Back-End-Bound
Back-End 的职责:
- 接管Front-End 提交的微指令
- 必要时对Front-End 提交的微指令进行重排
- 从内存中获取对应的指令操作数
- 执行微指令、提交后果到内存
Back-End Bound 示意局部pipeline slots 因为Back-End短少一些必要的资源导致没有uOps交付给Back-End。
Back-End 处理器的外围局部是通过调度器乱序地将筹备好的uOps分发给对应执行单元,一旦执行实现,uOps将会依据程序的程序返回对应的后果。例如:像cache-misses 引起的阻塞(进展)或者因为除法运算器过载引起的进展都能够归为此类。此类别能够在进行细分为两大类:Memory-Bound 、Core Bound。
演绎总结一下就是:
Front End Bound = Bound in Instruction Fetch -> Decode (Instruction Cache, ITLB)Back End Bound = Bound in Execute -> Commit (Example = Execute, load latency)
Bad Speculation = When pipeline incorrectly predicts execution (Example branch mispredict memory ordering nuke)
Retiring = Pipeline is retiring uops
一个微指令状态能够依照下图决策树进行归类:
(图片起源:intel 官网文档)
上图中的叶子节点,程序运行肯定工夫之后各个类别都会有一个pipeline slot 的占比,只有Retiring 的才是咱们所冀望的后果,那么每个类别占比应该是多少才是正当或者说性能相对来说是比拟好,没有必要再持续优化?intel 在实验室里依据不同的程序类型提供了一个参考的规范:
(图片起源:intel 用户手册)
只有Retiring 类别是越高越好,其余三类都是占比越低越好。如果某一个类别占比比较突出,那么它就是咱们进行优化时重点关注的对象。
目前有两个支流的性能剖析工具是基于该方法论进行剖析的:Intel vtune(免费而且还老贵~),另外一个是开源社区的pm-tools。
有了下面的一些常识之后咱们在来看下开始的示例的各分类状况:
尽管各项指标都在后面的参照表的范畴之内,然而只有retiring 没有达到100%都还是有可优化空间的。上图中显然瓶颈在Back-End。
3.3 如何针对不同类别进行优化?
应用Vtune或者pm-tools 工具时咱们应该关注的是除了retired之外的其余三个大分类中占比比拟高,针对这些较为突出的进行剖析优化。另外应用工具剖析工程中须要关注MUX Reliability (多元分析可靠性)这个指标,它越靠近1示意以后后果可靠性越高,如果低于0.7 示意以后剖析后果不牢靠,那么倡议加长程序运行工夫以便采集足够的数据进行剖析。上面咱们来针对三大分类进行剖析优化。
3.3.1 Front-End Bound
(图片起源:intel 官网文档)
上图中展现了Front-End的职责即取指令(可能会依据预测提前取指令)、解码、分发给后端pipeline, 它的性能受限于两个方面一个是latency、bandwidth。对于latency,个别就是取指令(比方L1 ICache、iTLB未命中或解释型编程语言python\java等)、decoding (一些非凡指令或者排队问题)导致提早。当Front-End 受限了,pipeline利用率就会升高,下图非绿色局部示意slot没有被应用,ClockTicks 1 的slot利用率只有50%。对于BandWidth 将它划分成了MITE,DSB和LSD三个子类,感兴趣的同学能够通过其余路径理解下这三个子分类。
(图片起源:intel 官网文档)
3.3.1.1 于Front-End的优化倡议:
- 代码尽可能减少代码的footprint7:
C/C++能够利用编译器的优化选项来帮忙优化,比方GCC -O* 都会对footprint进行优化或者通过指定-fomit-frame-pointer也能够达到成果;
- 充分利用CPU硬件个性:宏交融(macro-fusion)
宏交融个性能够将2条指令合并成一条微指令,它能晋升Front-End的吞吐。 示例:像咱们通常用到的循环:
所以倡议循环条件中的类型采纳无符号的数据类型能够应用到宏交融个性晋升Front-End 吞吐量。
- 调整代码布局(co-locating-hot-code):
①充分利用编译器的PGO 个性:-fprofile-generate -fprofile-use
②能够通过\_\_attribute\_\_ ((hot)) \_\_attribute\_\_ ((code)) 来调整代码在内存中的布局,hot的代码
在解码阶段有利于CPU进行预取。
其余优化选项,能够参考:GCC优化选项 GCC通用属性选项
- 分支预测优化
① 打消分支能够缩小预测的可能性能:比方小的循环能够开展比方循环次数小于64次(能够应用GCC选项 -funroll-loops)
② 尽量用if 代替:? ,不倡议应用a=b>0? x:y 因为这个是没法做分支预测的
③ 尽可能减少组合条件,应用繁多条件比方:if(a||b) {}else{} 这种代码CPU没法做分支预测的
④对于多case的switch,尽可能将最可能执行的case 放在最后面
⑤ 咱们能够依据其动态预测算法投其所好,调整代码布局,满足以下条件:
前置条件,使条件分支后的的第一个代码块是最有可能被执行的
bool is_expect = true; if(is_expect) { // 被执行的概率高代码尽可能放在这里 } else { // 被执行的概率低代码尽可能放在这里 }后置条件,使条件分支的具备向后指标的分支不太可能的指标 do { // 这里的代码尽可能减少运行 } while(conditions);
3.3.2 Back-End Bound
这一类别的优化波及到CPU Cache的应用优化,CPU cache[14]它的存在就是为了补救超高速的 CPU与DRAM之间的速度差距。CPU 中存在多级cache(register\L1\L2\L3) ,另外为了减速virtual memory address 与 physical address 之间转换引入了TLB。
如果没有cache,每次都到DRAM中加载指令,那这个提早是没法承受的。
(图片起源:intel 官网文档)
3.3.2.1 优化倡议:
- 调整算法缩小数据存储,缩小前后指令数据的依赖进步指令运行的并发度
- 依据cache line调整数据结构的大小
- 防止L2、L3 cache伪共享
(1)正当应用缓存行对齐
CPU的缓存是弥足珍贵的,应该尽量的进步其使用率,平时应用过程中可能存在一些误区导致CPU cache无效利用率比拟低。上面来看一个不适宜进行缓存行对齐的例子:
#include <stdlib.h> #define CACHE_LINE struct S1 { int r1; int r2; int r3; S1 ():r1 (1), r2 (2), r3 (3){} } CACHE_LINE; int main (int argc, char *argv[]){ // 与后面统一 }
上面这个是测试成果:
做了缓存行对齐:
#include <string.h> #include <stdio.h> #define CACHE_LINE __attribute__((aligned(64))) struct S1 { int r1; int r2; int r3; S1(): r1(1),r2(2),r3(3){} } CACHE_LINE; int main(int argc,char* argv[]) { // 与后面统一 }
测试后果:
通过比照两个retiring 就晓得,这种场景下没有做cache 对齐缓存利用率高,因为在单线程中采纳了缓存行导致cpu cache 利用率低,在下面的例子中缓存行利用率才3*4/64 = 18%。缓存行对齐应用准则:
- 多个线程存在同时写一个对象、构造体的场景(即存在伪共享的场景)
- 对象、构造体过大的时候
- 将高频拜访的对象属性尽可能的放在对象、构造体首部
(2)伪共享
后面次要是缓存行误用的场景,这里介绍下如何利用缓存行解决SMP 体系下的伪共享(false shared)。多个CPU同时对同一个缓存行的数据进行批改,导致CPU cache的数据不统一也就是缓存生效问题。为什么伪共享只产生在多线程的场景,而多过程的场景不会有问题?这是因为linux 虚拟内存的个性,各个过程的虚拟地址空间是互相隔离的,也就是说在数据不进行缓存行对齐的状况下,CPU执行过程1时加载的一个缓存行的数据,只会属于过程1,而不会存在一部分是过程1、另外一部分是过程2。
(上图中不同型号的L2 cache 组织模式可能不同,有的可能是每个core 独占例如skylake)
伪共享之所以对性能影响很大,是因为他会导致本来能够并行执行的操作,变成了并发执行。这是高性能服务不能承受的,所以咱们须要对齐进行优化,办法就是CPU缓存行对齐(cache line align)解决伪共享,原本就是一个以空间换取工夫的计划。比方下面的代码片段:
#define CACHE_LINE __attribute__((aligned(64))) struct S1 { int r1; int r2; int r3; S1(): r1(1),r2(2),r3(3){} } CACHE_LINE;
所以对于缓存行的应用须要依据本人的理论代码区别对待,而不是随声附和。
3.3.3 Bad Speculation分支预测
(图片起源:intel 官网文档)
当Back-End 删除了微指令,就呈现Bad Speculation,这意味着Front-End 对这些指令所作的取指令、解码都是无用功,所以为什么说开发过程中应该尽可能的避免出现分支或者应该晋升分支预测准确度可能晋升服务的性能。尽管CPU 有BTB记录历史预测状况,然而这部分cache 是十分稀缺,它能缓存的数据十分无限。
分支预测在Font-End中用于减速CPU获取指定的过程,而不是等到须要读取指令的时候才从主存中读取指令。Front-End能够利用分支预测提前将须要预测指令加载到L2 Cache中,这样CPU 取指令的时候提早就极大减小了,所以这种提前加载指令时存在误判的状况的,所以咱们应该防止这种状况的产生,c++罕用的办法就是:
- 在应用if的中央尽可能应用gcc的内置分支预测个性(其余状况能够参考Front-End章节)
#define likely(x) __builtin_expect(!!(x), 1) //gcc内置函数, 帮忙编译器分支优化 #define unlikely(x) __builtin_expect(!!(x), 0) if(likely(condition)) { // 这里的代码执行的概率比拟高 } if(unlikely(condition)) { // 这里的代码执行的概率比拟高 } // 尽量避免远调用
- 防止间接跳转或者调用
在c++中比方switch、函数指针或者虚函数在生成汇编语言的时候都可能存在多个跳转指标,这个也是会影响分支预测的后果,尽管BTB可改善这些然而毕竟BTB的资源是很无限的。(intel P3的BTB 512 entry ,一些较新的CPU没法找到相干的数据)
四、写在最初
这里咱们再看下最开始的例子,采纳下面提到的优化办法优化完之后的评测成果如下:
g++ cache\_line.cpp -o cache\_line -fomit-frame-pointer; task\_set -c 1 ./cache\_line
耗时从原来的15s 升高到当初9.8s,性能晋升34%:retiring 从66.9% 晋升到78.2% ;Back-End bound 从31.4%升高到21.1%
五、CPU常识充电站
[1] CPI(cycle per instruction) 均匀每条指令的均匀时钟周期个数
[2] IPC (instruction per cycle) 每个CPU周期的指令吞吐数
[3] uOps 古代处理器每个时钟周期至多能够译码 4 条指令。译码过程产生很多小片的操作,被称作微指令(micro-ops, uOps)
[4] pipeline slot pipeline slot 示意用于解决uOps 所须要的硬件资源,TMAM中假设每个 CPU core在每个时钟周期中都有多个可用的流水线插槽。流水线的数量称为流水线宽度。
[5] MIPS(MillionInstructions Per Second) 即每秒执行百万条指令数 MIPS= 1/(CPI×时钟周期)= 主频/CPI
[6]cycle 时钟周期:cycle=1/主频
[7] memory footprint 程序运行过程中所须要的内存大小.包含代码段、数据段、堆、调用栈还包含用于存储一些暗藏的数据比方符号表、调试的数据结构、关上的文件、映射到过程空间的共享库等。
[8] MITE Micro-instruction Translation Engine
[9]DSB Decode stream Buffer 即decoded uop cache
[10]LSD Loop Stream Detector
[11] 各个CPU维度剖析
[12] TMAM实践介绍
[13] CPU Cache
[14] 微架构
作者:vivo- Li Qingxing