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

why is it faster to print number in binary using arithmetic instead of _bittest

本文关键字:二进制 式打印 数字 bitest 为什么      更新时间:2023-10-16

接下来两个代码部分的目的是用二进制打印数字
第一个通过两条指令(_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: %fn", ((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: %fn", ((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指令运行速度比两条快?

不回答(Bo回答了),但第二个内循环版本可以简化一点:

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

与以32b为目标的gcc 7.2有细微差异(少1条指令)。

(我假设32b目标,因为您将long转换为32位阵列,这仅在32b目标上有意义……我假设x86,因为它包括<windows.h>,所以它显然适用于过时的操作系统目标,尽管我认为windows现在甚至有64b版本?(我不在乎。))

答案:

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

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

另一个因素是内存中数据的可用性和内存写入,当数据必须从更高级别的缓存甚至内存本身提取时,单个缓存未命中会导致数十到数百个周期的停滞。拥有紧凑的数据结构有利于可预测的访问模式,并且不会耗尽所有缓存线,这对于最大限度地利用CPU性能至关重要。

如果您正处于"为什么3条指令比2条指令快"的阶段,那么您几乎可以从任何x86优化文章/书籍开始,并持续阅读几个月或几年,这是一个非常复杂的主题。

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

我假设您使用的是x86-64 MSVC CL19(或生成类似代码的东西)。

_bittest较慢,因为MSVC做得很糟糕,并且将值保持在内存中,而bt [mem], regbt reg,reg慢得多这是编译器错过的优化。即使将num设为局部变量而不是全局变量,即使初始值设定项仍然是常量,也会发生这种情况!

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

(对于asm来说,通常最好制作一个带有args的函数来获取编译器无法进行持续传播的代码。在这种情况下,它不能,因为它不知道在main运行之前是否有任何内容修改了num,因为它不是static。)


您的指令计数没有包括整个循环,因此您的计数是错误的,但更重要的是,您没有考虑不同指令的不同成本。(参见Agner Fog的说明书和优化手册。)

这是具有_bittest内在的整个内部循环,具有Haswell/Skylake:的uop计数

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

Godbolt编译器资源管理器上MSVC CL19-Ox的Asm输出

$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,1个延迟,每时钟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个融合域uop,每次迭代从前端发出需要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)将ASCII'0'减去0或-1(加0或1),并有条件地将其转换为'1'

此操作的第一步(从位掩码获得0/-1的矢量)是如何执行_mm256_movemask_epi8(VPMOVMSKB)的逆运算?。

  • 将32位解压缩为32字节SIMD矢量的最快方法(有128b版本)。如果没有SSSE3(pshufb),我认为punpcklbw/punpcklwd(可能还有pshufd)就是你需要将num的每个字节重复8次,并制作两个16字节的向量
  • intel avx2中的movemask指令是否有相反的指令

在标量代码中,这是以每个时钟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位的速度运行。

不同之处在于代码_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  

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