【问题标题】:Branch prediction and branch target prediction optimization分支预测和分支目标预测优化
【发布时间】:2015-11-24 06:51:10
【问题描述】:

我的代码频繁调用具有多个(不可预测的)分支的函数。当我进行分析时,我发现这是一个小瓶颈,大部分 CPU 时间都用于条件 JMP。

考虑以下两个函数,其中原始函数有多个显式分支。

void branch_example_original(void* mem, size_t s)
{
    if(!(s & 7)) {
        /* logic in _process_mem_64 inlined */
    }
    else if(!(s & 3)) {
        /* logic in _process_mem_32 inlined */
    }
    else if(!(s & 1)) {
        /* logic in _process_mem_16 inlined */
    }
    else {
        /* logic in _process_mem_8 inlined */
    }
}

这是新功能,我试图删除导致瓶颈的分支。

void branch_example_new(void* mem, size_t s)
{
    const fprocess_mem mem_funcs[] = {_process_mem_8, _process_mem_16, _process_mem_32, _process_mem_64};
    const uint32_t magic = 3 - !!(s & 7) - !!(s & 3) - !!(s & 1);
    mem_funcs[magic](mem, size >> magic);
}

但是,当我分析新代码时,性能仅提高了约 20%,而且 CALL 本身(到 mem_funcs 数组中的一个 func)花费了很长时间。

第二个变体是否只是一个更隐含的条件,因为 CPU 仍然无法预测将被调用的函数?我是否正确假设这与分支目标预测有关?

为什么会发生这种情况,还有其他解决方案吗?

编辑:

感谢您的想法,但我也想解释一下为什么会发生这种情况。

【问题讨论】:

  • 这看起来像是一个处理对齐/未对齐内存地址的函数。你能做些什么来保证对齐吗?你知道最常走哪条路吗?你能预测调用点的对齐方式吗(例如,如果你知道你的内存块是 64 字节对齐的)?
  • 它确实处理对齐/未对齐的内存,但在这种情况下我无法保证大小或对齐。
  • @nneonneo:即使您不能保证对齐或大小,您通常也可以进行一次字节介绍,直到对齐,然后向量直到您在 15B 以内最后,然后是一个字节的清理。所以你大部分时间都在做大的对齐块,使用标量设置/清理。
  • 达夫的设备?或其衍生物。

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


【解决方案1】:

第二个变体只是一个更隐含的条件,因为 CPU 仍然无法预测将要调用的函数?我是否正确 假设这与分支目标预测有关?

是的,无条件间接分支需要一个分支目标缓冲区命中,以便 CPU 确定接下来从哪里获取代码。现代 CPU 是大量流水线的,如果它们要避免管道中没有任何事情可做的气泡,则需要在它们执行的位置之前提前获取代码。必须等到magic 被计算出来才能避免指令获取气泡。我认为,性能计数器会将 BTB 未命中显示为分支错误预测。

正如我在评论中所建议的那样,如果可以的话,您应该重构代码以围绕矢量化循环进行标量介绍和清理。介绍处理元素,直到您到达对齐的元素。清理循环处理在最后一个完整向量之后有非零数量的元素要处理的情况。这样您就不会因为第一个元素的大小或对齐方式不理想而陷入标量循环。


根据您正在处理的内容,如果可以重复工作和重叠,那么您可以进行无分支启动,执行未对齐的块,然后对齐其余部分。一些库可能会像这样阻止memset

// not shown: check that count >= 16
endp = dest + count;
unaligned_store_16B( dest );    // e.g. x86 movdqu
dest+=16;
dest &= ~0xf;  // align by 16, first aligned write overlaps by up to 15B
for ( ; dest < endp-15 ; dest+=16) {
    aligned_store_16B( dest );  // e.g. x86 movdqa
}
// handle the last up-to-15 bytes from dest to endp similarly.

这使得处理循环的未对齐开始无分支,因为您不在乎未对齐的开始重叠了多少。

请注意,大多数单缓冲区函数是不可重复的。例如就地a[i] *= 2sum+=a[i] 需要避免两次处理相同的输入。通常使用标量循环,直到您到达对齐的地址。不过,a[i] &amp;= 0x7fmaxval = max(a[i], maxval) 是例外。


具有两个可以错位不同数量的独立指针的函数比较棘手。您必须小心不要通过遮罩更改它们的相对偏移量。 memcpy 是处理从 src 到 dest 缓冲区的数据的函数的最简单示例。如果(src+3) %16 == 0(dest+7) %16 ==0memcpy 必须工作。除非您可以对调用者施加约束,否则您通常能做的最好的事情就是让每个加载或每个存储都在主循环中对齐。

在 x86 上,未对齐的移动指令(movdqu 和朋友)与需要对齐的版本一样快当地址对齐时。因此,当 src 和 dest 具有相同(错误)对齐并且加载和存储都可以对齐时,您不需要单独版本的循环。 IIRC,这适用于 Intel Nehalem 和更新的 CPU,以及最近的 AMD。

