为什么使用算术而不是_bittest以二进制打印数字会更快

时间:2017-11-18 21:01:27

标签: c++ performance assembly cpu-architecture

下两个代码部分的目的是以二进制打印数字 第一个通过两个指令(_bittest)执行此操作,而第二个通过纯算术指令(三个指令)执行此操作 第一个代码部分:

#include <intrin.h>
#include <stdio.h>  
#include <Windows.h>

long num = 78002;
int main()
{
    unsigned char bits[32];
    long nBit;
    LARGE_INTEGER a, b, f;
    QueryPerformanceCounter(&a);
    for (size_t i = 0; i < 100000000; i++)
    {
        for (nBit = 0; nBit < 31; nBit++)
        {
            bits[nBit] = _bittest(&num, nBit);
        }
    }
    QueryPerformanceCounter(&b);
    QueryPerformanceFrequency(&f);
    printf_s("time is: %f\n", ((float)b.QuadPart - (float)a.QuadPart) / (float)f.QuadPart);

    printf_s("Binary representation:\n");
    while (nBit--)
    {
        if (bits[nBit])
            printf_s("1");
        else
            printf_s("0");
    }
    return 0;
}

内部循环编译为指令bt和setb
第二个代码部分:

#include <intrin.h>
#include <stdio.h>  
#include <Windows.h>
long num = 78002;
int main()
{
    unsigned char bits[32];
    long nBit;

    LARGE_INTEGER a, b, f;
    QueryPerformanceCounter(&a);
    for (size_t i = 0; i < 100000000; i++)
    {
        long curBit = 1;
        for (nBit = 0; nBit < 31; nBit++)
        {
            bits[nBit] = (num&curBit) >> nBit;
            curBit <<= 1;
        }
    }
    QueryPerformanceCounter(&b);
    QueryPerformanceFrequency(&f);
    printf_s("time is: %f\n", ((float)b.QuadPart - (float)a.QuadPart) / (float)f.QuadPart);

    printf_s("Binary representation:\n");
    while (nBit--)
    {
        if (bits[nBit])
            printf_s("1");
        else
            printf_s("0");
    }
    return 0;
}

内循环编译并添加(左移)和sar 第二个代码部分运行速度比第一个代码部分快三倍。

为什么三个cpu指令运行得比两个快?

3 个答案:

答案 0 :(得分:1)

没有回答(Bo做了),但第二个内循环版本可以简化一下:

    long numCopy = num;
    for (nBit = 0; nBit < 31; nBit++) {
        bits[nBit] = numCopy & 1;
        numCopy >>= 1;
    }

gcc 7.2 targetting 32b有微妙的区别(少1个指令)。

