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

时间:2018-12-17 22:14:12

标签: assembly x86 hex simd avx512

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

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

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

3 个答案:

答案 0 :(得分: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(Intel和AMD)具有1个循环的循环延迟(https://agner.org/optimize/),因此,此循环传输的依赖链不会成为瓶颈。 (循环中有太多指令,即使在5宽Ryzen上,每个迭代甚至无法运行1个周期。)

即使我们通过使用带有结尾指针的cmp / jb在Ryzen上启用cmp / jcc融合进行了优化,它仍然是7 oups,比管道在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:我们可能会在移位x & 0x0f0f0f0f低半字节和shr(x,4) & 0x0f0f0f0f高半字节之前使用SWAR(寄存器中的SIMD)进行与操作,然后有效地展开通过交替处理每个寄存器中的一个字节。 (没有任何有效的方法来等效于punpcklbw或将整数映射到非连续的ASCII代码,我们仍然只需要分别处理每个字节。但是我们可能需要展开字节提取并读取AH然后再读取AL (使用movzx)来保存移位指令。读取高8位寄存器会增加延迟,但我认为在当前CPU上并不会花费额外的成本。在英特尔CPU上写入高8位寄存器通常是不好的:花费额外的合并uop读取完整的寄存器,并插入前端会有延迟。因此,通过改组寄存器来扩大存储范围可能不是一件好事。在内核代码中,您不能使用XMM reg,但是可以使用BMI2 pdep可以将半字节扩展为字节,但这可能比仅掩盖2种方式还差。)

测试程序:

// 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

替代实现:

视情况而定,而不是查找表:需要更多说明,通常会比较慢。但是它不需要任何静态数据。可以用分支代替cmov来完成,但这在大多数情况下甚至会更慢。 (假设0..9和a..f数字随机混合,则预测效果不佳。)

很有趣,此版本从缓冲区的末尾开始并减少指针。 (并且循环条件使用指针比较。)如果您不希望前导零,则可以使它在EDX变为零后停止,并使用EDI + 1作为数字的开头。

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

;; 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的数字检查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位包含单独的结果,可以分别用movq和{{1 }}。  根据您的随机播放控件,这就像将其用于一个64位整数一样。

SSSE3 movhps并行查找表。无需弄乱循环,我们可以在具有pshufb的CPU上执行一些SIMD操作。 (SSSE3甚至不是x86-64的基准;它是Intel Core2和AMD Bulldozer的新功能。)

pshufb is a byte shuffle由向量控制,而不是由立即数控制(与所有早期的SSE1 / SSE2 / SSE3随机播放不同)。具有固定的目标位置和可变的shuffle控件,我们可以将其用作并行查找表,以并行方式执行16x查找(从向量中的16个字节的条目表开始)。

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

剩下的是ASCII码,这是XMM寄存器,其最低有效位是寄存器的最低字节。由于x86是低位字节序,因此没有免费的方法可以将它们以相反的顺序存储到内存中,首先是MSB。

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

bswap

可以将AND掩码和pshufb控件打包到一个16字节向量中,类似于下面的;; 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

itohex_AVX512F

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

SSE2,保证在x86-64中可用

在没有SSSE3 movq的情况下,我们需要依靠标量pshufb将字节以正确的打印顺序放置,并且bswap的另一种方式是首先与每对的高半字节交织

我们只需要添加punpcklbw,然后为大于9的数字添加另一个'0'(而不是查找表)(将它们置于'a' - ('0'+10)范围内)即可。 SSE2的压缩字节比较大于pcmpgtb。加上按位与,这就是我们有条件添加的全部内容。

'a'..'f'

此版本比其他大多数版本需要更多的向量常量。 4x 16字节为64字节,可容纳在一个缓存行中。您可能想在第一个向量之前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 而不是align 64,所以它们都来自同一缓存行。

这甚至可以仅使用MMX来实现,仅使用8字节常量,但是您需要一个align 16,所以这可能仅在没有SSE2的非常老的CPU上是一个好主意,或将128位操作分成64位(例如Pentium-M或K8)。在具有消除运动的矢量寄存器的现代CPU(如Bulldozer和IvyBrige)上,它仅适用于XMM寄存器,不适用于MMX。我确实安排了寄存器的使用,因此第二个emms不在关键路径上,但是我没有第一次这样做。


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

或者,如果输入数字来自不同的来源,那么vpshufb ymm可能在某些CPU上是{em>可能最高的价格,而不是仅仅执行128位操作。


AVX512VBMI (Cannonlake,Skylake-X中不存在)具有2寄存器字节混洗vpermt2b,可以将vinserti128与字节倒车。 甚至更好的是,我们有VPMULTISHIFTQB可以从源的每个qword提取8个未对齐的字节

我们可以使用它来将所需的半字节直接提取为所需的顺序,从而避免了单独的右移指令。 (不过,它仍然带有垃圾位。)

要将其用于64位整数,请使用广播源和控制矢量,该矢量在矢量底部捕获输入qword的高32位,在矢量顶部捕获低32位。 / p>

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

vpmultishiftqb

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


AVX2可变移位或AVX512F合并掩码可保存交织

使用AVX512F,在将数字广播到XMM寄存器中之后,我们可以使用合并掩码右移一个双字,而另一个双字保持不变。

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

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

对于该函数的非循环独立版本,我将两半的一个16字节常量用于不同的事情:vpshufb位于上半部,而8字节是set1_epi8(0x0f)控件下半部分的向量。这并不会节省很多,因为EVEX广播内存操作数允许pshufb,只需要4个字节的空间即可。

vpandd   xmm0, xmm0, dword [AND_mask]{1to4}

答案 1 :(得分:2)

使用 AVX2 或 AVX-512 内部函数

根据要求,将我的 asm 答案的某些版本移植到 C(我写的也是有效的 C++)。 Godbolt compiler-explorer link。它们编译回 asm 几乎和我手写的 asm 一样好。 (并且我检查了编译器生成的 asm 中的向量常量是否与我的 db 指令匹配。在将 asm 转换为内在函数时绝对需要检查,特别是如果您使用 _mm_set_ 而不是 setr用于在最高优先顺序中看起来更“自然”的常量。setr 使用内存顺序,与 asm 相同。)

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

这些以 MSD 优先打印顺序打印整数。 为小端内存顺序输出调整多移位常量或随机控制,就像人们显然想要大散列的十六进制输出一样。或者对于 SSSE3 版本,只需删除 pshufb 字节反转。)

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

AVX512VBMI(冰湖及更新)

#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 中无法进行次要优化。通常在内联之后,您的变量将位于寄存器中,如果您有一个指针,您将不知道它是否位于页面的末尾。 uint64_t 版本确实需要广播,但由于内存中的对象是 uint64_t,编译器可以使用 {1to2} 广播内存源操作数。 (至少 clang 和 ICC 足够智能,可以使用 -m32 -march=icelake-client,或者在 64 位模式下使用引用而不是值 arg。)

clang -O3 -m32 实际上与我手写的 asm 的编译完全相同,除了常量的 vmovdqa 加载,而不是 vmovq,因为在这种情况下它实际上是全部需要的。当常量的前 8 个字节为 0 时,编译器不够聪明,无法仅使用 vmovq 加载并省略 .rodata 中的 0 个字节。另请注意,asm 输出中的 multishift 常量匹配,因此 {{1 }} 是正确的; .


AVX2

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

_mm_set_epi8

以上是我认为更好的,尤其是在 Haswell 上,但在 Zen 上也是如此,其中可变移位 // 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) } 具有较低的吞吐量和较高的延迟,即使它只是一个 uop。即使在 Skylake 上也能更好地解决后端端口瓶颈问题:3 条指令仅在端口 5 上运行,而以下版本的 4 条指令(包括 vpsrlvdvmovd xmm, reg 和 2x vpbroadcastd xmm,xmm) , 但前端 uops 的数量相同(假设向量常量作为内存源操作数的微融合)。它还需要少 1 个向量常量,这总是很好,特别是如果它不在循环中。

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

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

vpsrlvd

与SSSE3版本相比,这通过使用// 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) } (或掩码移位)将vpunpcklbwvpsrlvd的字节放入同一个XMM来节省num>>4 register 以设置 1 寄存器字节洗牌。 num 在 Skylake 及更高版本以及 Zen 1/Zen 2 上是单 uop。不过,在 Zen 上它的延迟更高,并且没有根据 https://uops.info/ 完全流水线化(2c 吞吐量而不是您的 1c d 期望它是一个端口的单个 uop。)但至少它不会与这些 CPU 上的 vpsrlvdvpshufb 竞争相同的端口。 (在 Haswell 上,它有 2 个 uops,其中一个用于 p5,所以它确实竞争,这比 SSSE3 版本更糟糕,因为它需要一个额外的常量。)

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

该函数需要另外 2 个向量常量(十六进制 lut,以及组合的 AND 和 shuffle 掩码)。 GCC 和 clang 愚蠢地将一个掩码的 2 次使用“优化”为 2 个单独的掩码常量,这真的很愚蠢。(但在循环中,只需要设置开销和一个寄存器,不需要额外的每次转换成本。)无论如何,对于 vpbroadcastd 版本,您需要 2 个单独的 16 字节常量,但我的手写 asm 版本通过使用一个 16 字节常量的 2 个半部分很聪明。

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


更简洁地编写vpand

vpshufb 使用 GCC 和 Clang 完全高效地编译(它们有效地优化了终止为 0 的字符串文字,并且只发出一个对齐的向量常量)。但不幸的是,MSVC 将实际字符串保存在 .rdata 中,而不对其进行对齐。所以我用了更长的,不太好读的,hex_lut

答案 2 :(得分:-1)

确实是

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 t.o
gcc -no-pie t.o -o t