繁体   English   中英

如何将二进制整数转换为十六进制字符串?

[英]How to convert a binary integer number to a hex string?

给定寄存器中的数字(二进制​​整数),如何将其转换为十六进制 ASCII 数字字符串? (即将其序列化为文本格式。)

数字可以存储在内存中或即时打印,但存储在内存中并同时打印通常更有效。 (您可以修改存储的循环,改为一次打印一个。)

我们能否有效地处理与 SIMD 并行的所有半字节? (SSE2 或更高版本?)

相关: 16 位版本,可将 1 个字节转换为 2 个十六进制数字,您可以将其打印或存储到缓冲区。 并且在汇编中将 bin 转换为 hex有另一个 16 位版本,在答案的一半中包含大量文本解释,涵盖了问题的 int -> hex-string 部分。

如果针对代码大小而不是速度进行优化,那么使用 DAS 可以节省一些字节


16 是 2 的幂 与十进制或其他不是 2 的幂的基数不同,我们不需要除法,我们可以先提取最重要的数字(即按打印顺序) 否则,我们只能首先获得最低有效位(其值取决于数字的所有位),我们必须倒退:请参阅如何在没有来自 c 库的 printf 的情况下在汇编级编程中打印整数? 对于非 2 次方碱基。

每个 4 位组的位映射到一个十六进制数字。 我们可以使用移位或旋转以及 AND 掩码将输入的每个 4 位块提取为 4 位整数。

