双布尔乘法有多快,它能被矢量化吗

how fast is double*bool multiplication, can it be vectorized?

本文关键字:矢量化 布尔乘      更新时间:2023-10-16

我将常数vector<bool>乘以不同的vector<double>多次。我想知道这有多快,先把它转换成vector<double>,这样就可以使用sse了,不是更快吗?

    void applyMask(std::vector<double>& frame, const std::vector<bool>& mask)
    {
        std::transform(frame.begin(), frame.end(), mask.begin(), frame.begin(), [](const double& x, const bool& m)->double{ return x*m;});
    }

似乎您正试图使用vector<bool>的掩码来归零vector<double>的部分。

目前的情况是,它不可向量化。此外,vector<bool>模板专用化将阻碍编译器进行任何类型的自动向量化。

所以你基本上有两个选择:

简单的方法是将vector<bool>转换为相应的零和一的vector<double>。然后,问题简化为同一数据类型的简单向量到向量相乘,这是完全可向量化的。(甚至可自动矢量化)

更难的方法(可能更快)是使用_mm_and_pd_mm_blendv_pd()内部函数/指令进行一些破解。但这需要做更多的工作,因为您必须手动向量化代码。


我建议你选择简单的方法。除非您真的需要,否则无需深入手动矢量化。

我试过这两种方法,你的函数和你的问题一样,还有这个:

void applyMask(std::vector<double>& frame, const std::vector<bool>& mask)
{
    std::transform(frame.begin(), frame.end(), mask.begin(), frame.begin(), [](const double& x, const bool& m)->double{ return m?x:0.0;});
}

我还尝试将bool的向量更改为两倍,以查看每个选项之间的差异。