(我假设32b目标,因为你将long转换为32位数组,这只在32b目标上有意义...我假设x86,因为它包括{{1虽然我认为Windows现在甚至有64b版本,但是它显然已经过时的操作系统目标?(我不在乎。))

答案:

  

为什么三个cpu指令运行得比两个快?

因为指令的数量只与性能相关(通常越少越好),但是现代的x86 CPU是更复杂的机器,在执行之前将实际的x86指令转换为微代码,通过以下方式进一步转换:顺序执行和寄存器重命名(打破虚假依赖链),然后它执行生成的微码,不同的CPU单元只能执行一些微操作,所以在理想情况下你可能得到2-3微 - ops在单个周期内由2-3个单元并行执行,在最坏的情况下,您可能正在执行一个完整的微代码循环,实现一些复杂的x86指令,需要几个周期才能完成,阻塞大部分CPU单元。

另一个因素是来自内存和内存写入的数据的可用性,单个高速缓存未命中,当必须从更高级别的高速缓存中获取数据时,或者甚至是内存本身时,会产生数十到数百个周期的停顿。拥有紧凑的数据结构有利于可预测的访问模式而不会耗尽所有缓存行,这对于利用最大的CPU性能至关重要。

如果您处于阶段&#34;为什么3条指令比2条指令更快&#34;,您几乎可以从任何x86优化文章/书开始,并继续阅读几个月或几年,它& #39;相当复杂的主题。

您可能需要查看此答案https://gamedev.stackexchange.com/q/27196以便进一步阅读...

答案 1 :(得分:1)

我假设你正在使用x86-64 MSVC CL19(或类似代码的东西)。

_bittest速度较慢,因为MSVC执行的工作非常糟糕,并且将值保留在内存中,bt [mem], reg bt reg,reg慢。 这是编译器遗漏优化。即使初始化程序仍然是常量,即使你将num设为局部变量而不是全局变量,也会发生这种情况!

我为英特尔Sandybridge系列CPU提供了一些性能分析,因为它们很常见;你没有说,是的,这很重要:bt [mem], reg在Ryzen上每3个周期有一个吞吐量,Haswell每5个周期吞吐量一个。其他性能特征不同......

(仅仅看一下asm,通常最好用args创建一个函数来获取编译器不能进行常量传播的代码。在这种情况下它不能因为它不知道是否任何内容在num运行之前修改main,因为它不是static。)

您的指令计数不包括整个循环因此您的计数错误,但更重要的是您没有考虑不同指令的不同成本。 (见Agner Fog's instruction tables and optimization manual。)

这是你内在的_bittest内在循环,Haswell / Skylake的uop计数:

    for (nBit = 0; nBit < 31; nBit++) {
        bits[nBit] = _bittest(&num, nBit);
        //bits[nBit] = (bool)(num & (1UL << nBit));   // much more efficient
    }

<强> Asm output from MSVC CL19 -Ox on the Godbolt compiler explorer

$LL7@main:
    bt       DWORD PTR num, ebx          ; 10 uops (microcoded), one per 5 cycle throughput
    lea      rcx, QWORD PTR [rcx+1]      ; 1 uop
    setb     al                          ; 1 uop
    inc      ebx                         ; 1 uop
    mov      BYTE PTR [rcx-1], al        ; 1 uop (micro-fused store-address and store-data)
    cmp      ebx, 31
    jb       SHORT $LL7@main             ; 1 uop (macro-fused with cmp)

这是15个融合域uop,因此它可以在3.75个周期内发出(每个时钟4个)。但这不是瓶颈:Agner Fog的测试发现bt [mem], reg的吞吐量为每5个时钟周期一个。

IDK为什么它比你的其他循环慢3倍。也许其他ALU指令竞争与bt相同的端口,或者它导致问题的数据依赖性,或者仅仅是微编码指令是一个问题,或者外部循环效率较低?

无论如何,使用bt [mem], reg代替bt reg, reg是一个主要的错过优化。这个循环比其他循环更快,具有1 uop,1c延迟,每时钟2个吞吐量bt r9d, ebx

  

内部循环编译并添加(左移)和sar。

咦?这些是MSVC与curBit <<= 1;源代码行关联的指令(即使该行完全由add self,self实现,并且可变计数算术右移是不同行的一部分。)

但整个循环是这个笨重的混乱:

    long curBit = 1;
    for (nBit = 0; nBit < 31; nBit++)  {
        bits[nBit] = (num&curBit) >> nBit;
        curBit <<= 1;
    }

$LL18@main:               # MSVC CL19  -Ox
    mov      ecx, ebx                  ; 1 uop
    lea      r8, QWORD PTR [r8+1]      ; 1 uop   pointer-increment for bits
    mov      eax, r9d                  ; 1 uop.  r9d holds num
    inc      ebx                       ; 1 uop
    and      eax, edx                  ; 1 uop
       # MSVC says all the rest of these instructions are from             curBit <<= 1; but they're obviously not.
    add      edx, edx                  ; 1 uop
    sar      eax, cl                   ; 3 uops (variable-count shifts suck)
    mov      BYTE PTR [r8-1], al       ; 1 uop (micro-fused)
    cmp      ebx, 31
    jb       SHORT $LL18@main         ; 1 uop (macro-fused with cmp)

所以这是11个融合域uops,每次迭代需要2.75个时钟周期才能从前端发出。

我没有看到任何循环传输的dep链比前端瓶颈更长,所以它可能会快速运行。

每次迭代都将ebx复制到ecx而不是仅使用ecx作为循环计数器(nBit),这是一个明显错过的优化。 cl需要移位计数用于变量计数移位(除非您启用BMI2指令,如果MSVC甚至可以这样做。)

这里有一些主要的错过优化(在“快速”版本中),所以您应该以不同的方式编写源代码,手动保存编译器以减少错误代码。它实际上是相当实现的,而不是将其转换为CPU可以高效执行的操作,或者使用bt reg,reg / setc

如何在asm或内在函数中快速执行此操作

使用SSE2 / AVX。获取向量的每个字节元素中的正确字节(包含相应的位),并使用具有该元素的正确位的掩码获取PANDN(以反转向量)。 PCMPEQB零。这给你0 / -1。要获取ASCII数字,请使用_mm_sub_epi8(set1('0'), mask)将0或-1(加0或1)减去ASCII '0',有条件地将其转换为'1'

这的第一步(从位掩码中获取0 / -1的向量)是How to perform the inverse of _mm256_movemask_epi8 (VPMOVMSKB)?

在标量代码中,这是以每个时钟1位>>字节运行的一种方式。有可能在不使用SSE2的情况下做得更好(一次存储多个字节以绕过所有当前CPU上存在的每个时钟瓶颈的1个存储),但为什么要这么麻烦?只需使用SSE2。

  mov    eax, [num]
  lea    rdi, [rsp + xxx]  ; bits[]
.loop:
    shr   eax, 1     ; constant-count shift is efficient (1 uop).  CF = last bit shifted out
    setc  [rdi]      ; 2 uops, but just as efficient as setc reg / mov [mem], reg

    shr   eax, 1
    setc  [rdi+1]

    add   rdi, 2
    cmp   end_pointer    ; compare against another register instead of a separate counter.
    jb   .loop

两个展开以避免前端出现瓶颈,因此每个时钟可以运行1位。

答案 2 :(得分:0)

不同之处在于代码_bittest(&num, nBit);使用指向num的指针,这使得编译器将其存储在内存中。内存访问使代码慢了很多。

        bits[nBit] = _bittest(&num, nBit);
00007FF6D25110A0  bt          dword ptr [num (07FF6D2513034h)],ebx     ; <-----
00007FF6D25110A7  lea         rcx,[rcx+1]  
00007FF6D25110AB  setb        al  
00007FF6D25110AE  inc         ebx  
00007FF6D25110B0  mov         byte ptr [rcx-1],al  

另一个版本将所有变量存储在寄存器中,并使用非常快速的寄存器移位和添加。没有内存访问。