分支感知编程

时间:2015-09-15 08:48:47

标签: c++ c performance optimization branch-prediction

我正在阅读该分支机构错误预测可能是应用程序性能的热门瓶颈。正如我所看到的,人们经常会显示汇编代码来揭示问题,并指出程序员通常可以预测分支在大多数时间内的位置并避免分支错误预测。

我的问题是:

1-是否可以使用某些高级编程技术避免分支错误预测(即无汇编)?

2-我应该记住用高级编程语言生成分支友好的代码(我最感兴趣的是C和C ++)?

欢迎使用代码示例和基准测试!

8 个答案:

答案 0 :(得分:29)

  人们常常...并声明程序员通常可以预测分支可以去哪里

(*)有经验的程序员经常提醒人类程序员在预测时非常糟糕。

  

1-是否可以使用某种高级编程技术(即没有汇编)来避免分支错误预测?

不是标准的c ++或c。至少不是一个分支。您可以做的是最小化依赖关系链的深度,以便分支错误预测不会产生任何影响。现代cpu将执行分支的两个代码路径并删除未选择的代码路径。然而,这是一个限制,这就是分支预测仅在深度依赖链中起作用的原因。

某些编译器提供了手动建议的扩展,例如gcc中的__builtin_expect。这是关于它的stackoverflow question。更好的是,一些编译器(例如gcc)支持分析代码并自动检测最佳预测。由于(*),使用分析而不是手动工作是明智的。

  

2-我应该记住用高级编程语言生成分支友好的代码(我最感兴趣的是C和C ++)?

首先,您应该记住,分支误预测只会影响您在程序中性能最关键的部分,并且在您测量并发现问题之前不要担心它。

  

但是当一些探查器(valgrind,VTune,...)告诉我在foo.cpp的第n行时,我得到了一个分支预测惩罚,我该怎么办?

伦丁给出了非常明智的建议

  1. 测量是否重要。
  2. 如果重要的话,那么
    • 最小化计算的依赖关系链的深度。如何做到这一点可能非常复杂,而且超出了我的专业知识,如果没有潜入装配,你就无法做到。您可以用高级语言做的是最小化条件检查的数量(**)。否则,你将受编译器优化的支配。避免深度依赖链也可以更有效地使用无序超标量处理器。
    • 让您的分支始终可预测。这种效果可以在stackoverflow question中看到。在这个问题中,数组上有一个循环。循环包含一个分支。分支取决于当前元素的大小。对数据进行排序时,使用特定编译器编译并在特定cpu上运行时,可以证明循环更快。当然,保持所有数据排序也会花费cpu时间,可能比分支错误预测更多,因此, measure
  3. 如果仍有问题,请使用profile guided optimization(如果有)。
  4. 可以切换2.和3.的顺序。手动优化代码是很多工作。另一方面,对于某些程序来说,收集分析数据也很困难。

    (**)一种方法是通过例如展开来转换你的循环。您也可以让优化器自动执行此操作。你必须测量,因为展开会影响你与缓存的交互方式,最终可能会导致悲观。

答案 1 :(得分:17)

Linux内核根据likely gcc builtins定义unlikely__builtin_expect个宏:

    #define likely(x)   __builtin_expect(!!(x), 1)
    #define unlikely(x) __builtin_expect(!!(x), 0)

