通过提前计算条件来避免管道停滞

Avoid stalling pipeline by calculating conditional early

提问人:Jibb Smart 提问时间:4/20/2018 最后编辑:Hadi BraisJibb Smart 更新时间:5/22/2020 访问量:2290

问:

在谈论 if 的性能时,我们通常会谈论错误预测如何使管道停滞不前。我看到的推荐解决方案是:

  1. 信任通常具有一个结果的条件的分支预测器;或
  2. 如果可能的话,避免使用一点比特魔法进行分支;或
  3. 在可能的情况下进行有条件的移动。

我找不到的是,我们是否可以及早计算病情,以便在可能的情况下提供帮助。所以,而不是:

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

执行以下操作:

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

这样的事情能否完全避免这个条件的停滞(取决于管道的长度以及我们可以在布尔值和假设之间放置的工作量)?它不一定是我写的,但是有没有办法尽早评估条件,这样 CPU 就不必尝试预测分支了?

另外,如果这有帮助,编译器可能会这样做吗?

性能 与语言无关 的编译器优化 CPU-架构 分支预测

评论

0赞 Jibb Smart 4/20/2018
@MitchWheat -- 我不明白“在运行时之前不知道值”与这个问题有什么关系。我的理解是,在评估条件时,CPU 已经猜到了接下来会发生什么,这可能是正确的,也可能不正确。我想知道的是,是否有办法提前计算该条件,以便 CPU 不必猜测,尽管我想我还没有问清楚这个问题。编辑:我已经编辑了问题,以使我的意图更加清晰
0赞 Jibb Smart 4/20/2018
@BenVoigt -- 明白了。这是有道理的。如果你把你的评论变成一个答案(并给其他人足够的时间,如果需要的话,在这个领域也比我更有知识的人来挑战它),我会接受它。恕我直言,您已经回答了问题,并且您的评论有足够的信息来回答。谢谢!
1赞 hayesti 6/2/2018
MICRO-45 有一篇很好的论文试图回答您的确切问题。他们发现,在他们选择的基准中,大约38%的条件分支可以利用早期评估(解耦)。但是,它确实需要对 ISA 进行修改。
0赞 Jibb Smart 6/2/2018
@hayesti哇,太酷了!这很好地回答了这个问题。

答:

6赞 Ben Voigt 4/20/2018 #1

无序执行绝对是一回事(不仅是编译器,甚至处理器芯片本身也可以对指令进行重新排序),但它对数据依赖引起的管道停滞比对错误预测引起的流水线停滞更有帮助。

控制流方案的好处在某种程度上受到以下事实的限制:在大多数体系结构上,条件分支指令仅根据标志寄存器做出决策,而不是基于通用寄存器。除非中间的“工作”非常不寻常,否则很难提前很久设置标志寄存器,因为大多数指令都会更改标志寄存器(在大多数架构上)。

也许确定以下组合

TST (reg)
J(condition)

可以设计为在提前设置足够远时最大限度地减少失速。当然,这需要处理器的很大程度的帮助,而不仅仅是编译器。处理器设计人员可能会针对更普遍的情况进行优化,即提前(无序)执行为分支设置标志的指令,结果标志通过管道转发,提前结束停顿。(reg)

评论

