我正在尝试学习一些基本的x86程序集,因此我已经开始解决Project Euler问题。我希望对我的代码进行一些批评,希望包括操作效率或代码本身的可读性/风格。我将为Linux提供64位的Makefile。
代码的目的是求和[0,1000)中所有可被3或5整除的数字。
可以使用make RUN=euler_1运行代码。
NB:
我知道,大多数编译器都使用movshr的某种组合来替换已知数字的模,以避免整数除法。例如,请参见此线程。
Makefile
.PHONY: clean

all:    $(RUN).elf
    ./$^

%.elf:  %.o 
    ld $^ -o $@ -lc -e main -dynamic-linker /lib64/ld-linux-x86-64.so.2

%.o:    %.asm
    nasm -f elf64 $^

clean:
    rm -f *.o *.elf

euler_1.asm
extern printf
global main

section .data
fmt: db "%d", 0x0a, 0

section .text
    
;; main - Calculate the sum of all numbers between [0, 1000) that are divisible
;; by 3 or 5.
;;  sum : R8
main:   
    ; sum = 0
    mov r8, 0   
    ; for i in [0, 1000) {
    mov rcx, 0
for0:   
    ; if i % 3 == 0 or i % 5 == 0 {

    ; i % 3 == 0
    mov rax, rcx
    mov rdx, 0
    mov r9, 3
    div r9
    test rdx, rdx
    jne if01
    ; sum = sum + i
    add r8, rcx
    jmp if0

if01:
    ; i % 5 == 0
    mov rax, rcx
    mov rdx, 0
    mov r9, 5
    div r9
    test rdx, rdx
    jne if0
    ; sum = sum + i
    add r8, rcx
    jmp if0
    ; }
if0:
    inc rcx
    cmp rcx, 1000
    jl  for0
    ; }
    
    ; printf("%d", sum)
    lea rdi, [rel fmt]
    mov rsi, r8
    mov rax, 0
    call printf
    
    ; sys_exit(0)
    mov rdi, 0
    mov rax, 60
    syscall


评论

看起来像一个有趣的问题!您能否更新问题文本以准确描述程序的作用?现在,它已隐藏在代码的注释中。

糟糕!谢谢@Edward

嗨,如果您的目标是学习汇编,也可以尝试反汇编已编译的C程序。例如gcc test.c -g && gdb ./a.out;和disas / m main;甚至尝试godbolt.org

我最近也问过x86_64中的Project Euler 1(但是AT&T而不是Intel语法),也许其中一些注释对您有帮助:codereview.stackexchange.com/q/245990/195637

#1 楼

以下是一些可以帮助您改进代码的事情。另一个评论提出了一些好的观点,但是这里还没有涵盖。
确定是否使用stdlib
Makefile和对printf的调用都表明您正在使用标准C库。 ,这很好,但是程序使用syscall终止,但不是。原因是标准C启动程序在调用main之前先进行设置,然后在main返回后再次将其拆解。此代码通过使用syscall结束程序来跳过删除操作,这不是一个好习惯。有两种选择:要么根本不使用C库(也就是说,编写您自己的打印例程),要么让拆解实际上发生:
xor eax, eax    ; set exit code to 0 to indicate success
ret             ; return to _libc_start_main which called our main

