使用汇编加速位测试操作

Posted

技术标签:

【中文标题】使用汇编加速位测试操作【英文标题】:Speeding up a bit test operation by using assembly 【发布时间】:2014-07-13 13:25:16 【问题描述】:

我有一个性能问题 - 我根本无法超越编译器生成代码的发布版本速度。它慢了 25%。我编写的函数在我的测试中被调用了大约 2000 万次,所以让它运行得更快会得到回报。

C++ 中的代码非常简单:

static inline char GetBit(char *data, size_t bit)
 
    return 0 != (data[bit / 8] & (1 << (bit % 8))); 

这是我为 64 位 MASM 编写的版本:

mov   rax, rdx  
mov   r10, 8h
xor   rdx, rdx  
div   rax, r10  
mov   al, byte ptr [rax+rcx]  
mov   bl, 1h
mov   cl, dl  
shl   bl, cl  
and   al, bl  
shr   al, cl  
ret 

好吧,我不是一个汇编程序专家,但我认为编译器不能仅仅创建更好的汇编程序就可以将代码速度提高 25%。所以诀窍[可能]在函数调用中。它尊重 C++ 代码的 inline 关键字并且不生成任何调用,但我无法使其适用于 asm 代码:​​

extern "C" inline char GetBitAsm(char *data, size_t bit);

我已经使用 dumpbin 反汇编了代码,我可以清楚地看到我的代码 + 函数调用。虽然没有为编译器的版本生成调用:

mov   rdx, qword ptr [bit]  
mov   rcx, qword ptr [data]  
call  GetBitAsm (013F588EFDh)  
mov   byte ptr [isbit], al  

另外还有 2 次读取和 1 次写入内存,而在编译器生成的内容中,可能只有 1 次读取。我在某处读到 div 操作码大约需要 20 个周期,而单个内存访问至少需要 100 个周期。因此,我认为从内存中删除 mov rdx 和 mov rcx ,用父函数中寄存器中的值替换它们就可以了

问题:

    这真的是它运行如此缓慢的原因吗?

    如何让asm写的函数在release版本中内联?

    如何进一步增强我的汇编代码,使其更快?

【问题讨论】:

你真的看过纯C版本的反汇编吗?如果编译器的代码明显更快,我不会感到惊讶。也许内联起到了作用(您可以通过将GetBit 放在不同的翻译单元中来测试它)但这可能不是唯一的原因。对于初学者,您可以将div 替换为右移(乘以3)和按位与(乘以0x7),编译器当然会这样做。一旦你减少到几个 1 周期位混洗指令,调度和指令选择就会产生重大影响,而编译器在这方面做得很好。 另请注意,有一个专门的指令称为BT 正是用于此目的。 IMO,如果您“不是一个汇编程序专家”,那么假设您只是坐下来编写性能优于许多人多年经验的代码就有点过分了。几十年来,编译器生成的代码没有您尝试的那么慢。 删除 div 操作码被证明是一个好主意。没有我的代码运行得和编译器的代码一样快。 如果使用 MSVC,请使用 _bittest 内在函数。如果使用 gcc,use inline asm to generate the bt instruction. 【参考方案1】:

相对于任何编译器的内联代码,听到的函数调用和汇编代码中的 DIV 指令会降低性能。单独的函数调用开销可能大于编译器代码平均占用的周期数。 DIV 指令可能要大几倍。

现代处理器上的内存访问通常是免费的,因为它们可以从处理器的缓存中得到满足。在您的汇编版本中,您的内存访问平均花费 0 个周期,因为您的代码可能足够慢,以至于处理器可以在需要访问它之前轻松地将内存预取到其缓存中。另一方面,编译器的代码可能足够快,以至于它从内存中读取值的速度可能比处理器获取它的速度要快。它必须定期停止等待获取编译。因此,虽然编译器代码中的平均内存访问周期时间会更高,但这仅仅是因为它得到了更好的优化。

解决问题的最佳方法是让编译器进行优化。坦率地说,它似乎比你知道如何生成更好的代码。即使是汇编专家也很难改进编译器,并且需要在更广泛的范围内查看问题,而不仅仅是这一功能的指令选择。

如果您仍然宁愿使用自己的汇编代码,那么请使用编译器的内联汇编功能,并摆脱 DIV 指令。它的性能仍然不如编译器的版本,但应该会更接近。

【讨论】:

