避免管道停滞,通过计算条件提前执行。

13

谈到ifs的性能时,通常会讨论误预测如何会使流水线停滞。我看到的推荐解决方案有:

  1. 对于通常只有一个结果的条件,信任分支预测器;或者
  2. 尽可能使用一些位运算技巧来避免分支; 或者
  3. 尽可能使用条件移动。

我找不到的是我们是否可以提前计算条件以在可能的情况下帮助。所以,与其:

... work
if (a > b) {
    ... more work
}

像这样做:

bool aGreaterThanB = a > b;
... work
if (aGreaterThanB) {
    ... more work
}

像这样的代码是否有可能在条件语句中完全避免停顿(取决于流水线长度和我们可以放置在 bool 和 if 之间的工作量)?它不必像我写的那样,但是否有一种方法可以尽早评估条件语句,以便CPU不必尝试预测分支

此外,如果有帮助的话,编译器是否有可能自行执行此操作?


@MitchWheat -- 我不明白“值在运行时未知”与问题有何关系。据我所知,当条件被评估时,CPU已经猜测了接下来会发生什么,这可能是正确的,也可能是错误的。我想知道的是是否有一种方法可以提前计算出该条件,以便CPU不必猜测,尽管我想我没有很清楚地表达我的问题。编辑:我已经编辑了问题,以使我的意图更加清晰。 - Jibb Smart
@BenVoigt -- 明白了,这很有道理。如果您将您的评论转化为答案(并给其他比我更熟悉这个领域的人足够的时间来挑战它,如果需要的话),我会接受它。您已经回答了问题,而且您的评论已经提供了足够的信息来符合答案的要求,以我个人的看法。谢谢! - Jibb Smart
1
有一篇来自MICRO-45的好论文(链接:https://people.engr.ncsu.edu/ericro/publications/conference_MICRO-45.pdf)试图回答你的确切问题。他们发现,在他们所选的基准测试中,约38%的条件分支可以利用早期评估(解耦)。但是,这确实需要ISA修改。 - hayesti
@hayesti 哇,太酷了!这回答得非常好。 - Jibb Smart
3个回答

24
是的,尽早计算分支条件可能会有益处,这样任何错误预测都可以尽早解决,前端管道的部分就可以尽早重新填充。在最好的情况下,如果已经有足够的工作在进行中以完全隐藏前端泡沫,则错误预测可能是自由的。
不幸的是,在乱序CPU上,“尽早”有一个相当微妙的定义,因此让分支尽早解决并不像只是在源代码中移动行那么简单 - 您可能需要更改计算条件的方式。
令人遗憾的是,“更早”并不指源文件中条件/分支的位置,也不指对应于比较或分支的汇编指令的位置。因此,在根本上,它在您的示例中大多数7是无效的。
即使源级别定位很重要,在您的示例中也可能不起作用,因为:
您将条件的评估上移并将其分配给一个bool,但它不是测试(<运算符)可能会出现错误预测的地方,而是后续的条件分支:毕竟,它是一个“分支”错误预测。在您的示例中,分支在两个位置都是相同的:它的形式只是从if (a > b)更改为if (aGreaterThanB)
此外,您转换代码的方式不太可能欺骗大多数编译器。优化编译器不会按照您编写的顺序逐行发出代码,而是根据源级依赖关系安排事物。将条件提前可能会被忽略,因为编译器希望将检查放在自然位置:在具有标志寄存器的体系结构上,在分支之前约为正确。
例如,考虑以下两个简单函数的实现,它们遵循您建议的模式。第二个函数将条件移到函数顶部。
int test1(int a, int b) {
    int result = a * b;
    result *= result;
    if (a > b) {
        return result + a;
    }
    return result + b * 3;
}

int test2(int a, int b) {
    bool aGreaterThanB = a > b;
    int result = a * b;
    result *= result;
    if (aGreaterThanB) {
        return result + a;
    }
    return result + b * 3;
}

我检查了gcc、clang2和MSVC,发现它们都编译了这两个函数identically(输出在编译器之间有所不同,但对于每个编译器,这两个函数的输出是相同的)。例如,使用gcc编译test2的结果如下:

test2(int, int):
  mov eax, edi
  imul eax, esi
  imul eax, eax
  cmp edi, esi
  jg .L4
  lea edi, [rsi+rsi*2]
.L4:
  add eax, edi
  ret
cmp指令对应于a > b条件,gcc已将其移回到所有“工作”之后,并将其放在条件分支jg旁边。

什么有效

因此,如果我们知道源代码中操作顺序的简单操作无效,则可以做些什么来解决问题?事实证明,您可以通过任何可以将分支条件“向上”移动到数据流图中来提高性能,从而使误判能够更早地得到解决。我不会深入探讨现代CPU如何依赖数据流,但您可以在这里找到简要概述,并在末尾找到进一步阅读的指针。

遍历链接列表

这是一个涉及链接列表遍历的实际示例。

考虑将空终止的链接列表中的所有值相加,该链接列表还将其长度1存储为列表头结构的成员。链接列表实现为一个list_head对象和零个或多个列表节点(具有单个int值负载),定义如下:

struct list_node {
    int value;
    list_node* next;
};

struct list_head {
    int size;
    list_node *first;
};

规范的搜索循环将使用node->next == nullptr在最后一个节点中的哨兵来确定已经到达列表的末尾,如下所示:

long sum_sentinel(list_head list) {
    int sum = 0;
    for (list_node* cur = list.first; cur; cur = cur->next) {
        sum += cur->value;
    }
    return sum;
}

这已经是最简单的形式了。

然而,这会将结束求和的分支(即首次出现cur == null的分支)放在节点之间指针追踪的末尾,这是数据流图中最长的依赖关系。如果此分支预测错误,那么错误预测的解决将发生“晚”,前端气泡将直接增加运行时间。

另一方面,您可以通过显式计算节点来进行求和,如下所示:

long sum_counter(list_head list) {
    int sum = 0;
    list_node* cur = list.first;
    for (int i = 0; i < list.size; cur = cur->next, i++) {
        sum += cur->value;
    }
    return sum;
}

与哨兵解决方案相比,似乎我们增加了额外的工作:现在我们需要初始化、跟踪和递减计数4。然而,关键在于这个递减依赖链非常短,因此它会在指针追踪工作之前“先行”运行,而误判会在还有有效的剩余指针追踪工作要做的时候早早发生,可能会大大提高运行时间。
让我们实际尝试一下。首先,我们检查两种解决方案的汇编代码,以便验证没有出现意外情况:
<sum_sentinel(list_head)>:
test   rsi,rsi
je     1fe <sum_sentinel(list_head)+0x1e>
xor    eax,eax
loop:
add    eax,DWORD PTR [rsi]
mov    rsi,QWORD PTR [rsi+0x8]
test   rsi,rsi
jne    loop
cdqe   
ret    


<sum_counter(list_head)>:
test   edi,edi
jle    1d0 <sum_counter(list_head)+0x20>
xor    edx,edx
xor    eax,eax
loop:
add    edx,0x1
add    eax,DWORD PTR [rsi]
mov    rsi,QWORD PTR [rsi+0x8]
cmp    edi,edx
jne    loop:
cdqe   
ret    

如预期的那样,哨兵方法稍微简单一些:设置期间少了一条指令,在循环中也少了一条指令5,但总体来说,关键的指针追踪和加法步骤是相同的,我们预计这个循环会受到后续节点指针延迟的影响。
事实上,当预测影响可以忽略不计时,这两个循环在对短或长列表求和时表现几乎相同。对于长列表,由于在到达列表末尾时只有一次错误预测,因此分支预测影响自动较小,并且当运行时在L1内包含的列表中时,运行时间渐近地达到每个节点几乎恰好为4个周期,这正是我们根据英特尔最佳情况下的4个周期负载到使用延迟所预期的。
对于短列表,如果列表模式可预测(始终相同或以某种适度的周期循环,可以具有良好的预测性!),则分支错误预测可以被忽略不计。在这种情况下,由于多个列表可以同时进行(例如,如果总结列表数组),因此每个节点的时间可以小于4个周期,当求和许多短列表时。无论如何,这两个实现的表现几乎相同。例如,当列表始终具有5个节点时,使用任一实现求和一个列表的时间约为12个周期:
** Running benchmark group Tests written in C++ **
                     Benchmark   Cycles   BR_MIS
       Linked-list w/ Sentinel    12.19     0.00
          Linked-list w/ count    12.40     0.00

让我们将分支预测添加到其中,通过更改列表生成代码以创建平均长度为5的列表,但实际长度在[0, 10]中均匀分布。求和代码未更改:只有输入不同。随机列表长度的结果:

** Running benchmark group Tests written in C++ **
                     Benchmark   Cycles   BR_MIS
       Linked-list w/ Sentinel    43.87     0.88
          Linked-list w/ count    27.48     0.89
< p > BR_MIS列显示我们每个列表几乎会出现一个分支预测错误6,这是预期的,因为循环退出是不可预测的。

然而,哨兵算法现在需要大约44个周期,而计数算法需要大约27.5个周期。计数算法快约16.5个周期。您可以玩弄列表长度和其他因素,并改变绝对时间,但差异几乎总是在16-17个周期左右,这恰好与最近英特尔的分支预测惩罚相同!通过提前解决分支条件,我们避免了前端气泡,在那里什么都不会发生。

提前计算迭代次数

另一个例子是像一个循环一样计算浮点值的循环,比如泰勒级数逼近,其中终止条件取决于计算出的值的某个函数。这具有与上述相同的效果:终止条件取决于缓慢的循环传递依赖项,因此解决它与解决值本身一样慢。如果退出不可预测,您将遭受停顿。

如果您可以更改为提前计算迭代次数,则可以使用脱耦的整数计数器作为终止条件,避免气泡。即使提前计算需要一些时间,它仍然可以提供整体加速(而且计算可以与循环的第一次迭代并行运行,因此它可能比您预期的成本要低得多)。


1 MIPS是一个有趣的例外,没有标志寄存器 - 测试结果直接存储在通用寄存器中。

2 Clang以无分支的方式编译了这个和许多其他变体,但它仍然很有趣,因为您仍然具有测试指令和条件移动(代替分支)的相同结构。

3 类似于C++11的std::list

4 结果发现,在x86上,每个节点的工作实际上在两种方法之间非常相似,因为dec隐式设置了零标志,所以我们不需要额外的test指令,而指针追踪中使用的mov则不需要,因此计数器方法有一个额外的dec,而哨兵方法有一个额外的test,使其几乎相等。

5 尽管这部分只是因为gcc没有将增量for循环转换为递减循环以利用dec设置零标志,避免了cmp。也许新版本的gcc做得更好。另见注释4。

6 我想这更接近于0.9而不是1.0,因为也许分支预测器仍然会正确处理长度= 10的情况,因为一旦循环了9次,下一个迭代将总是退出。一个不那么合成/精确的分布不会表现出来。

7 我说“大多数情况”是因为在某些情况下,通过这种源代码或汇编级别的重排可能会节省一两个周期,因为这些事情可能对乱序处理器中的执行顺序产生轻微影响,执行顺序也受到汇编顺序的影响,但仅在数据流图的约束范围内。 请参见这个评论


gcc是否有意将add edx,0x1放在sum_counter的那个位置?我的意思是,它是否试图将分支的条件与分支远离开来?sum_counter循环体很小,处理器可能会一起解码所有指令,在执行add edx,0x1之前进行预测。我们如何知道sum_counter比其他函数更快,因为条件是早期计算而不是条件计算速度更快?sum_sentinel中的分支条件依赖于内存访问。 - Hadi Brais
“让我们将分支预测加入其中”是什么意思?代码长什么样? - Hadi Brais
2
@HadiBrais - 是的,计算条件的方式已经改变了。这就是重点:你需要影响到数据流图,这意味着源代码需要进行更改,因为重新排列独立行(或汇编)不会影响数据流图。然而,我不同意我改变它是为了使计算“更快”,至少大多数人理解这个术语的方式是如此:sum_counter 变量有更多的指令、更多的总 uops 等。改变的是分支在数据流图中的位置:它已经向上移动(即更靠近根节点)。 - BeeOnRope
1
在汇编和可能在 C/C++ 级别上,你可以想出一个例子,其中仅通过移动跳转指令就可以帮助: 指令在指令流中出现的顺序也会影响执行顺序: 顺序受数据流图的约束,但在这些约束内,较早出现的指令可能会更早地执行(因为当 CPU 调度程序有多个选项时,它们更喜欢这些指令,并且因为它们更早可用于调度)。然而,这种效应相当温和——只是这里或那里的一个周期。 - BeeOnRope
1
这是我在SO上看过的最有趣的答案之一。 - Nemo
显示剩余7条评论

6

乱序执行绝对是一件事情(不仅编译器,甚至处理器芯片本身也可以重新排序指令),但它更有助于解决由数据依赖引起的流水线停顿问题,而不是由预测错误引起的问题。

在控制流场景中的好处受到限制,因为在大多数架构上,条件分支指令仅基于标志寄存器做出决策,而不是基于通用寄存器。除非介入的“工作”非常不寻常,否则很难提前设置标志寄存器,因为大多数指令都会改变标志寄存器(在大多数架构上)。

也许可以通过识别组合来:

TST (reg)
J(condition)

当预先设置(reg)时,可设计为最小化停顿。这当然需要处理器的大量帮助,而不仅仅是编译器。而处理器设计者很可能会优化更一般情况下的指令早期(乱序)执行,以设置分支标志位,并将结果标志位通过管道转发,从而尽早结束停顿。


是的,但你可以提前完成大部分分支工作,只留下最后的cmp/jcc(在现代x86中宏融合成单个比较和分支uop,因此它实际上直接在寄存器比较时进行分支,并产生标志输出)。没有宏融合的分支指令的实际执行(以检查预测结果)并不特殊;它具有与setcc或add-with-carry一样的正常数据依赖标志。你对标志被“通过管道转发”进行描述使其听起来像是被特殊处理了,但实际上并非如此。 - Peter Cordes
@PeterCordes:但是OP建议的是将cmp放在前面...这会导致跳转可见的错误标志。他可以使用sub提前进行比较,然后使用tst+j(cc)一起执行,但正如你所说,OOO执行引擎已经识别了cmp+j(cc),因此尝试提前执行比较是没有意义的。 - Ben Voigt
2
OP在谈论重新排列C源代码的方式,以不改变语义。你说得对,在大多数情况下,早期的cmp不是有效的汇编实现方式,而且做额外的工作来比较寄存器(cmp/setcc为后面的test/jnz做准备)是没有意义的。无论如何,是的,a<b不是一个好的例子;如果计算a和/或b的成本很高,那么将其放在前面可能是有益的,特别是如果这导致您正在使用的优化编译器生成的汇编代码发生变化。(不能保证源代码排序会产生任何效果!) - Peter Cordes
但是你最后一段话中错误的关键是,jcc或者融合的cmp/jcc都像其他指令一样按照最早准备好的先执行。分支uops不会被优先执行,所以只有当它们的输入(标志或寄存器)准备好并且有空闲的执行端口时才会执行。(Haswell只在p6上运行预测为taken的分支,或者在p0或p6上运行预测为not-taken的分支)。如果有很多更早的独立指令,即使它的输入早已准备好,jcc也可能不会早执行。(与@Bee的低ILP不同) - Peter Cordes
1
此外,在ARM模式下,可以很容易地避免设置标志位,它是一种基于每个指令的选择,就像在SPARC上的addccadd。 ARM Thumb模式使得adds(加和设置标志)优于add,但MIPS甚至没有标志,而对于更复杂的条件,您需要将其比较到寄存器中。但是,是的,在x86上,长时间尝试避免设置标志位是不值得的(尽管在顺序Pentium上将cmp放在jcc几个指令之前是一种有用的优化)。其他一些RISC也具有由大多数指令设置的标志,例如x86,我想。 - Peter Cordes
像往常一样,PowerPC 是复杂的:它的 CR(条件寄存器)被分成了 8 个 4 位字段,您可以将其比较到任何一个字段并在任何一个字段上进行分支或 cmov(条件移动),因此即使没有乱序执行,您也可以同时拥有多个标志依赖链。这主要是类似于 x86 和 m68k 的 CISC 架构使得早期设置标志变得困难。 - Peter Cordes

3
分支预测错误的主要问题不是其产生的惩罚期间会多消耗一些周期(相对较快),而是如果存在数据依赖关系,则分支条件必须先解决,可能会在管线中发生非常晚的预测错误。
对于基于先前计算的分支,依赖性的工作方式与其他操作类似。此外,分支通过预测的管线非常早,以便机器可以继续获取和分配进一步的操作。如果预测不正确(这往往是数据相关分支的情况,而不像通常表现出更可预测的模式的循环控制),则只有在解决依赖性并证明预测错误时才会发生清除。发生的时间越晚,惩罚就越大。
由于乱序执行在依赖关系解决后立即调度操作(假设没有端口压力),将操作向前移动可能并不会有太大帮助,因为它不会改变依赖链,并且不会太大程度地影响调度时间。唯一潜在的好处是,如果将其移动得足够远,以使OOO窗口能够更早地看到它,但现代CPU通常运行数百个指令,而在不破坏程序的情况下将指令提升到那么远很难。但如果你正在运行某个循环,如果可能,计算未来迭代的条件可能会很简单。
这些都不会改变完全独立的预测过程,但一旦分支达到机器的OOO部分,它将立即得到解决,必要时清除,并且产生最小的惩罚。

OoO执行通常按照最早准备好的顺序运行指令,因此将关键路径指令放在前面可能有助于避免资源冲突(多个指令准备就绪,但没有足够的执行单元来运行它们)。缓存未命中或其他后端停顿后的执行往往会出现一些突发情况。有可能存在这样的情况,即将关键路径指令放在其他独立工作之前可能会有所收益。但总体而言,OoO执行使这几乎成为一个非问题。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接