如何将二进制整数转换为十六进制字符串?
How to convert a binary integer number to a hex string?
给定一个寄存器中的数字(二进制整数),如何将其转换为一串十六进制 ASCII 数字? (即将其序列化为文本格式。)
数字可以存储在内存中或即时打印,但同时存储在内存中和打印通常效率更高。 (您可以修改存储的循环,改为一次打印一个。)
我们能否通过 SIMD 并行有效地处理所有半字节? (SSE2 或更高版本?)
相关:16-bit version that converts 1 byte to 2 hex digits which you could print or store to a buffer. And 有另一个 16 位版本,在答案的一半中有大量的文本解释,涵盖了问题的 int -> hex-string 部分。
如果针对 code-size 而不是速度进行优化,则有 a hack using DAS that saves a few bytes。
16 是 2 的幂。与十进制或其他不是 2 的幂的基数不同,我们不需要除法,我们可以先提取 most-significant 数字(即按打印顺序)。否则我们只能首先得到 least-significant 数字(其值取决于数字的所有位)并且我们必须倒退:参见 How do I print an integer in Assembly Level Programming without printf from the c library? for non-power-of-2 bases.
每个 4 位位组映射到一个十六进制数字。我们可以使用移位或旋转以及 AND 掩码,将输入的每个 4 位块提取为 4 位整数。
不幸的是,0..9 a..f 十六进制数字在 ASCII 字符集中不连续 (http://www.asciitable.com/)。我们要么需要条件行为(分支或 cmov),要么我们可以使用查找 table.
查找 table 通常对于指令数和性能来说是最有效的,因为我们要重复这样做;现代 CPU 具有非常快的 L1d 缓存,这使得附近字节的重复加载非常便宜。流水线/out-of-order 执行隐藏了 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 系统 V (non-Windows) 的 RDI 和 ESI。只需删除从堆栈加载的部分,并将循环更改为使用 ESI 而不是 EAX。 (并使寻址模式为 64 位。您可能需要将 hex_lut
地址放入循环外的寄存器中;参见 this and this)。
此版本转换为十六进制 ,带 个前导零。如果你想删除它们,输入上的 bit_scan(input)/4
如 lzcnt
或 __builtin_clz
,或者输出 ASCII 字符串上的 SIMD compare -> pmovmksb -> tzcnt 会告诉你有多少个 0 数字有(因此您可以从第一个 non-zero 开始打印或复制)。或者从低半字节开始转换并向后工作,当右移使值变为零时停止,如使用 cmov 而不是查找的第二个版本所示 table.
直到BMI2 (shrx
/ rorx
),x86缺少copy-and-shift指令,所以旋转in-place然后copy/AND很难被击败1。现代 x86(Intel 和 AMD)有 1 个周期的循环延迟(https://agner.org/optimize/ and https://uops.info/),所以这个 loop-carried 依赖链不会成为瓶颈。 (即使在 5-wide Ryzen 上,循环中的指令也太多了 运行 每次迭代甚至 1 个周期。)
为了便于阅读,我使用了 mov ecx,8
和 dec ecx/jnz
; lea ecx, [edi+8]
位于顶部,cmp edi, ecx / jb .digit_loop
作为循环分支,整体机器代码大小更小,在更多 CPU 上效率更高。 dec/jcc
macro-fusion 变成单个 uop 只发生在 Intel Sandybridge-family 上; AMD 只将 jcc 与 cmp 或 test 融合。此优化将使 Ryzen 上的 front-end 降低到 7 微指令,与英特尔相同,这仍然超过它在 1 个周期内可以发出的速度。
脚注 1:我们可能会使用 SWAR(寄存器中的 SIMD)在移位之前执行 AND:x & 0x0f0f0f0f
低半字节和 shr(x,4) & 0x0f0f0f0f
高半字节,然后通过交替处理来自每个寄存器的一个字节来有效展开。 (没有任何有效的方法来做 punpcklbw
的等价物或将整数映射到 non-contiguous ASCII 码,我们仍然只需要分别处理每个字节。但我们可能会展开 byte-extraction 和读取 AH 然后读取 AL(使用 movzx
)以保存移位指令。读取高 8 位寄存器会增加延迟,但我认为在当前 CPU 上不会花费额外的微指令。写入高 8 位寄存器通常不好Intel CPU:读取完整寄存器需要额外的合并 uop,插入它有 front-end 延迟。因此通过改组寄存器获得更广泛的存储可能不好。在不能使用 XMM regs 的内核代码中,但可以使用 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
测试运行s:
$ ./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
替代实现:
条件而不是 lookup-table:需要更多的指令,并且可能会更慢。但它不需要任何静态数据。
它可以通过分支而不是 cmov
来完成,但大多数时候那样会更慢。 (假设随机混合 0..9 和 a..f 数字,它不会预测得很好。)https://codegolf.stackexchange.com/questions/193793/little-endian-number-to-string-conversion/193842#193842 显示针对 code-size 优化的版本。 (除了开头的 bswap
之外,它是一个正常的 uint32_t -> 带零填充的十六进制。)
只是为了好玩,这个版本从缓冲区的末尾开始并递减一个指针。 (并且循环条件使用 pointer-compare。)一旦 EDX 变为零,您可以让它停止,如果您不想要前导零,则使用 EDI+1 作为数字的开头。
使用 cmp eax,9
/ ja
而不是 cmov
是 lef作为 reader 的练习。这个的 16 位版本可以使用不同的寄存器(比如 BX 作为临时寄存器)仍然允许 lea cx, [bx + 'a'-10]
copy-and-add。或者只是 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 仅取决于半字节值,cmov
消耗了所有 3 个结果。但是在迭代中有很多 ILP,只有 shr edx,4
和指针递减为 loop-carried 依赖项。我本可以通过安排节省 1 个字节的 code-size,这样我就可以使用 cmp al, 'a'
或其他东西。 And/or add al,'0'
如果我不关心从 EAX 单独重命名 AL 的 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 及更高版本,最好使用字节洗牌作为半字节查找 table。
这些 SIMD 版本中的大多数可以使用两个打包的 32 位整数作为输入,结果向量的低 8 字节和高 8 字节包含单独的结果,您可以使用 movq
和 movhps
。
根据您的随机播放控件,这与将它用于一个 64 位整数完全一样。
SSSE3 pshufb
并行查找 table。不需要搞乱循环,我们可以在具有 pshufb
的 CPU 上通过一些 SIMD 操作来做到这一点。 (SSSE3 甚至不是 x86-64 的基线;它是 Intel Core2 和 AMD Bulldozer 的新功能)。
pshufb
is a byte shuffle 由矢量控制,而不是立即数(不同于所有早期的 SSE1/SSE2/SSE3 洗牌)。使用固定目标和变量 shuffle-control,我们可以将其用作并行查找 table 以并行执行 16 次查找(从向量中的 16 个条目 table 字节)。
所以我们将整个整数加载到向量寄存器中,并使用 bit-shift 和 punpcklbw
将其半字节解压缩为字节。然后使用 pshufb
将这些半字节映射到十六进制数字。
这给我们留下了一个 XMM 寄存器的 ASCII 数字,其中最低有效数字是寄存器的最低字节。由于 x86 是 little-endian,因此没有自由的方法以相反的顺序将它们存储到内存中,MSB 在前。
我们可以使用额外的 pshufb
将 ASCII 字节重新排序为打印顺序,或者在整数寄存器的输入上使用 bswap
(并反转半字节 -> 字节解包)。如果整数来自内存,通过一个整数寄存器 bswap
有点糟透了(尤其是对于 AMD Bulldozer-family),但如果你首先将整数放在 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 fused-domain uop,所以它和 movq
一样便宜。但在 Ryzen 上,它需要在商店顶部洗牌。另外,如果你想并行转换两个整数,或者一个 64 位整数,这个技巧就没用了。
SSE2,保证在 x86-64 中可用:
没有 SSSE3 pshufb
,我们需要依靠标量 bswap
将字节按正确的打印顺序排列,而 punpcklbw
另一种方式与每个字节的高半字节交错先配对。
而不是 table 查找,我们只需添加 '0'
,并为大于 9 的数字添加另一个 'a' - ('0'+10)
(将它们放入 'a'..'f'
范围) . SSE2 对 greater-than、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
所以它可能只在非常老的 CPU 上是个好主意有 SSE2,或者将 128 位操作分成 64 位的一半(例如 Pentium-M 或 K8)。在矢量寄存器为 mov-elimination 的现代 CPU 上(如 Bulldozer 和 IvyBrige),它仅适用于 XMM 寄存器,不适用于 MMX。我确实安排了寄存器的使用,所以第二个 movdqa
不在关键路径上,但我没有为第一个这样做。
AVX 可以节省 movdqa
,但更有趣的是 AVX2 我们可以从大量输入 中一次生成 32 个字节的十六进制数字。 2 个 64 位整数或 4 个 32 位整数;使用 128->256 位广播负载将输入数据复制到 eah巷。从那里开始,in-lane vpshufb ymm
和一个从每个 128 位通道的低半部分或高半部分读取的控制向量应该为您设置在低通道中解压缩的低 64 位输入的半字节,以及在高通道中解压缩的高 64 位输入的半字节。
或者如果输入数字来自不同的来源,也许 vinserti128
高的数字 可能 在某些 CPU 上是值得的,而不是仅仅做单独的 128 位操作。
AVX512VBMI (Cannonlake/IceLake, not present in Skylake-X) has a 2-register byte shuffle vpermt2b
that could combine the puncklbw
interleaving with byte-reversing. Or even better, we have VPMULTISHIFTQB
可以从source.
的每个qword中提取8个未对齐的8位位域
我们可以使用它来直接将我们想要的半字节提取到我们想要的顺序中,避免单独的 right-shift 指令。 (它仍然带有垃圾位,但 vpermb
忽略了高垃圾。)
要将其用于 64 位整数,请使用广播源和多移位控件,将输入 qword 的高 32 位解压缩到向量底部,低 32 位解包到向量顶部. (假设输入little-endian)
要将此用于超过 64 位的输入,请使用 vpmovzxdq
将每个输入双字 zero-extend 转换为 qword,设置 vpmultishiftqb
在每个 qword 中具有相同的 28,24,...,4,0 控制模式。 (例如,从 256 位输入向量或四个双字生成 zmm 输出向量 -> 一个 ymm reg 以避免 clock-speed 限制和实际 运行 宁 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
不是 lane-crossing 因为只涉及一个车道(不像 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 在 vpshufb
上的吞吐量为 0.5c xmm/ymm)
AVX2 variable-shift 或 AVX512F merge-masking 保存交错
对于 AVX512F,我们可以使用 merge-masking 到 right-shift 一个双字,同时在将数字广播到 XMM 寄存器后保持另一个不变。
或者我们可以使用 AVX2 variable-shift vpsrlvd
来做完全相同的事情,shift-count 矢量 [4, 0, 0, 0]
. Intel Skylake 及更高版本有 single-uop vpsrlvd
; Haswell/Broadwell 取多个微指令 (2p0 + p5)。 Ryzen 的 vpsrlvd xmm
是 1 uop,3c 延迟,每 2 个时钟吞吐量 1。 (比立即轮班更糟糕)。
然后我们只需要 single-register 字节洗牌,vpshufb
,来交错半字节和 byte-reverse。但是你需要一个掩码寄存器中的常量,它需要几条指令来创建。在将多个整数转换为十六进制的循环中,这将是一个更大的胜利。
对于函数的 non-looping stand-alone 版本,我将一个 16 字节常量的两半用于不同的事情:set1_epi8(0x0f)
在上半部分,8 个字节的pshufb
控制矢量在下半部分。这不会节省很多,因为 EVEX 广播内存操作数允许 vpandd xmm0, xmm0, dword [AND_mask]{1to4}
,只需要 4 个字节的 space 作为常量。
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
马上就是
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
使用 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 洗牌来将字节放入打印顺序。特别是对于 bswap 仅为 1 uop 的 32 位整数(与英特尔 64 位寄存器的 2 uop 相比,与 AMD 不同)。
这些以 MSD 优先打印顺序打印整数。 调整 multishift 常量或 shuffle 控制小端内存顺序输出,就像人们显然想要十六进制输出一样一个大哈希。或者对于 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
的内存源操作数中。没有办法告诉编译器它可以使用高 32 位“无关”的 64 位广播内存源操作数,如果该值无论如何都来自内存(并且已知不在未映射页面之前的页面,例如 32 位模式堆栈 arg)。因此,C 中不提供较小的优化。通常在内联后,您的 vars 将在寄存器中,如果您有指针,您将不知道它是否在页面末尾。 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 常量匹配,因此 _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, reg
、vpbroadcastd xmm,xmm
和 2x vpshufb
)对于下面的版本,但前端 uops 的数量相同(假设向量常量的微融合作为内存源操作数)。它还需要少 1 个向量常量,这总是很好,尤其是当它不在循环中时。
AVX-512 可以使用合并屏蔽移位代替可变计数移位,以需要设置屏蔽寄存器为代价节省一个向量常数。这将 space 保存在 .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 版本相比,通过使用 vpsrlvd
(或掩码移位)将 num>>4
和 num
的字节放入相同的 XMM 寄存器来设置 1 寄存器字节洗牌。 vpsrlvd
在 Skylake 及更高版本以及 Zen 1 / Zen 2 上是单 uop。不过,在 Zen 上它的延迟更高,并且根据 https://uops.info/ 没有完全流水线化(2c 吞吐量而不是 1c 你' d 期望它是一个端口的单个 uop。)但至少它不会与那些 CPU 上的 vpshufb
和 vpbroadcastd xmm,xmm
竞争相同的端口。 (在 Haswell 上,它是 2 微指令,其中一个用于 p5,所以它 确实 竞争,这比 SSSE3 版本更糟糕,因为它需要一个额外的常量。)
Haswell 的一个不错的选择可能是 _mm_slli_epi64(v, 32-4)
/ _mm_blend_epi32
- vpblendd
运行s 在任何端口上,不需要 shuffle 端口。或者甚至可能在一般情况下,因为这只需要 vmovd
设置,而不是 vmovd
+ vpbroadcastd
此函数需要 2 个其他向量常量(十六进制 lut,以及组合的 AND 和洗牌掩码)。 GCC 和 clang 愚蠢地将一个掩码的 2 次使用“优化”为 2 个单独的掩码常量,这真的很愚蠢。(但在一个循环中,只花费设置开销和一个寄存器,没有额外的开销每次转换成本。)无论如何,对于 uint64_t
版本,你需要 2 个单独的 16 字节常量,但我的手写 asm 版本很聪明,使用了一个 16 字节常量的两半。
MSVC 避免了这个问题:它更按字面意义编译内在函数并且不尝试优化它们(这通常是一件坏事,但在这里它避免了这个问题。)但是 MSVC 错过了使用 AVX-512 GP-register-source vpbroadcastd xmm0, esi
对于 _mm_set1_epi32
和 -arch:AVX512
。使用 -arch:AVX2
(因此广播必须使用 2 条单独的指令完成)它使用该向量常量作为内存源操作数两次(对于 vpand
和 vpshufb
)而不是加载到寄存器中,这是非常值得怀疑的,但可能还可以,并且实际上可以节省前端微指令。 IDK 它会在循环中做什么,其中提升负载显然更好。
更紧凑地写hex_lut
:
hex_lut = _mm_loadu_si128((const __m128i*)"0123456789abcdef");
使用 GCC 和 Clang 完全高效地编译(它们有效地优化掉了以 0 结尾的字符串文字,并且只发出一个对齐的向量常量)。但不幸的是,MSVC 将实际字符串保留在 .rdata 中,而没有对齐它。所以我用了更长的,不太好读,_mm_setr_epi8('0', '1', ..., 'f');
给定一个寄存器中的数字(二进制整数),如何将其转换为一串十六进制 ASCII 数字? (即将其序列化为文本格式。)
数字可以存储在内存中或即时打印,但同时存储在内存中和打印通常效率更高。 (您可以修改存储的循环,改为一次打印一个。)
我们能否通过 SIMD 并行有效地处理所有半字节? (SSE2 或更高版本?)
相关:16-bit version that converts 1 byte to 2 hex digits which you could print or store to a buffer. And
如果针对 code-size 而不是速度进行优化,则有 a hack using DAS that saves a few bytes。
16 是 2 的幂。与十进制或其他不是 2 的幂的基数不同,我们不需要除法,我们可以先提取 most-significant 数字(即按打印顺序)。否则我们只能首先得到 least-significant 数字(其值取决于数字的所有位)并且我们必须倒退:参见 How do I print an integer in Assembly Level Programming without printf from the c library? for non-power-of-2 bases.
每个 4 位位组映射到一个十六进制数字。我们可以使用移位或旋转以及 AND 掩码,将输入的每个 4 位块提取为 4 位整数。
不幸的是,0..9 a..f 十六进制数字在 ASCII 字符集中不连续 (http://www.asciitable.com/)。我们要么需要条件行为(分支或 cmov),要么我们可以使用查找 table.
查找 table 通常对于指令数和性能来说是最有效的,因为我们要重复这样做;现代 CPU 具有非常快的 L1d 缓存,这使得附近字节的重复加载非常便宜。流水线/out-of-order 执行隐藏了 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 系统 V (non-Windows) 的 RDI 和 ESI。只需删除从堆栈加载的部分,并将循环更改为使用 ESI 而不是 EAX。 (并使寻址模式为 64 位。您可能需要将 hex_lut
地址放入循环外的寄存器中;参见 this and this)。
此版本转换为十六进制 ,带 个前导零。如果你想删除它们,输入上的 bit_scan(input)/4
如 lzcnt
或 __builtin_clz
,或者输出 ASCII 字符串上的 SIMD compare -> pmovmksb -> tzcnt 会告诉你有多少个 0 数字有(因此您可以从第一个 non-zero 开始打印或复制)。或者从低半字节开始转换并向后工作,当右移使值变为零时停止,如使用 cmov 而不是查找的第二个版本所示 table.
直到BMI2 (shrx
/ rorx
),x86缺少copy-and-shift指令,所以旋转in-place然后copy/AND很难被击败1。现代 x86(Intel 和 AMD)有 1 个周期的循环延迟(https://agner.org/optimize/ and https://uops.info/),所以这个 loop-carried 依赖链不会成为瓶颈。 (即使在 5-wide Ryzen 上,循环中的指令也太多了 运行 每次迭代甚至 1 个周期。)
为了便于阅读,我使用了 mov ecx,8
和 dec ecx/jnz
; lea ecx, [edi+8]
位于顶部,cmp edi, ecx / jb .digit_loop
作为循环分支,整体机器代码大小更小,在更多 CPU 上效率更高。 dec/jcc
macro-fusion 变成单个 uop 只发生在 Intel Sandybridge-family 上; AMD 只将 jcc 与 cmp 或 test 融合。此优化将使 Ryzen 上的 front-end 降低到 7 微指令,与英特尔相同,这仍然超过它在 1 个周期内可以发出的速度。
脚注 1:我们可能会使用 SWAR(寄存器中的 SIMD)在移位之前执行 AND:x & 0x0f0f0f0f
低半字节和 shr(x,4) & 0x0f0f0f0f
高半字节,然后通过交替处理来自每个寄存器的一个字节来有效展开。 (没有任何有效的方法来做 punpcklbw
的等价物或将整数映射到 non-contiguous ASCII 码,我们仍然只需要分别处理每个字节。但我们可能会展开 byte-extraction 和读取 AH 然后读取 AL(使用 movzx
)以保存移位指令。读取高 8 位寄存器会增加延迟,但我认为在当前 CPU 上不会花费额外的微指令。写入高 8 位寄存器通常不好Intel CPU:读取完整寄存器需要额外的合并 uop,插入它有 front-end 延迟。因此通过改组寄存器获得更广泛的存储可能不好。在不能使用 XMM regs 的内核代码中,但可以使用 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
测试运行s:
$ ./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
替代实现:
条件而不是 lookup-table:需要更多的指令,并且可能会更慢。但它不需要任何静态数据。
它可以通过分支而不是 cmov
来完成,但大多数时候那样会更慢。 (假设随机混合 0..9 和 a..f 数字,它不会预测得很好。)https://codegolf.stackexchange.com/questions/193793/little-endian-number-to-string-conversion/193842#193842 显示针对 code-size 优化的版本。 (除了开头的 bswap
之外,它是一个正常的 uint32_t -> 带零填充的十六进制。)
只是为了好玩,这个版本从缓冲区的末尾开始并递减一个指针。 (并且循环条件使用 pointer-compare。)一旦 EDX 变为零,您可以让它停止,如果您不想要前导零,则使用 EDI+1 作为数字的开头。
使用 cmp eax,9
/ ja
而不是 cmov
是 lef作为 reader 的练习。这个的 16 位版本可以使用不同的寄存器(比如 BX 作为临时寄存器)仍然允许 lea cx, [bx + 'a'-10]
copy-and-add。或者只是 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 仅取决于半字节值,cmov
消耗了所有 3 个结果。但是在迭代中有很多 ILP,只有 shr edx,4
和指针递减为 loop-carried 依赖项。我本可以通过安排节省 1 个字节的 code-size,这样我就可以使用 cmp al, 'a'
或其他东西。 And/or add al,'0'
如果我不关心从 EAX 单独重命名 AL 的 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 及更高版本,最好使用字节洗牌作为半字节查找 table。
这些 SIMD 版本中的大多数可以使用两个打包的 32 位整数作为输入,结果向量的低 8 字节和高 8 字节包含单独的结果,您可以使用 movq
和 movhps
。
根据您的随机播放控件,这与将它用于一个 64 位整数完全一样。
SSSE3 pshufb
并行查找 table。不需要搞乱循环,我们可以在具有 pshufb
的 CPU 上通过一些 SIMD 操作来做到这一点。 (SSSE3 甚至不是 x86-64 的基线;它是 Intel Core2 和 AMD Bulldozer 的新功能)。
pshufb
is a byte shuffle 由矢量控制,而不是立即数(不同于所有早期的 SSE1/SSE2/SSE3 洗牌)。使用固定目标和变量 shuffle-control,我们可以将其用作并行查找 table 以并行执行 16 次查找(从向量中的 16 个条目 table 字节)。
所以我们将整个整数加载到向量寄存器中,并使用 bit-shift 和 punpcklbw
将其半字节解压缩为字节。然后使用 pshufb
将这些半字节映射到十六进制数字。
这给我们留下了一个 XMM 寄存器的 ASCII 数字,其中最低有效数字是寄存器的最低字节。由于 x86 是 little-endian,因此没有自由的方法以相反的顺序将它们存储到内存中,MSB 在前。
我们可以使用额外的 pshufb
将 ASCII 字节重新排序为打印顺序,或者在整数寄存器的输入上使用 bswap
(并反转半字节 -> 字节解包)。如果整数来自内存,通过一个整数寄存器 bswap
有点糟透了(尤其是对于 AMD Bulldozer-family),但如果你首先将整数放在 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 fused-domain uop,所以它和 movq
一样便宜。但在 Ryzen 上,它需要在商店顶部洗牌。另外,如果你想并行转换两个整数,或者一个 64 位整数,这个技巧就没用了。
SSE2,保证在 x86-64 中可用:
没有 SSSE3 pshufb
,我们需要依靠标量 bswap
将字节按正确的打印顺序排列,而 punpcklbw
另一种方式与每个字节的高半字节交错先配对。
而不是 table 查找,我们只需添加 '0'
,并为大于 9 的数字添加另一个 'a' - ('0'+10)
(将它们放入 'a'..'f'
范围) . SSE2 对 greater-than、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
所以它可能只在非常老的 CPU 上是个好主意有 SSE2,或者将 128 位操作分成 64 位的一半(例如 Pentium-M 或 K8)。在矢量寄存器为 mov-elimination 的现代 CPU 上(如 Bulldozer 和 IvyBrige),它仅适用于 XMM 寄存器,不适用于 MMX。我确实安排了寄存器的使用,所以第二个 movdqa
不在关键路径上,但我没有为第一个这样做。
AVX 可以节省 movdqa
,但更有趣的是 AVX2 我们可以从大量输入 中一次生成 32 个字节的十六进制数字。 2 个 64 位整数或 4 个 32 位整数;使用 128->256 位广播负载将输入数据复制到 eah巷。从那里开始,in-lane vpshufb ymm
和一个从每个 128 位通道的低半部分或高半部分读取的控制向量应该为您设置在低通道中解压缩的低 64 位输入的半字节,以及在高通道中解压缩的高 64 位输入的半字节。
或者如果输入数字来自不同的来源,也许 vinserti128
高的数字 可能 在某些 CPU 上是值得的,而不是仅仅做单独的 128 位操作。
AVX512VBMI (Cannonlake/IceLake, not present in Skylake-X) has a 2-register byte shuffle vpermt2b
that could combine the puncklbw
interleaving with byte-reversing. Or even better, we have VPMULTISHIFTQB
可以从source.
我们可以使用它来直接将我们想要的半字节提取到我们想要的顺序中,避免单独的 right-shift 指令。 (它仍然带有垃圾位,但 vpermb
忽略了高垃圾。)
要将其用于 64 位整数,请使用广播源和多移位控件,将输入 qword 的高 32 位解压缩到向量底部,低 32 位解包到向量顶部. (假设输入little-endian)
要将此用于超过 64 位的输入,请使用 vpmovzxdq
将每个输入双字 zero-extend 转换为 qword,设置 vpmultishiftqb
在每个 qword 中具有相同的 28,24,...,4,0 控制模式。 (例如,从 256 位输入向量或四个双字生成 zmm 输出向量 -> 一个 ymm reg 以避免 clock-speed 限制和实际 运行 宁 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
不是 lane-crossing 因为只涉及一个车道(不像 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 在 vpshufb
上的吞吐量为 0.5c xmm/ymm)
AVX2 variable-shift 或 AVX512F merge-masking 保存交错
对于 AVX512F,我们可以使用 merge-masking 到 right-shift 一个双字,同时在将数字广播到 XMM 寄存器后保持另一个不变。
或者我们可以使用 AVX2 variable-shift vpsrlvd
来做完全相同的事情,shift-count 矢量 [4, 0, 0, 0]
. Intel Skylake 及更高版本有 single-uop vpsrlvd
; Haswell/Broadwell 取多个微指令 (2p0 + p5)。 Ryzen 的 vpsrlvd xmm
是 1 uop,3c 延迟,每 2 个时钟吞吐量 1。 (比立即轮班更糟糕)。
然后我们只需要 single-register 字节洗牌,vpshufb
,来交错半字节和 byte-reverse。但是你需要一个掩码寄存器中的常量,它需要几条指令来创建。在将多个整数转换为十六进制的循环中,这将是一个更大的胜利。
对于函数的 non-looping stand-alone 版本,我将一个 16 字节常量的两半用于不同的事情:set1_epi8(0x0f)
在上半部分,8 个字节的pshufb
控制矢量在下半部分。这不会节省很多,因为 EVEX 广播内存操作数允许 vpandd xmm0, xmm0, dword [AND_mask]{1to4}
,只需要 4 个字节的 space 作为常量。
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
马上就是
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
使用 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 洗牌来将字节放入打印顺序。特别是对于 bswap 仅为 1 uop 的 32 位整数(与英特尔 64 位寄存器的 2 uop 相比,与 AMD 不同)。
这些以 MSD 优先打印顺序打印整数。 调整 multishift 常量或 shuffle 控制小端内存顺序输出,就像人们显然想要十六进制输出一样一个大哈希。或者对于 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
的内存源操作数中。没有办法告诉编译器它可以使用高 32 位“无关”的 64 位广播内存源操作数,如果该值无论如何都来自内存(并且已知不在未映射页面之前的页面,例如 32 位模式堆栈 arg)。因此,C 中不提供较小的优化。通常在内联后,您的 vars 将在寄存器中,如果您有指针,您将不知道它是否在页面末尾。 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 常量匹配,因此 _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, reg
、vpbroadcastd xmm,xmm
和 2x vpshufb
)对于下面的版本,但前端 uops 的数量相同(假设向量常量的微融合作为内存源操作数)。它还需要少 1 个向量常量,这总是很好,尤其是当它不在循环中时。
AVX-512 可以使用合并屏蔽移位代替可变计数移位,以需要设置屏蔽寄存器为代价节省一个向量常数。这将 space 保存在 .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 版本相比,通过使用 vpsrlvd
(或掩码移位)将 num>>4
和 num
的字节放入相同的 XMM 寄存器来设置 1 寄存器字节洗牌。 vpsrlvd
在 Skylake 及更高版本以及 Zen 1 / Zen 2 上是单 uop。不过,在 Zen 上它的延迟更高,并且根据 https://uops.info/ 没有完全流水线化(2c 吞吐量而不是 1c 你' d 期望它是一个端口的单个 uop。)但至少它不会与那些 CPU 上的 vpshufb
和 vpbroadcastd xmm,xmm
竞争相同的端口。 (在 Haswell 上,它是 2 微指令,其中一个用于 p5,所以它 确实 竞争,这比 SSSE3 版本更糟糕,因为它需要一个额外的常量。)
Haswell 的一个不错的选择可能是 _mm_slli_epi64(v, 32-4)
/ _mm_blend_epi32
- vpblendd
运行s 在任何端口上,不需要 shuffle 端口。或者甚至可能在一般情况下,因为这只需要 vmovd
设置,而不是 vmovd
+ vpbroadcastd
此函数需要 2 个其他向量常量(十六进制 lut,以及组合的 AND 和洗牌掩码)。 GCC 和 clang 愚蠢地将一个掩码的 2 次使用“优化”为 2 个单独的掩码常量,这真的很愚蠢。(但在一个循环中,只花费设置开销和一个寄存器,没有额外的开销每次转换成本。)无论如何,对于 uint64_t
版本,你需要 2 个单独的 16 字节常量,但我的手写 asm 版本很聪明,使用了一个 16 字节常量的两半。
MSVC 避免了这个问题:它更按字面意义编译内在函数并且不尝试优化它们(这通常是一件坏事,但在这里它避免了这个问题。)但是 MSVC 错过了使用 AVX-512 GP-register-source vpbroadcastd xmm0, esi
对于 _mm_set1_epi32
和 -arch:AVX512
。使用 -arch:AVX2
(因此广播必须使用 2 条单独的指令完成)它使用该向量常量作为内存源操作数两次(对于 vpand
和 vpshufb
)而不是加载到寄存器中,这是非常值得怀疑的,但可能还可以,并且实际上可以节省前端微指令。 IDK 它会在循环中做什么,其中提升负载显然更好。
更紧凑地写hex_lut
:
hex_lut = _mm_loadu_si128((const __m128i*)"0123456789abcdef");
使用 GCC 和 Clang 完全高效地编译(它们有效地优化掉了以 0 结尾的字符串文字,并且只发出一个对齐的向量常量)。但不幸的是,MSVC 将实际字符串保留在 .rdata 中,而没有对齐它。所以我用了更长的,不太好读,_mm_setr_epi8('0', '1', ..., 'f');