进一步了解启动和拆解的方式在Linux中工作时,请仔细阅读此内容。
小心地管理寄存器
专业的汇编语言程序员(和好的编译器)要做的一件事就是管理寄存器的使用。在这种情况下,总和的最终用途是打印出来,而要打印出来,我们需要rsi寄存器中的值。那么为什么不使用rsi而不是r8作为运行总和呢?正如其他评论所指出的,有更好的方法可以做到这一点,但让我们更深入地了解一下。该代码当前执行此操作:
; sum = 0
mov r8, 0   
; for i in [0, 1000) {
mov rcx, 0

可以,但是让我们看一下清单文件,以了解NASM将其转换为的内容:列表文件的编号,第二个是地址,第三个是编码指令。因此,我们看到这两个指令使用11个字节。我们可以做得更好!另一则评论正确地提到了mov r8, 0指令,因此让我们尝试一下:
13                                      ; sum = 0
14 00000000 41B800000000                mov r8, 0   
15                                      ; for i in [0, 1000) {
16 00000006 B900000000                  mov rcx, 0

更好,只有六个字节。我们还能做得更好。正如正确注释的注释之一所示,在64位x86机器上,如果您对r8寄存器的下半部分进行xor,则它也会清除上半部分。因此,我们要这样做:
19 00000000 4D31C0                          xor     r8, r8
20 00000003 4831C9                          xor     rcx, rcx

保存了一个字节,但是没有xor寄存器。我们可以通过清除rXX然后将该值复制到e8来做得更好吗?
19 00000000 4D31C0                          xor     r8, r8
20 00000003 31C9                            xor     ecx, ecx

不,我们不能,除非我们也遵循上述建议并使用ecx而不是r8
14 00000000 31C9                            xor     ecx, ecx
20 00000002 4989C8                          mov     r8, rcx

现在我们减少到4个字节,我们不再需要rsi指令,该指令可以为我们节省另外3个字节,仅用这两项就可以节省10个字节。
如果可行,请避免使用r8
mov rsi, r8指令是x86_64架构上最慢的指令之一,如果尝试除以零,也可能导致异常。由于这两个原因,通常最好避免这样做。在这种情况下,一种避免出现这种情况的方法是,注意它看起来很像div,并保留两个计数器:一个从5开始递减计数,另一个从3开始递减计数。
在可行的地方使用本地标签
很明显,div必须是文件全局符号,但不需要fizzbuzzmain(这两个名字都很差)。在NASM中,我们可以通过在标签前面加上一个句点来指定本地标签,因此可以使用for0代替if01。这样做的好处是我们可以在另一个函数中重用标签,而不必担心冲突。
在可行的情况下避免无条件跳转
x86处理器会尽力找出下一步将执行的指令。它有各种各样的方法可以实现,包括多级缓存和分支预测。这样做是为了使软件运行更快。您可以通过在可行时完全避免分支,特别是通过避免无条件跳转来帮助实现此目的。仔细考虑一下,我们通常可以通过重组代码来做到这一点。这是原始代码:
19 00000000 31C9                            xor     ecx, ecx
20 00000002 31F6                            xor     esi, esi

我们可以这样重写:
        test rdx, rdx
        jne if01
        ; sum = sum + i
        add rsi, rcx
        jmp if0

if01:
        ; i % 5 == 0
        mov rax, rcx
        mov rdx, 0
        mov r9, 5
        div r9
        test rdx, rdx
        jne if0
        ; sum = sum + i
        add rsi, rcx
        jmp if0
        ; }
if0:
        inc rcx
        cmp rcx, 1000
        jl  for0


评论


\ $ \ begingroup \ $
无条件跳转不一定是坏事。实际上,与条件跳转相比,它们更容易被正确预测。但是在这种情况下,您是完全正确的,应该对代码进行重组以使其完全失败。通常,一个好的经验法则是预期的情况应该是失败的情况,而意外的情况应该是采取分支的情况。在编写大量汇编代码时,对可读性和性能的关注很好地结合在一起:合理地避免尽可能多的分支。
\ $ \ endgroup \ $
–科迪·格雷
20/12/20在17:17

\ $ \ begingroup \ $
我想知道是否应该使用cmov指令来避免在代码的那部分根本没有分支。
\ $ \ endgroup \ $
–丹尼尔·谢普勒(Daniel Schepler)
20/12/21在19:14

\ $ \ begingroup \ $
@DanielSchepler:这是一个很好的建议。
\ $ \ endgroup \ $
–爱德华
20/12/21在19:16

\ $ \ begingroup \ $
关于“没有e8寄存器”:我以为r8d就是这样。 (但是,在我使用GNU asm进行的测试中,xor%r8,%r8和xor%r8d的%r8d最终具有相同的长度。)
\ $ \ endgroup \ $
–丹尼尔·谢普勒(Daniel Schepler)
20/12/21在21:05

\ $ \ begingroup \ $
@DanielSchepler:是的,r8的32位版本是r8d。它不会使异或归零变小,因为它仍然需要寄存器编号的REX前缀。但是您仍然应该使用xor r8d,r8d,因为Silvermont不会将xor r8,r8视为独立于旧值的“归零习语”,但是32位操作数大小的xor(所有编译器始终使用)对每种情况都是最佳的专门处理任何一种归零习惯的CPU。请参见在x86汇编中将寄存器设置为零的最佳方法是:xor,mov或and?
\ $ \ endgroup \ $
– Peter Cordes
20/12/21在23:18



#2 楼



if01if0不是最大的名字。

而不是重新加载r9,请使用两个寄存器。让r9始终包含3,而r10始终包含5。


r8递增到一个位置。 ),而不是向上保留指令(cmp)。


mov rdx, 0编码为7个字节。 xor rdx, rdx的长度要短得多。


所有这些,请考虑
main:
    mov r8, 0   
    mov r9, 3
    mov r10, 5

    ; for i in (1000, 0] 
    mov rcx, 999

for0:   
    mov rax, rcx
    xor rdx, rdx
    div r9
    test rdx, rdx
    jeq accumulate

    mov rax, rcx
    xor rdx, rdx
    div r10
    test rdx, rdx
    jne next

accumulate:
    add r8, rcx
next:
    dec rcx
    jne  for0

PS:我希望您知道这个问题具有非常简单的算术解决方案。 >

评论


