我正在学习x86-64,并且正在使用一些我最了解的编译器生成的汇编代码。它是一个递归阶乘程序,该程序将调用自己直到达到基数,其中将1放入rax中,然后依次与每个先前递减的计数值相乘。我了解在变量访问的情况下的对齐方式,其中访问未对齐的数据会付出巨大的代价,并且我认为要对齐的文本段几乎相同。
在程序中,我发现有两个标记点使我感到困惑,第一个是在 rdi 寄存器的递减中使用了三个堆栈分配的局部变量空间之一,该寄存器保存了用户提供的数字计算阶乘。为什么不直接使用rax代替:
mov qword [rbp + - 16]
使用
mov rdi, rax?.
第二个是在执行每个阶乘时使用其他两个堆栈局部变量,然后执行似乎是多余的操作,其中乘法结果从rax移到局部变量,然后再移回到rax之前函数返回。
mov qword [rbp + -24], rax
mov rax, rdi
imul rax, qword [rbp + -24]
mov qword [rbp + -8], rax
mov rax, qword [rbp + -8]
利用任何未经修改的通用寄存器并省略这些堆栈局部变量,或者这些操作是否是16字节对齐的一部分,这些计算会不会快得多?
rec:
push rbp
mov rbp, rsp
sub rsp, 24
push rbx
push r12
push r13
push r14
push r15
.sec0:
mov qword [rbp + -8], 1
test rdi, rdi
je .sec1
.sec2:
mov rax, rdi
sub rax, 1
mov qword [rbp + -16], rax ;; point 1.0
push rcx
push rdx
push rsi
push rdi
push r8
push r9
push r10
push r11
mov rdi, qword [rbp + -16] ;; point 1.1
call rec
pop r11
pop r10
pop r9
pop r8
pop rdi
pop rsi
pop rdx
pop rcx
mov qword [rbp + -24], rax ;; point 2.0
mov rax, rdi
imul rax, qword [rbp + -24] ;; point 2.1
mov qword [rbp + -8], rax ;; point 2.2
mov rax, qword [rbp + -8] ;; point 2.3
pop r15
pop r14
pop r13
pop r12
pop rbx
leave
ret
.sec1:
mov rax, qword [rbp + -8]
pop r15
pop r14
pop r13
pop r12
pop rbx
leave
ret
答案 0 :(得分:1)
您不会说该示例是从哪个代码生成的,或者是在哪个编译器上生成的,但它必须非常原始,甚至可能是Undergrad编译器类中的某些玩具编译器。没错,那是非常次优的。即使我测试过的最旧版本的gcc
,在不进行所有优化的情况下,也不会产生糟糕的代码。让我们看一下使用几种不同的编译器进行编译时得到的结果。比较的一种好方法是在godbolt结束。
我测试了以下代码:
unsigned long long factorial(const unsigned long long n)
{
return (n <= 1) ? 1
: n*(factorial(n-1));
}
factorial()
函数是您描述的简单的单行递归实现。我还写了factorial_tail()
,它是带有累加器的尾递归版本,以使某些编译器更容易注意到该函数为tail-recursive modulo an associative operation,因此可以自动转换为紧密循环。>
但是,现代编译器通常对此非常聪明。
除了-fomit-frame-pointer
以外,没有其他优化方法(以抑制保存和恢复堆栈帧),这就是gcc 8.2的作用:
factorial:
sub rsp, 24
mov QWORD PTR [rsp+8], rdi
cmp QWORD PTR [rsp+8], 1
jbe .L2
mov rax, QWORD PTR [rsp+8]
sub rax, 1
mov rdi, rax
call factorial
imul rax, QWORD PTR [rsp+8]
jmp .L4
.L2:
mov eax, 1
.L4:
add rsp, 24
ret
您仍然可以看到函数将中间结果保存在堆栈中紧靠8字节返回地址的上方,并在堆栈中进行了一些不必要的复制。这样做的目的是,在调试时,临时值存在于离散的内存位置,并且可以对其进行监视,检查和修改。
您问:“利用任何未修改的通用寄存器并省略这些堆栈局部变量[...],这些计算会不会快得多吗?”确实会!您不能将所有阶乘因子都保存在不同的寄存器中,因为可能会有数十亿。但是您可以自动重构代码,直到只需要恒定的暂存空间。
在生产代码中,您将打开优化功能。出于学习目的,针对空间进行优化的代码比针对速度进行完全优化的代码更容易理解,后者通常更长,更复杂。使用gcc -std=c11 -g -Os -mavx
,我们得到的是:
factorial:
mov eax, 1
.L3:
cmp rdi, 1
jbe .L1
imul rax, rdi
dec rdi
jmp .L3
.L1:
ret
GCC很聪明,可以得出because multiplication is associative and has an identity,(4×(3×(2×1)))= 1×4×3×2×1。因此,它可以保持运行总计从左到右依次乘以(4,然后是12,然后是24),然后完全消除call
。该代码只是一个紧密的循环,几乎与使用高级语言编写for
循环所得到的结果相同。
如果您优化时间而不是使用-O3
进行空间调整,则GCC会尝试对循环进行矢量化处理,具体取决于您是否为其指定了标志-mavx
。其他进行最大程度优化的编译器会展开循环,但不使用向量指令。
Clang 7.0.0产生的一条指令稍长一些,带有相同标志的代码会稍快一些,因为它知道足以检查是否在结尾处终止循环,而不是跳回然后在起始处进行检查。我希望此代码比GCC的代码略微。
factorial: # @factorial
mov eax, 1
cmp rdi, 2
jb .LBB0_2
.LBB0_1: # =>This Inner Loop Header: Depth=1
imul rax, rdi
dec rdi
cmp rdi, 1
ja .LBB0_1
.LBB0_2:
ret
MSVC 19.0无法解决将转换应用于该代码的问题,仍然使用call
生成递归代码,但是我们可以通过重构并添加一个显式的累加器参数来提示它:
unsigned long long factorial_tail(const unsigned long long n,
const unsigned long long p)
/* The n parameter is the current value counted down, and the p parameter
* is the accumulating product. Call this function with an initial value
* of p = 1.
*/
{
return (n <= 1) ? p
: factorial_tail( n-1, n*p );
}
此版本明确地是尾递归的,每个现代编译器都知道尾调用消除。这会用/Ox /arch:avx
编译为:
factorial_tail PROC
mov rax, rdx
cmp rcx, 1
jbe SHORT $LN4@factorial_
mov rdx, rcx
imul rdx, rax
dec rcx
jmp factorial_tail
$LN4@factorial_:
ret 0
您在不同的代码清单中观察到,“似乎是多余的操作,其中乘法的结果从rax移到局部变量,然后在函数返回之前移回rax。”确实,在每个函数中循环的迭代。并没有意识到,已经将运行中的产品放到rax
上了,它可以并且应该将其保留在那里。
Intel的19.0.1编译器也无法告知它可以将factorial()
转换为循环,但是可以使用factorial_tail()
。使用-std=c11 -g -avT -Os
,它产生的代码比MSVC更好,并且与clang非常相似:
factorial_tail:
cmp rdi, 1 #14.16
jbe ..B2.5 # Prob 12% #14.16
..B2.3: # Preds ..B2.1 ..B2.3
imul rsi, rdi #15.44
dec rdi #15.39
cmp rdi, 1 #14.16
ja ..B2.3 # Prob 88% #14.16
..B2.5: # Preds ..B2.3 ..B2.1
mov rax, rsi #14.16
ret
它意识到应该避免将值从一个寄存器复制到另一个寄存器并在循环迭代之间复制回来。相反,它选择将其保留在其初始位置rsi
(第二个函数参数)中,并在最后仅将返回值移动到rax
一次。