在条件上更新变量的最快方法是什么?

时间:2016-06-21 13:19:21

标签: c++ optimization

我有一个指针ptr和一个条件cond。如果ptrcond,我需要尽可能快的方式重置true,如果ptrcond,则需要保持false不变。目前的实施是非常简单的:

void reset_if_true(void*& ptr, bool cond)
{
    if (cond)
        ptr = nullptr;
}

我知道上面的代码性能很好,我不能指望优化它的主要性能提升。但是,这段代码每秒被调用数百万次,并且保存的每一小纳秒都是相关的。

我正在考虑摆脱分支的事情,例如:

void* p[] = { ptr, nullptr };
ptr = p[cond];

但我不确定这是继续进行的最佳方式。

5 个答案:

答案 0 :(得分:86)

void reset_if_true(void*& ptr, bool cond)
{
    if (cond)
        ptr = nullptr;
}

在大多数情况下,天真的解决方案无疑是最快的。虽然它有一个分支,在现代流水线处理器上可能很慢,但如果分支错误预测,它只会很慢。由于分支预测器现在非常好,除非cond的值非常难以预测,否则简单的条件分支可能是编写代码的最快方式。

如果不是,那么一个优秀的编译器知道并且能够在考虑目标架构的情况下优化代码。哪个转到gnasher729's point:只需用简单的方式编写代码并将优化留在优化器手中。

虽然一般来说这是一个很好的建议,但有时它会走得太远。如果您真的关心此代码的速度,则需要检查并查看编译器实际使用它的内容。检查它正在生成的目标代码,并确保它是合理的并且函数的代码内联。

这样的检查可能非常有启发性。例如,让我们考虑x86-64,其中分支在分支预测被挫败的情况下可能非常昂贵(这实际上是唯一一个有趣的问题,所以让我们假设{{ 1}}完全不可预测)。几乎所有的编译器都会为天真的实现生成以下内容:

cond

这与你想象的密码差不多。但是如果你将分支预测器放在一个病态的情况下,它可能最终比使用条件移动慢:

reset_if_true(void*&, bool):
    test   sil, sil              ; test 'cond'
    je     CondIsFalse
    mov    QWORD PTR [rdi], 0    ; set 'ptr' to nullptr, and fall through
  CondIsFalse:
    ret

条件移动具有相对较高的延迟,因此它们比预测良好的分支慢得多,但它们可能比完全不可预测的分支更快。您希望编译器在定位x86体系结构时知道这一点,但它没有(至少在这个简单的示例中)对reset_if_true(void*&, bool): xor eax, eax ; pre-zero the register RAX test sil, sil ; test 'cond' cmove rax, QWORD PTR [rdi] ; if 'cond' is false, set the register RAX to 'ptr' mov QWORD PTR [rdi], rax ; set 'ptr' to the value in the register RAX ret ; (which is either 'ptr' or 0) 的可预测性有任何了解。它假定了一个简单的情况,即分支预测将在您身边,并生成代码A而不是代码B.

如果您因为不可预测的情况而决定要鼓励编译器生成无分支代码,您可以尝试以下方法:

cond

这成功地说服了现代版本的Clang生成无分支代码B,但在GCC和MSVC中是一个完全的悲观。如果您还没有检查生成的程序集,那么您就不会知道。如果要强制GCC和MSVC生成无分支代码,则必须更加努力。例如,您可以使用问题中发布的变体:

void reset_if_true_alt(void*& ptr, bool cond)
{
    ptr = (cond) ? nullptr : ptr;
}

当定位x86时,所有编译器都会为此生成无分支代码,但它并不是特别漂亮的代码。实际上,它们都没有产生条件移动。相反,您可以多次访问内存以构建数组:

void reset_if_true(void*& ptr, bool cond)
{
    void* p[] = { ptr, nullptr };
    ptr = p[cond];
}

丑陋且可能非常低效。我预测即使在分支被错误预测的情况下,它也会为条件跳转版本提供资金。当然,你必须对它进行基准测试,但这可能不是一个好的选择。