\ $ \ begingroup \ $
特别为最终评论+1。
\ $ \ endgroup \ $
–爱德华
20 Dec 20'2:18

\ $ \ begingroup \ $
什么汇编程序使用助记符jeq?还是仅仅是错字?同样,在清除或加载小的立即数时,无需使用完整的64位寄存器。高32位会自动清除,因此您可以执行xor edx,edx等操作。而且从来没有理由做mov reg,0。
\ $ \ endgroup \ $
–科迪·格雷
20/12/20在12:54

\ $ \ begingroup \ $
@CodyGray:编译器自动对齐功能。在手写汇编中,如果要匹配该行为,则应在标签前使用align 16。如果我想要更长的清零指令,则可以在32位异或清零指令之前使用一些虚拟的段覆盖前缀和REX.W = 0。段前缀(与REP不同)可以应用于某些形式的XOR,因此将来的CPU不太可能将前缀+操作码组合用作其他指令。
\ $ \ endgroup \ $
– Peter Cordes
20/12/20在19:22

\ $ \ begingroup \ $
如果我不想在xor-zeroing,mov reg上使用额外的前缀,则0确实不错,除非您需要避免对P6-系列进行部分注册惩罚(例如,在setcc之前);我想我在关于在x86汇编中将寄存器设置为零的最佳方法是什么的最佳答案中提到过:xor,mov或and?现代CPU具有足够的后端端口及其前端宽度,因此消除Xor零位通常对于吞吐量而言并不重要。而且只有英特尔实际上在前端消除了它。 AMD Zen仅消除了mov reg,reg。 (@俄罗斯)
\ $ \ endgroup \ $
– Peter Cordes
20 Dec 20'19:24



\ $ \ begingroup \ $
除了异或归零之外,我们知道除以3或5的余数将适合32位寄存器,因此我们可以类似地将REX前缀与测试edx edx保存在一起。 (对于mov r9d,3,尽管NASM会为您做该优化,因为它是完全等效的。其他汇编程序则不会,并且了解x86-64的隐式零扩展对于理解某些编译器生成的代码非常重要,因为它们始终会优化mov -立即;不幸的是,并非总是其他insns。)
\ $ \ endgroup \ $
– Peter Cordes
20/12/21在6:53

#3 楼

关于实现选择以及如何实现的一些简要说明:
当数字仅增加到1000时,div不需要64位操作数大小,这比Intel的div r32慢得多在Ice Lake之前:我在另一份代码审查中解释了详细信息:检查数字在NASM Win64汇编程序中是否为素数。
(通常,对于其他说明,test edx, edx会在此处保存代码大小。即使使用64位数字和64位divi % 5始终适合32位,因此可以安全地忽略高32位。请参阅
在x86-64中使用32位寄存器/指令的优点-它是x86-64的默认操作数大小,不需要任何机器码前缀。为提高效率,除非确实需要该特定指令的64位操作数大小,并且隐式零扩展为64位将不能满足您的需要,否则请使用它。
Don不会花费额外的指令;通常需要64位操作数大小,例如用于指针增量。)
当然,对于除以编译时常数,div是一个慢速选项,编译器完全避免使用,而是使用定点乘法逆。就像在为什么GCC在实现整数除法中为什么使用乘以奇数的乘法?

此外,如果您使用倒数计数器,当它们达到0(和/或展开)时重置为3或5,则根本不需要除法。来处理3、5模式,例如FizzBu​​zz-请参阅此Stack Overflow答案,其中我编写了有关此类技术的大型教程,在此不再赘述。与FizzBu​​zz不同,即使数字是3和5的倍数,您也只希望计数一次。
您只需将数字展开15(这样模式就可以完全重复)并硬编码类似
.unroll15_loop:
                                    ; lets say ECX=60 for example
    add  eax, ecx                   ; += 60
    lea  eax, [rax + rcx + 3]       ; += 63
    lea  eax, [rax + rcx + 5]       ; += 65
    lea  eax, [rax + rcx + 6]       ; += 66
    ...
    add  ecx, 15
    cmp  ecx, 1000-15
    jbe  .unroll15_loop
   ; handle the last not full group of 15 numbers