// check count >= 16
endp = dest + count;
unaligned_copy_16B( dest, src );  // load with movdqu, store with movdqu
// src+=16; dest+=16;  // combine this with aligning dest, below

dest_misalign = dest & 0xf;  // number of bytes the first aligned iteration will overlap
src  += 16 - dest_misalign;  // src potentially still misaligned
dest += 16 - dest_misalign;  // dest aligned

for ( ; dest <= endp-16 ; src+=16, dest+=16) {
    tmpvec = unaligned_load_16B( src ); // x86 movdqu is fast if src is aligned
    aligned_store_16B( dest, tmpvec );  // x86 movdqa
}
// handle the last dest to endp bytes.

对齐的目标可能比对齐的源更有可能。当我们对齐的指针已经对齐时,不会发生重叠的重复工作。

如果您不使用 memcpy,则将 src 对齐可能是一个优势,这样加载可以作为内存操作数折叠到另一条指令中。这会保存一条指令,并且在许多情况下还会在内部保存一条 Intel uop。

对于 src 和 dest 具有不同对齐方式的情况,我没有测试对齐加载和未对齐存储是否更快,或者相反。我选择对齐的存储是因为潜在的存储-> 加载转发对短缓冲区的好处。如果 dest 缓冲区是对齐的,并且只有几个向量长,并且将立即再次读取,则来自 dest 的对齐加载将停止约 10 个周期(英特尔 SnB),如果加载跨越两个先前存储之间的边界t 还没有进入 L1 缓存。 (即存储转发失败)。请参阅http://agner.org/optimize/ 了解有关此类低级详细信息的信息(尤其是微架构指南。)

仅当缓冲区很小(可能高达 64B?),或者如果您的下一个循环开始从缓冲区末尾读取(仍将在缓存,即使开头已经被驱逐)。否则,存储到缓冲区开头的存储将使其从存储缓冲区到 L1,因此存储转发不会发挥作用。

对于具有不同对齐方式的大型缓冲区,对齐加载和未对齐存储可能会做得更好。我只是在这里编造一些东西,但如果未对齐的存储即使跨越缓存行或页面行也可以快速退休,这可能是真的。当然,在实际加载数据之前,未对齐的加载不能退出。随着更多的加载/存储指令在运行中,缓存未命中停止事情的机会就会减少。 (您可能会利用更多 CPU 的加载/存储缓冲区。)再次,纯粹的猜测。如果未对齐的商店比未对齐的加载更好或更差,我尝试在 Google 上搜索,但只是得到了有关如何执行它们的点击,以及适用于两者的未对齐处罚。

【讨论】:

  • 感谢您的解释!我还尝试实现您的解决方案(减去循环,因为我不做副本),它加快了小块的速度,即使有初始化的开销。
  • 如果 memcpy 是您想要的,那么在许多系统上调用 memcpy 是最快的方法。例如,MacOS X 将在启动时设置 memcpy 代码,该代码针对您计算机中的特定处理器进行了优化,并且执行您甚至不了解的优化。
  • @gnasher729:我使用 memcpy 作为可重做操作的易于理解的示例,而不是您实际上想要自己实现的东西。
  • +=15 会更好吗?
  • @user3528438:考虑到这一点,我意识到当 src 和 dest 有不同的对齐方式时,我遇到了一个错误。现在修好了。我添加了memset 作为一个可以重新处理字节的单缓冲区函数的简单示例。 (不过,大多数就地函数是不可重复的)。
【解决方案2】:

现代处理器不仅具有分支预测,还具有跳转预测。例如,如果您调用一个虚函数,它可能会预测实际函数与上一次调用中的相同,并在实际读取指向函数的指针之前开始执行——如果跳转预测错误,事情就会变慢。

同样的事情也发生在您的代码中。您不再使用分支预测,但处理器使用跳转预测来预测调用四个函数指针中的哪一个,当函数指针不可预测时,这会减慢速度。

【讨论】:

  • 这与我在第一段中所说的相同。分支目标缓冲区必须正确预测间接分支将跳转到的位置,否则您会陷入停顿。在 CPU 术语中,无条件跳转仍然称为分支,因为它们会破坏指令字节进入管道的顺序流。
【解决方案3】:

你可以试试这样的:

switch(s & 7) {
case 0:
    /* _process_mem_64 */
    break;
case 1:
case 3:
case 5:
case 7:
    /* _process_mem_8 */
    break;
case 2:
case 6:
    /* _process_mem_16 */
    break;
case 4:
    /* _process_mem_32 */
    break;
}

这只涉及到跳转表的一次跳转,不需要调用指令。

【讨论】:

  • 谢谢,但它是导致分支的代码,而不是调用指令。就我而言,删除它解决了问题。
猜你喜欢
  • 2014-04-25
  • 2014-03-03
  • 1970-01-01
  • 2018-07-02
  • 2013-09-04
  • 2016-07-01
  • 2011-02-01
  • 2012-06-25
  • 2012-07-02
相关资源
最近更新 更多