0赞 Peter Cordes 4/25/2018
是的,但您可以提前完成分支的大部分工作,只留下最后一个(在现代 x86 宏上,它融合成一个单一的比较和分支 uop,所以它实际上直接在寄存器上分支比较,并产生标志输出。在没有宏融合的情况下实际执行分支指令(检查预测结果)并不特殊;它有一个正常的数据依赖关系标志,就像或随携带添加一样。你对“通过管道转发”的标志的描述听起来像是经过特殊处理的,但实际上并非如此。cmp/jccsetcc
0赞 Ben Voigt 4/25/2018
@PeterCordes:但 OP 的建议是把更早......这将导致跳转可见错误的标志。他可以提前进行比较,将 + 放在一起,但正如你所说,OOO 执行引擎已经识别了 +,所以试图提前进行比较是没有意义的。cmpsubtstj(cc)cmpj(cc)
2赞 Peter Cordes 4/25/2018
OP 谈论的是以一种不改变语义的方式对 C 源代码进行重新排序。你是对的,在大多数情况下,在asm中做一个早期并不是一个有效的实现,并且做额外的工作来比较到寄存器中(cmp/setcc为以后做准备)是没有意义的。无论如何,是的,这不是一个好例子;如果 和/或的计算成本很高,那么早点放置可能会很好,特别是如果这会导致您正在使用的优化编译器生成的 ASM 发生变化。(不保证源排序会做任何事情!cmptest/jnza<bab
0赞 Peter Cordes 4/25/2018
但是你最后一段的关键问题是,或者融合都是像任何其他指令一样安排的,通常是按照最旧的-就绪-优先的顺序。分支 uop 不会优先提前执行,因此它们仅在输入准备就绪(标志或寄存器)并且有备用执行端口时才会执行。(Haswell 仅在端口 6 上运行预测占用的分支,或在 p0 或 p6 上运行预测未占用的分支)。如果有很多早期的独立指令,即使其输入很早就准备好了,也可能无法提前执行。(与@Bee的低ILP不同)jcccmp/jccjcc
1赞 Peter Cordes 4/25/2018
此外,ARM 模式下的 ARM 可以轻松避免标志设置,它是每条指令的选择,就像在 SPARC 上一样。不过,ARM 拇指模式使(添加和设置标志)比 更好。MIPS甚至没有标志,您可以将其与寄存器进行比较以用于更复杂的条件。但是,是的,在 x86 上,不值得尝试长时间避免标志设置(尽管在有序的 Pentium 上放置一些指令是一个有用的优化)。其他一些 RISC 也有由大多数指令设置的标志,比如 x86,我认为。addccaddaddsaddcmpjcc
24赞 BeeOnRope 4/20/2018 #2

是的,尽早计算分支条件可能是有益的,这样任何误报都可以尽早解决,管道的前端部分可以尽早开始重新填充。在最好的情况下,如果已经有足够的工作来完全隐藏前端泡沫,那么错误预测可能是免费的

不幸的是,在无序 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,并且都以相同的方式编译了这两个函数(编译器之间的输出不同,但对于每个编译器,这两个函数的输出是相同的)。例如,编译结果如下:test2gcc

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

该指令与条件相对应,gcc 已将其移回所有“工作”之后,并将其放在条件分支旁边。cmpa > bjg

什么有效

因此,如果我们知道在源代码中对操作顺序的简单操作是行不通的,那么什么行得通呢?事实证明,在数据流图中将分支条件“向上”移动的任何操作都可以通过更早地解决错误预测来提高性能。我不打算深入探讨现代 CPU 如何依赖于数据流,但您可以在此处找到简要概述,并在最后提供进一步阅读的指针。

遍历链表

下面是一个涉及链接列表遍历的真实示例。

考虑将所有值求和的任务,一个以 null 结尾的链表,该链表也将其长度1 存储为列表头结构的成员。链表实现为一个对象和零个或多个列表节点(具有单个有效负载),定义如下:list_headint value

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 个周期加载到使用延迟的预期。

对于短列表,如果列表的模式是可预测的,则分支错误预测是可以忽略的:要么始终相同,要么在某个中等周期内循环(如果预测良好,可以是 1000 个或更多!在这种情况下,当对许多短列表求和时,每个节点的时间可以少于 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

该列显示,正如预期的那样,我们每个列表6 都得到了近一个分支错误预测,因为循环出口是不可预测的。BR_MIS

但是,哨兵算法现在需要 ~44 个周期,而计数算法需要 ~27.5 个周期。计数算法快了大约 16.5 个周期。您可以使用列表长度和其他因素,并更改绝对时序,但增量几乎总是在 16-17 个周期左右,这与最近英特尔的分支错误预测惩罚大致相同,这并非巧合!通过尽早解决分支条件,我们避免了前端气泡,在这种气泡中,什么都不会发生。

提前计算迭代计数

另一个例子是计算浮点值的循环,比如泰勒级数近似,其中终止条件取决于计算值的某个函数。这与上述效果相同:终止条件取决于慢循环携带的依赖关系,因此它的解析速度与值本身的计算一样慢。如果出口不可预测,您将在出口时遇到停滞。

如果可以更改它以预先计算迭代计数,则可以使用解耦的整数计数器作为终止条件,从而避免冒泡。即使前期计算增加了一些时间,它仍然可以提供整体加速(无论如何,计算可以与循环的第一次迭代并行运行,因此通过查看其延迟,它的成本可能要低得多)。


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

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

3 像 C++11 .std::list

4 事实证明,在 x86 上,两种方法之间的每个节点工作实际上非常相似,因为隐式设置零标志的方式,因此我们不需要额外的指令,而指针追逐中使用的则不需要,因此计数器方法具有额外的功能,而哨兵方法具有额外的测试, 让它大约洗一洗。dectestmovdec

5 虽然这部分只是因为 gcc 没有设法将递增的 for 循环转换为递减的循环以利用设置零标志的优势,但避免了 .也许较新的 gcc 版本做得更好。另见脚注4。deccmp

6 我想这更接近 0.9 而不是 1.0,因为也许分支预测器仍然会得到正确的长度 = 10 大小写,因为一旦你循环了 9 次,下一次迭代将始终退出。合成性较差/精确的分布不会表现出来。

7 我之所以这么说,主要是因为在某些情况下,您可以通过这种源代码或程序集级别的重新排序来节省一两个周期,因为这些事情可能会对无序处理器中的执行顺序产生轻微影响,执行顺序也受程序集顺序的影响但仅限于数据流图的约束范围内。另请参阅此评论

评论

0赞 Hadi Brais 4/24/2018
gcc 是否故意放置在该位置?我的意思是,它是否试图将分支的条件放置在远离分支的地方?循环的主体很小,处理器可能会将其所有指令一起解码,在执行之前可以做一个预测。我们怎么知道 比其他函数快,因为条件是提前计算的,而不是因为条件的计算速度要快得多?中的分支条件取决于内存访问。add edx,0x1sum_countersum_counteradd edx,0x1sum_countersum_sentinel
0赞 Hadi Brais 4/24/2018
“让我们将分支预测添加到组合中”是什么意思?代码是什么样子的?
2赞 BeeOnRope 4/25/2018
@HadiBrais - 是的,条件的计算方式发生了变化。这就是重点:您需要影响数据流图,这意味着源的更改,因为重新排序独立的行(或程序集)不会影响数据流图。但是,我不同意我更改它以使计算速度更快,因为至少大多数人会理解该术语:变体具有更多指令、更多总 uops 等。改变的是分支在数据流图中的位置:它已经向上移动(即,更接近根节点)。sum_counter
1赞 BeeOnRope 4/25/2018
在程序集,可能在 C/C++ 级别,您可能会想出一个仅移动跳转指令会有所帮助的示例:指令流中出现的顺序指令也会影响执行顺序:顺序受数据流图的约束,但在这些约束中,较早出现的指令可能会更早执行(这都是因为 CPU 调度器在具有多个选项时会优先执行它们, 并且因为它们可以更早地进行调度)。然而,这种影响是相当温和的——这里或那里的循环。
1赞 Nemo 11/3/2019
这是我在 SO 上见过的最有趣的答案之一。
3赞 Leeor 6/8/2018 #3

分支错误预测的主要问题不在于它在刷新年轻操作时产生的几个周期(相对较快),而是如果存在数据依赖关系,它可能会在管道上很晚发生,分支条件必须首先解决。

对于基于先前计算的分支,依赖项的工作方式与其他操作相同。此外,分支很早就沿着管道进行预测,以便机器可以继续获取和分配进一步的操作。如果预测不正确(这与通常表现出更可预测的模式的循环控件不同,则仅当依赖关系得到解决并且预测被证明是错误的时,才会发生刷新。这种情况发生得越晚,处罚就越大。

由于无序执行会在解决依赖关系后立即调度操作(假设没有端口压力),因此提前移动操作可能无济于事,因为它不会改变依赖关系链,也不会对调度时间产生太大影响。唯一的潜在好处是,如果你把它移得足够远,这样 OOO 窗口就可以更早地看到它,但现代 CPU 通常会提前运行数百条指令,并且在不破坏程序的情况下将指令提升到那么远是很困难的。但是,如果您正在运行一些循环,那么如果可能的话,计算未来迭代的条件可能很简单。

这些都不会改变完全正交的预测过程,但是一旦分支到达机器的OOO部分,它将立即得到解决,如果需要,它会被清除,并产生最小的惩罚。

评论

0赞 Peter Cordes 6/8/2018
OoO exec 通常以最旧-就绪-优先的顺序运行指令,因此尽早放置关键路径指令对于避免资源冲突可能很重要。(多个指令已准备就绪,没有足够的执行单元来运行它们)。在缓存未命中或其他后端停顿之后执行往往有些突发。在某些情况下,通过将关键路径指令放在其他独立工作之前可能会有所收获,这是合理的。但仍然是 +1,总的来说,OoO exec 使这接近于非问题。