我摆脱了 div,这似乎是最大的问题。现在它的运行速度与编译器版本一样快 `mov rax, rdx shr rax, 3h and dx, 7h bt word ptr [rcx+rax], dx lahf mov al, ah and al, 1h ret` @user3834213 你也可以考虑使用setc al 您可能已经达到了两个版本的处理器访问内存速度的极限。您是在实际情况下测量代码的性能,即您实际上是按照预期处理位,还是仅单独测量 GetBit 函数的性能? @RossRidge:这是一个真实的工业样本。从 FEA 求解器运行(如 nastran、ansys 等)返回一组数百万个节点或元素 ID(作为整数)。它们存储在二叉树结构中。然后应用交集、联合等操作。我正在寻找一种通过优化我们的代码来减少运行时间的方法。在某些情况下可能需要几个小时。 如果您按顺序访问位,您可以尝试创建一个流式位读取器,它只从数据数组中读取每个字节一次。如果您的数据是对齐的,那么一次读取 64 位值也可以提高性能。【参考方案2】:

我会在这里做一个长镜头,并推测一下你想要做什么,所以请耐心等待:

您的代码有几件事让我印象深刻,(C++ 和程序集)首先是其他人提到的您使用 div 和 mod。这些操作相当慢,您无法与编译器竞争的原因之一是,它很可能会优化这些操作。

您正在使用 2 的幂,计算机是为使用 2 的幂而设计的。这意味着这相当于您的代码:

static inline char GetBit(char *data, size_t bit)
 
    return 0 != (data[bit >> 3] & (1 << (bit & 0x07))); 

您可以使用它来改进您的程序集,但这不太可能给您带来很大的性能提升。

另一方面,如果您的目标是加快代码速度,我会建议进行以下更改:

    在您的大位掩码中,将基本类型更改为您的处理器本机大小,即 uint32_t 用于 32 位机器,uint64_t 用于 64 位机器。

    另外,将 getBit() 函数分成两个函数,getWord() 和 getBit()。

    getWord() 应该很长:

    static inline uint32_t getWord(const uint32_t *data, size_t bit)  
        return data[ bit / sizeof(*data)*8 ]; // Again, the compiler will most 
                                              // likely pick up that this is a 
                                              // division by a power of 2 and 
                                              // optimize accordingly.
                                              // Check to be certain.
    
    
    static inline uint32_t getBit(const uint32_t *data, size_t bit)  
         return getWord(data, bit) & (1 << (bit & (sizeof(*data)*8 - 1)); 
         // Or just % like above, check which is faster. 
    
    

    如果您使用此位掩码重写代码,应该会真正加快速度:

      如果您在缓冲区中跳动很多次,您可能只会从上述建议中获得轻微的改善。

      但是,如果您以线性方式迭代数据,我建议您将代码更改为:

       uint32_t mask = 1;
       uint32_t word;
       for ( int bit = 0; bit < 2048; i++) 
           word = getWord(buffer, i); // You could also move this outside a smaller loop, but I'm not sure it's worth it.
           if (word & mask) 
               cout << "Bit " << bit << " is set." << endl;
           
      
           // Most modern compilers will recognize the following as a ROL 
           // (ROtational Left shift) and replace it with one instruction.
           mask = (mask << 1 | mask >> (sizeof(mask)*8-1)); 
       
      

    这是一个好主意的原因是处理器经过优化以使用原生大小的整数,您可以避免对齐问题,升级寄存器中的值等。您可能还注意到,通过在外部使用掩码循环避免额外的移位/除法,因为我们只是让遮罩在填充时滚动。

【讨论】:

首选CHAR_BITS而不是硬编码8。很少找到没有8位/字节的架构,但有可能。 公平点,但 uint32_t 会在这些架构上定义,还是您需要完全不同的东西?【参考方案3】:

除了已经说过的所有事情之外,您还必须注意“内联”功能:

您可以尝试从(纯 C/C++)函数中删除“内联”并将该函数移动到另一个 C 文件中,以确保编译器不会内联该函数。你会看到这个函数运行起来会慢很多。

原因:当一个函数“内联”时,编译器可能会优化很多。当函数不是“内联”时,编译器必须将函数参数存储在堆栈上(使用“推送”)并执行“调用”指令。这将花费大量时间并使代码比“内联”函数慢得多。

对于小段代码,这些操作所需的时间远远超过使用汇编代码可以节省的时间!

【讨论】:

请注意,“将其移至其他文件”步骤很重要。如果定义是可见的,编译器可以并且在不存在inline 的情况下进行内联。 在 x86-64 上,新的 ABI 使函数调用更便宜,因为您在堆栈上传递大多数参数。

以上是关于使用汇编加速位测试操作的主要内容,如果未能解决你的问题,请参考以下文章

汇编速查表

汇编语言中test的用法

在win7—64位上使用DEBUG调试汇编程序

汇编 测试AL寄存器中的数,如果是负数转到标号NEXT去执行

在win7—64位上使用DEBUG调试汇编程序

20160402_C语言位操作符的使用