分支预测和分支目标预测优化

时间:2015-08-29 21:34:15

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

我的代码经常调用具有多个(不可预测的)分支的函数。当我分析时,我发现它是一个小瓶颈,大部分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仍然无法预测将被调用的函数?假设这与分支目标预测有关,我是否正确?

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

修改

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

3 个答案:

答案 0 :(得分:7)

  

第二种变化只是一种更隐式的条件,就像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] &= 0x7fmaxval = max(a[i], maxval)是例外情况。

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

在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.

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

如果你没有做memcpy,那么让src对齐是有利的,所以加载可以作为内存操作数折叠成另一条指令。这样可以保存指令,并且在许多情况下还可以在内部保存英特尔uop。

对于src和dest具有不同对齐的情况,我还没有测试对齐加载和未对齐存储是否更快,或者相反。我选择了对齐的商店,因为对于短缓冲区可能具有存储 - &gt;加载转发优势。如果dest缓冲区是对齐的,并且只有几个向量长,并且将立即再次读取,那么如果负载越过前两个商店之间的边界,那么来自dest的对齐加载将停止约10个周期(Intel SnB) 39; t尚未进入L1缓存。 (即商店转发失败)。有关此类低级详细信息(尤其是微型指南)的信息,请参阅http://agner.org/optimize/

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

对于具有不同路线,对齐负载和未对齐存储的大型缓冲区,可能会做得更好。我只是在这里制作东西,但如果未对齐的商店即使跨越缓存行或页面行也可以快速退出,这可能是真的。当然,在实际加载数据之前,未对齐的加载不能退出。随着飞行中的加载/存储指令越多,缓存未命中的可能性就越小。 (您可能会利用更多CPU的加载/存储缓冲区。)同样,纯粹的推测。我试图谷歌如果未对齐的商店比未对齐的货物更好或更差,但只是得到了关于如何做它们的命中,以及适用于两者的错位罚款。

答案 1 :(得分:4)

您可以尝试这样的事情:

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;
}

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

答案 2 :(得分:3)

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

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