最后,我提出了一个完全不同的算法,因为我认为在这种情况下可以使用更好的算法。

  1. 乘法

    xmm0变量是一个SSE寄存器。但它只是用来做双打的工作,而不是并行化。

     b0e:       8b 50 08                mov    0x8(%rax),%edx
     b11:       66 0f ef c0             pxor   %xmm0,%xmm0
     b15:       48 83 c6 10             add    $0x10,%rsi
     b19:       48 83 c0 08             add    $0x8,%rax
     b1d:       31 c9                   xor    %ecx,%ecx
     b1f:       83 e2 01                and    $0x1,%edx
     b22:       f2 0f 2a c2             cvtsi2sd %edx,%xmm0
     b26:       f2 0f 59 46 f8          mulsd  -0x8(%rsi),%xmm0
     b2b:       f2 0f 11 46 f8          movsd  %xmm0,-0x8(%rsi)
     b30:       83 c1 01                add    $0x1,%ecx
     b33:       ba 01 00 00 00          mov    $0x1,%edx
     b38:       48 d3 e2                shl    %cl,%rdx
     b3b:       48 85 10                test   %rdx,(%rax)
     b3e:       66 0f ef c0             pxor   %xmm0,%xmm0
     b42:       0f 95 c2                setne  %dl
     b45:       83 f9 3f                cmp    $0x3f,%ecx
     b48:       0f b6 d2                movzbl %dl,%edx
     b4b:       f2 0f 2a c2             cvtsi2sd %edx,%xmm0
     b4f:       48 8d 56 08             lea    0x8(%rsi),%rdx
     b53:       f2 0f 59 06             mulsd  (%rsi),%xmm0
     b57:       f2 0f 11 06             movsd  %xmm0,(%rsi)
     b5b:       0f 85 17 01 00 00       jne    c78 <main+0x298>
    

    这大约是22条指令。jne是循环分支。它被重复了8次,因为循环被展开了那么多次。这也是为什么我说";大约22个指令";。它会因重复而变化。

  2. 三元算子

    在这种情况下,我们选择布尔值为true时的值。这增加了一个分支,这意味着代码可能会以不同的速度运行,这取决于有多少标志是真或假。

     a83:       83 c1 01                add    $0x1,%ecx
     a86:       ba 01 00 00 00          mov    $0x1,%edx
     a8b:       48 d3 e2                shl    %cl,%rdx
     a8e:       48 85 10                test   %rdx,(%rax)
     a91:       66 0f ef c0             pxor   %xmm0,%xmm0
     a95:       74 05                   je     a9c <main+0xbc>
     a97:       f2 0f 10 45 08          movsd  0x8(%rbp),%xmm0
     a9c:       83 f9 3f                cmp    $0x3f,%ecx
     a9f:       f2 0f 11 45 08          movsd  %xmm0,0x8(%rbp)
     aa4:       48 8d 55 10             lea    0x10(%rbp),%rdx
     aa8:       0f 84 d4 01 00 00       je     c82 <main+0x2a2>
    

    也就是说,每个循环减少了11条指令。第二个je用于循环,就像上面的代码一样。

  3. 两个双矢量

    另一方面,当我们使用double时,我们避免了(1)中的转换,如果反复使用相同的掩码,并且如果你的向量相当大,这将是一个很好的优化:

    a9d:   31 d2                   xor    %edx,%edx
    a9f:   66 41 0f 2e 44 24 28    ucomisd 0x28(%r12),%xmm0
    aa6:   0f 9a c2                setp   %dl
    aa9:   0f 45 d0                cmovne %eax,%edx
    aac:   f2 0f 59 4b 20          mulsd  0x20(%rbx),%xmm1
    ab1:   f2 0f 11 4b 20          movsd  %xmm1,0x20(%rbx)
    ab6:   66 0f ef c9             pxor   %xmm1,%xmm1
    aba:   f2 0f 2a ca             cvtsi2sd %edx,%xmm1
    

    这是8条指令!我们没有看到树枝。但这是优化的一部分。应该至少有一个分支,所以它将是9条指令。

    看起来Mysticial的答案是正确的。不过,我并没有试着看看每一套指令的执行速度有多快。这并不是并行的。如果您想要完全并行化,那么您肯定必须在汇编中编写它,或者至少使用内部函数。

  4. 装配

    使用AVX,您可以一次加载8个带掩码的替身:

      8a3:   b8 a5 ff ff ff          mov    $0xffffffa5,%eax
      8a8:   c5 f9 92 c8             kmovb  %eax,%k1
      8ac:   62 f1 fd 49 28 85 50    vmovapd -0xb0(%rbp),%zmm0{%k1}
      8b3:   ff ff ff 
    

    在这个例子中,我在%eax(0xA5)中放置了一个8位掩码,在%k1中复制它,然后我从-0xb0(%rbp)加载一个值到%zmm0,在对应掩码位为0的任何位置屏蔽双精度(将它们设置为全零)。

    您还需要一条指令将%zmm0保存回内存,两条指令增加指针,以及一个计数器和一个分支。因此,在C++的最佳情况下,8个指令而不是9*8=72。无乘法,单次转换非常快(kmovb)。唯一的限制是:数组的大小必须是8的倍数。

    你也可以使用内在的,类似这样的东西:

     #include <immintrin.h>
         __mmask8 mask = 0xA5;
         __m512d a, b;
         __m512d res = _mm512_mask_blend_pd( mask, a, b );
    

    你必须检查一下文件。没有使用a或b中的一个。

    注意,这是一个";整数";指示它可以使用doubles,因为我们要么按原样加载64位,要么将其设置为全零,这与(double)0相同。

  5. 了解算法

    仔细想想你的问题,我还注意到你试图做的是在数组中保存一些零。您可以采取不同的做法,避免一个完整的加载/多重/保存周期。

    最接近支持该功能的C++算法是std::replace_if。问题是,测试针对的是与被替换的输入相同的数组值。所以在你的情况下,这没有帮助。

    std::replace_if算法如下所示:

     template<class ForwardIt, class UnaryPredicate, class T>
     void replace_if(ForwardIt first, ForwardIt last,
                     UnaryPredicate p, const T& new_value)
     {
         for (; first != last; ++first) {
             if(p(*first)) {
                 *first = new_value;
             }
         }
     }
    

    在您的情况下,您需要两个输入,并且new_value是已知的(0.0),所以这不是必需的,尽管如果存在,它肯定会被优化。所以现在我们可以像这样重写applyMask()函数:

     template<class ForwardValuesIt, class ForwardMaskIt, class T>
     void mask_if(ForwardValuesIt first, ForwardValuesIt last,
                  ForwardMaskIt mf, ForwardMaskIt ml,
                  const T& new_value = T())
     {
         for (; first != last && mf != ml; ++first, ++ml) {
             if(!*ml) {
                 *first = new_value;
             }
         }
     }
    

    这里的一个缺点是CCD_ 27中的CCD_。我觉得它不干净。但就算法而言,它使它更快。如果你只屏蔽了很少的两次,它将比transform()更快地完成最后一次读取/修改/写入循环。