遍历 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 asmlayout 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 lpcmp 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 以这种方式隐含地使用它们,这就是它们被命名为 sidi 的原因。但是,切勿仅仅因为寄存器名称而使用额外的 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 asmlayout 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