相关:16 位版本,可将 1 个字节转换为 2 个十六进制数字,您可以将其打印或存储到缓冲区。Converting bin to hex in assembly还有另一个 16 位版本,在答案的一半中包含大量文本解释,涵盖了问题的 int -> hex-string 部分。
如果针对代码大小而不是速度进行优化,那么使用 DAS 可以节省一些字节。
16 是 2 的幂。与十进制或其他不是 2 的幂的基数不同,我们不需要除法,我们可以先提取最重要的数字(即按打印顺序)。否则我们只能先得到最低有效位(并且它的值取决于数字的所有位),我们必须倒退:请参阅如何在没有来自 c 库的 printf 的情况下在汇编级编程中打印整数?对于非 2 次方碱基。
每个 4 位组的位映射到一个十六进制数字。我们可以使用移位或旋转以及 AND 掩码将输入的每个 4 位块提取为 4 位整数。
不幸的是, 0..9 a..f 十六进制数字在 ASCII 字符集中( http://www.asciitable.com/ ) 不连续。我们要么需要条件行为(分支或 cmov),要么可以使用查找表。
查找表对于指令计数和性能通常是最有效的,因为我们反复这样做;现代 CPU 具有非常快的 L1d 缓存,这使得重复加载附近的字节非常便宜。流水线/乱序执行隐藏了 L1d 缓存加载的约 5 个周期延迟。
;; NASM syntax, i386 System V calling convention
global itohex ; inputs: char* output, unsigned number
itohex:
push edi ; save a call-preserved register for scratch space
mov edi, [esp+8] ; out pointer
mov eax, [esp+12] ; number
mov ecx, 8 ; 8 hex digits, fixed width zero-padded
.digit_loop: ; do {
rol eax, 4 ; rotate the high 4 bits to the bottom
mov edx, eax
and edx, 0x0f ; and isolate 4-bit integer in EDX
movzx edx, byte [hex_lut + edx]
mov [edi], dl ; copy a character from the lookup table
inc edi ; loop forward in the output buffer
dec ecx
jnz .digit_loop ; }while(--ecx)
pop edi
ret
section .rodata
hex_lut: db "0123456789abcdef"
为了适应 x86-64,调用约定将在寄存器而不是堆栈中传递参数,例如 x86-64 System V(非 Windows)的 RDI 和 ESI。只需从堆栈中删除加载的部分,并将循环更改为使用 ESI 而不是 EAX。(并使寻址模式为 64 位。您可能需要将hex_lut
地址 LEA 放入循环外的寄存器中;请参阅this和this)。
此版本转换为带前导零的十六进制。如果你想删除它们,bit_scan(input)/4
喜欢lzcnt
或__builtin_clz
放在输入上,或者 SIMD compare -> pmovmksb -> tzcnt 在输出 ASCII 字符串上会告诉你有多少个 0 数字(因此你可以从第一个非零)。或者从低半字节开始转换并向后工作,当右移使值为零时停止,如使用 cmov 而不是查找表的第二个版本所示。
在 BMI2 ( shrx
/ rorx
) 之前,x86 缺少复制和移位指令,因此就地旋转然后复制/与很难击败1。现代 x86(Intel 和 AMD)的循环延迟为 1 个周期(https://agner.org/optimize/和https://uops.info/),因此这个循环承载的依赖链不会成为瓶颈。(循环中的指令太多,即使在 5 宽的 Ryzen 上,它也无法在每次迭代中运行 1 个周期。)
我使用mov ecx,8
and dec ecx/jnz
for 是为了便于阅读; 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 上不会花费额外的微指令。在 Intel CPU 上写入高 8 位寄存器通常不好:读取完整寄存器需要额外的合并 uop,插入它需要前端延迟。所以通过改组寄存器来扩大商店可能并不好。在不能使用 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
测试运行:
$ ./a.out 12315
0000301b
$ ./a.out 12315123
00bbe9f3
$ ./a.out 999999999
3b9ac9ff
$ ./a.out 9999999999 # apparently glibc strtoul saturates on overflow
ffffffff
$ ./a.out 0x12345678 # strtoul with base=0 can parse hex input, too
12345678
替代实现:
Conditional 而不是 lookup-table:需要更多的指令,并且可能会更慢。但它不需要任何静态数据。
可以使用分支而不是 来完成cmov
,但大多数情况下会更慢。(它不会很好地预测,假设随机混合 0..9 和 a..f 数字。) https://codegolf.stackexchange.com/questions/193793/little-endian-number-to-string-conversion /193842#193842显示了针对代码大小优化的版本。(除了bswap
开头的 a 之外,它是一个普通的 uint32_t -> 带有零填充的十六进制。)
只是为了好玩,这个版本从缓冲区的末尾开始并递减一个指针。(并且循环条件使用指针比较。)一旦 EDX 变为零,您可以让它停止,并使用 EDI+1 作为数字的开头,如果您不想要前导零。
使用cmp eax,9
/ja
而不是cmov
留给读者作为练习。一个 16 位版本可以使用不同的寄存器(比如可能 BX 作为临时寄存器)仍然允许lea cx, [bx + 'a'-10]
复制和添加。或者只是add
/ cmp
and 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
lea
我们可以使用 2x +在每次迭代中暴露更多的 ILP cmp/cmov
。cmp 和两个 LEA 仅取决于半字节值,并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 位整数完全一样。
SSSE3pshufb
并行查找表。无需搞乱循环,我们可以在具有pshufb
. (即使对于 x86-64,SSSE3 也不是基准;它是 Intel Core2 和 AMD Bulldozer 的新特性)。
pshufb
是由向量控制的字节混洗,而不是立即数(与所有早期的 SSE1/SSE2/SSE3 混洗不同)。使用固定目的地和可变 shuffle-control,我们可以将其用作并行查找表,以并行执行 16 次查找(来自向量中的 16 个字节条目表)。
因此,我们将整个整数加载到向量寄存器中,并通过位移和 . 将其半字节解压缩为字节punpcklbw
。然后使用 apshufb
将这些半字节映射到十六进制数字。
这给我们留下了 ASCII 数字一个 XMM 寄存器,其中最低有效位作为寄存器的最低字节。由于 x86 是 little-endian,因此没有免费的方法以相反的顺序将它们存储到内存中,首先是 MSB。
我们可以使用 extrapshufb
将 ASCII 字节重新排序为打印顺序,或者bswap
在整数寄存器中使用输入(并反转半字节 -> 字节解包)。如果整数来自内存,那么通过整数寄存器bswap
有点糟糕(特别是对于 AMD Bulldozer 系列),但如果你首先将整数放在 GP 寄存器中,那就很好了。
;; NASM syntax, i386 System V calling convention
section .rodata
align 16
hex_lut: db "0123456789abcdef"
low_nibble_mask: times 16 db 0x0f
reverse_8B: db 7,6,5,4,3,2,1,0, 15,14,13,12,11,10,9,8
;reverse_16B: db 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0
section .text
global itohex_ssse3 ; tested, works
itohex_ssse3:
mov eax, [esp+4] ; out pointer
movd xmm1, [esp+8] ; number
movdqa xmm0, xmm1
psrld xmm1, 4 ; right shift: high nibble -> low (with garbage shifted in)
punpcklbw xmm0, xmm1 ; interleave low/high nibbles of each byte into a pair of bytes
pand xmm0, [low_nibble_mask] ; zero the high 4 bits of each byte (for pshufb)
; unpacked to 8 bytes, each holding a 4-bit integer
movdqa xmm1, [hex_lut]
pshufb xmm1, xmm0 ; select bytes from the LUT based on the low nibble of each byte in xmm0
pshufb xmm1, [reverse_8B] ; printing order is MSB-first
movq [eax], xmm1 ; store 8 bytes of ASCII characters
ret
;; The same function for 64-bit integers would be identical with a movq load and a movdqu store.
;; but you'd need reverse_16B instead of reverse_8B to reverse the whole reg instead of each 8B half
可以将 AND 掩码和 pshufb 控件打包成一个 16 字节的向量,itohex_AVX512F
如下所示。
AND_shuffle_mask: times 8 db 0x0f ; low half: 8-byte AND mask
db 7,6,5,4,3,2,1,0 ; high half: shuffle constant that will grab the low 8 bytes in reverse order
将其加载到向量寄存器中并将其用作 AND 掩码,然后将其用作pshufb
控件以相反的顺序抓取低 8 字节,将它们保留在高 8 中。您的最终结果(8 个 ASCII 十六进制数字)将在XMM 寄存器的上半部分,所以使用movhps [eax], xmm1
. 在 Intel CPU 上,这仍然只是 1 个融合域 uop,所以它和movq
. 但在 Ryzen 上,它需要在商店顶部进行洗牌。另外,如果您想并行转换两个整数或 64 位整数,则此技巧毫无用处。
SSE2,保证在 x86-64 中可用:
如果没有 SSSE3 pshufb
,我们需要依靠标量bswap
将字节按正确的打印顺序排列,punpcklbw
另一种方法是先与每对的高半字节交错。
我们简单地添加 ,而不是查找表,然后为大于 9 的数字'0'
添加另一个(将它们放入范围内)。SSE2 有一个压缩字节比较大于,。除了按位与之外,这就是我们需要有条件地添加一些东西的全部内容。'a' - ('0'+10)
'a'..'f'
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
在第一个向量之前而不是 just 之前align 16
,因此它们都来自同一个缓存行。
这甚至可以只用 MMX 来实现,只使用 8 字节常量,但是你需要一个emms
所以它可能只在没有 SSE2 或拆分 128 位操作的非常旧的 CPU 上是一个好主意分成 64 位的一半(例如 Pentium-M 或 K8)。在对矢量寄存器具有 mov-elimination 的现代 CPU(如 Bulldozer 和 IvyBrige)上,它仅适用于 XMM 寄存器,而不适用于 MMX。我确实安排了寄存器的使用,所以第二个movdqa
不在关键路径上,但我第一个没有这样做。
AVX 可以节省一个movdqa
,但更有趣的是,使用AVX2,我们可以从大输入中一次生成 32 个字节的十六进制数字。2 个 64 位整数或 4 个 32 位整数;使用 128->256 位广播负载将输入数据复制到每个通道。从那里开始,vpshufb ymm
具有从每个 128 位通道的低半或高半读取的控制向量的通道内应该设置您在低通道中解压的低 64 位输入的半字节,以及高半字节的半字节64 位输入在高通道中解包。
或者,如果输入数字来自不同的来源,那么在某些 CPU 上,vinserti128
高数字可能是值得的,而不是仅执行单独的 128 位操作。
AVX512VBMI(Cannonlake/IceLake,Skylake-X 中不存在)具有 2 个寄存器字节混洗vpermt2b
,可以将puncklbw
交错与字节反转相结合。 甚至更好的是,我们VPMULTISHIFTQB
可以从 source 的每个 qword 中提取 8 个未对齐的 8 位位域。
我们可以使用它直接将我们想要的半字节提取到我们想要的顺序中,避免单独的右移指令。(它仍然带有垃圾位,但vpermb
忽略了高垃圾。)
要将其用于 64 位整数,请使用广播源和多移位控件,该控件将向量底部的输入 qword 的高 32 位和向量顶部的低 32 位解包。(假设小端输入)
要将其用于超过 64 位的输入,请使用vpmovzxdq
将每个输入 dword 零扩展为 qword,在每个 qword 中设置vpmultishiftqb
相同的 28,24,...,4,0 控制模式。(例如,从 256 位输入向量或四个 dwords -> ymm reg 生成一个 zmm 输出向量,以避免时钟速度限制和实际运行 512 位 AVX512 指令的其他影响。)
请注意,更广泛vpermb
使用每个控制字节的 5 或 6 位,这意味着您需要将 hexLUT 广播到 ymm 或 zmm 寄存器,或者在内存中重复它。
itohex_AVX512VBMI: ; Tested with SDE
vmovq xmm1, [multishift_control]
vpmultishiftqb xmm0, xmm1, qword [esp+8]{1to2} ; number, plus 4 bytes of garbage. Or a 64-bit number
mov ecx, [esp+4] ; out pointer
;; VPERMB ignores high bits of the selector byte, unlike pshufb which zeroes if the high bit is set
;; and it takes the bytes to be shuffled as the optionally-memory operand, not the control
vpermb xmm1, xmm0, [hex_lut] ; use the low 4 bits of each byte as a selector
vmovq [ecx], xmm1 ; store 8 bytes of ASCII characters
ret
;; For 64-bit integers: vmovdqa load [multishift_control], and use a vmovdqu store.
section .rodata
align 16
hex_lut: db "0123456789abcdef"
multishift_control: db 28, 24, 20, 16, 12, 8, 4, 0
; 2nd qword only needed for 64-bit integers
db 60, 56, 52, 48, 44, 40, 36, 32
# I don't have an AVX512 CPU, so I used Intel's Software Development Emulator
$ /opt/sde-external-8.4.0-2017-05-23-lin/sde -- ./a.out 0x1235fbac
1235fbac
vpermb xmm
不是车道交叉,因为只涉及一条车道(与vpermb ymm
或 zmm 不同)。但不幸的是,在 CannonLake 上(根据 instlatx64 结果),它仍然有 3 个周期的延迟,所以延迟pshufb
会更好。但是pshufb
根据高位有条件地归零,因此它需要屏蔽控制向量。vpermb xmm
假设只有 1 uop ,这会使吞吐量变得更糟。在一个循环中,我们可以将向量常量保存在寄存器中(而不是内存操作数),它只保存 1 条指令而不是 2 条指令。
(更新:是的,https ://uops.info/确认是 1 uop,延迟为 3c,Cannon Lake 和 Ice Lake 的吞吐量为 1c。ICL 的xmm/ymmvpermb
吞吐量为 0.5c )vpshufb
AVX2 可变移位或 AVX512F 合并屏蔽以保存交错
使用 AVX512F,在将数字广播到 XMM 寄存器之后,我们可以使用合并掩码来右移一个 dword,同时保持另一个不修改。
或者我们可以使用 AVX2 变量移位vpsrlvd
来做完全相同的事情,移位计数向量为[4, 0, 0, 0]
。英特尔 Skylake 及更高版本具有单 uop vpsrlvd
;Haswell/Broadwell 采用多个微指令 (2p0 + p5)。Ryzenvpsrlvd xmm
是 1 uop,3c 延迟,1 per 2 时钟吞吐量。(比立即轮班更糟糕)。
然后我们只需要一个单寄存器字节混洗,vpshufb
来交错半字节和字节反转。但是你需要一个掩码寄存器中的常量,它需要几个指令来创建。在将多个整数转换为十六进制的循环中,这将是一个更大的胜利。
对于该函数的非循环独立版本,我将一个 16 字节常量的两半用于不同的事情:set1_epi8(0x0f)
上半部分和下半部分的 8 字节pshufb
控制向量。这并没有节省很多,因为 EVEX 广播内存操作数允许vpandd xmm0, xmm0, dword [AND_mask]{1to4}
,只需要 4 个字节的空间用于常量。
itohex_AVX512F: ;; Saves a punpcklbw. tested with SDE
vpbroadcastd xmm0, [esp+8] ; number. can't use a broadcast memory operand for vpsrld because we need merge-masking into the old value
mov edx, 1<<3 ; element #3
kmovd k1, edx
vpsrld xmm0{k1}, xmm0, 4 ; top half: low dword: low nibbles unmodified (merge masking). 2nd dword: high nibbles >> 4
; alternatively, AVX2 vpsrlvd with a [4,0,0,0] count vector. Still doesn't let the data come from a memory source operand.
vmovdqa xmm2, [nibble_interleave_AND_mask]
vpand xmm0, xmm0, xmm2 ; zero the high 4 bits of each byte (for pshufb), in the top half
vpshufb xmm0, xmm0, xmm2 ; interleave nibbles from the high two dwords into the low qword of the vector
vmovdqa xmm1, [hex_lut]
vpshufb xmm1, xmm1, xmm0 ; select bytes from the LUT based on the low nibble of each byte in xmm0
mov ecx, [esp+4] ; out pointer
vmovq [ecx], xmm1 ; store 8 bytes of ASCII characters
ret
section .rodata
align 16
hex_lut: db "0123456789abcdef"
nibble_interleave_AND_mask: db 15,11, 14,10, 13,9, 12,8 ; shuffle constant that will interleave nibbles from the high half
times 8 db 0x0f ; high half: 8-byte AND mask