如何将数字转换为十六进制?

问题描述 投票:3回答:1

给定寄存器中的数字(二进制​​整数),如何将其转换为十六进制ASCII数字字符串?

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

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

assembly x86 hex simd avx512
1个回答
9
投票

16是2的幂。与十进制(How do I print an integer in Assembly Level Programming without printf from the c library?)或不是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
itohex:   ; inputs: char* output,  unsigned number
    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"

直到BMI2(shrx / rorx),x86缺少复制和移位指令,因此就地旋转然后复制/ AND很难被击败1。现代x86(英特尔和AMD)的旋转(https://agner.org/optimize/)具有1个周期的延迟,因此这个循环携带的依赖链不会成为瓶颈。 (循环中有太多指令,即使在5宽Ryzen上,每次迭代也能运行1个周期。)

即使我们通过使用带有结束指针的cmp / jb来优化Ryzen上的cmp / jcc融合,它仍然是7 uops,超过管道可以在1个周期内处理。 dec/jcc宏观融合到一个单一的uop只发生在Intel Sandybridge家族; AMD仅融合cmp或使用jcc进行测试。我使用mov ecx,8和dec / jnz来提高人类的可读性; lea ecx, [edi+8]cmp edi, ecx / jb .digit_loop总体上较小,并且在更多CPU上更有效。

脚注1:我们可能会使用SWAR(寄存器中的SIMD)在移位之前执行AND:x & 0x0f0f0f0f低半字节和shr(x,4) & 0x0f0f0f0f高半字节,然后通过交替处理每个寄存器中的字节来有效地展开。 (没有任何有效的方法来执行相当于punpcklbw或将整数映射到非连续的ASCII代码,我们仍然只需要分别执行每个字节。但是我们可以展开字节提取并读取AH然后AL(使用movzx)读取高8位寄存器可以增加延迟,但我认为它不会在当前CPU上花费额外的uops。在Intel CPU上写入高8位寄存器通常不好:它需要额外的合并uop来读取完全寄存器,有一个前端延迟插入它。所以通过改组寄存器来扩大存储可能不是很好。在内核代码中你不能使用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

Alternate implementations:

条件而不是查找表:需要多个指令,并且通常会更慢。但它不需要任何静态数据。它可以用分支而不是cmov来完成,但在大多数情况下它会更慢。 (假设随机混合0..9和a..f数字,它预测不会很好。)

只是为了好玩,这个版本从缓冲区的末尾开始并递减指针。 (并且循环条件使用指针比较。)一旦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

通过使用其十六进制数字中包含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

SIMD with SSE2, SSSE3, AVX2 or AVX512F, and ~2 instructions with AVX512VBMI

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

大多数SIMD版本可以与两个打包的32位整数一起用作输入,结果向量的低8位字节和高8字节包含单独的结果,您可以使用movqmovhps单独存储。根据您的shuffle控件,这与将其用于一个64位整数完全相同。

SSSE3 pshufb并行查找表。不需要乱用循环,我们可以通过几个SIMD操作,在具有pshufb的CPU上执行此操作。 (即使对于x86-64,SSSE3也不是基线;它是Intel Core2和AMD Bulldozer的新功能)。

pshufb is a byte shuffle由一个向量控制,而不是立即(不像所有早期的SSE1 / SSE2 / SSE3 shuffle)。使用固定目标和变量shuffle-control,我们可以将它用作并行查找表,以并行执行16x查找(从向量中的16个字节表)。

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

这使得我们用ASCII数字作为XMM寄存器,最低有效位作为寄存器的最低字节。由于x86是little-endian,因此没有自由的方式以相反的顺序将它们存储到内存中,而MSB优先。

我们可以使用额外的pshufb将ASCII字节重新排序为打印顺序,或者在整数寄存器中使用bswap(并反转半字节 - >字节解包)。如果整数来自内存,通过bswap的整数寄存器有点糟糕(特别是对于AMD推土机系列),但如果你首先在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。在英特尔CPU上,这仍然只有1个融合域uop,所以它和movq一样便宜。但是对于Ryzen来说,在商店之上花费很多钱。另外,如果你想要并行转换两个整数或64位整数,这个技巧是没用的。

SSE2,保证在x86-64中可用:

如果没有SSSE3 pshufb,我们需要依靠标量bswap将字节按正确的顺序排列,而punpcklbw则首先与每对的高半字节交错。

我们只需添加'0'而不是表查找,并为大于9的数字添加另一个'a' - ('0'+10)(将它们放入'a'..'f'范围)。 SSE2有一个打包字节比较大于,pcmpgtb。除了按位AND,我们需要有条件地添加一些东西。

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的非常旧的CPU上,或者拆分128位操作为64位半部分(例如Pentium-M或K8)。在具有用于矢量寄存器的mov-elimination的现代CPU(如Bulldozer和IvyBrige)上,它仅适用于XMM寄存器,而不适用于MMX。我确实安排了寄存器使用,所以第二个movdqa不在关键路径,但我没有为第一个做到这一点。


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

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


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

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

要将其用于64位整数,请使用广播源和控制向量,该向量可以捕获向量底部输入qword的高32位,以及向量顶部的低32位。

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

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(according to instlatx64 results)上,它仍然具有3个周期的延迟,因此pshufb对延迟会更好。但pshufb基于高位有条件地为零,因此需要屏蔽控制向量。假设vpermb xmm只有1 uop,这使吞吐量变得更糟。在我们可以将向量常量保持在寄存器(而不是存储器操作数)中的循环中,它只保存1条指令而不是2条指令。


AVX2可变移位或AVX512F合并屏蔽以节省交错

使用AVX512F,在将数字广播到XMM寄存器之后,我们可以使用合并屏蔽来右移一个双字而不保留另一个双字。

或者我们可以使用AVX2变量vpsrlvd完成相同的事情,使用[4, 0, 0, 0]的移位计数向量。英特尔Skylake及其后来有单一的vpsrlvd; Haswell / Broadwell需要多次uop(2p0 + p5)。 Ryzen的vpsrlvd xmm是1 uop,3c延迟,每2时钟吞吐量1。 (比立即轮班更糟)。

然后我们只需要一个单寄存器字节shuffle,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

    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
© www.soinside.com 2019 - 2024. All rights reserved.