相关: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 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