【问题标题】:How to efficiently scan 2 bit masks alternating each iteration如何有效地扫描 2 位掩码交替每次迭代
【发布时间】:2022-01-28 19:13:30
【问题描述】:

给定 2 个位掩码,应交替访问 (0,1,0,1...)。我试图获得一个运行时高效的解决方案,但没有找到比以下示例更好的方法。

uint32_t mask[2] { ... };
uint8_t mask_index = 0;
uint32_t f = _tzcnt_u32(mask[mask_index]);
while (f < 32) {
    // element adding to result vector removed, since not relevant for question itself
    mask[0] >>= f + 1;
    mask[1] >>= f + 1;
    mask_index ^= 1;
    f = _tzcnt_u32(mask[mask_index]);
}

ASM 输出(MSVC,x64)似乎被炸毁了。

inc         r9  
add         r9,rcx  
mov         eax,esi  
mov         qword ptr [rdi+rax*8],r9  
inc         esi  
lea         rax,[rcx+1]  
shrx        r11d,r11d,eax  
mov         dword ptr [rbp],r11d  
shrx        r8d,r8d,eax  
mov         dword ptr [rbp+4],r8d  
xor         r10b,1  
movsx       rax,r10b  
tzcnt       ecx,dword ptr [rbp+rax*4]  
mov         ecx,ecx  
cmp         rcx,20h  
jb          main+240h (07FF632862FD0h)  
cmp         r9,20h  
jb          main+230h (07FF632862FC0h) 

有人给点建议吗?

(这是Solve loop data dependency with SIMD - finding transitions between -1 and +1 in an int8_t array of sgn values 使用 SIMD 创建位掩码的后续)

更新

我想知道一个潜在的解决方案是否可以通过将两个比特流的块加载到寄存器(在我的例子中为 AVX2)中来使用 SIMD,如下所示:

|m0[0]|m1[0]|m0[1]|m1[1]|m0[2]|m1[2]|m0[n+1]|m1[n+1]|

每个流有 1 个注册块

|m0[0]|m0[1]|m0[2]|m0[n+1]|

|m1[0]|m1[1]|m1[2]|m1[n+1]|

或将流分成相同大小的块,并一次处理尽可能多的 lanes 放入寄存器。假设我们有 256*10 个元素,可能会在 10 次迭代中结束,如下所示: |m0[0]|m0[256]|m0[512]|...| |m1[0]|m1[256]|m1[512]|...| 并单独处理join

不确定这是否是实现每个周期更多迭代并限制水平位扫描、移位/清除操作和避免分支的方法。

【问题讨论】:

  • 尽可能删除 while 循环,或尝试展开循环。也许使用 int64 而不是 2x int32 并进行位移以获得正确的 int。基准。 tzcnt 有多快?如果您可以在不跳转的情况下按顺序处理值,您可能会获得最佳性能。
  • 您是否尝试过使用std::swap(mask[0], mask[1]) 编写它,而不是欺骗编译器将掩码实际保存在内存中并对其使用变量索引?或者像杰罗姆说的,展开。 (您还每次都在欺骗 MSVC 将 uint8_t mask_index 零扩展为指针宽度。由于某种原因,它选择了符号扩展,其中 mov-elimination 甚至在 Intel CPU 上也不起作用......无论如何,不​​要使用完全没有索引,但如果你这样做了,至少要设为unsigned,而不是 8 位,因为它会存在于寄存器中。)
  • 我尽量避免在热点中使用 std 进行计算,以便完全控制(和学习)。正如@PeterCordes 指出的那样,这是处理孤立问题的另一个问题的后续行动。对我来说,Jérôme Richard 得出的答案完美地解释和解决了这个问题。我的结论是通过使用 1 个掩码而不是 2 个掩码来避免在早期阶段进行隐含分支。
  • std::swap 是微不足道的,但可以肯定的是,我也可以在这里手动使用一个临时的。不管你如何交换,只要你这样做,编译器就会为你解决这个问题,最坏的情况是xchg,最好是展开。我的主要观点是,有更多对编译器友好的方式来表达事物。
  • 嗯,如果你确实有一个面具而不是两个?然后你在tzcnt和班次之间使用not?或blsi / add 清除连续运行的运行?

标签: c++ performance optimization bit-manipulation x86-64


【解决方案1】:

这很难优化这个循环。主要问题是循环的每次迭代都依赖于前一个迭代,甚至循环中的指令也是依赖的。这会创建一个长的几乎顺序的指令链来执行。结果,处理器无法有效地执行此操作。此外,这条链中的一些指令具有相当高的延迟:tzcnt 在 Intel 处理器上具有 3 个周期的延迟,而 L1 加载/存储具有 3 个周期的延迟。

一种解决方案是直接使用寄存器而不是使用间接访问的数组,以减少链的长度,尤其是具有最高延迟的指令。这可以通过展开循环两次并将问题分成两个不同的问题来完成:

uint32_t m0 = mask[0];
uint32_t m1 = mask[1];
uint8_t mask_index = 0;

