C/C++ 性能优化背后的方法论:TMAM

开发过程当中咱们多少都会关注服务的性能,然而性能优化是相对比较困难,每每须要多轮优化、测试,属于费时费力,有时候还未必有好的效果。可是若是有较好的性能优化方法指导、工具辅助分析能够帮助咱们快速发现性能瓶颈所在,针对性地进行优化,能够事半功倍。html

性能优化的难点在于找出关键的性能瓶颈点,若是不借助一些工具辅助定位这些瓶颈是很是困难的,例如:c++程序一般你们可能都会借助perf /bcc这些工具来寻找存在性能瓶颈的地方。性能出现瓶颈的缘由不少好比 CPU、内存、磁盘、架构等。本文就仅仅是针对CPU调优进行调优,即如何榨干CPU的性能,将CPU吞吐最大化。(实际上CPU出厂的时候就已经决定了它的性能,咱们须要作的就是让CPU尽量作有用功),因此针对CPU利用率优化,实际上就是找出咱们写的不够好的代码进行优化。java

1、示例

先敬上代码:python

#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;
 }
注:代码逻辑比较简单就是作一个累加操做,仅仅是为了演示。

编译+运行:linux

g++ cache_line.cpp -o cache_line ; task_set -c 1 ./cache_line

下图是示例cache\_line在CPU 1核心上运行,CPU利用率达到99.7%,此时CPU基本上是满载的,那么咱们如何知道这个cpu运行cache\_line 服务过程当中是否作的都是有用功,是否还有优化空间?c++

有的同窗可能说,能够用perf 进行分析寻找热点函数。确实是可使用perf,可是perf只能知道某个函数是热点(或者是某些汇编指令),可是无法确认引发热点的是CPU中的哪些操做存在瓶颈,好比取指令、解码、.....算法

若是你还在为判断是CPU哪些操做致使服务性能瓶颈而不知所措,那么这篇文章将会你给你授道解惑。本文主要经过介绍自顶向下分析方法(TMAM)方法论来快速、精准定位CPU性能瓶颈以及相关的优化建议,帮助你们提高服务性能。为了让你们更好的理解本文介绍的方法,须要准备些知识。编程

2、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资源出现瓶颈。

3、自顶向下分析(TMAM)

TMAM 即 Top-down Micro-architecture Analysis Methodology自顶向下的微架构分析方法。这是Intel CPU 工程师概括总结用于优化CPU性能的方法论。TMAM 理论基础就是将各种CPU各种微指令进行归类从大的方面先确承认能出现的瓶颈,再进一步下钻分析找到瓶颈点,该方法也符合咱们人类的思惟,从宏观再到细节,过早的关注细节,每每须要花费更多的时间。这套方法论的优点在于:

  1. 即便没有硬件相关的知识也可以基于CPU的特性优化程序
  2. 系统性的消除咱们对程序性能瓶颈的猜想:分支预测成功率低?CPU缓存命中率低?内存瓶颈?
  3. 快速的识别出在多核乱序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 职责:

  1. 取指令
  2. 将指令进行解码成微指令
  3. 将指令分发给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 的职责:

  1. 接收Front-End 提交的微指令
  2. 必要时对Front-End 提交的微指令进行重排
  3. 从内存中获取对应的指令操做数
  4. 执行微指令、提交结果到内存

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调整数据结构的大小
  • 避免L二、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,而不会存在一部分是进程一、另一部分是进程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无法找到相关的数据)

4、写在最后

这里咱们再看下最开始的例子,采用上面提到的优化方法优化完以后的评测效果以下:

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%

5、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
相关文章
相关标签/搜索