避免指令缓存未命中(2019)

2021-01-09 15:50:25

现代处理器非常复杂,许多部件都有可能成为瓶颈。短代码的性能相对容易推断,尤其是在将内存影响保持在最低水平的情况下。在这种情况下,静态分析工具(如LLVM MCA和微基准)都可以提供很多信息。但是,整个程序的行为不仅仅是这些小部分的总和。随着代码变大和越来越复杂,其他效果开始出现。这种潜在问题之一是过多的指令高速缓存未命中。

每个程序都具有不同的属性,并且那些大规模的效果将对其产生不同的影响。但是,如果其工作是对少量数据执行复杂的逻辑,则指令高速缓存可能会在某个时候成为问题。实际的影响可能因代码库而异,这就是为什么我在本文中不显示任何数字的原因。让我们考虑一下这只是一个想法的集合,但是要说出这些想法对给定应用程序有多大帮助并不容易。

首先,让我们快速看一下处理器前端。下面是它在Skylake中的排列方式的简化图,单位之间的数字是每个周期的最大值。

在每个周期中,处理器都会使用来自分支预测单元的信息来从指令高速缓存中提取多达16个字节,以预测控制流。预解码单元确定指令长度,并将最多五个指令长度放入指令队列中。每个周期从指令队列中将多达5条指令(带有宏融合)带到解码器。有一个复杂的解码器可以处理最多转换为4 µops的指令,还有3个简单的解码器只能处理单µopop指令。总的来说,所有解码器每个周期只能产生不超过5 µops的数据。需要超过4 µops的指令将通过Microcode Sequence ROM,它每个周期发出4 µops,并且在激活时,解码器被禁用。还有一个解码的ICache(DSB),用于缓存解码的µop。每个周期最多可发射6 µops。所有µop,无论其来源如何,都将最终出现在指令解码队列(IDQ)中。环路流检测器(LSD)会检测到较小的循环并将其保留在队列中,因此在循环期间不需要从DSB进行读取,解码或读取。 IDQ是前端的最后一部分,排队的微操作继续到后端。

从指令缓存的角度来看,前端有两个缺点。首先,指令按顺序处理,这会严重限制处理器隐藏高速缓存未命中延迟的能力。 HyperThreading可以确保处理器的这一部分仍然可以做一些有用的工作,但这也是第二个问题的根源–包括L1指令高速缓存和µop高速缓存在内的所有资源都在硬件线程之间共享。

现代处理器提供了各种指标来帮助监控其行为。但是,如果要高效地完成提取相关数据的任务,则需要采取适当的方法。自上而下的分析对于帮助理解大型代码库中的微体系结构现象而言是无价的。这个想法是使用PMU计数器监视程序行为,并从CPU的主要功能部分开始识别瓶颈,然后深入挖掘问题的确切根源。可以通过VTune或toplev之类的工具以自动化方式完成此操作。

FE Frontend_Bound:39.48 +-0.00%插槽BE Backend_Bound:16.19 +-0.00%插槽此类别表示由于缺少用于在后端中接受新的uops的必需资源而导致无法交付uops的插槽比例... FE Frontend_Bound.Frontend_Latency ::24.92 +-0.00%SlotsFE Frontend_Bound.Frontend_Bandwidth:13.45 +-0.00%SlotsFE Frontend_Bound.Frontend_Latency.ICache_Misses:14.45 +-0.00%时钟< ==此度量标准表示由于指令高速缓存未命中而导致CPU停止的周期的一部分。 。采样事件:frontend_retired.l2_miss:pp frontend_retired.l1i_miss:ppFE Frontend_Bound.Frontend_Latency.ITLB_Misses:8.71 +-0.00%Clocks该指标表示由于指令TLB丢失而导致CPU停顿的周期的一部分。 :pp frontend_retired.itlb_miss:ppFE Frontend_Bound.Frontend_Latency.Branch_Resteers:8.42 +-0.00%Clocks_Est此指标表示由于分支Restee而使CPU停止的周期的一部分rs ...采样事件:br_misp_retired.all_branches:uFE Frontend_Bound.Frontend_Bandwidth.MITE:31.93 +-0.00%CoreClocks此指标表示由于MITE管道(传统解码管道)而可能限制CPU使用的周期的核心部分。