(有关include/linux/compiler.h

中的宏定义,请参阅here

您可以使用它们:

if (likely(a > 42)) {
    /* ... */
} 

if (unlikely(ret_value < 0)) {
    /* ... */
}

答案 2 :(得分:12)

作为一个警告,我不是一个微优化向导。我不确切知道硬件分支预测器是如何工作的。对我来说,它是一个神奇的野兽,我用它剪刀纸石,它似乎能够读懂我的想法,并一直打败我。我是一个设计&amp;建筑类型。

尽管如此,由于这个问题是关于高层次的思维方式,我可能会提供一些提示。

<强>仿形

如上所述,我不是计算机体系结构向导,但我确实知道如何使用VTune分析代码并测量分支错误预测和缓存未命中等问题,并且始终在性能关键字段中执行此操作。如果您不知道如何做到这一点(分析),那么您应该首先考虑这一点。大多数这些微观热点最好是事先用手中的剖面仪发现的。

分支机构消除

很多人都在提供一些关于如何提高分支机构可预测性的优秀低级建议。在某些情况下,您甚至可以手动尝试辅助分支预测器,并优化静态分支预测(编写if语句以首先检查常见情况,例如)。这里有一篇关于英特尔细节的全面文章:https://software.intel.com/en-us/articles/branch-and-loop-reorganization-to-prevent-mispredicts

但是,除了基本常见情况/罕见情况预测之外,这样做很难做到,并且几乎总是最好保存,以便之后进行测量。人类很难准确预测分支预测器的性质。与页面错误和缓存未命中等相比,预测起来要困难得多,甚至在复杂的代码库中几乎不可能完全人为地预测。

但是,有一种更简单,更高级的方法可以缓解分支错误预测,并且可以避免完全分支。

跳过小/罕见的工作

我职业生涯早期常犯的一个错误,就是看到很多同伴在他们开始学习之前尝试做的事情,然后他们才开始学习并且仍在坚持下去。试图跳过小型或罕见的工作。

这方面的一个示例是记住大型查找表以避免重复执行一些相对便宜的计算,例如使用跨越兆字节的查找表以避免重复调用cos和{{1} }。对于人类大脑来说,这似乎是节省工作来计算它并存储它,除了经常从这个巨大的LUT通过内存层次结构加载到存储器层次结构中的存储器通常最终比计划他们打算保存。

另一种情况是添加一堆小分支以避免小的计算,这些计算在整个代码中不必要地做(无法影响正确性)作为一种天真的优化尝试,只是为了找到分支成本而不仅仅是做不必要的计算。

这种天真的分支优化尝试也适用于即使是稍微昂贵但难得的工作。以C ++为例:

sin

请注意,这是一个简单/说明性的示例,因为大多数人使用copy-and-swap对值传递的参数实现复制赋值,无论如何都要避免分支。

在这种情况下,我们会进行分支以避免自行分配。然而,如果自我分配只是做多余的工作并且不会阻碍结果的正确性,它通常可以提升你真实世界的表现,只需要自我复制:

struct Foo
{
    ...
    Foo& operator=(const Foo& other)
    {
        // Avoid unnecessary self-assignment.
        if (this != &other)
        {
            ...
        }
        return *this;
    }
    ...
};

...这可以提供帮助,因为自我指派往往非常罕见。我们通过冗余的自我分配来减缓罕见情况,但我们通过避免检查所有其他情况来加速常见情况。当然,由于在分支方面存在共同/罕见的情况偏差,因此不太可能显着减少分支误预测,但是嘿,不存在的分支不能被错误预测。

小矢量的天真尝试

作为一个个人故事,我之前曾在一个大规模的C代码库中工作,这个代码库通常有很多像这样的代码:

struct Foo
{
    ...
    Foo& operator=(const Foo& other)
    {
        // Don't check for self-assignment.
        ...
        return *this;
    }
    ...
};

...当然,由于我们拥有相当广泛的用户群,一些罕见的用户最终会输入我们软件中长度超过255个字符并溢出缓冲区的材料的名称,从而导致段错误。我们的团队正在使用C ++并开始将大量这些源文件移植到C ++中并用这样的代码替换这些代码:

char str[256];
// do stuff with 'str'

......毫不费力地消除了那些缓冲区溢出。但是,至少在那时,像std::string str = ...; // do stuff with 'str' std::string这样的容器是堆(免费存储)分配的结构,我们发现自己交易正确/安全以提高效率。其中一些被替换的区域是性能关键的(称为紧密循环),虽然我们通过这些大规模替换消除了大量错误报告,但用户开始注意到这些速度减慢。

那么我们想要的东西就像是这两种技术之间的混合体。我们希望能够在那里打一些东西来实现C风格的固定缓冲区变体的安全性(这对于常见情况来说非常精细且非常有效),但仍然适用于缓冲区不具备的罕见情况。 #39; t足够用户输入。我是团队中的表演爱好者之一,也是为数不多的使用剖析器的人之一(我很遗憾地与许多认为他们太聪明而无法使用的人一起工作),所以我被调用了。

我的第一次天真尝试是这样的(非常简化:实际使用了新的放置等等,并且是完全符合标准的序列)。它涉及使用固定大小的缓冲区(在编译时指定的大小)用于常见情况,如果大小超过该容量则使用动态分配的缓冲区。

std::vector

这次尝试彻底失败了。虽然它没有支付堆/免费商店的价格来构建,但template <class T, int N> class SmallVector { public: ... T& operator[](int n) { return num < N ? buf[n]: ptr[n]; } ... private: T buf[N]; T* ptr; }; 中的分支使其比operator[]std::string更糟糕,并且显示为分析热点而不是std::vector<char>(我们的供应商实现mallocstd::allocator使用了operator new。那么我很快就想到了在构造函数中将malloc分配给ptr。即使在常见情况下,现在buf指向ptr,现在buf可以像这样实现:

operator[]

......随着简单的分支消除,我们的热点消失了。我们现在有一个通用的,符合标准的容器,我们可以使用它与前一个C风格的固定缓冲区解决方案一样快(唯一的区别是一个额外的指针和构造函数中的一些指令),但是可以处理大小需要大于T& operator[](int n) { return ptr[n]; } 的罕见情况。现在我们使用它甚至超过N(但仅仅因为我们的用例支持一堆小的,临时的,连续的随机访问容器)。快速做到归结为只消除std::vector中的分支。

常见案例/罕见案例歪斜

多年来剖析和优化之后学到的一件事就是&#34;绝对快速无处不在&#34; 代码。许多优化行为在这里交易效率低下,以提高效率。用户可能会将您的代码视为绝对快速无处不在,但这来自智能权衡,其中优化与常见情况一致(常见情况与现实用户端方案一致并来自热点)从测量这些常见情景的分析器中指出。)

当您将表现偏向常见情况并远离罕见情况时,往往会出现好事。对于普通案例来说,要加快速度,通常罕见的情况必须变慢,但那是件好事。

零成本例外处理

常见案例/罕见案例倾斜的一个例子是许多现代编译器中使用的异常处理技术。他们应用零成本EH,这不是真正的零成本&#34;全面的。在抛出异常的情况下,它们现在比以前更慢。然而,在没有抛出异常的情况下,它们现在比以前更快,并且在成功的场景中通常比这样的代码更快:

operator[]

当我们在这里使用零成本EH并避免手动检查和传播错误时,在非特殊情况下,事情往往比上述代码风格更快。粗略地说,这是由于分支减少所致。然而作为交换,当抛出异常时,必须发生更加昂贵的事情。然而,常见案例和罕见案例之间的偏差倾向于帮助现实世界的情景。我们不太关心加载文件失败的速度(极少数情况下),因为加载文件成功(常见情况),这就是为什么许多现代C ++编译器实现&#34;零成本&#34; EH。这也是为了扭曲常见情况和罕见情况,在性能方面将它们推向远离每个案例。

虚拟调度和同质性

面向对象代码中的许多分支,其中依赖关系流向抽象(稳定的抽象原则,例如),其分支的大部分(当然除了循环,这对于分支预测器很好)动态调度的形式(虚函数调用或函数指针调用)。

在这些情况下,常见的诱惑是将所有类型的子类型聚合到存储基指针的多态容器中,循环遍历它并在该容器中的每个元素上调用虚方法。这可能导致很多分支错误预测,特别是如果此容器一直在更新。伪代码可能如下所示:

if (!try_something())
    return error;
if (!try_something_else())
    return error;
...

避免这种情况的策略是根据其子类型开始对此多态容器进行排序。这是在游戏行业中流行的相当旧式的优化。我不知道今天有多大帮助,但这是一种高级别的优化。

我发现即使在最近获得类似效果的情况下仍然有用的另一种方法是将多态容器拆分为每个子类型的多个容器,导致代码如下: p>

for each entity in world:
    entity.do_something() // virtual call

...当然这会妨碍代码的可维护性并降低可扩展性。但是,您不必为此世界中的每个子类型执行此操作。我们只需要为最常见的做。例如,这个想象中的视频游戏可能包括人类和兽人。它也可能有仙女,地精,巨魔,精灵,侏儒等,但它们可能不像人类和兽人那样普遍。所以我们只需要将人类和兽人从其他人身上分开。如果你能负担得起,你还可以拥有一个存储所有这些子类型的多态容器,我们可以将它们用于性能较低的循环。这有点类似于用于优化参考局部性的热/冷分裂。

面向数据的优化

优化分支预测和优化内存布局往往会模糊不清。对于分支预测器,我只是很少尝试特别的优化,而这只是在我用尽所有其他事情之后。然而,我发现重点关注内存和参考位置确实使我的测量结果导致更少的分支误预测(通常不知道确切原因)。

这里有助于研究面向数据的设计。我发现一些与优化相关的最有用的知识来自于在面向数据的设计环境中研究内存优化。面向数据的设计倾向于强调更少的抽象(如果有的话),以及处理大块数据的更庞大的高级接口。从本质上讲,这种设计倾向于减少不同分支和跳转代码的数量,使用更多循环代码处理大量同类数据。

即使您的目标是减少分支错误预测,也可以更有效地关注更快地消费数据。例如,我之前从无分支SIMD中发现了一些很大的收获,但是思维方式仍然在更快地消耗数据(它确实如此,并且感谢来自这里的一些帮助,比如Harold)。

<强> TL; DR

所以,无论如何,从高级别的角度来看,这些是可以在整个代码中减少分支错误预测的一些策略。他们缺乏计算机体系结构方面的最高水平的专业知识,但我希望这是一个适当的有用的响应,因为问题的级别。一般来说,很多这样的建议在优化方面都有些模糊,但我发现优化分支预测通常需要通过超越优化(内存,并行化,矢量化,算法)来模糊。在任何情况下,最安全的选择是确保在冒险之前掌握一个分析器。

答案 3 :(得分:7)

也许最常见的技术是使用单独的方法进行正常和错误返回。 C别无选择,但C ++有例外。编译器知道异常分支是例外的,因此是意外的。

这意味着异常分支确实很慢,因为它们不可预测,但非错误分支更快。平均而言,这是净胜利。

答案 4 :(得分:6)

一般来说,保持热内循环与最常遇到的高速缓存大小成比例是个好主意。也就是说,如果你的程序一次处理数据,比如说,少于32千字节,并且对它做了大量的工作,那么你就可以很好地利用L1缓存了。

相反,如果您的热内循环咀嚼100MByte数据并且只对每个数据项执行一次操作,那么CPU将花费大部分时间从DRAM获取数据。

这很重要,因为CPU首先具有分支预测的部分原因是能够预取下一条指令的操作数。通过安排代码可以减少分支误预测的性能影响,这样无论采用什么分支,下一个数据都很有可能来自L1缓存。虽然不是一个完美的策略,L1缓存大小似乎普遍停留在32或64K;这在整个行业几乎是不变的。不可否认,以这种方式进行编码通常并不简单,依赖其他人推荐的配置文件驱动优化等可能是最直接的方法。

无论如何,根据CPU的缓存大小,计算机上运行的其他内容,主内存带宽/延迟等等,是否会出现分支误预测的问题会有所不同。

答案 5 :(得分:2)

  

1-是否可以使用某种高级编程技术(即没有汇编)来避免分支错误预测?

避免?也许不是。降低?当然...

  

2-我应该记住用高级编程语言生成分支友好的代码(我最感兴趣的是C和C ++)?

值得注意的是,一台机器的优化不一定是另一台机器的优化。考虑到这一点,profile-guided optimisation相当适合重新分类分支,基于您给出的任何测试输入。这意味着您不需要执行任何编程来执行此优化,并且 应相对于您正在分析的任何一台机器进行相对定制。显然,当您的测试输入和您分析的机器大致符合常见期望时,将获得最佳结果......但这些也是任何其他优化,分支预测相关或其他方面的考虑因素。

答案 6 :(得分:2)

为了回答您的问题,让我解释一下分支预测是如何工作的。

首先,当处理器正确预测采取的分支时,存在分支惩罚。如果处理器预测分支采用,那么它必须知道预测分支的目标,因为执行流将从该地址继续。假设分支目标地址已经存储在分支目标缓冲区(BTB)中,它必须从BTB中找到的地址中获取新指令。因此,即使分支被正确预测,您仍然会浪费几个时钟周期 由于BTB具有关联缓存结构,因此目标地址可能不存在,因此可能浪费更多的时钟周期。

另一方面,如果CPU预测一个分支没有被采取,如果它是正确的那么就没有惩罚,因为CPU已经知道连续指令在哪里。

正如我上面所解释的,预测未采取分支的吞吐量高于预测的分支

  

是否有可能使用某种高级编程技术(即没有汇编)来避免分支错误预测?

是的,有可能。您可以通过组织代码来避免所有分支都具有重复的分支模式,以便始终采用或不采用 但是如果你想获得更高的吞吐量,你应该按照我上面解释的最有可能不采取的方式组织分支。

  

我应该记住什么才能生成高分支友好代码   级别编程语言(我最感兴趣的是C和C ++)?

如果可能消除分支。如果在编写if-else或switch语句时不是这种情况,请首先检查最常见的情况,以确保最有可能不采用分支。尝试使用_ _builtin_expect(condition, 1)函数强制编译器生成要处理的条件。

答案 7 :(得分:1)

无分支并不总是更好,即使分支的两边都是微不足道的。 When branch prediction works, it's faster than a loop-carried data dependency

有关<div ID="CANVAS" style="background:#000;width:600px;height:400px"></div> <script> var startx=-1,starty=-1,points=0,box; var canvas=document.getElementById('CANVAS'); canvas.onclick=dopoint; canvas.onmousemove=sizebox; function dopoint(e){ if (points==0){ var area=canvas.getBoundingClientRect(); box=document.createElement('DIV'); box.style.position='relative'; box.style.border='2px solid yellow'; canvas.appendChild(box); startx=e.clientX-area.left; starty=e.clientY-area.top; box.style.left=startx+'px'; box.style.top=starty+'px'; box.style.width='10px'; box.style.height='10px'; } points=1-points; } function sizebox(e){ if (points==1){ var x=e.clientY,y=e.clientY; //here I'm thinking subtract old point from new point to get distance (for width and height) if (x>startx){ box.style.left=startx+'px'; box.style.width=(x-startx)+'px'; }else{ box.style.left=x+'px'; box.style.width=(startx-x)+'px'; } if (y>starty){ box.style.top=starty+'px'; box.style.height=(y-starty)+'px'; }else{ box.style.top=y+'px'; box.style.height=(starty-y)+'px'; } } } </script> gcc -O3转换为无分支代码的情况,请参阅gcc optimization flag -O3 makes code slower than -O2,以便将其设置得更慢。

有时您确信条件是不可预测的(例如,在排序算法或二进制搜索中)。或者你更关心的是最坏情况下的速度不比速度快1.5倍的速度快10倍。

有些习语更有可能编译成无分支形式(如if() x86条件移动指令)。

cmov

第一种方式始终写入x = x>limit ? limit : x; // likely to compile branchless if (x>limit) x=limit; // less likely to compile branchless, but still can ,而第二种方式不会修改其中一个分支中的x。这似乎是某些编译器倾向于为x版本发出分支而不是cmov的原因。即使if是已经存在于寄存器中的本地x变量,这也适用,因此“写入”它不涉及存储到内存,只是更改寄存器中的值。

编译器仍然可以做任何他们想做的事情,但我发现这个成语的差异可以有所作为。根据您正在测试的内容,它是occasionally better to help the compiler mask and AND rather than doing a plain old cmov.我在答案中做到了,因为我知道编译器将拥有使用单个指令生成掩码所需的内容(以及查看clang如何执行此操作)。 / p>

TODO:http://gcc.godbolt.org/

上的示例