从微架构到向量化--CPU性能优化指北

引入

定位程序性能问题,相信大家都有很多很好的办法,比如用top/uptime观察负载和CPU使用率,用dstat/iostat观察io情况,ptrace/meminfo/vmstat观察内存、上下文切换和软硬中断等等,但是如果具体到CPU问题,我们可能只能够分析到CPU占用率很高或者系统负载很高,更细化的指标确无从着手;就算能够知道IPC或者Retiring很高,优化可能也一筹莫展。毕竟现代CPU微架构十分复杂,而且无论是Intel® 64 and IA-32 Architectures Software Developer Manuals还是Intel® 64 and IA-32 Architectures Optimization Reference Manual都太长了,作为软件开发人员很难在里面找到自己想要的知识,这篇文章就结合Top-down Micro-architecture Analysis Methodology(TMAM)、ClickHouse源码的优化经验,聊一聊CPU高性能优化具体该怎么分析怎么做。

微架构分析的TMAM方法

定位CPU问题,我们可以采用Intel的TMAM方法https://cdrdv2.intel.com/v1/dl/getContent/671488?explicitVersion=true&fileName=248966-046A-software-optimization-manual.pdf
首先介绍下现代微架构的结构,如图所示:
image.png
前端负责内存取指(fetch instructions from memory)和转译微指令(translate them into micro-operations / uops)。转译后得到的微指令将被馈送到后端部分。
后端负责对每个微指令进行调度(schedule)、执行(execute)和提交退役(commit / retire)。这种生产-消费的流水线模型就是使用队列(“ready-uops-queue”)来缓存微指令,以待后端消费。
TMAM是什么?如何定位CPU问题?
而TMAM根据cpu时钟周期(cycle)和CPU pipeline slot将CPU瓶颈分为Retiring、Fountend Bound、Backend Bound、Bad Speculation四种:
image.png
接下来我们看下引起这四种瓶颈的原因。
引起这四种瓶颈的原因是什么?

Bad Speculation

错误分支预测,是由于提交了不会retired的uops引起的。
分为两类,一类是分支预测错误(Branch Misspredict),一类是机器清除(Machine Clears)。
若该分类的值大于一定程度时,需先调查解决该分类。因为哪怕此时其他分支也占据很大的比重,但可能暴露出这些问题的都是那些处于错误分支上的指令,并不能真实地反映出CPU运行该程序正确指令流的特性,所以需要先解决该分类的问题,再去调查研究其他的分支。

Branch Misspredict

Branch Misspredict
错误的分支预测导致的bound。

Machine Clears

Machine Clears
当CPU检测到某些条件时,便会触发Machine Clears操作,清除流水线上的指令,以保证CPU的合理正确运行。比如发生错误的Memory访问顺序(memory ordering violations);自修改代码(self-modifying code);访问非法地址空间(load illegal address ranges),这些操作都会触发Machine Clear。

Frontend Bound

Front-End 职责:

  1. 取指令
  2. 将指令进行解码成微指令
  3. 将指令分发给Back-End,每个周期最多分发4条微指令

所以这里指的是指令获取、解码过程中的瓶颈,可能是因为取指延迟、取指带宽、指令Cache Miss、指令解码效率低(如CPUID等)。
具体来说,它可以分成Frontend Latency和Frontend Bandwidth两类

接下来我们结合SkyLake微架构来分别看一下这些指标的含义,具体的计算方法可以参考https://github.com/andikleen/pmu-tools,知乎上也有一篇讲解https://zhuanlan.zhihu.com/p/61015720
SkyLake微架构

Frontend Latency

ICache_Misses
ICache就是我们通常指的L1指令缓存,ICache_Misses指的是L1指令缓存未命中。
ITLB_Misses
ITLB_Misses就是我们熟悉的TLB(Translation Lookaside Buffer,转换后援缓冲器)未命中。
Branch_Resteers
Branch Resteers 是指当 CPU 预测分支指令(如条件跳转指令)错误时,需要回滚到正确的程序路径重新执行分支指令的过程。这种情况通常发生在 CPU 预测错误的分支目标地址与实际分支目标地址不一致时,需要进行分支重定向(Branch Redirect)或者分支修正(Branch Correction)。Branch Resteers,还能继续向下展开为三类,分别为Branch Misprediction,Machine Clears和new branch address clears三类。
DSB_Switches
这里指的是微指令译码器(Decode Pipeline)和微指令缓存(Decoded ICache)之间切换产生的延迟。
LCP
对于正在decode的指令,若发生dynamically changing prefix length,即有LCP,长度改变前缀,便会出现几个周期的Stall。LCP就是指的这部分的延迟。
MS_Switches
MS ROM会存放一些CISC指令的uops流,比如CPUID指令,MS_Switches指的是微指令译码器(Decode Pipeline)和微指令缓存(Decoded ICache)切换到MSROM的开销。

Frontend Bandwidth

MITE
微指令译码器(Decode Pipeline)效率问题引起的bound。
DSB
微指令缓存(Decoded ICache)的效率问题,没有利用好DSB cache structure,或者在读取的时候发生了Bank conflict引起的bound。
LSD
LSD是Loop Stream Detector的缩写,它位于uOp Queue内部,当检测到循环指令全部在uOp Queue中时,只需要不断从LSD中取出相应的uops序列即可,不需要前端译码。如果uops循环的大小与当前硬件的LSD结构不匹配,就会出现LSD带宽不足的情况。

Backend Bound

Back-End 的职责:

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

这里指的是指令执行过程中获取内存、更新内存、指令过载导致的瓶颈。

我们结合下面这个图来看一下Backend Bound
image.png

Memory Bound

Memory Bound分为Store Buffer Bound、L1/L2/L3 Bound和Mem Bound。
Cache Line很小,在Scheduler中,没被列进来。
判断方法如下图所示:
image.png

Core Bound

Core Bound分为Divider和Ports Utilization。
Divider指的是长延迟的除法操作可能会导致执行串行化,造成短期执行的饥饿期。
Ports Utilization指的是处理器中的指令流被迫按顺序执行,这反映了程序中指令级并行性(ILP)的缺乏。

Retiring

有效的uops(微指令)
**Retiring分为两类:MicroCode Sequence和Base。**MicroCode Sequence就是我们上面提到的MSROM中的指令,它是通过Microcode Sequencer (MS) unit来生成的复杂CISC指令,
Retiring不代表没有优化空间,Micro Sequencer例如Floating Point之类的指令要避免。
高的Base往往意味着向量化能够得到很大的提升。

怎么优化这四个瓶颈?

熟悉语言和平台特性、编译优化选项、通用属性选项、Pragmas和编译器内置函数

从前面我们已经知道了造成瓶颈的原因,那么从代码层面我们可以怎么优化呢?
要对于比较底层的问题进行优化,那么对于编译器做了什么、能提供什么必须有所了解。
下面我就从语言和平台特性、编译优化选项、通用属性选项和编译器内置函数这几个方面简单总结一下。
优化选项、通用属性选项、Pragmas详见:
https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
https://www.rowleydownload.co.uk/arm/documentation/gnu/gcc/index.html#SEC_Contents
https://gcc.gnu.org/onlinedocs/gcc/Pragmas.html

语言和平台特性

相信大家很清楚,熟悉语言和平台特性是一切优化的基础,比如现代C++的STL、零拷贝、原地构造、移动语义、智能指针、constexpr等基础知识;线程进程同步方法、内存序以及不同同步方法/内存序(屏障)的执行开销;Linux系统的特性比如SMP/NUMA、THP、IO_Uring、ebpf等等。这些方面不是本文的重点,因此不在此赘述。

优化选项

除了我们熟知的静态单赋值(SSA)、NRVO(具名返回值优化)、寄存器图着色、死代码消除、常量传播、循环展开、尾递归优化、指令重排、自动内联、缓存预取、分支预测……外,现代编译器还能进行很多强大的优化,下面列出了O2和O3执行的优化,熟悉这些选项是很有用的。

O2

