如何将二进制整数转换为十六进制字符串?
Posted
技术标签:
【中文标题】如何将二进制整数转换为十六进制字符串?【英文标题】:How to convert a binary integer number to a hex string? 【发布时间】:2019-05-18 07:46:03 【问题描述】:给定一个寄存器中的数字(二进制整数),如何将其转换为十六进制 ASCII 数字字符串? (即将其序列化为文本格式。)
数字可以存储在内存中或即时打印,但存储在内存中并同时打印通常更有效。 (您可以修改一个存储循环,改为一次打印一个。)
我们能否有效地处理与 SIMD 并行的所有半字节? (SSE2 或更高版本?)
【问题讨论】:
这旨在为 int->hex 问题提供一个体面的规范重复目标。我的答案中的所有功能在发布之前都经过了测试。决定编写过时的 32 位代码而不是 x86-64 的部分原因是为了证明提供标量循环版本是合理的。 SSE2 是 x86-64 的基线,因此您应该始终从 int->hex 使用它,除非您想要一个不带前导零的可变宽度结果。 (即便如此,您也可以使用pcmpeqb
/ pmovmskb
/ bsf
轻松找到第一个非0数字的位置。)
对于大缓冲区,另请参见 github.com/zbjornson/fast-hex 以了解 binary->hex 和 hex->binary。
【参考方案1】:
相关:16-bit version 将 1 个字节转换为 2 个十六进制数字,您可以将其打印或存储到缓冲区。 Converting bin to hex in assembly 有另一个 16 位版本,在答案的一半中有大量文本解释,涵盖了问题的 int -> hex-string 部分。
如果优化代码大小而不是速度,则有 a hack using DAS that saves a few bytes。
16 是 2 的幂。与十进制或其他不是 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 ; 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,调用约定将在寄存器而不是堆栈中传递 args,例如适用于 x86-64 System V(非 Windows)的 RDI 和 ESI。只需从堆栈中删除加载的部分,并将循环更改为使用 ESI 而不是 EAX。 (并使寻址模式为 64 位。您可能需要将 hex_lut
地址LEA 放入循环外的寄存器中;请参阅this 和this)。
此版本转换为十六进制 前导零。如果你想删除它们,bit_scan(input)/4
就像输入上的 lzcnt
或 __builtin_clz
,或者输出 ASCII 字符串上的 SIMD compare -> pmovmksb -> tzcnt 会告诉你有多少个 0 数字(因此你可以从第一个非零开始打印或复制)。或者从低半字节开始转换并向后工作,当右移使值为零时停止,如使用 cmov 而不是查找表的第二个版本所示。
直到 BMI2 (shrx
/ rorx
),x86 都缺少复制和移位指令,因此就地旋转然后复制/AND 很难被击败1。现代 x86(Intel 和 AMD)的循环延迟为 1 个周期(https://agner.org/optimize/ 和 https://uops.info/),因此这个循环承载的依赖链不会成为瓶颈。 (循环中的指令太多,即使在 5 宽的 Ryzen 上,它也无法在每次迭代中运行 1 个周期。)
为了便于阅读,我使用了mov ecx,8
和dec 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 上不会花费额外的 uops。在 Intel CPU 上写入高 8 寄存器通常不好:它需要额外的合并uop 读取完整的寄存器,并在前端延迟插入它。因此,通过混洗寄存器获得更广泛的存储可能并不好。在内核代码中,您不能使用 XMM regs,但可以使用 BMI2(如果可用),@987654357 @ 可以将半字节扩展为字节,但这可能比仅屏蔽 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 数字。)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
/cmp
和 jcc
,如果你想避免 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 仅依赖于 nibble 值,cmov
消耗所有 3 个结果。但是在迭代中有很多 ILP,只有 shr edx,4
和指针递减作为循环携带的依赖项。我可以通过安排节省 1 字节的代码大小,以便我可以使用 cmp al, 'a'
或其他东西。和/或 add al,'0'
如果我不关心将 AL 与 EAX 分开重命名的 CPU。
使用十六进制数字中同时包含9
和a
的数字检查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 字节包含单独的结果,您可以使用 movq
和 movhps
分别存储这些结果。
根据您的随机播放控制,这与将其用于一个 64 位整数完全一样。
SSSE3 pshufb
并行查找表。无需搞乱循环,我们可以在具有pshufb
的 CPU 上通过一些 SIMD 操作来做到这一点。 (SSSE3 甚至对于 x86-64 也不是基准;它是 Intel Core2 和 AMD Bulldozer 的新特性)。
pshufb
is a byte shuffle 由向量控制,而不是立即数(与所有早期的 SSE1/SSE2/SSE3 洗牌不同)。有了固定的目的地和可变的 shuffle-control,我们可以将其用作并行查找表,以并行执行 16 次查找(来自向量中的 16 个字节条目表)。
所以我们将整个整数加载到向量寄存器中,并通过位移和punpcklbw
将其半字节解压缩为字节。然后使用 pshufb
将这些半字节映射到十六进制数字。
这给我们留下了一个 XMM 寄存器的 ASCII 数字,其中最低有效位作为寄存器的最低字节。由于 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 hex数字)将位于 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 的非常旧的 CPU 上是一个好主意,或者它将 128 位操作分成 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 寄存器字节洗牌 vpermt2b
,可以结合 puncklbw
交错和字节反转。 或者更好的是,我们有VPMULTISHIFTQB
,它可以从源的每个 qword 中提取 8 个未对齐的 8 位位域。
我们可以使用它直接将我们想要的半字节提取到我们想要的顺序中,避免单独的右移指令。 (它仍然带有垃圾位,但vpermb
忽略了高垃圾。)
要将其用于 64 位整数,请使用广播源和多移位控件,该控件将向量底部的输入 qword 的高 32 位和向量顶部的低 32 位解包。 (假设小端输入)
要将其用于超过 64 位的输入,请使用 vpmovzxdq
将每个输入 dword 零扩展为一个 qword,为 vpmultishiftqb
设置相同的 28,24,。 ..,4,0 每个 qword 中的控制模式。 (例如,从 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 (according to instlatx64 results) 上,它仍然有 3 个周期的延迟,因此pshufb
的延迟会更好。但是pshufb
根据高位有条件地归零,因此它需要屏蔽控制向量。假设vpermb xmm
仅为 1 uop,这会使吞吐量变得更糟。在我们可以将向量常量保存在寄存器中(而不是内存操作数)的循环中,它只保存了 1 条指令而不是 2 条。
(更新:是的,https://uops.info/ 确认 vpermb
是 1 uop,延迟为 3c,Cannon Lake 和 Ice Lake 的吞吐量为 1c。ICL 的吞吐量为 0.5c,vpshufb
xmm/ymm)
AVX2 可变移位或 AVX512F 合并掩码以保存交错
使用 AVX512F,在将数字广播到 XMM 寄存器后,我们可以使用合并掩码将一个 dword 右移,同时不修改另一个 dword。
或者我们可以使用 AVX2 变量移位 vpsrlvd
来做完全相同的事情,移位计数向量为 [4, 0, 0, 0]
。 Intel Skylake 及更高版本有单微指令vpsrlvd
; Haswell/Broadwell 采用多个微指令 (2p0 + p5)。 Ryzen 的vpsrlvd xmm
是 1 uop,3c 延迟,1 per 2 时钟吞吐量。 (比直接轮班更糟糕)。
然后我们只需要一个单寄存器字节洗牌,vpshufb
,交错半字节和字节反转。但是你需要一个掩码寄存器中的常量,它需要几个指令来创建。在将多个整数转换为十六进制的循环中,这将是一个更大的胜利。
对于函数的非循环独立版本,我将一个 16 字节常量的两半用于不同的事物:上半部分 set1_epi8(0x0f)
,下半部分 pshufb
控制向量的 8 个字节一半。这并没有节省很多,因为 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 xmm0k1, 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
【讨论】:
你的版本无疑比我的优化得更好,但我在这里创建了一个用于往返十六进制的库:github.com/zbjornson/fast-hex/tree/master/src。我已经有一年没有看过它了,因为我错过了改进。 Agner 最近还发现了 impls:github.com/darealshinji/vectorclass/blob/master/special/…。 @PeterCordes 是否可以使用 C 编译器内置函数或通用__attribute__ ((vector_size
gcc s 扩展来获得 AVX512VBMI 版本?
@user2284570:当然使用 Intel intriniscs (_mm_multishift_epi64_epi8
) 或 GNU C __builtin_ia32_something
是的,你可以在 asm 中做几乎所有事情,尽管你在编译器的摆布下将广播负载折叠到内存操作数。但是仅使用可以为任何 ISA 编译的可移植 GNU C 本机向量 __attribute__((vector_size(16)))
代码,您不太可能编写 GCC 或 clang 实际上将优化到 vpmultishiftqb
的东西。 (-march=icelake-client
)。您也许可以编写一些可以以这种方式进行优化的东西。
@PeterCordes 我的意思是我不理解你的 asm 代码。所以我的意思是我想要一个使用 _mm_mask_multishift_epi64_epi8()
(或类似的)内置函数的完整示例。特别是因为它用于以矢量方式一次转换 11 个 64 位整数。
@user2284570:我发布了 AVX2 和 AVX512VBMI 版本的第二个答案;事实证明,重新考虑优化选择对于寄存器中的变量而不是来自内存的变量以及编译器的限制是有益的。所以只是天真地将 asm 转换为内在函数不会那么好。不过,我没有计算出超过 128 位输出向量的随机播放。如果您有更多数据要转换,可能值得使用 mm256 一次转换 2x 或 64 位,甚至可能使用 mm512 向量转换 4x。【参考方案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 位整数(与 AMD 不同,64 位寄存器在 Intel 上为 2)。
这些以 MSD 优先打印顺序打印整数。 调整多移位常量或随机控制以用于小端内存顺序输出,就像人们显然想要大散列的十六进制输出一样。或者对于 SSSE3 版本,只需删除 pshufb byte-reverse。)
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 位是“不关心”,如果该值无论如何来自内存(并且已知不在 a 的末尾)未映射页面之前的页面,例如 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
具有较低的吞吐量和较高的延迟,即使它只是一个微指令。即使在 Skylake 上也能更好地解决后端端口瓶颈:3 条指令仅在端口 5 上运行,而以下版本的 4 条指令(包括 vmovd xmm, reg
、vpbroadcastd xmm,xmm
和 2x vpshufb
),但前面的数量相同-end uops(假设将向量常量微融合为内存源操作数)。它还需要少 1 个向量常数,这总是很好的,尤其是如果它不在循环中。
AVX-512 可以使用合并屏蔽移位而不是可变计数移位,以需要设置屏蔽寄存器为代价节省一个向量常量。这节省了.rodata
中的空间,但不会消除所有常量,因此缓存未命中仍会停止此操作。而mov r,imm
/ kmov k,r
在你使用它的任何循环之外都是 2 uop 而不是 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
,通过使用vpsrlvd
(或掩码移位)将num>>4
和num
的字节放入同一个XMM寄存器以设置1-寄存器字节洗牌。 vpsrlvd
在 Skylake 及更高版本以及 Zen 1 / Zen 2 上是单 uop。不过,在 Zen 上它的延迟更高,并且根据 https://uops.info/ 没有完全流水线化(2c 吞吐量而不是您期望的 1c作为一个端口的单个 uop。)但至少它不会与那些 CPU 上的 vpshufb
和 vpbroadcastd xmm,xmm
竞争相同的端口。 (在 Haswell 上,它是 2 个微指令,其中一个用于 p5,所以它确实竞争,这比 SSSE3 版本更糟糕,因为它需要一个额外的常量。)
Haswell 的一个不错的选择可能是 _mm_slli_epi64(v, 32-4)
/ _mm_blend_epi32
- vpblendd
在任何端口上运行,不需要随机端口。或者甚至在一般情况下,因为这只需要vmovd
设置,而不是vmovd
+ vpbroadcastd
此函数需要 2 个其他向量常量(十六进制 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
(因此广播必须使用两条单独的指令完成)它使用该向量常量作为内存源操作数两次(对于vpand
和vpshufb
)而不是加载到寄存器中,这很值得怀疑,但可能没问题,实际上可以节省前端 uops。 IDK 在提升负载明显更好的循环中它会做什么。
更紧凑地写hex_lut
:
hex_lut = _mm_loadu_si128((const __m128i*)"0123456789abcdef");
使用 GCC 和 Clang 完全有效地编译(它们有效地优化了以 0 结尾的字符串文字,并且只发出一个对齐的向量常量)。但不幸的是,MSVC 将实际字符串保留在 .rdata 中,而没有对齐它。所以我用了更长的,不太好读的,_mm_setr_epi8('0', '1', ..., 'f');
【讨论】:
【参考方案3】:确实是这样
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
【讨论】:
cmp cx, [length]
从一字节 db
中读取 2 个字节。无论如何,也没有明显的理由将length
保留在静态存储中;尤其是不要在每次循环迭代时都阅读它。将其作为寄存器 arg。 (例如,它可以是一个 equ 常数)。
也没有理由使用 16 位 CX,尤其是不要在 Intel P6 系列 CPU 上通过在读取 RCX 之前增加 CX 来创建部分寄存器停顿。 (像普通人一样使用 ECX 可以解决这个问题。)使用 AH 作为临时工具也是完全没有必要的。 x86-64 有许多其他寄存器,您可以使用它们,而不会通过分别使用 AL 和 AH 来创建对 AMD CPU 的错误依赖。如果您首先使用movzx
加载到完整的注册中,则不需要第二个mov bl, ah
,只需and edx, 0xf
/ movzx eax, byte [hex_nums + rdx]
。
另外,hex_nums
可以进入section .rodata
。而msg
的大小固定为8字节,而length
则伪装成可变的。
此外,这会向后打印结果:通过首先打印最低有效字节(最低地址)来对 dword 进行字节反转。运行它,结果是CEEF0FFF
\n 0123
。 0123 来自 hex_nums,其中 write(1, msg, 13)
读取过去 msg
和 db 10
换行符,进入 hex_nums 中的 "0123"
。
@PeterCordes 是的,它应该是dw
,但在这种情况下它也适用于db
,因为第二个字节来自.text
的填充并且是00
。以上是关于如何将二进制整数转换为十六进制字符串?的主要内容,如果未能解决你的问题,请参考以下文章