重要提示:不想看汇编代码直接看结果的,请滚动到文章末尾的结果部分
在知乎上看到这个问题,觉得挺有趣的。下面的回答五花八门,但是就是没有直接给出真正的benchmark结果的。还有直接搬反汇编代码的,只不过汇编里用了 x87 FPU 指令集,天那这都 202x 年了真的还有程序用这个老掉牙的浮点运算指令集的吗?
我也决定研究一下,读反汇编,写 benchmark。平台以 x86-64 为准,编译器 clang 12,开编译器优化(不开优化谈速度无意义)
代码及反汇编
https://gcc.godbolt.org/z/rvT9nEE9Y
简单汇编语言科普
在深入反汇编之前,先要对汇编语言有简单的了解。本文由于原始代码都很简单,甚至没有循环和判断,所以涉及到的汇编指令也很少。
- 汇编语言与平台强相关,这里以 x86-64(x86的64位兼容指令集,由于被AMD最先发明,也称作AMD64)为例,简称x64
- x64汇编语言也有两种语法,一种为 Intel 语法(主要被微软平台编译器使用),一种为 AT&T 语法(是gcc兼容编译器的默认语法,但是gcc也支持输出intel 语法)。个人感觉 Intel 语法更易懂,这里以 Intel 语法为例
基本语法。例如
mov rcx, rax
:mov
是指令名“赋值”。rcx
和rax
是mov
指令的两个操作数,他们都是通用寄存器名。Intel 汇编语法,第一个操作数同时被用于存储运算结果。所以:mov rcx, rax
,赋值指令,将寄存器rax
中的值赋值给寄存器rcx
。翻译为C语言代码为rcx = rax
add rcx, rax
,加法指令,将寄存器rcx
和rax
的值相加后,结果赋值给rcx
。翻译为 C 语言代码为rcx += rax
寄存器。编译器优化后,多数操作都直接在寄存器中操作,不涉及内存访问。下文只涉及三类寄存器(x64平台)。
- 以
r
打头的rxx
是 64 位寄存器 - 以
e
打头的exx
是 32 位寄存器,同时就是同名 64 位rxx
寄存器的低 32 位部分。 xmmX
是 128 位 SSE 寄存器。由于本文不涉及 SIMD 运算,可以简单的将其当做浮点数寄存器。对于双精度浮点数,只使用寄存器的低 64 位部分
- 以
调用约定。C语言特性,所有代码都依附于函数,调用函数时父函数向子函数传值、子函数向父函数返回值的方式叫做函数
调用约定
。调用约定是保证应用程序 ABI 兼容的最基本要求,不同的平台。不同的操作系统有不同的调用约定。本文的反汇编代码都是使用 godbolt 生成的,godbolt 使用的是 Linux 平台,所以遵循 Linux 平台通用的 System V 调用约定 调用约定。因为本文涉及到的代码都非常简单(都只有一个函数参数),读者只需要知道三点:- 函数的第一个整数参数通过
rdi / edi
寄存器传入(rdi / edi
存放调用方的第一个参数的值)。rdi
为 64 位寄存器,对应long
类型(Linux 平台)。edi
为 32 位寄存器,对应int
类型 - 函数的第一个浮点数参数通过
xmm0
寄存器传入,不区分单、双精度 - 函数的返回值整数类型通过
rax / eax
存放,浮点数通过xmm0
存放
- 函数的第一个整数参数通过
整数情况
整数除100
int int_div(int num) {
return num / 100;
}
结果为
int_div(int): # @int_div(int)
movsxd rax, edi
imul rax, rax, 1374389535
mov rcx, rax
shr rcx, 63
sar rax, 37
add eax, ecx
ret
稍作解释。movsxd
为带符号扩展赋值,可翻译为 rax = (long)edi
;imul
为有符号整数乘法;shr
为逻辑右移(符号位补0);sar
为算数右移(符号位不变)
可以看到编译器使用乘法和移位模拟除法运算,意味着编译器认为这么一大串指令也比除法指令快。代码里一会算术右移一会逻辑右移是为了兼容负数。如果指定为无符号数,结果会简单一些
unsigned int_div_unsigned(unsigned num) {
return num / 100;
}
结果为
int_div_unsigned(unsigned int): # @int_div_unsigned(unsigned int)
mov eax, edi
imul rax, rax, 1374389535
shr rax, 37
ret
也可以强制让编译器生成除法指令,使用 volatile
大法
int int_div_force(int num) {
volatile int den = 100;
return num / den;
}
结果为
int_div_force(int): # @int_div_force(int)
mov eax, edi
mov dword ptr [rsp - 4], 100
cdq
idiv dword ptr [rsp - 4]
ret
稍作解释。cdq
(Convert Doubleword to Quadword)是有符号 32 位至 64 位整数转化;idiv
是有符号整数除法。 整数除法指令使用比较复杂。首先操作数不能是立即数。然后如果除数是 32 位,被除数必须被转化为 64 位,cdq
指令就是在做这个转化(因为有符号位填充的问题)。另外汇编里出现了内存操作 dword ptr [rsp - 4]
,这是 volatile
的负作用,会对结果有些影响。
整数乘0.01
int int_mul(int num) {
return num * 0.01;
}
结果为
.LCPI3_0:
.quad 0x3f847ae147ae147b # double 0.01
int_mul(int): # @int_mul(int)
cvtsi2sd xmm0, edi
mulsd xmm0, qword ptr [rip + .LCPI3_0]
cvttsd2si eax, xmm0
ret
稍作解释。cvtsi2sd
(ConVerT Single Integer TO Single Double)是整数到双精度浮点数转换,可翻译为 xmm0 = (double) edi
。mulsd
是双精度浮点数乘法,cvttsd2si
是双精度浮点数到整数转换(截断小数部分)。
因为没有整数和浮点数运算的指令,实际运算中会先将整数转换为浮点数,运算完毕后还要转回来。计算机中整数和浮点数存储方法不同,整数就是简单的补码,浮点数是 IEEE754
的科学计数法表示,这个转换并不是简单的位数补充。
浮点数的情况
浮点数除100
double double_div(double num) {
return num / 100;
}
结果为
.LCPI4_0:
.quad 0x4059000000000000 # double 100
double_div(double): # @double_div(double)
divsd xmm0, qword ptr [rip + .LCPI4_0]
ret
稍作解释。divsd
是双精度浮点数除法。因为 SSE
寄存器不能直接 mov
赋值立即数,立即数的操作数都是先放在内存中的,即 qword ptr [rip + .LCPI4_0]
浮点数乘0.01
double double_mul(double num) {
return num * 0.01;
}
结果为
.LCPI5_0:
.quad 0x3f847ae147ae147b # double 0.01
double_mul(double): # @double_mul(double)
mulsd xmm0, qword ptr [rip + .LCPI5_0]
ret
结果与除法非常接近,都只有一个指令,不需要解释了
Benchmark
https://quick-bench.com/q/1rmqhuLLUyxRJNqSlcJfhubNGdU
结果
按照用时从小到大排序:
- 浮点数乘 100%
- 无符号整数除 150%
- 有符号整数除(编译为乘法和移位) 200%
- 整数乘 220%
- 强制整数除 900%
- 浮点数除 1400%
分析
- 浮点数相乘只需要一条指令
mulsd
,而且其指令延时只有 4~5 个周期,理论最快毫无疑问。 - 无符号整数除编译为乘法、移位和赋值指令,整数乘法指令
imul
延时约 3~4 个周期,再加上移位和赋值,总用时比浮点数乘略高。 - 有符号整数除编译之后指令个数比无符号版本略多,但多出来的移位、加法等指令都很轻量,所以用时很接近。
- 整数乘的用时居然和编译为乘法的整数除十分接近我也很意外。整数、浮点数互转指令 cvtsi2sd 和 cvttsd2si 根据 CPU 型号不同有 3~7 的指令延时。当然 CPU 指令执行效率不能只看延时,还得考虑多指令并发的情况。但是这 3 条指令互相依赖,无法并发。
- 强制除法指令较慢符合期望。32 位整数除法指令
imul
延时约 10~11,如果为 64 位整数甚至高达 57。另外内存访问(实际情况应该只涉及到高速缓存)对速度也会有一些影响。 - 最慢的是浮点数除法。其指令
divsd
依据 CPU 型号不同有 14 ~ 20 延时,但是居然比有内存访问的强制整数除法还慢有些意外。
本文中没有测试单精度浮点数(float
)的情况,因为默认情况下编译器为了精度考虑会将 float
转化为 double
计算,结果再转回去,导致 float
运算比 double
还慢。这点可以使用 --ffast-math
避免,但是 quick-bench 没有提供这个选项的配置。另外值得一提的是,如果启用 --ffast-math
编译参数,编译器会把浮点数除编译为浮点数乘
注:所有指令的延时信息都可在此找到:https://www.agner.org/optimiz...
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。