如果你仍然迫切希望消除MSVC或GCC上的分支,你必须做一些更丑陋的事情,包括重新解释指针位并将它们弄乱。类似的东西:

reset_if_true_alt(void*&, bool):
    mov     rax, QWORD PTR [rdi]
    movzx   esi, sil
    mov     QWORD PTR [rsp-16], 0
    mov     QWORD PTR [rsp-24], rax
    mov     rax, QWORD PTR [rsp-24+rsi*8]
    mov     QWORD PTR [rdi], rax
    ret

这将为您提供以下内容:

void reset_if_true_alt(void*& ptr, bool cond)
{
    std::uintptr_t p = reinterpret_cast<std::uintptr_t&>(ptr);
    p &= -(!cond);
    ptr = reinterpret_cast<void*>(p);
}

同样,在这里,我们获得的指令多于简单的分支,但至少它们是相对低延迟的指令。现实数据的基准将告诉您是否值得权衡。如果您要实际签到这样的代码,请告诉您填写评论所需的理由。

一旦我走下了那个叮叮当当的兔子洞,我就能够迫使MSVC和GCC使用条件移动指令。显然他们没有进行这种优化,因为我们正在处理一个指针:

reset_if_true_alt(void*&, bool):
    xor   eax, eax
    test  sil, sil
    sete  al
    neg   eax
    cdqe
    and   QWORD PTR [rdi], rax
    ret
void reset_if_true_alt(void*& ptr, bool cond)
{
    std::uintptr_t p = reinterpret_cast<std::uintptr_t&>(ptr);
    ptr = reinterpret_cast<void*>(cond ? 0 : p);
}

考虑到CMOVNE的延迟和相似数量的指令,我不确定这实际上是否会比以前的版本更快。你跑的基准会告诉你它是不是。

同样地,如果我们对这个条件进行颠簸,我们就会节省一次内存访问:

reset_if_true_alt(void*&, bool):
    mov    rax, QWORD PTR [rdi]
    xor    edx, edx
    test   sil, sil
    cmovne rax, rdx
    mov    QWORD PTR [rdi], rax
    ret
void reset_if_true_alt(void*& ptr, bool cond)
{
   std::uintptr_t c = (cond ? 0 : -1);
   reinterpret_cast<std::uintptr_t&>(ptr) &= c;
}

(那是GCC.MSVC略有不同,更喜欢reset_if_true_alt(void*&, bool): xor esi, 1 movzx esi, sil neg rsi and QWORD PTR [rdi], rsi ret negsbbneg指令的特征序列,但是两个在道德上是等价的.Clang将它转换为我们在上面看到的相同的条件移动。)如果我们需要避免分支,这可能是最好的代码,考虑到它在保留的某些程度上在所有测试的编译器上生成合理的输出)源代码中的可读性。

答案 1 :(得分:16)

这里最低悬的水果并不是你想象的那样。正如其他几个答案中所讨论的那样,reset_if_true将被编译为机器代码,其速度与您可以合理地期望获取它的作用一样快。如果这还不够快,您需要开始考虑更改它的作用。我看到两个选项,一个很简单,一个不那么容易:

  1. 更改调用约定:

    template <class T>
    inline T* reset_if_true(T* ptr, bool condition)
    {
        return condition ? nullptr : ptr;
    }
    

    然后更改调用者以读取类似

    的内容
    ptr_var = reset_if_true(ptr_var, expression);
    

    这样做是为了让ptr_var更有可能在每秒数百万次调用reset_if_true的关键最内层循环中生活在一个寄存器中,并赢得&# 39; t是与之关联的任何内存访问。 ptr_var被迫退出内存是代码中最昂贵的东西,就像它现在一样;甚至比潜在的错误预测分支更昂贵。 (一个足够好的编译器可以为你提供reset_if_true的转换是可以接受的,但它并不总是可以这样做。)

  2. 更改周围的算法,以便reset_if_true不再被调用每秒数百万次。

    由于您没有告诉我们周围的算法是什么,我无法帮助您。但是,我可以告诉你,做一些涉及每秒数百万次检查条件的事情,可能表示一个具有二次时间复杂度或更差的算法,这总是意味着你至少应该考虑找到一个更好的一个。 (可能 更好,唉。)

