代码的目的是求和[0,1000)中所有可被3或5整除的数字。
可以使用
make RUN=euler_1
运行代码。 NB:
我知道,大多数编译器都使用
mov
和shr
的某种组合来替换已知数字的模,以避免整数除法。例如,请参见此线程。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
#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
必须是文件全局符号,但不需要fizzbuzz
和main
(这两个名字都很差)。在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 楼
if01
和if0
不是最大的名字。而不是重新加载
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位div
,i % 5
始终适合32位,因此可以安全地忽略高32位。请参阅在x86-64中使用32位寄存器/指令的优点-它是x86-64的默认操作数大小,不需要任何机器码前缀。为提高效率,除非确实需要该特定指令的64位操作数大小,并且隐式零扩展为64位将不能满足您的需要,否则请使用它。
Don不会花费额外的指令;通常需要64位操作数大小,例如用于指针增量。)
当然,对于除以编译时常数,
div
是一个慢速选项,编译器完全避免使用,而是使用定点乘法逆。就像在为什么GCC在实现整数除法中为什么使用乘以奇数的乘法? 此外,如果您使用倒数计数器,当它们达到0(和/或展开)时重置为3或5,则根本不需要除法。来处理3、5模式,例如FizzBuzz-请参阅此Stack Overflow答案,其中我编写了有关此类技术的大型教程,在此不再赘述。与FizzBuzz不同,即使数字是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
评论
看起来像一个有趣的问题!您能否更新问题文本以准确描述程序的作用?现在,它已隐藏在代码的注释中。糟糕!谢谢@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