或应用一些数学运算,而不是实际查看每个数字,请对15个数字范围内的3和5的倍数之和使用闭式公式,以i * nmuls偏移,其中i是范围的起点,而nmuls是倍数。
例如在[60, 75)范围内,我们有60、63、65、66、69、70、72。所以这是15个数字中的8个。所以就像[0, 15)+ 8*60一样。手动或循环执行0..14部分,并记住结果。 (欧拉计画与算术一样关乎数学;取决于您想算多少数学还是想要算力多少。)
方便地,8恰好是比例-x86寻址模式支持的因素,因此我们甚至可以做到
(43 + 5 + 6 + ...是一个常量表达式,因此汇编器可以在汇编时为您完成此操作[reg + reg*scale + disp8]寻址模式。不幸的是,三分量LEA在Intel CPU上具有3个周期的延迟,并且循环依赖将成为循环的瓶颈。因此,使用单独的add指令实际上会更有效。 />当然,我们已经将其简化为线性增加的级数的和,并且可以对整个区间范围内的封闭形式应用高斯公式(n * (n+1) / 2),而只需要处理n%15接近n。顺便说一句,clang知道如何将sum += i;转换为闭合形式的简单for循环,将其安排为避免除以2之前的临时溢出(右移)。 Matt Godbolt在CppCon2017上的演讲“最近我的编译器为我做了什么?以解开编译器的盖子为例。另请参阅https://stackoverflow.com/questions/38552116/how-to-remove-noise-from-gcc-clang-assembly-output

评论


\ $ \ begingroup \ $
其余部分可以基于简单的表查找来实现。尤其是当您要倒数至0时;但是,即使您要进行计数,也可以在表格中包含基数的倍数和要使用的常数项的条目。
\ $ \ endgroup \ $
–丹尼尔·谢普勒(Daniel Schepler)
20/12/21在22:32

\ $ \ begingroup \ $
@DanielSchepler:哦,清理的0..14元素?是的,最好只是查找答案,而不是将跳闸表变成一系列lea和NOP指令之类的东西。尽管如果使每个LEA具有相同的长度,则可以执行计算的跳转(如end_of_block-剩余* 4),而不是从指针表中加载,因此不会涉及静态数据。这可能很有趣,但是有效的选择是一张桌子。
\ $ \ endgroup \ $
– Peter Cordes
20/12/21在22:47

#4 楼

在适当的地方使用条件移动指令
通过@Edward扩展答案中的讨论:如果可以使用条件移动指令,则将进一步减少分支的数量,从而帮助处理器。
如果结合使用建议保持模数3和模数5计数器而不是进行除法,则主循环主体的轮廓可能如下所示(但未经测试):计数器的初始值为0,则需要将mod3_reg初始化为2,并将mod5_reg初始化为4。另一方面,如果调整为从1开始,则可以将两者都初始化为0,这样会更简单。) br />
还要注意,根据@PeterCordes的一些评论,cmov可能存在问题,从而在循环中创建了足够的额外依赖关系,以至于实际上可能不值得。在这种情况下,如果您非常关心性能,则在目标计算机上运行基准测试很重要。

评论


\ $ \ begingroup \ $
用3和5来预加载,用dec倒数,而不是inc,而是cmp。然后,您可以直接使用cmove重新加载。
\ $ \ endgroup \ $
–爱德华
20/12/21在20:23

\ $ \ begingroup \ $
当然,这绝对是可能的。然后,您可以利用dec为您预加载标志并避免进行测试或cmp的事实。最后,我最终以这种方式进行了操作,以提高可读性-随着计数的减少,描述寄存器代表的内容会更加复杂。
\ $ \ endgroup \ $
–丹尼尔·谢普勒(Daniel Schepler)
20/12/21在20:41

\ $ \ begingroup \ $
如果您出于简单性而不是性能考虑,则可以在cm3 / cmov(count_reg旁边)之后增加mod3和mod5 reg,因此一切都可以从零开始。或使用递减计数器,它们只在计数器= 0时从3和5开始,与它们的重置值相同。
\ $ \ endgroup \ $
– Peter Cordes
20/12/21在22:58

\ $ \ begingroup \ $
但是,这种无分支策略可能仅对极低的限制才有好处。大多数现代分支预测器将快速学习这种模式。但是,您每次在循环中始终要为前端吞吐量(假设单uu CMOVE为12 uop(Intel Broadwell和更高版本,或者永远是AMD,永远如此))和cmp / jl的宏融合而付出高昂的代价像Intel之前在Ice Lake之前的4宽管道,每次迭代需要3个周期才能发布;而使用倒数计数器,则可以降低到10 oups,Zen可以在2个周期内发布。
\ $ \ endgroup \ $
– Peter Cordes
20/12/21在23:04

\ $ \ begingroup \ $
然后,您将使用在mod3和5计数器上使用inc / cmov创建的2循环循环依赖链。 (或更糟糕的是,在使用2-uop cmov的旧版Intel上进行3循环)。如果循环跳闸计数很低并且之后的代码并不完全取决于结果,那么乱序的exec可以隐藏其中的一部分。但是,否则(一千次迭代)某些分支机构的早期失误将为自己带来整体回报。至少通过accum_reg本身的dep链只有1个周期。 (GCC有时会简化无分支C,使cmov成为较长dep链的一部分,例如准备2个可能的val。)
\ $ \ endgroup \ $
– Peter Cordes
20/12/21在23:10