if(mask_index == 0) {
    uint32_t f = _tzcnt_u32(m0);

    while (f < 32) {
        m1 >>= f + 1;
        m0 >>= f + 1;
        f = _tzcnt_u32(m1);

        if(f >= 32)
            break;

        m0 >>= f + 1;
        m1 >>= f + 1;
        f = _tzcnt_u32(m0);
    }
}
else {
    uint32_t f = _tzcnt_u32(m1);

    while (f < 32) {
        m0 >>= f + 1;
        m1 >>= f + 1;
        f = _tzcnt_u32(m1);

        if(f >= 32)
            break;

        m0 >>= f + 1;
        m1 >>= f + 1;
        f = _tzcnt_u32(m0);
    }
}

// If mask is needed, m0 and m1 need to be stored back in mask.

这应该会快一点,特别是因为关键路径更小,而且两个班次可以并行执行。这是生成的汇编代码:

$loop:
        inc     ecx
        shr     edx, cl
        shr     eax, cl
        tzcnt   ecx, edx

        cmp     ecx, 32
        jae     SHORT $end_loop

        inc     ecx
        shr     eax, cl
        shr     edx, cl
        tzcnt   ecx, eax

        cmp     ecx, 32
        jb      SHORT $loop

请注意,现代 x86 处理器可以融合指令 cmp+jaecmp+jb 并且分支预测可以假设循环将继续,因此它只是错过预测最后的条件跳转。在 Intel 处理器上,关键路径由 1 周期延迟 inc、1 周期延迟 shr、3 周期延迟 tzcnt 组成,因此每轮 5 周期(1 轮 = 1 次迭代)的初始循环)。在 AMD Zen-like 处理器上,它是 1+1+2=4 个周期,非常好。进一步优化这一点似乎非常具有挑战性。

一种可能的优化方法是使用查找表,以便以更大的步长计算m0m1 的低位。但是,查找表获取具有 3 个周期的延迟,在实践中可能会导致代价高昂的缓存未命中,占用更多内存并使代码变得更加复杂,因为尾随 0 位的数量可能非常大(例如 28 位)。因此,我不确定这是一个好主意,尽管它确实值得一试。

【讨论】:

  • 我想知道我们是否可以使用blsmsk(m0),然后使用andn 将其应用于另一个。 (对自己来说,只有blsr)?所以关键路径变成了两个 1 循环链,一个依赖于另一个。使用独立的tzcnt + add 来获取要存储的实际索引值。会考虑一下,如果没有其他人先这样做,也许会在睡觉后把它写下来。嗯,Zen 有 2-uop blsmsk / blsr,只有 Intel 以单 uop 1c 延迟运行。 (即使是 Alder Lake E-core 也有 3c 延迟 1 uop)。
  • 太糟糕了,我们没有 SIMD tzcnt,或者我们甚至可以使用 SIMD add/subtract/boolean 来模拟那些 bithacks 以并行完成一大堆以便稍后整理,用延迟换取吞吐量.也许仍然这样做,但是存储向量然后以标量 tzcnt 的模式重新加载。 AMD 有 2/clock tzcnt 虽然它是 2 uops。 (可能是位反转 -> lzcnt)。
【解决方案2】:

这是另一种方式,未经测试。互联网上的人们都建议不要使用 goto,但有时,就像您的用例一样,该功能确实有帮助。

// Grab 2 more of these masks, or if you don't have any, return false
bool loadMasks( uint32_t& mask1, uint32_t& mask2 );
// Consume the found value
void consumeIndex( size_t index );

void processMasks()
{
    size_t sourceOffset = 0;
    uint32_t mask0, mask1;
    // Skip initial zeros
    while( true )
    {
        if( !loadMasks( mask0, mask1 ) )
            return;
        if( 0 != ( mask0 | mask1 ) )
            break;
        sourceOffset += 32;
    }

    constexpr uint32_t minusOne = ~(uint32_t)0;
    uint32_t idx;

    // Figure out the initial state, and jump
    if( _tzcnt_u32( mask0 ) > _tzcnt_u32( mask1 ) )
        goto testMask1;

    // Main loop below
testMask0:
    idx = _tzcnt_u32( mask0 );
    if( idx >= 32 )
    {
        sourceOffset += 32;
        if( !loadMasks( mask0, mask1 ) )
            return;
        goto testMask0;
    }
    consumeIndex( sourceOffset + idx );
    mask1 &= minusOne << ( idx + 1 );

testMask1:
    idx = _tzcnt_u32( mask1 );
    if( idx >= 32 )
    {
        sourceOffset += 32;
        if( !loadMasks( mask0, mask1 ) )
            return;
        goto testMask1;
    }
    consumeIndex( sourceOffset + idx );
    mask0 &= minusOne << ( idx + 1 );
    goto testMask0;
}

【讨论】:

    猜你喜欢
    • 2021-08-31
    • 1970-01-01
    • 1970-01-01
    • 2020-07-15
    • 1970-01-01
    • 2012-12-19
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多