-fauto-inc-dec:对自增和自减操作进行优化,将其转换为更高效的指令序列。
-fbranch-count-reg:使用寄存器来统计分支指令的执行次数,用于分支预测优化。
-fcombine-stack-adjustments:合并连续的堆栈调整操作,以减少不必要的指令。
-fcompare-elim:消除不必要的比较操作,减少程序的运行时间。
-fcprop-registers:通过寄存器传播常量的值,以减少内存访问。
-fdce:删除未使用的代码。
-fdefer-pop:推迟对堆栈的调整操作,以减少指令的数量。
-fdelayed-branch:推迟分支指令的执行,以减少流水线的停顿。
-fdse:进行死代码消除优化,删除不可达的代码。
-fforward-propagate:进行常量传播优化,将常量传播到使用该常量的代码中。
-fguess-branch-probability:根据先前的执行信息猜测分支指令的概率,以优化分支预测。
-fif-conversion:对if语句进行优化,将条件表达式转换为更简单的形式。
-fif-conversion2:进行更复杂的if语句优化,包括通过更改条件的计算顺序来提高性能。
-finline-functions-called-once:对只被调用一次的函数进行内联展开。
-fipa-modref:进行模块间引用分析优化,减少不必要的内存操作。
-fipa-profile:根据程序的执行信息进行优化。
-fipa-pure-const:将纯函数和常量传播进行优化。
-fipa-reference:进行引用分析优化,减少不必要的内存操作。
-fipa-reference-addressable:进行可寻址引用分析优化,减少不必要的内存操作。
-fmerge-constants:合并重复的常量,以减少内存的使用。
-fmove-loop-invariants:将循环不变式移动到循环外部,以减少循环迭代次数。
-fomit-frame-pointer:优化代码以减少堆栈帧的使用。
-freorder-blocks:重新排序基本块以优化执行路径。
-fshrink-wrap:将变量的生命周期范围缩小到最小,以减少内存的使用。
-fshrink-wrap-separate:在函数中单独进行缩小作用域的操作。
-fsplit-wide-types:将宽类型的变量分割为多个较窄的变量,以减少内存的使用。
-fssa-backprop:通过SSA(静态单赋值)形式的数据流分析来优化代码。
-fssa-phiopt:通过SSA形式的Phi函数优化来优化代码。
-ftree-bit-ccp:进行位级的常量传播优化。
-ftree-ccp:进行常量传播优化。
-ftree-ch:进行复杂表达式优化。
-ftree-coalesce-vars:合并变量来减少内存的使用。
-ftree-copy-prop:进行复制传播优化。
-ftree-dce:进行死代码消除优化。
-ftree-dominator-opts:进行支配关系优化。
-ftree-dse:进行死存储消除优化。
-ftree-forwprop:进行常量传播和复制传播的优化。
-ftree-fre:进行冗余表达式消除优化。
-ftree-phiprop:对Phi函数进行优化。
-ftree-pta:进行指针分析优化。
-ftree-scev-cprop:进行简单标量表达式和常量传播优化。
-ftree-sink:将表达式移动到循环外部,以减少循环迭代次数。
-ftree-slsr:进行简单局部标量替换优化。
-ftree-sra:进行标量寄存器分配优化。
-ftree-ter:进行三元表达式优化。
-falign-functions:强制函数在内存中按指定的对齐方式对齐。
-falign-jumps:强制跳转指令在内存中按指定的对齐方式对齐。
-falign-labels:强制标签在内存中按指定的对齐方式对齐。
-falign-loops:强制循环开始地址在内存中按指定的对齐方式对齐。
-fcaller-saves:在函数调用时,保存调用者寄存器的值,以便被调用函数可以修改这些寄存器的值。
-fcode-hoisting:将可能的计算移动到循环外部,以减少循环迭代次数。
-fcrossjumping:在不同的控制流路径中查找重复的代码块,并将其合并为一个共享的代码块。
-fcse-follow-jumps:在跳转指令后面的代码中进行公共子表达式消除。
-fcse-skip-blocks:跳过指定数量的基本块,以提高公共子表达式消除的效率。
-fdelete-null-pointer-checks:删除空指针检查,以提高代码的执行速度。
-fdevirtualize:对虚函数调用进行优化,将虚函数调用转化为直接调用。
-fdevirtualize-speculatively:假设虚函数调用的目标是唯一的,并将其转化为直接调用。
-fexpensive-optimizations:进行一些代价较高的优化,可能会增加编译时间。
-ffinite-loops:假设循环最多执行有限次数,进行一些循环优化。
-fgcse:进行全局公共子表达式消除,删除重复计算的代码。
-fgcse-lm:对循环进行公共子表达式消除,删除循环内重复计算的代码。
-fhoist-adjacent-loads:将相邻的加载指令移动到循环外部,以减少循环迭代次数。
-finline-functions:对函数进行内联展开,将函数调用处替换为函数体。
-finline-small-functions:对小函数进行内联展开。
-findirect-inlining:对间接函数调用进行内联展开。
-fipa-bit-cp:进行位级的常量传播优化。
-fipa-cp:进行常量传播优化。
-fipa-icf:进行间接代码优化,合并相似的间接调用。
-fipa-ra:进行间接寄存器分配优化。
-fipa-sra:进行间接寄存器分配优化,同时进行标量寄存器分配优化。
-fipa-vrp:进行值范围传播优化。
-fisolate-erroneous-paths-dereference:对错误路径上的指针解引用进行隔离。
-flra-remat:在循环中重新材料化值范围,以减少循环迭代次数。
-foptimize-sibling-calls:对兄弟函数调用进行优化。
-foptimize-strlen:对strlen函数进行优化。
-fpartial-inlining:对函数进行部分内联展开。
-fpeephole2:进行指令级别的优化。
-freorder-blocks-algorithm=stc:按指定的算法对基本块进行重新排序。
-freorder-blocks-and-partition:对基本块进行重新排序和分区,以提高指令级优化效果。
-freorder-functions:对函数进行重新排序,以提高指令级优化效果。
-frerun-cse-after-loop:在循环后重新运行公共子表达式消除。
-fschedule-insns:对指令进行调度以提高执行效率。
-fschedule-insns2 -fsched-interblock:对指令进行调度以提高执行效率。
-fstore-merging:合并存储操作,减少存储操作的数量。
-fstrict-aliasing:启用严格别名规则,优化代码对内存的访问。
-fthread-jumps:在多线程环境中,对线程间的跳转进行优化。
-ftree-builtin-call-dce:删除未使用的内建函数调用。
-ftree-pre:进行部分复写消除优化。
-ftree-switch-conversion:对switch语句进行转换优化。
-ftree-tail-merge:合并尾递归函数的调用。
-ftree-vrp:进行值范围传播优化。