不幸的是, 0..9 a..f 十六进制数字在 ASCII 字符集中( http://www.asciitable.com/ ) 不连续。 我们要么需要条件行为(分支或 cmov),要么可以使用查找表。

查找表对于指令计数和性能通常是最有效的,因为我们反复这样做; 现代 CPU 具有非常快的 L1d 缓存,这使得重复加载附近的字节非常便宜。 流水线/乱序执行隐藏了 L1d 缓存加载的约 5 个周期延迟。

;; NASM syntax, i386 System V calling convention
global itohex      ; inputs: char* output,  unsigned number
itohex:
    push   edi           ; save a call-preserved register for scratch space
    mov    edi, [esp+8]  ; out pointer
    mov    eax, [esp+12] ; number

    mov    ecx, 8        ; 8 hex digits, fixed width zero-padded
.digit_loop:             ; do {
    rol    eax, 4          ; rotate the high 4 bits to the bottom

    mov    edx, eax
    and    edx, 0x0f       ; and isolate 4-bit integer in EDX

    movzx  edx, byte [hex_lut + edx]
    mov    [edi], dl       ; copy a character from the lookup table
    inc    edi             ; loop forward in the output buffer

    dec    ecx
    jnz    .digit_loop   ; }while(--ecx)

    pop    edi
    ret

section .rodata
    hex_lut:  db  "0123456789abcdef"

为了适应 x86-64,调用约定将在寄存器而不是堆栈中传递参数,例如 x86-64 System V(非 Windows)的 RDI 和 ESI。 只需从堆栈中删除加载的部分,并将循环更改为使用 ESI 而不是 EAX。 (并使寻址模式为 64 位。您可能需要将hex_lut地址LEA 到循环外的寄存器中;请参阅thisthis )。

此版本转换为前导零的十六进制。 如果你想删除它们, bit_scan(input)/4 like lzcnt__builtin_clz在输入上,或者 SIMD compare -> pmovmksb -> tzcnt 在输出 ASCII 字符串上会告诉你有多少个 0 数字(因此你可以打印或从第一个非零开始复制)。 或者从低半字节开始转换并向后工作,当右移使值为零时停止,如使用 cmov 而不是查找表的第二个版本所示。

在 BMI2 ( shrx / rorx ) 之前,x86 缺少复制和移位指令,因此就地旋转然后复制/与很难击败1 现代 x86(Intel 和 AMD)具有 1 周期的旋转延迟( https://agner.org/optimize/https://uops.info/ ),因此这个循环承载的依赖链不会成为瓶颈。 (循环中的指令太多,即使在 5 宽的 Ryzen 上,它也无法在每次迭代中运行 1 个周期。)

为了便于阅读,我使用了mov ecx,8dec ecx/jnz lea ecx, [edi+8]在顶部和cmp edi, ecx / jb .digit_loop作为循环分支,整体机器代码大小更小,在更多 CPU 上效率更高。 dec/jcc宏融合到单个 uop 仅发生在英特尔 Sandybridge 系列上; AMD 仅将 jcc 与 cmp 或 test 融合。 这种优化将使 Ryzen 前端的 uop 降低到 7 微秒,与英特尔相同,这仍然比它在 1 个周期内可以发出的多。

脚注 1:我们可以在移位前使用 SWAR(寄存器中的 SIMD)进行 AND: x & 0x0f0f0f0f低半字节,以及shr(x,4) & 0x0f0f0f0f高半字节,然后通过交替处理来自每个寄存器的字节来有效展开。 (没有任何有效的方法来做等效的punpcklbw或将整数映射到不连续的 ASCII 代码,我们仍然只需要分别处理每个字节。但我们可能会展开字节提取并读取 AH 然后读取 AL(使用movzx )节省移位指令。读取高 8 寄存器会增加延迟,但我认为在当前 CPU 上不会花费额外的微指令。在英特尔 CPU 上写入高 8 寄存器通常不好:它需要额外的合并微指令来读取完整的寄存器,插入它时有前端延迟。因此,通过改组寄存器来获得更广泛的存储可能并不好。在内核代码中,您不能使用 XMM regs,但可以使用 BMI2(如果可用), pdep可以将半字节扩展为字节但这可能比仅仅掩盖两种方式更糟糕。)

测试程序:

// hex.c   converts argv[1] to integer and passes it to itohex
#include <stdio.h>
#include <stdlib.h>

void itohex(char buf[8], unsigned num);

int main(int argc, char**argv) {
    unsigned num = strtoul(argv[1], NULL, 0);  // allow any base
    char buf[9] = {0};
    itohex(buf, num);   // writes the first 8 bytes of the buffer, leaving a 0-terminated C string
    puts(buf);
}

编译:

nasm -felf32 -g -Fdwarf itohex.asm
gcc -g -fno-pie -no-pie -O3 -m32 hex.c itohex.o

测试运行:

$ ./a.out 12315
0000301b
$ ./a.out 12315123
00bbe9f3
$ ./a.out 999999999
3b9ac9ff
$ ./a.out 9999999999   # apparently glibc strtoul saturates on overflow
ffffffff
$ ./a.out 0x12345678   # strtoul with base=0 can parse hex input, too
12345678

替代实现:

Conditional 而不是 lookup-table :需要更多的指令,并且可能会更慢。 但它不需要任何静态数据。

它可以通过分支而不是cmov来完成,但大多数时候这会更慢。 (它不会很好地预测,假设随机混合 0..9 和 a..f 数字。) https://codegolf.stackexchange.com/questions/193793/little-endian-number-to-string-conversion /193842#193842显示了针对代码大小优化的版本。 (除了一开始的bswap ,它是一个普通的 uint32_t -> 带有零填充的十六进制。)

只是为了好玩,这个版本从缓冲区的末尾开始并递减一个指针 (并且循环条件使用指针比较。)一旦 EDX 变为零,您可以让它停止,并使用 EDI+1 作为数字的开头,如果您不想要前导零。

使用cmp eax,9 / ja代替cmov作为练习留给读者。 它的 16 位版本可以使用不同的寄存器(比如可能将 BX 作为临时寄存器)以仍然允许lea cx, [bx + 'a'-10]复制和添加。 或者只需add / cmpjcc ,如果您想避免cmov与不支持 P6 扩展的古老 CPU 兼容。

;; NASM syntax, i386 System V calling convention
itohex:   ; inputs: char* output,  unsigned number
itohex_conditional:
    push   edi             ; save a call-preserved register for scratch space
    push   ebx
    mov    edx, [esp+16]   ; number
    mov    ebx, [esp+12]   ; out pointer

    lea    edi, [ebx + 7]   ; First output digit will be written at buf+7, then we count backwards
.digit_loop:                ; do {
    mov    eax, edx
    and    eax, 0x0f            ; isolate the low 4 bits in EAX
    lea    ecx, [eax + 'a'-10]  ; possible a..f value
    add    eax, '0'             ; possible 0..9 value
    cmp    ecx, 'a'
    cmovae eax, ecx             ; use the a..f value if it's in range.
                                ; for better ILP, another scratch register would let us compare before 2x LEA,
                                ;  instead of having the compare depend on an LEA or ADD result.

    mov    [edi], al        ; *ptr-- = c;
    dec    edi

    shr    edx, 4

    cmp    edi, ebx         ; alternative:  jnz on flags from EDX to not write leading zeros.
    jae    .digit_loop      ; }while(ptr >= buf)

    pop    ebx
    pop    edi
    ret

我们可以使用 2x lea + cmp/cmov在每次迭代中暴露更多的 ILP。 cmp 和两个 LEA 仅取决于半字节值,而cmov消耗了所有 3 个结果。 但是在迭代中有很多 ILP,只有shr edx,4和指针递减作为循环携带的依赖项。 我可以通过安排节省 1 字节的代码大小,以便我可以使用cmp al, 'a'或其他东西。 和/或add al,'0'如果我不关心将 AL 与 EAX 分开重命名的 CPU。

通过使用在其十六进制数字中同时包含9a的数字来检查 off-by-1 错误的测试用例:

$ nasm -felf32 -g -Fdwarf itohex.asm && gcc -g -fno-pie -no-pie -O3 -m32 hex.c itohex.o && ./a.out 0x19a2d0fb
19a2d0fb

带有 SSE2、SSSE3、AVX2 或 AVX512F 的 SIMD,以及带有 AVX512VBMI 的 ~2 条指令

对于 SSSE3 及更高版本,最好使用字节混洗作为半字节查找表。

大多数这些 SIMD 版本可以使用两个压缩的 32 位整数作为输入,结果向量的低 8 字节和高 8 字节包含单独的结果,您可以使用movqmovhps分别存储这些结果。 根据您的随机播放控制,这与将其用于一个 64 位整数完全一样。

SSSE3 pshufb并行查找表 无需搞乱循环,我们可以在具有pshufb的 CPU 上通过一些 SIMD 操作来做到这一点。 (即使对于 x86-64,SSSE3 也不是基准;它是 Intel Core2 和 AMD Bulldozer 的新特性)。

pshufb是由向量控制的字节混洗,而不是立即数(与所有早期的 SSE1/SSE2/SSE3 混洗不同)。 使用固定目的地和可变 shuffle-control,我们可以将其用作并行查找表,以并行执行 16 次查找(来自向量中的 16 个字节条目表)。

因此,我们将整个整数加载到向量寄存器中,并使用位移和punpcklbw将其半字节解包为字节。 然后使用pshufb将这些半字节映射到十六进制数字。

这给我们留下了 ASCII 数字一个 XMM 寄存器,其中最低有效位作为寄存器的最低字节。 由于 x86 是 little-endian,因此没有免费的方法以相反的顺序将它们存储到内存中,首先是 MSB。

我们可以使用额外的pshufb将 ASCII 字节重新排序为打印顺序,或者在整数寄存器中的输入上使用bswap (并反转半字节 -> 字节解包)。 如果整数来自内存,那么通过整数寄存器进行bswap有点糟糕(尤其是对于 AMD Bulldozer 系列),但如果您首先将整数放在 GP 寄存器中,那就很好了。

;; NASM syntax, i386 System V calling convention

section .rodata
 align 16
    hex_lut:  db  "0123456789abcdef"
    low_nibble_mask: times 16 db 0x0f
    reverse_8B: db 7,6,5,4,3,2,1,0,   15,14,13,12,11,10,9,8
    ;reverse_16B: db 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0

section .text

global itohex_ssse3    ; tested, works
itohex_ssse3:
    mov    eax,  [esp+4]    ; out pointer
    movd   xmm1, [esp+8]    ; number

    movdqa xmm0, xmm1
    psrld  xmm1, 4          ; right shift: high nibble -> low  (with garbage shifted in)
    punpcklbw xmm0, xmm1    ; interleave low/high nibbles of each byte into a pair of bytes
    pand   xmm0, [low_nibble_mask]   ; zero the high 4 bits of each byte (for pshufb)
    ; unpacked to 8 bytes, each holding a 4-bit integer

    movdqa xmm1, [hex_lut]
    pshufb xmm1, xmm0       ; select bytes from the LUT based on the low nibble of each byte in xmm0

    pshufb xmm1, [reverse_8B]  ; printing order is MSB-first

    movq   [eax], xmm1      ; store 8 bytes of ASCII characters
    ret
;; The same function for 64-bit integers would be identical with a movq load and a movdqu store.
;; but you'd need reverse_16B instead of reverse_8B to reverse the whole reg instead of each 8B half

可以将 AND 掩码和 pshufb 控件打包成一个 16 字节的向量,类似于下面的itohex_AVX512F

AND_shuffle_mask: times 8 db 0x0f       ; low half: 8-byte AND mask
                   db 7,6,5,4,3,2,1,0   ; high half: shuffle constant that will grab the low 8 bytes in reverse order

将其加载到向量寄存器中并将其用作 AND 掩码,然后将其用作pshufb控件以相反的顺序获取低 8 字节,将它们保留在高 8 中。您的最终结果(8 个 ASCII 十六进制数字)将在XMM 寄存器的上半部分,所以使用movhps [eax], xmm1 在 Intel CPU 上,这仍然只是 1 个融合域 uop,所以它和movq一样便宜。 但在 Ryzen 上,它需要在商店顶部进行洗牌。 另外,如果您想并行转换两个整数或 64 位整数,则此技巧毫无用处。

SSE2,保证在 x86-64 中可用

如果没有 SSSE3 pshufb ,我们需要依靠标量bswap将字节按正确的打印顺序排列,并以另一种方式punpcklbw首先与每对的高半字节交错。

我们简单地添加'0' ,而不是表查找,然后为大于 9 的数字添加另一个'a' - ('0'+10) (将它们放入'a'..'f'范围内)。 SSE2 对大于pcmpgtb进行压缩字节比较。 除了按位与之外,这就是我们需要有条件地添加一些东西的全部内容。

itohex:             ; tested, works.
global itohex_sse2
itohex_sse2:
    mov    edx,  [esp+8]    ; number
    mov    ecx,  [esp+4]    ; out pointer
    ;; or enter here for fastcall arg passing.  Or rdi, esi for x86-64 System V.  SSE2 is baseline for x86-64
    bswap  edx
    movd   xmm0, edx

    movdqa xmm1, xmm0
    psrld  xmm1, 4          ; right shift: high nibble -> low  (with garbage shifted in)
    punpcklbw xmm1, xmm0    ; interleave high/low nibble of each byte into a pair of bytes
    pand   xmm1, [low_nibble_mask]   ; zero the high 4 bits of each byte
    ; unpacked to 8 bytes, each holding a 4-bit integer, in printing order

    movdqa  xmm0, xmm1
    pcmpgtb xmm1, [vec_9]
    pand    xmm1, [vec_af_add] ; digit>9 ?  'a'-('0'+10)  :  0
    
    paddb   xmm0, [vec_ASCII_zero]
    paddb   xmm0, xmm1      ; conditional add for digits that were outside the 0..9 range, bringing them to 'a'..'f'

    movq   [ecx], xmm0      ; store 8 bytes of ASCII characters
    ret
    ;; would work for 64-bit integers with 64-bit bswap, just using movq + movdqu instead of movd + movq


section .rodata
align 16
    vec_ASCII_zero: times 16 db '0'
    vec_9:          times 16 db 9
    vec_af_add:     times 16 db 'a'-('0'+10)
    ; 'a' - ('0'+10) = 39 = '0'-9, so we could generate this from the other two constants, if we were loading ahead of a loop
    ; 'A'-('0'+10) = 7 = 0xf >> 1.  So we could generate this on the fly from an AND.  But there's no byte-element right shift.

    low_nibble_mask: times 16 db 0x0f

这个版本比大多数其他版本需要更多的向量常数。 4x 16 字节是 64 字节,适合一个高速缓存行。 您可能希望在第一个向量之前align 64而不是仅align 16 ,因此它们都来自同一缓存行。

这甚至可以只用 MMX 实现,只使用 8 字节常量,但是你需要一个emms ,所以它可能只在没有 SSE2 或拆分 128 位的非常旧的 CPU 上是一个好主意操作分成 64 位的一半(例如 Pentium-M 或 K8)。 在对矢量寄存器具有 mov-elimination 的现代 CPU(如 Bulldozer 和 IvyBrige)上,它仅适用于 XMM 寄存器,而不适用于 MMX。 我确实安排了寄存器的使用,所以第二个movdqa不在关键路径上,但我第一次没有这样做。


AVX 可以保存一个movdqa ,但更有趣的是,使用AVX2,我们可以从大量输入中一次生成 32 个字节的十六进制数字 2 个 64 位整数或 4 个 32 位整数; 使用 128->256 位广播负载将输入数据复制到每个通道。 从那里开始,具有从每个 128 位通道的低半或高半读取的控制向量的通道vpshufb ymm应该设置您在低通道中解压的低 64 位输入的半字节,以及用于输入的高 64 位在高通道中解包。

或者,如果输入数字来自不同的来源,也许vinserti128在某些 CPU 上可能是值得的,而不是仅执行单独的 128 位操作。


AVX512VBMI (Cannonlake/IceLake,Skylake-X 中不存在)有一个 2 寄存器字节 shuffle vpermt2b ,可以将puncklbw交错与字节反转相结合。 甚至更好的是,我们有VPMULTISHIFTQB ,它可以从 source 的每个 qword 中提取 8 个未对齐的 8 位位域

我们可以使用它直接将我们想要的半字节提取到我们想要的顺序中,避免单独的右移指令。 (它仍然带有垃圾位,但vpermb忽略了高垃圾。)

要将其用于 64 位整数,请使用广播源和多移位控件,该控件将向量底部的输入 qword 的高 32 位和向量顶部的低 32 位解包。 (假设小端输入)

要将其用于超过 64 位的输入,请使用vpmovzxdq将每个输入 dword 零扩展为 qword ,为vpmultishiftqb设置每个 qword 中相同的 28,24,...,4,0 控制模式。 (例如,从 256 位输入向量或四个 dwords -> ymm reg 生成一个 zmm 输出向量,以避免时钟速度限制和实际运行 512 位 AVX512 指令的其他影响。)

请注意,更宽vpermb使用每个控制字节的 5 或 6 位,这意味着您需要将 hexLUT 广播到 ymm 或 zmm 寄存器,或者在内存中重复它。

itohex_AVX512VBMI:                         ;  Tested with SDE
    vmovq          xmm1, [multishift_control]
    vpmultishiftqb xmm0, xmm1, qword [esp+8]{1to2}    ; number, plus 4 bytes of garbage.  Or a 64-bit number
    mov    ecx,  [esp+4]            ; out pointer
   
     ;; VPERMB ignores high bits of the selector byte, unlike pshufb which zeroes if the high bit is set
     ;; and it takes the bytes to be shuffled as the optionally-memory operand, not the control
    vpermb  xmm1, xmm0, [hex_lut]   ; use the low 4 bits of each byte as a selector

    vmovq   [ecx], xmm1     ; store 8 bytes of ASCII characters
    ret
    ;; For 64-bit integers: vmovdqa load [multishift_control], and use a vmovdqu store.

section .rodata
align 16
    hex_lut:  db  "0123456789abcdef"
    multishift_control: db 28, 24, 20, 16, 12, 8, 4, 0
    ; 2nd qword only needed for 64-bit integers
                        db 60, 56, 52, 48, 44, 40, 36, 32
# I don't have an AVX512 CPU, so I used Intel's Software Development Emulator
$ /opt/sde-external-8.4.0-2017-05-23-lin/sde -- ./a.out 0x1235fbac
1235fbac

vpermb xmm不是车道交叉,因为只涉及一条车道(与vpermb ymm或 zmm 不同)。 但不幸的是,在 CannonLake 上( 根据 instlatx64 结果),它仍然有 3 个周期的延迟,因此pshufb的延迟会更好。 但是pshufb根据高位有条件地归零,因此它需要屏蔽控制向量。 假设vpermb xmm只有 1 uop,这会使吞吐量变得更糟。 在一个循环中,我们可以将向量常量保存在寄存器中(而不是内存操作数),它只保存 1 条指令而不是 2 条指令。

(更新:是的, https ://uops.info/ 确认vpermb为 1 uop,延迟为 3c,Cannon Lake 和 Ice Lake 的吞吐量为 1c。ICL 的vpshufb xmm/ymm 吞吐量为 0.5c)


AVX2 可变移位或 AVX512F 合并屏蔽以保存交错

使用 AVX512F,在将数字广播到 XMM 寄存器之后,我们可以使用合并掩码来右移一个 dword,同时保持另一个不修改。

或者我们可以使用 AVX2 可变移位vpsrlvd来做完全相同的事情,移位计数向量为[4, 0, 0, 0] 英特尔 Skylake 及更高版本具有单微指令vpsrlvd Haswell/Broadwell 采用多个微指令 (2p0 + p5)。 Ryzen 的vpsrlvd xmm为 1 uop,3c 延迟,1 per 2 时钟吞吐量。 (比立即轮班更糟糕)。

然后我们只需要一个单寄存器字节混洗vpshufb来交错半字节和字节反转。 但是你需要一个掩码寄存器中的常量,它需要几个指令来创建。 在将多个整数转换为十六进制的循环中,这将是一个更大的胜利。

对于该函数的非循环独立版本,我将一个 16 字节常量的两半用于不同的事情:上半部分为set1_epi8(0x0f) ,下半部分为 8 个字节的pshufb控制向量。 这并没有节省很多,因为 EVEX 广播内存操作数允许vpandd xmm0, xmm0, dword [AND_mask]{1to4} ,常量只需要 4 个字节的空间。

itohex_AVX512F:       ;; Saves a punpcklbw.  tested with SDE
    vpbroadcastd  xmm0, [esp+8]    ; number.  can't use a broadcast memory operand for vpsrld because we need merge-masking into the old value
    mov     edx, 1<<3             ; element #3
    kmovd   k1, edx
    vpsrld  xmm0{k1}, xmm0, 4      ; top half:  low dword: low nibbles unmodified (merge masking).  2nd dword: high nibbles >> 4
      ; alternatively, AVX2 vpsrlvd with a [4,0,0,0] count vector.  Still doesn't let the data come from a memory source operand.

    vmovdqa xmm2, [nibble_interleave_AND_mask]
    vpand   xmm0, xmm0, xmm2     ; zero the high 4 bits of each byte (for pshufb), in the top half
    vpshufb xmm0, xmm0, xmm2     ; interleave nibbles from the high two dwords into the low qword of the vector

    vmovdqa xmm1, [hex_lut]
    vpshufb xmm1, xmm1, xmm0       ; select bytes from the LUT based on the low nibble of each byte in xmm0

    mov      ecx,  [esp+4]    ; out pointer
    vmovq   [ecx], xmm1       ; store 8 bytes of ASCII characters
    ret

section .rodata
align 16
    hex_lut:  db  "0123456789abcdef"
    nibble_interleave_AND_mask: db 15,11, 14,10, 13,9, 12,8  ; shuffle constant that will interleave nibbles from the high half
                      times 8 db 0x0f              ; high half: 8-byte AND mask

使用 AVX2 或 AVX-512 内部

根据要求,将我的 asm 答案的某些版本移植到 C(我写的也是有效的 C++)。 Godbolt 编译器-浏览器链接 他们编译回 asm 几乎和我的手写 asm 一样好。 (并且我检查了编译器生成的 asm 中的向量常量是否与我的db指令匹配。在将 asm 转换为内在函数时肯定要检查一些东西,特别是如果您使用_mm_set_而不是setr来表示在最高时可能看起来更“自然”的常量-一阶setr使用内存顺序,与 asm 相同。)

与我的 32 位 asm 不同,它们正在优化它们在寄存器中的输入编号,而不是假设它必须从内存中加载。 (所以我们不假设广播是免费的。)但是 TODO:探索使用bswap而不是 SIMD shuffle 来获取字节到打印顺序。 特别是对于 bswap 仅为 1 uop 的 32 位整数(与 AMD 不同,64 位寄存器在 Intel 上为 2)。

这些以 MSD 优先打印顺序打印整数。 为 little-endian 内存顺序输出调整 multishift constant 或 shuffle 控件,就像人们显然想要大散列的十六进制输出一样。 或者对于 SSSE3 版本,只需删除 pshufb 字节反转。)

AVX2 / 512 还允许更广泛的版本,一次处理 16 或 32 字节的输入,产生 32 或 64 字节的十六进制输出。 可能通过改组在 128 位通道内重复每个 64 位,在两倍宽度的向量中,例如使用vpermq_mm256_permutex_epi64(_mm256_castsi128_si256(v), _MM_SHUFFLE(?,?,?,?))

AVX512VBMI(Ice Lake 及更新版本)

#include <immintrin.h>
#include <stdint.h>

#if defined(__AVX512VBMI__) || defined(_MSC_VER)
// AVX512VBMI was new in Icelake
//template<typename T>   // also works for uint64_t, storing 16 or 8 bytes.
void itohex_AVX512VBMI(char *str, uint32_t input_num)
{
    __m128i  v;
    if (sizeof(input_num) <= 4) {
        v = _mm_cvtsi32_si128(input_num); // only low qword needed
    } else {
        v = _mm_set1_epi64x(input_num);   // bcast to both halves actually needed
    }
    __m128i multishift_control = _mm_set_epi8(32, 36, 40, 44, 48, 52, 56, 60,   // high qword takes high 32 bits.  (Unused for 32-bit input)
                                               0,  4,  8, 12, 16, 20, 24, 28);  // low qword takes low 32 bits
    v = _mm_multishift_epi64_epi8(multishift_control, v);
    // bottom nibble of each byte is valid, top holds garbage. (So we can't use _mm_shuffle_epi8)
    __m128i hex_lut = _mm_setr_epi8('0', '1', '2', '3', '4', '5', '6', '7',
                                    '8', '9', 'a', 'b', 'c', 'd', 'e', 'f');
    v = _mm_permutexvar_epi8(v, hex_lut);

    if (sizeof(input_num) <= 4)
        _mm_storel_epi64((__m128i*)str, v);  // 8 ASCII hex digits (u32)
    else
        _mm_storeu_si128((__m128i*)str, v);  // 16 ASCII hex digits (u64)
}
#endif

我的 asm 版本使用 64 位广播加载其堆栈 arg 从内存中,即使是 u32 arg。 但这只是为了让我可以将负载折叠到vpmultishiftqb的内存源操作数中。 没有办法告诉编译器它可以使用 64 位广播内存源操作数,高 32 位是“不关心”,如果值来自内存(并且已知不在未映射页面之前的页面,例如 32 位模式堆栈 arg)。 因此,在 C 中无法进行这种次要优化。通常在内联后,您的 var 将位于寄存器中,如果您有一个指针,您将不知道它是否位于页面末尾。 uint64_t 版本确实需要广播,但由于内存中的对象是 uint64_t,编译器可以使用{1to2}广播内存源操作数。 (至少 clang 和 ICC 足够聪明,可以使用-m32 -march=icelake-client ,或者在 64 位模式下使用引用而不是值 arg。)

clang -O3 -m32实际上与我的手写 asm 的编译方式相同,除了vmovdqa加载常量,而不是vmovq ,因为在这种情况下它实际上是所有需要的。 编译器不够聪明,仅使用vmovq加载并在常量的前 8 个字节为 0 时忽略 .rodata 中的 0 个字节。还要注意 asm 输出中的 multishift 常量匹配,因此_mm_set_epi8是正确的; .


AVX2

这利用了输入是 32 位整数的优势; 该策略不适用于 64 位(因为它需要两倍宽的位移)。

// Untested, and different strategy from any tested asm version.

// requires AVX2, can take advantage of AVX-512
// Avoids a broadcast, which costs extra without AVX-512, unless the value is coming from mem.
// With AVX-512, this just saves a mask or variable-shift constant.  (vpbroadcastd xmm, reg is as cheap as vmovd, except for code size)
void itohex_AVX2(char *str, uint32_t input_num)
{
    __m128i  v = _mm_cvtsi32_si128(input_num);
    __m128i hi = _mm_slli_epi64(v, 32-4);  // input_num >> 4 in the 2nd dword
    // This trick to avoid a shuffle only works for 32-bit integers
#ifdef __AVX512VL__
                                          // UNTESTED, TODO: check this constant
    v = _mm_ternarylogic_epi32(v, hi, _mm_set1_epi8(0x0f), 0b10'10'10'00);  // IDK why compilers don't do this for us
#else
    v = _mm_or_si128(v, hi);              // the overlaping 4 bits will be masked away anyway, don't need _mm_blend_epi32
    v = _mm_and_si128(v, _mm_set1_epi8(0x0f));     // isolate the nibbles because vpermb isn't available
#endif
    __m128i nibble_interleave = _mm_setr_epi8(7,3, 6,2, 5,1, 4,0,
                                              0,0,0,0,  0,0,0,0);
    v = _mm_shuffle_epi8(v, nibble_interleave);  // and put them in order into the low qword
    __m128i hex_lut = _mm_setr_epi8('0', '1', '2', '3', '4', '5', '6', '7',
                                    '8', '9', 'a', 'b', 'c', 'd', 'e', 'f');
    v = _mm_shuffle_epi8(hex_lut, v);

    _mm_storel_epi64((__m128i*)str, v);  // movq 8 ASCII hex digits (u32)
}

以上我认为更好,尤其是在 Haswell 上,但在 Zen 上,可变移位vpsrlvd具有较低的吞吐量和较高的延迟,即使它只是一个 uop。 即使在 Skylake 上,后端端口瓶颈也更好:3 条指令仅在端口 5 上运行,而以下版本的 4 条指令(包括vmovd xmm, regvpbroadcastd xmm,xmm和 2x vpshufb ),但前面的数量相同-end uops(假设将向量常量微融合为内存源操作数)。 它还需要少 1 个向量常数,这总是很好,特别是如果它不在循环中。

AVX-512 可以使用合并屏蔽移位而不是可变计数移位,以需要设置屏蔽寄存器为代价节省一个向量常数。 这节省了.rodata中的空间,但不会消除所有常量,因此缓存未命中仍会停止此操作。 并且mov r,imm / kmov k,r在您使用它的任何循环之外都是 2 微指令而不是 1。

还有 AVX2:itohex_AVX512F asm 版本的端口,带有我后来添加的vpsrlvd想法。

// combining shuffle and AND masks into a single constant only works for uint32_t
// uint64_t would need separate 16-byte constants.
// clang and GCC wastefully replicate into 2 constants anyway!?!

// Requires AVX2, can take advantage of AVX512 (for cheaper broadcast, and alternate shift strategy)
void itohex_AVX2_slrv(char *str, uint32_t input_num)
{
    __m128i  v = _mm_set1_epi32(input_num);
#ifdef __AVX512VL__
    // save a vector constant, at the cost of a mask constant which takes a couple instructions to create
    v = _mm_mask_srli_epi32(v, 1<<3, v, 4);  // high nibbles in the top 4 bytes, low nibbles unchanged.
#else
    v = _mm_srlv_epi32(v, _mm_setr_epi32(0,0,0,4));  // high nibbles in the top 4 bytes, low nibbles unchanged.
#endif

    __m128i nibble_interleave_AND_mask = _mm_setr_epi8(15,11, 14,10, 13,9, 12,8,     // for PSHUFB
                                    0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f); // for PAND
    v = _mm_and_si128(v, nibble_interleave_AND_mask);     // isolate the nibbles because vpermb isn't available
    v = _mm_shuffle_epi8(v, nibble_interleave_AND_mask);  // and put them in order into the low qword
    __m128i hex_lut = _mm_setr_epi8('0', '1', '2', '3', '4', '5', '6', '7',
                                    '8', '9', 'a', 'b', 'c', 'd', 'e', 'f');
    v = _mm_shuffle_epi8(hex_lut, v);

    _mm_storel_epi64((__m128i*)str, v);  // movq 8 ASCII hex digits (u32)
}

与 SSSE3 版本相比,这通过使用vpunpcklbw (或掩码移位)将num>>4num的字节放入同一个 XMM 寄存器以设置 1 寄存器字节混洗, vpsrlvd节省了 vpunpcklbw。 vpsrlvd在 Skylake 及更高版本以及 Zen 1 / Zen 2 上是单 uop。不过,在 Zen 上它的延迟更高,并且根据https://uops.info/没有完全流水线化(2c 吞吐量而不是你想要的 1c期望它是一个端口的单个 uop。)但至少它不会与这些 CPU 上的vpshufbvpbroadcastd xmm,xmm竞争相同的端口。 (在 Haswell 上,它是 2 个微指令,包括一个用于 p5 的微指令,所以它确实存在竞争,这比 SSSE3 版本更糟糕,因为它需要一个额外的常数。)

Haswell 的一个不错的选择可能是_mm_slli_epi64(v, 32-4) / _mm_blend_epi32 - vpblendd在任何端口上运行,不需要随机端口。 或者甚至在一般情况下,因为这只需要一个vmovd设置,而不是vmovd + vpbroadcastd

此函数需要 2 个其他向量常量(hex lut,以及组合的 AND 和 shuffle 掩码)。 GCC 和 clang 愚蠢地将一个掩码的 2 次使用“优化”为 2 个单独的掩码常量,这真的很愚蠢。 (但在循环中,只需要设置开销和寄存器,没有额外的每次转换成本。)对于uint64_t版本,你需要 2 个单独的 16 字节常量,但我的手写 asm 版本很聪明通过使用一个 16 字节常量的 2 半。

MSVC 避免了这个问题:它更直接地编译内在函数并且不尝试优化它们(这通常是一件坏事,但在这里它避免了这个问题。)但是 MSVC 错过了使用AVX-512 GP-register-source vpbroadcastd xmm0, esi_mm_set1_epi32-arch:AVX512 使用-arch:AVX2 (因此必须使用 2 个单独的指令来完成广播),它使用该向量常量作为内存源操作数两次(对于vpandvpshufb )而不是加载到寄存器中,这很值得怀疑,但可能还可以实际上节省了前端微指令。 IDK 在提升负载更明显好的循环中会做什么。


更紧凑地编写hex_lut

hex_lut = _mm_loadu_si128((const __m128i*)"0123456789abcdef"); 使用 GCC 和 Clang 完全有效地编译(它们有效地优化了以 0 结尾的字符串文字,并且只发出一个对齐的向量常量)。 但不幸的是,MSVC 将实际字符串保留在 .rdata 中,而没有对齐它。 所以我使用了更长的,不太好读的_mm_setr_epi8('0', '1', ..., 'f');

它是

section .data
msg resb 8
db 10
hex_nums db '0123456789ABCDEF'
xx dd 0FF0FEFCEh
length dw 4

section .text
global main

main:
    mov rcx, 0
    mov rbx, 0
sw:
    mov ah, [rcx + xx]
    mov bl, ah
    shr bl, 0x04
    mov al, [rbx + hex_nums]
    mov [rcx*2 + msg], al
    and ah, 0x0F
    mov bl, ah
    mov ah, [rbx + hex_nums]
    mov [rcx*2 + msg + 1], ah
    inc cx
    cmp cx, [length]
    jl  sw

    mov rax, 1
    mov rdi, 1
    mov rsi, msg
    mov rdx, 9   ;8 + 1
    syscall

    mov rax, 60
    mov rdi, 0
    syscall

nasm -f elf64 x.asm -o to
gcc -no-pie to -ot

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM