遍历 NASM 中的数组
looping over an array in NASM
我想学习汇编编程以编写快速高效的代码。
我怎么遇到一个我无法解决的问题。
我想遍历一个双字数组并添加其组件,如下所示:
%include "asm_io.inc"
%macro prologue 0
push rbp
mov rbp,rsp
push rbx
push r12
push r13
push r14
push r15
%endmacro
%macro epilogue 0
pop r15
pop r14
pop r13
pop r12
pop rbx
leave
ret
%endmacro
segment .data
string1 db "result: ",0
array dd 1, 2, 3, 4, 5
segment .bss
segment .text
global sum
sum:
prologue
mov rdi, string1
call print_string
mov rbx, array
mov rdx, 0
mov ecx, 5
lp:
mov rax, [rbx]
add rdx, rax
add rbx, 4
loop lp
mov rdi, rdx
call print_int
call print_nl
epilogue
Sum 由简单的 C 驱动程序调用。函数 print_string、print_int 和 print_nl 如下所示:
section .rodata
int_format db "%i",0
string_format db "%s",0
section .text
global print_string, print_nl, print_int, read_int
extern printf, scanf, putchar
print_string:
prologue
; string address has to be passed in rdi
mov rsi,rdi
mov rdi,dword string_format
xor rax,rax
call printf
epilogue
print_nl:
prologue
mov rdi,0xA
xor rax,rax
call putchar
epilogue
print_int:
prologue
;integer arg is in rdi
mov rsi, rdi
mov rdi, dword int_format
xor rax,rax
call printf
epilogue
在对所有数组元素求和后打印结果时,它显示 "result: 14" 而不是 15。我尝试了几种元素组合,似乎我的循环总是跳过数组的第一个元素。
有人能告诉我为什么循环会跳过第一个元素吗?
编辑
我忘了说我正在使用 x86_64 Linux 系统
我不确定为什么您的代码打印了错误的数字。可能是您应该使用调试器追踪的某个地方的差错。带 layout asm
和 layout reg
的 gdb 应该有帮助。实际上,我认为您将越过阵列的末尾。那里可能有一个 -1,您正在将它添加到您的累加器中。
如果您的最终目标是编写快速高效的代码,您应该看看我最近添加到 https://whosebug.com/tags/x86/info 的一些链接。特别是Agner Fog 的优化指南非常适合帮助您了解什么 运行 在当今的机器上有效,什么不能。例如leave
较短,但需要 3 微指令,而 mov rsp, rbp / pop rbp
需要 2 微指令。或者只是省略帧指针。 (这些天 gcc 默认为 -fomit-frame-pointer
for amd64。)乱用 rbp 只会浪费指令并花费你一个寄存器,尤其是。在值得在 ASM 中编写的函数中(即通常所有内容都存在于寄存器中,并且您不会调用其他函数)。
执行此操作的“正常”方法是用 asm 编写您的函数,从 C 调用它以获得结果,然后用 C 打印输出。如果您希望您的代码可移植到 Windows,你可以使用像
这样的东西
#define SYSV_ABI __attribute__((sysv_abi))
int SYSV_ABI myfunc(void* dst, const void* src, size_t size, const uint32_t* LH);
那么即使你为 Windows 编译,你也不必改变你的 ASM 来在不同的寄存器中寻找它的参数。 (SysV 调用约定比 Win64 更好:寄存器中有更多参数,并且允许使用所有向量寄存器而不保存它们。)确保你有一个足够新的 gcc,它修复了 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=66275 , 不过
另一种方法是使用一些 assembler 宏来 %define
一些寄存器名称,这样您就可以 assemble Windows 或 SysV ABI 的相同来源。或者在常规入口点之前有一个 Windows 入口点,它使用一些 MOV 指令将 args 放入函数其余部分期望的寄存器中。但这显然效率较低。
了解 asm 中的函数调用是什么样子很有用,但通常情况下,自己编写函数调用是浪费时间。您完成的例程将只是 return 结果(在寄存器或内存中),而不是打印它。您的 print_int
等例程效率极低。 (push/pop 每个被调用者保存的寄存器,即使你使用其中的 none,并且多次调用 printf 而不是使用以 \n
结尾的单一格式字符串。)我知道你没有不要声称 此 代码是高效的,您只是在学习。您可能已经知道这不是非常紧凑的代码。 :P
我的观点是,编译器在大多数时候都非常擅长他们的工作。花时间只为代码的热门部分编写 asm:通常只是一个循环,有时包括围绕它的设置/清理代码。
所以,进入你的循环:
lp:
mov rax, [rbx]
add rdx, rax
add rbx, 4
loop lp
Never use the loop
instruction。它解码为 7 微指令,而宏融合比较和分支为 1 微指令。 loop
的最大吞吐量为每 5 个周期一个(Intel Sandybridge/Haswell 及更高版本)。相比之下,dec ecx / jnz lp
或 cmp rbx, array_end / jb lp
会让您的循环 运行 在每个循环中进行一次迭代。
由于您使用的是单寄存器寻址模式,因此使用 add rdx, [rbx]
也比单独的 mov
-load 更有效。 (这是与索引寻址模式的更复杂的权衡,since they can only micro-fuse in the decoders / uop-cache, not in the rest of the pipeline, on Intel SnB-family。在这种情况下,add rdx, [rbx+rsi]
或某些东西将在 Haswell 和更高版本上保持微融合)。
当手写 asm 时,如果方便的话,通过在 rsi 中保留源指针和在 rdi 中保留目标指针来帮助自己。 movs
insn 以这种方式隐含地使用它们,这就是它们被命名为 si
和 di
的原因。但是,切勿仅仅因为寄存器名称而使用额外的 mov
指令。如果你想要更多的可读性,使用 C 和一个好的编译器。
;;; This loop probably has lots of off-by-one errors
;;; and doesn't handle array-length being odd
mov rsi, array
lea rdx, [rsi + array_length*4] ; if len is really a compile-time constant, get your assembler to generate it for you.
mov eax, [rsi] ; load first element
mov ebx, [rsi+4] ; load 2nd element
add rsi, 8 ; eliminate this insn by loading array+8 in the first place earlier
; TODO: handle length < 4
ALIGN 16
.loop:
add eax, [ rsi]
add ebx, [4 + rsi]
add rsi, 8
cmp rsi, rdx
jb .loop ; loop while rsi is Below one-past-the-end
; TODO: handle odd-length
add eax, ebx
ret
未经调试请勿使用此代码。 gdb(layout asm
和 layout reg
)还不错,在每个 Linux 发行版中都可用。
如果您的数组总是非常短的编译时常量长度,只需完全展开循环即可。否则,像这样使用两个累加器的方法可以让两个加法并行发生。 (Intel 和 AMD CPU 有两个加载端口,因此它们每个时钟可以从内存中进行两次加法运算。Haswell 有 4 个执行端口可以处理标量整数运算,因此它可以在每个周期执行 1 次迭代。以前的 Intel CPU 可以发出每个周期 4 微指令,但执行端口将落后于跟上它们。展开以最小化循环开销会有所帮助。)
所有这些技术(尤其是多重累加器)同样适用于矢量指令。
segment .rodata ; read-only data
ALIGN 16
array: times 64 dd 1, 2, 3, 4, 5
array_bytes equ $-array
string1 db "result: ",0
segment .text
; TODO: scalar loop until rsi is aligned
; TODO: handle length < 64 bytes
lea rsi, [array + 32]
lea rdx, [rsi - 32 + array_bytes] ; array_length could be a register (or 4*a register, if it's a count).
; lea rdx, [array + array_bytes] ; This way would be lower latency, but more insn bytes, when "array" is a symbol, not a register. We don't need rdx until later.
movdqu xmm0, [rsi - 32] ; load first element
movdqu xmm1, [rsi - 16] ; load 2nd element
; note the more-efficient loop setup that doesn't need an add rsi, 32.
ALIGN 16
.loop:
paddd xmm0, [ rsi] ; add packed dwords
paddd xmm1, [16 + rsi]
add rsi, 32
cmp rsi, rdx
jb .loop ; loop: 4 fused-domain uops
paddd xmm0, xmm1
phaddd xmm0, xmm0 ; horizontal add: SSSE3 phaddd is simple but not optimal. Better to pshufd/paddd
phaddd xmm0, xmm0
movd eax, xmm0
; TODO: scalar cleanup loop
ret
同样,此代码可能存在错误,并且无法处理对齐和长度的一般情况。它是展开的,因此每次迭代都会执行两个 * 四个打包的整数 = 32 字节的输入数据。
它应该 运行 在 Haswell 上每个周期迭代一次,否则在 SnB/IvB 上每 1.333 个周期迭代 1 次。前端可以在一个周期内发出所有 4 微指令,但如果没有 Haswell 的第 4 个 ALU 端口来处理 add
和宏融合 cmp/jb
,执行单元将无法跟上。每次迭代展开到 4 paddd
将对 Sandybridge 有所帮助,并且可能对 Haswell 也有帮助。
使用 AVX2 vpadd ymm1, [32+rsi]
,您可以获得双倍的吞吐量(如果数据在缓存中,否则您仍然会遇到内存瓶颈)。做一个256b向量的水平和,从vextracti128 xmm1, ymm0, 1
/ vpaddd xmm0, xmm0,xmm1
开始,然后就和SSE的情况一样了。参见 this answer for more details about efficient shuffles for horizontal ops。
我想学习汇编编程以编写快速高效的代码。 我怎么遇到一个我无法解决的问题。
我想遍历一个双字数组并添加其组件,如下所示:
%include "asm_io.inc"
%macro prologue 0
push rbp
mov rbp,rsp
push rbx
push r12
push r13
push r14
push r15
%endmacro
%macro epilogue 0
pop r15
pop r14
pop r13
pop r12
pop rbx
leave
ret
%endmacro
segment .data
string1 db "result: ",0
array dd 1, 2, 3, 4, 5
segment .bss
segment .text
global sum
sum:
prologue
mov rdi, string1
call print_string
mov rbx, array
mov rdx, 0
mov ecx, 5
lp:
mov rax, [rbx]
add rdx, rax
add rbx, 4
loop lp
mov rdi, rdx
call print_int
call print_nl
epilogue
Sum 由简单的 C 驱动程序调用。函数 print_string、print_int 和 print_nl 如下所示:
section .rodata
int_format db "%i",0
string_format db "%s",0
section .text
global print_string, print_nl, print_int, read_int
extern printf, scanf, putchar
print_string:
prologue
; string address has to be passed in rdi
mov rsi,rdi
mov rdi,dword string_format
xor rax,rax
call printf
epilogue
print_nl:
prologue
mov rdi,0xA
xor rax,rax
call putchar
epilogue
print_int:
prologue
;integer arg is in rdi
mov rsi, rdi
mov rdi, dword int_format
xor rax,rax
call printf
epilogue
在对所有数组元素求和后打印结果时,它显示 "result: 14" 而不是 15。我尝试了几种元素组合,似乎我的循环总是跳过数组的第一个元素。 有人能告诉我为什么循环会跳过第一个元素吗?
编辑
我忘了说我正在使用 x86_64 Linux 系统
我不确定为什么您的代码打印了错误的数字。可能是您应该使用调试器追踪的某个地方的差错。带 layout asm
和 layout reg
的 gdb 应该有帮助。实际上,我认为您将越过阵列的末尾。那里可能有一个 -1,您正在将它添加到您的累加器中。
如果您的最终目标是编写快速高效的代码,您应该看看我最近添加到 https://whosebug.com/tags/x86/info 的一些链接。特别是Agner Fog 的优化指南非常适合帮助您了解什么 运行 在当今的机器上有效,什么不能。例如leave
较短,但需要 3 微指令,而 mov rsp, rbp / pop rbp
需要 2 微指令。或者只是省略帧指针。 (这些天 gcc 默认为 -fomit-frame-pointer
for amd64。)乱用 rbp 只会浪费指令并花费你一个寄存器,尤其是。在值得在 ASM 中编写的函数中(即通常所有内容都存在于寄存器中,并且您不会调用其他函数)。
执行此操作的“正常”方法是用 asm 编写您的函数,从 C 调用它以获得结果,然后用 C 打印输出。如果您希望您的代码可移植到 Windows,你可以使用像
这样的东西#define SYSV_ABI __attribute__((sysv_abi))
int SYSV_ABI myfunc(void* dst, const void* src, size_t size, const uint32_t* LH);
那么即使你为 Windows 编译,你也不必改变你的 ASM 来在不同的寄存器中寻找它的参数。 (SysV 调用约定比 Win64 更好:寄存器中有更多参数,并且允许使用所有向量寄存器而不保存它们。)确保你有一个足够新的 gcc,它修复了 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=66275 , 不过
另一种方法是使用一些 assembler 宏来 %define
一些寄存器名称,这样您就可以 assemble Windows 或 SysV ABI 的相同来源。或者在常规入口点之前有一个 Windows 入口点,它使用一些 MOV 指令将 args 放入函数其余部分期望的寄存器中。但这显然效率较低。
了解 asm 中的函数调用是什么样子很有用,但通常情况下,自己编写函数调用是浪费时间。您完成的例程将只是 return 结果(在寄存器或内存中),而不是打印它。您的 print_int
等例程效率极低。 (push/pop 每个被调用者保存的寄存器,即使你使用其中的 none,并且多次调用 printf 而不是使用以 \n
结尾的单一格式字符串。)我知道你没有不要声称 此 代码是高效的,您只是在学习。您可能已经知道这不是非常紧凑的代码。 :P
我的观点是,编译器在大多数时候都非常擅长他们的工作。花时间只为代码的热门部分编写 asm:通常只是一个循环,有时包括围绕它的设置/清理代码。
所以,进入你的循环:
lp:
mov rax, [rbx]
add rdx, rax
add rbx, 4
loop lp
Never use the loop
instruction。它解码为 7 微指令,而宏融合比较和分支为 1 微指令。 loop
的最大吞吐量为每 5 个周期一个(Intel Sandybridge/Haswell 及更高版本)。相比之下,dec ecx / jnz lp
或 cmp rbx, array_end / jb lp
会让您的循环 运行 在每个循环中进行一次迭代。
由于您使用的是单寄存器寻址模式,因此使用 add rdx, [rbx]
也比单独的 mov
-load 更有效。 (这是与索引寻址模式的更复杂的权衡,since they can only micro-fuse in the decoders / uop-cache, not in the rest of the pipeline, on Intel SnB-family。在这种情况下,add rdx, [rbx+rsi]
或某些东西将在 Haswell 和更高版本上保持微融合)。
当手写 asm 时,如果方便的话,通过在 rsi 中保留源指针和在 rdi 中保留目标指针来帮助自己。 movs
insn 以这种方式隐含地使用它们,这就是它们被命名为 si
和 di
的原因。但是,切勿仅仅因为寄存器名称而使用额外的 mov
指令。如果你想要更多的可读性,使用 C 和一个好的编译器。
;;; This loop probably has lots of off-by-one errors
;;; and doesn't handle array-length being odd
mov rsi, array
lea rdx, [rsi + array_length*4] ; if len is really a compile-time constant, get your assembler to generate it for you.
mov eax, [rsi] ; load first element
mov ebx, [rsi+4] ; load 2nd element
add rsi, 8 ; eliminate this insn by loading array+8 in the first place earlier
; TODO: handle length < 4
ALIGN 16
.loop:
add eax, [ rsi]
add ebx, [4 + rsi]
add rsi, 8
cmp rsi, rdx
jb .loop ; loop while rsi is Below one-past-the-end
; TODO: handle odd-length
add eax, ebx
ret
未经调试请勿使用此代码。 gdb(layout asm
和 layout reg
)还不错,在每个 Linux 发行版中都可用。
如果您的数组总是非常短的编译时常量长度,只需完全展开循环即可。否则,像这样使用两个累加器的方法可以让两个加法并行发生。 (Intel 和 AMD CPU 有两个加载端口,因此它们每个时钟可以从内存中进行两次加法运算。Haswell 有 4 个执行端口可以处理标量整数运算,因此它可以在每个周期执行 1 次迭代。以前的 Intel CPU 可以发出每个周期 4 微指令,但执行端口将落后于跟上它们。展开以最小化循环开销会有所帮助。)
所有这些技术(尤其是多重累加器)同样适用于矢量指令。
segment .rodata ; read-only data
ALIGN 16
array: times 64 dd 1, 2, 3, 4, 5
array_bytes equ $-array
string1 db "result: ",0
segment .text
; TODO: scalar loop until rsi is aligned
; TODO: handle length < 64 bytes
lea rsi, [array + 32]
lea rdx, [rsi - 32 + array_bytes] ; array_length could be a register (or 4*a register, if it's a count).
; lea rdx, [array + array_bytes] ; This way would be lower latency, but more insn bytes, when "array" is a symbol, not a register. We don't need rdx until later.
movdqu xmm0, [rsi - 32] ; load first element
movdqu xmm1, [rsi - 16] ; load 2nd element
; note the more-efficient loop setup that doesn't need an add rsi, 32.
ALIGN 16
.loop:
paddd xmm0, [ rsi] ; add packed dwords
paddd xmm1, [16 + rsi]
add rsi, 32
cmp rsi, rdx
jb .loop ; loop: 4 fused-domain uops
paddd xmm0, xmm1
phaddd xmm0, xmm0 ; horizontal add: SSSE3 phaddd is simple but not optimal. Better to pshufd/paddd
phaddd xmm0, xmm0
movd eax, xmm0
; TODO: scalar cleanup loop
ret
同样,此代码可能存在错误,并且无法处理对齐和长度的一般情况。它是展开的,因此每次迭代都会执行两个 * 四个打包的整数 = 32 字节的输入数据。
它应该 运行 在 Haswell 上每个周期迭代一次,否则在 SnB/IvB 上每 1.333 个周期迭代 1 次。前端可以在一个周期内发出所有 4 微指令,但如果没有 Haswell 的第 4 个 ALU 端口来处理 add
和宏融合 cmp/jb
,执行单元将无法跟上。每次迭代展开到 4 paddd
将对 Sandybridge 有所帮助,并且可能对 Haswell 也有帮助。
使用 AVX2 vpadd ymm1, [32+rsi]
,您可以获得双倍的吞吐量(如果数据在缓存中,否则您仍然会遇到内存瓶颈)。做一个256b向量的水平和,从vextracti128 xmm1, ymm0, 1
/ vpaddd xmm0, xmm0,xmm1
开始,然后就和SSE的情况一样了。参见 this answer for more details about efficient shuffles for horizontal ops。