O3

-fgcse-after-reload:在寄存器分配之后进行全局公共子表达式消除(GCSE)优化。
-fipa-cp-clone:通过复制函数来进行间接代码传播优化。
-floop-interchange:进行循环交换优化,改变循环的顺序。
-floop-unroll-and-jam:进行循环展开和循环合并的优化。
-fpeel-loops:将循环分解成多个部分,以减少循环迭代次数。
-fpredictive-commoning:通过提前计算和共享结果来进行预测性共享优化。
-fsplit-loops:将循环分割为多个部分,以便更好地利用指令级并行性。
-fsplit-paths:将控制流路径分割为多个部分,以便更好地利用指令级并行性。
-ftree-loop-distribution:将循环分布到多个线程或处理器上,以进行并行化处理。
-ftree-loop-vectorize:对循环进行向量化优化,以利用SIMD指令。
-ftree-partial-pre:进行局部部分预测优化,提前计算和共享部分结果。
-ftree-slp-vectorize:对循环进行超标量指令优化,将多条指令合并为一条指令。
-funswitch-loops:对循环进行开关优化,将循环展开成多个版本,通过开关语句来选择执行哪个版本。
-fvect-cost-model:使用向量化优化的成本模型进行优化。
-fvect-cost-model=dynamic:使用动态的向量化优化成本模型进行优化。
-fversion-loops-for-strides:对循环进行版本化优化,根据迭代步长来选择不同的版本进行执行。

通用属性选项

下面列出跟性能相关的Common Attributes,更多Attributes详见https://www.rowleydownload.co.uk/arm/documentation/gnu/gcc/index.html#SEC_Contents
aligned (alignment)
aligned属性指定函数第一条指令的最小对齐方式,以字节为单位。指定后,对齐方式必须是整数常数 2 的常量幂。不指定对齐参数意味着目标的理想对齐。
alloc_align (position)
该属性可以应用于返回指针并采用至少一个整数或枚举类型的参数的函数。它指示返回的指针在函数参数 alloc_alignposition处给出的边界上对齐。有意义的对齐是 2 大于 1 的幂。GCC 使用此信息来改进指针对齐分析。
例如

void* my_memalign (size_t, size_t) attribute ((alloc_align (1)));

always_inline
强制内联函数,无法内联会产生error
cold
函数上的cold属性用于通知编译器该函数不太可能被执行。
const
告诉 GCC,对具有相同参数值的函数的后续调用可以替换为第一次调用的结果,而不管两者之间的语句如何
例如:

int square (int) attribute ((const));

flatten
对于标记有此属性的函数,如果可能的话,此函数内的每个调用都是内联的。用特性noinline和类似的函数声明的函数不内联。是否考虑函数本身进行内联取决于其大小和当前的内联参数。
hot
函数上的属性用于通知编译器该函数是已编译程序的热点。该函数进行了更积极的优化,在许多目标上,它被放置在文本部分的特殊子部分中,因此所有热门函数看起来都很接近,从而改善了局部性。
pure
对pure函数的调用,如果函数除了返回值之外对程序的状态没有可观察的影响,则可能需要进行优化,例如常见的子表达式消除。使用 该属性声明此类函数允许 GCC 避免在重复调用具有相同参数值的函数时发出某些调用。
returns_nonnull
该属性指定函数返回值应为非 null 指针。例如,声明:

extern void *
mymalloc (size_t len) __attribute__((returns_nonnull));

允许编译器根据返回值永远不会为 null 的知识来优化调用方。
target (string, )
多个目标后端实现 target 属性,以指定要使用与命令行上指定的目标选项不同的目标选项来编译函数。原始目标命令行选项将被忽略。可以提供一个或多个字符串作为参数。每个字符串都由一个或多个逗号分隔的 -m 前缀后缀组成,共同构成与计算机相关的选项的名称。

编译器内置函数

提醒:除了具有库等价物的内置函数(如标准C库函数),或者扩展到库调用的内置函数外,GCC内置函数总是内联扩展的,因此没有相应的入口点,并且无法获得它们的地址。试图在函数调用以外的表达式中使用它们会导致编译时错误。
void __builtin___clear_cache (void *begin, void *end)
此函数用于为包含开始和排除结束之间的内存区域刷新处理器指令缓存。一些目标要求在修改包含代码的内存后刷新指令缓存,以获得确定性行为。
void __builtin_prefetch (const void *addr, …)
此函数用于在访问数据之前将数据移动到缓存中,从而最大限度地减少缓存未命中延迟。您可以将对__builtin_prefetch的调用插入到您知道内存中可能很快被访问的数据地址的代码中。如果目标支持它们,则会生成数据预取指令。如果预取在访问之前足够早地完成,那么在访问数据时数据将在缓存中。
long __builtin_expect (long exp, long c)
可以使用__builtin_expect为编译器提供分支预测信息。一般来说,pgo是更好的(-fprofile arcs),因为程序员在预测程序实际执行方面是出了名的糟糕。然而,有些应用程序很难收集这些数据。
下面是一个在clickhouse中使用分支预测的例子:
image.png

Pragmas

循环展开

循环展开现在编译器都会自动做了,有时候可能需要限制循环展开。
比如clickhouse里面的一段:
image.png
对小的循环体进行 unroll 可能是划算的,但最好不要 unroll 大的循环体,否则会造成uop decode pipeline的压力反而变慢。

对齐

从整个体系结构的角度来看,对齐有几个纬度:

  1. 总线寻址对齐
  2. 内存页对齐
  3. Cache Line对齐
  4. 缓存对齐

总线寻址对齐
总线对齐,64位机器就是8byte对齐,这一点在现代CPU中已经不重要了。编程时不需要考虑这个问题(见下图[见下图,图片来自https://lemire.me/blog/2012/05/31/data-alignment-for-speed-myth-or-reality/])。
image.png
内存页对齐
内存页对齐,也就是4K对齐,内核线性区分配和b+树中间节点都会使用4k页对齐,这主要是一些磁盘和内存结构需要考虑的。
Cache Line对齐
cpu缓存行对齐,也就是64byte对齐。这是最关键的一点:首先它可以防止(UMP架构下)MESI协议导致的缓存行失效(伪共享)。其次,它对性能有很大影响
(见下图,图片来自https://github.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值