上面是toplev结果的示例。我们可以看到指令高速缓存未命中是主要的瓶颈。毫不奇怪,也出现了TLB未命中的指令。在前端带宽方面,toplev指向传统解码管道。如果指令是从DSB或LSD提供的,那么就没有任何意义,并且没有指令提取,也没有缓存未命中。

有时,如果测试过程中代码行为发生变化,则最终摘要可能无法提供足够的信息。在这种情况下,图形可能是显示结果的一种更有用的方法。

toplev之类的工具非常适合初步识别问题,但一旦完成,我们需要的是比较不同解决方案的正确方法。最终,最重要的指标是程序在实际工作负载中的实际性能。 toplev仍然可以提供帮助,因为它显示了不同性能限制因素之间的平衡。性能统计也可以显示性能计数器统计信息。与我们最相关的事件是L1-icache-load-misses,尽管可能有更多特定于模型的寄存器。

现在,我们知道了如何诊断过多的指令高速缓存未命中,让我们来看看如何解决这个问题。

如果执行的指令数量有问题,最明显的解决方案是尝试减少该数量。显然,这说起来容易做起来难,但是有一些解决此问题的常用模式。一个示例是数据库中的准备好的语句。通常的想法是,如果客户端知道它将发送具有某种共性的请求,则可以尽早告知数据库引擎该请求将与特定模板匹配。此信息允许服务器在准备阶段进行尽可能多的工作,从而减少需要为每个单独的请求执行的逻辑量。

提取通用模式不必很明确。操作取决于用户输入的服务器或任何其他类型的应用程序都可能尝试查找重复模式并缓存一些公用部分。这是一个非常模糊的想法,很可能在许多应用程序中都不容易实现,但在某些情况下可能是很自然的解决方案。它还显示了“少做点事”方法的主要问题-这是非常特定于应用程序的。从正面来看,如果可以做到,则可能会提高整体性能,而不仅仅是指令高速缓存未命中。

另一个潜在的问题是确保准备阶段在执行阶段确实可以做些帮助。如果那意味着要预先计算一些值,那么这很简单。但是,如果准备工作给我们带来的唯一好处就是知道将要执行哪些代码路径以及在执行过程中将执行哪些分支,那么将很难从中受益。一种选择是为最常用的路径提供一些专用的代码,C ++模板在这里可能会派上用场。如果不容易确定最常见的路径,则可以在准备阶段使用即时编译器来生成代码。 1个

到目前为止,我们一直在尝试通过利用一些较早的知识来避免重复执行相同的工作,以减少执行指令的数量。换句话说,我们引入了两个阶段:

这有助于减少指令缓存的方法是,较小的执行阶段更可能适合指令缓存。这是否带来任何可衡量的收益,在很大程度上取决于应用程序的类型以及从执行到准备阶段可以转移多少逻辑。

我们已经将处理流程分为准备和执行阶段。如果执行阶段可以放入指令缓存中,那么我们就完成了。但是,通常情况并非如此。我们可以做的进一步改善这种情况的方法是将执行分为多个阶段。这次的目标不是重用准备中的工作,而是将需要为其执行相同代码的实体分组。换句话说,如果处理流水线由步骤A,B和C组成,则其思想是将它们分开,在每个阶段的前面添加一个队列,然后每次处理该队列中的多个元素时在这些阶段中循环。阶段之间的连接不必一对一,任何有向图都可以。

在上图中,有一个阶段将任务提供给两个阶段之一。例如,这可能是数据库服务器的前端。第一阶段进行一些初始请求处理,然后根据是读还是写,将其放入适当的队列中。每个阶段都会批量处理请求,第一个阶段会预热指令缓存,以便随后的指令可以利用它,并希望不会看到任何指令缓存未命中。为了使这项工作顺利进行,我们需要确保每个阶段都适合缓存,并且控制流不会有太大差异–如果每个请求在代码中采用不同的路径,我们就不会从预热的缓存中受益。

我刚刚描述的架构与SEDA非常相似。出于指令缓存的目的,无需在不同的线程上运行执行阶段。这实际上可能是不可取的,因为由线程之间的过度通信导致的延迟增加是SEDA的重要问题之一。如果按线程分批处理任务,则可以使用异步的每核线程体系结构完全避免这种情况。这就是我在Scylla中做到的效果,