答案 2 :(得分:11)

只要我们有sizeof(size_t) == sizeof(void*),nullptr用二进制表示为0,size_t用所有位(或者用std :: uintptr_t)表示,你可以这样做:

// typedef std::uintptr_t ptrint_t; // uncomment if you have it
typedef size_t ptrint_t; // comment out if you have std::uintptr_t

void reset_if_true(void*& ptr, bool cond)
{
    ((ptrint_t&)ptr) &= -ptrint_t( !cond );
}

但请注意,从bool转换为size_t所花费的时间非常依赖于实现,并且可能需要一个分支。

答案 3 :(得分:5)

代码绝对简单。

通过内联函数(如果编译器没有单独内联它),你肯定会使事情变得更快。例如,内联可能意味着您设置为null的指针变量可能保留在寄存器中。

除此之外,这段代码非常简单,如果有任何技巧可以使它更快,编译器就会使用它们。

答案 4 :(得分:3)

更新:我重新实现了我的答案。

在下面的代码中,想法是将指针转换为数字并将其乘以数字(cond)。注意inline已使用。乘法可能有助于使用使用流水线的架构。

#include <cstdint>

template <typename T>
inline T* reset_if_true(T* p, bool cond) {
  void* ptr = (void*)p; // The optimising compiler (-O3) will get rid of unnecessary variables.
  intptr_t ptrint;
  // This is an unrecommended practice.
  ptrint = (intptr_t)ptr;
  ptrint = ptrint * cond;  // Multiply the integer
  void* ptr2 = (void*)ptrint;
  T* ptrv = (T*)ptr2;
  return ptrv;
}

使用示例:

#include <iostream>
#include <vector>

void test1(){
    //doulbe d = 3.141592;
    //typedef std::vector<double> mytype;
    std::vector<double> data = {3,1,4};
    auto ptr = &data;
    std::cout << (void*)ptr << std::endl;
    auto ptr2 = reset_if_true(ptr, 1);
    //auto ptr2 = (mytype*)reset_if_true(ptr, 1);
    std::cout << reset_if_true(ptr, 1) << " -> " << (*(reset_if_true(ptr, 1))).size() << std::endl;
    std::cout << reset_if_true(ptr, 2) << " -> "<< (*(reset_if_true(ptr, 2))).size() << std::endl;
    std::cout << reset_if_true(ptr, 0) <<
        " is null? " << (reset_if_true(ptr, 0) == NULL) <<  // Dont dereference a null.
        std::endl;
}


void test2(){
    double data = 3.141500123;
    auto ptr = &data;
    std::cout << (void*)ptr << std::endl;
    auto ptr2 = reset_if_true(ptr, 1);
    //auto ptr2 = (mytype*)reset_if_true(ptr, 1);
    std::cout << reset_if_true(ptr, 1) << " -> " << (*(reset_if_true(ptr, 1))) << std::endl;
    std::cout << reset_if_true(ptr, 2) << " -> "<< (*(reset_if_true(ptr, 2))) << std::endl;
    std::cout << reset_if_true(ptr, 0) <<
        " is null? " << (reset_if_true(ptr, 0) == NULL) <<  // Dont dereference a null.
        std::endl;

}

int main(){ test1(); test2(); }

使用以下标志进行编译:{{1​​}}。 输出是:

-O3 -std=c++14

在编译器命令行0x5690 0x5690 -> 3 0x5690 -> 3 0 is null? 1 0x5690 0x5690 -> 3.1415 0x5690 -> 3.1415 0 is null? 1 中使用此类选项时,可能存在内存对齐问题。另见-s FORCE_ALIGNED_MEMORY=1。不要忘记使用reinterpret_cast

cond可以是任何非零值。如果我们知道它不是0或1,那么这里有一些性能改进的空间。在这种情况下,你可以使用-O3另一个整数类型来表示cond。

PS。这是一个更新的答案。正如我在答案中已经明确提到的那样,之前的答案存在问题。解决方案是使用int,当然还有intptr_t

使用的编译器选项:

inline