无论批处理如何适合整个程序体系结构,仍然需要注意一个关键问题。除非单个任务的等待时间无关紧要,否则它在队列中的停留时间需要有一个界限。这是吞吐量和延迟之间的折衷,针对此问题的正确解决方案将是针对特定应用的。

我们已经考虑了对代码及其体系结构的较高级别的更改,这些更改可能会改善指令缓存情况。现在是时候再来看底层的东西了,更具体地说是编译器可以为我们做些什么。

当涉及到指令缓存时,函数内联是一项可能会改变很多的优化。一方面,在其调用位置中插入函数体可能会增加代码大小,并增加指令高速缓存未命中的次数,但另一方面,它允许进行更多优化,避免调用约定开销,并改善代码局部性。关键是内联可以在两个方向上都产生显着差异,这绝对值得检查。

同样重要的是要记住,编译器通常允许的控制权不仅仅是-finline-functions或-finline-functions-once(仅一次)之类的相对粗粒度的标志。还有一组可调参数可以帮助控制编译器的行为。例如,内联单元增长限制了编译单元的增长。实际效果取决于源代码的组织方式,如果像这样的参数限制函数内联,则可能导致相当混乱的编译器行为。

影响内联决策的另一种方法是使用[[gnu :: always_inline]]或[[gnu :: noinline]](对于勇敢者,则使用[[gnu :: flatten]])之类的属性。在某些情况下这可能会有所帮助,但会给代码增加混乱,如果编译器在没有决策的情况下无法做出正确的决定,则可能意味着其启发式方法需要更多调整。

函数克隆也是一种优化类型,可以直接影响代码大小。如果未内联被调用者,则GCC可以使用它在各个函数之间进行恒定传播。

[[gnu :: noinline]] //禁用内联以显示克隆int被调用者(int x,int y){return x + 1000 / y; } int调用者(int x){返回被调用者(x,4); }

callee(int,int)[clone .constprop.0]:lea eax,[rdi + 250] retcallee(int,int):mov eax,1000 cdq idiv esi add eax,edi retcaller(int):jmp callee(int, int)[克隆.constprop.0]

几乎总是函数克隆会增加二进制的总大小。但是,它不一定是问题。如果程序将仅在热路径中执行,则只有几个克隆版本从持续传播中受益匪浅,因此我们可以预期指令缓存未命中的情况会减少。像本节中的大多数内容以及本文的大部分内容一样,其效果取决于代码库。

该功能定义需要在包含调用站点的翻译单元中可见,才能进行内联或克隆。再次,这意味着优化将取决于源代码的组织,这实际上不是所希望的。解决此问题的方法是链接时间优化,它可以内联跨目标文件的功能以及其他优化。

可能在CPU前端引起麻烦的另一种优化是循环展开。理想情况下,如果没有,则LSD可以检测到循环,那么我们希望它至少适合DSB。但是,这不一定是后端的最佳选择。结果,编译器可能会非常激进地进行循环展开并产生大量代码。这绝对是要提防的东西。

最后,可以要求编译器针对大小优化代码。起初,这听起来像是一个好主意,较小的程序更可能适合指令高速缓存。但是,这样做是以不进行一些面向性能的优化为代价的。最终结果很大程度上取决于-O可以带来多少代码大小缩减,以及该标志未启用的优化对于实现良好性能至关重要。

尽管编译器已经准备好进行各种非常积极的优化,但是存在一些无法克服的局限性。这就要求语言标准(除非适用“按原样”规则)或ABI规定的行为。为了解决这个问题,程序员需要意识到这些限制,并且在理想情况下,请遵循导致代码更易于优化的做法。

例如,函数调用约定通常要求非平凡的对象通过引用有效地传递,而不管它们的大小如何。同样,它们也无法在寄存器中返回,并且被调用方需要将该对象写入调用方提供的缓冲区中。

让我们看一下如何传递std :: unique_ptr< int>函数看起来像在x86_64-linux-gnu上:

void takes_unique_pointer(std :: unique_ptr< int>); void takes_unique_pointer_noexcept(std :: unique_ptr< int>)noexcept; void takes_raw_pointer(int *); std :: unique_ptr<整数>独特 ; void Gives_unique_pointer(){ta​​kes_unique_pointer(std :: move(unique)); } void Gives_unique_pointer_noexcept(){ta​​kes_unique_pointer_noexcept(std :: move(unique)); } int * raw; void Gives_raw_pointer(){ta​​kes_raw_pointer(std :: exchange(raw,{})); }

lets_unique_pointer():推送rbp sub rsp,16 mov rax,QWORD PTR unique [rip] lea rdi,[rsp + 8] mov QWORD PTR unique [rip],0 mov QWORD PTR [rsp + 8],rax调用takes_unique_pointer(std ::: unique_ptr< int>)mov rdi,QWORD PTR [rsp + 8]测试rdi,rdi je .L17 mov esi,4个调用运算符delete(void *,unsigned long).L17:添加rsp,16个pop rbp ret mov rbp ,rax jmp .L7gives_unique_pointer()[clone .cold] :. L7:mov rdi,QWORD PTR [rsp + 8] test rdi,rdi je .L16 mov esi,4 vzeroupper调用运算符delete(void *,unsigned long).L8 :mov rdi,rbp call _Unwind_Resume.L16:vzeroupper jmp .L8gives_unique_pointer_noexcept():sub rsp,24 mov rax,QWORD PTR unique [rip] lea rdi,[rsp + 8] mov QWORD PTR unique [rip],0 mov QWORD PTR [rsp + 8],rax调用takes_unique_pointer_noexcept(std :: unique_ptr< int>)mov rdi,QWORD PTR [rsp + 8]测试rdi,rdi je .L24 mov esi,4个调用运算符delete(void *,unsigned long)。 L24:添加rsp,24 retgives_raw_pointer():mov rdi,QWORD PTR raw [rip] mov QWORD PTR raw [rip],0 jmp takes_raw_point er(int *)

那是很多代码。即使std :: unique_ptr只是具有更多逻辑关联的单个指针,这也是不平凡的,但它不能通过寄存器传递。而且,Itanium C ++ ABI使调用者负责销毁按值传递给被调用者的对象。这意味着需要在每个调用站点上重复进行此清理操作,并且实际上无法对其进行优化,因为此时编译器不知道被调用者的操作。此外,由于异常,通过返回或抛出来离开函数的方法不止一种。除非编译器能够证明没有异常离开被调用者(因为没有异常),或者实现可用并且足够直接,否则就需要发出一个进行适当清理的附加处理程序。

显然,还有更多的事情可能明显地促进了代码大小的增长:对复制省略,虚拟和非虚拟重击的限制,在对虚拟函数的调用中调整对象指针,访问线程局部变量–仅举几例。但是,有一些通用准则。通常,很多复杂性是由非平凡的对象引起的,例如使用std :: string_view而不是std :: string可能是一个好主意,只要它不会导致指针悬空即可。顺便说一下,constexpr施加的限制迫使代码避免使用最昂贵的结构,而尝试制作尽可能多的constexpr代码很可能导致编译器生成更易于优化的简单代码。

编译器通常允许程序员提供一些有关代码预期特性的信息。出于我们的目的,最有趣的一个是GCC和Clang中的[[gnu :: cold]]和[[gnu :: hot]]属性。它们允许告诉编译器某个函数是不太可能执行还是热点。这会影响这些功能在输出二进制文件中的优化和位置。冷函数将在.text.cold节中组合在一起,以确保它们不影响其余代码,而热函数将在.text.hot中组合在一起,以改善指令缓存的位置。

此外,使用-freorder-blocks-and-partition标志,GCC可以拆分功能并将冷块放在.text.cold节中。包含对Cold函数的调用的块以及其他类似异常处理程序的块也被视为Cold。即使在内存中的其他位置,这些冷块仍被有效地视为同一功能的一部分,因此控件只需一条跳转指令即可流向它们,而无需遵守调用约定。

静态constexpr int阈值= 100; void hot_path(int value); [[gnu :: cold]] void cold_path(int value);无效do_work(int值){if(值>阈值){hot_path(值/ 5);回报; } cold_path(value / 3); }

do_work(int):cmp edi,100 jle .L2 movsx rax,edi sar edi,31 imul rax,rax,1717986919 sar rax,33 sub eax,

......