乐趣区

关于asm:硬核乘以-001-和除以-100-哪个快

重要提醒:不想看汇编代码间接看后果的,请滚动到文章开端的后果局部

在知乎上看到这个问题,感觉挺乏味的。上面的答复形形色色,然而就是没有间接给出真正的 benchmark 后果的。还有间接搬反汇编代码的,只不过汇编里用了 x87 FPU 指令集,天那这都 202x 年了真的还有程序用这个老掉牙的浮点运算指令集的吗?

我也决定钻研一下,读反汇编,写 benchmark。平台以 x86-64 为准,编译器 clang 12,开编译器优化(不开优化谈速度无意义)

代码及反汇编

https://gcc.godbolt.org/z/rvT9nEE9Y

简略汇编语言科普

在深刻反汇编之前,先要对汇编语言有简略的理解。本文因为原始代码都很简略,甚至没有循环和判断,所以波及到的汇编指令也很少。

  1. 汇编语言与平台强相干,这里以 x86-64(x86 的 64 位兼容指令集,因为被 AMD 最先创造,也称作 AMD64)为例,简称 x64
  2. x64 汇编语言也有两种语法,一种为 Intel 语法(次要被微软平台编译器应用),一种为 AT&T 语法(是 gcc 兼容编译器的默认语法,然而 gcc 也反对输入 intel 语法)。个人感觉 Intel 语法更易懂,这里以 Intel 语法为例
  3. 根本语法。例如 mov rcx, raxmov 是指令名“赋值”。rcxraxmov 指令的两个操作数,他们都是通用寄存器名。Intel 汇编语法,第一个操作数同时被用于存储运算后果。所以:

    1. mov rcx, rax,赋值指令,将寄存器 rax 中的值赋值给寄存器 rcx。翻译为 C 语言代码为 rcx = rax
    2. add rcx, rax,加法指令,将寄存器 rcxrax 的值相加后,后果赋值给 rcx。翻译为 C 语言代码为 rcx += rax
  4. 寄存器。编译器优化后,少数操作都间接在寄存器中操作,不波及内存拜访。下文只波及三类寄存器(x64 平台)。

    1. r 打头的 rxx 是 64 位寄存器
    2. e 打头的 exx 是 32 位寄存器,同时就是同名 64 位 rxx 寄存器的低 32 位局部。
    3. xmmX 是 128 位 SSE 寄存器。因为本文不波及 SIMD 运算,能够简略的将其当做浮点数寄存器。对于双精度浮点数,只应用寄存器的低 64 位局部
  5. 调用约定。C 语言个性,所有代码都依附于函数,调用函数时父函数向子函数传值、子函数向父函数返回值的形式叫做函数 调用约定。调用约定是保障应用程序 ABI 兼容的最根本要求,不同的平台。不同的操作系统有不同的调用约定。本文的反汇编代码都是应用 godbolt 生成的,godbolt 应用的是 Linux 平台,所以遵循 Linux 平台通用的 System V 调用约定 调用约定。因为本文波及到的代码都非常简单(都只有一个函数参数),读者只须要晓得三点:

    1. 函数的第一个整数参数通过 rdi / edi 寄存器传入(rdi / edi 寄存调用方的第一个参数的值)。rdi 为 64 位寄存器,对应 long 类型(Linux 平台)。edi 为 32 位寄存器,对应 int 类型
    2. 函数的第一个浮点数参数通过 xmm0 寄存器传入,不辨别单、双精度
    3. 函数的返回值整数类型通过 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)ediimul 为有符号整数乘法;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) edimulsd 是双精度浮点数乘法,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

后果

依照用时从小到大排序:

  1. 浮点数乘 100%
  2. 无符号整数除 150%
  3. 有符号整数除(编译为乘法和移位)200%
  4. 整数乘 220%
  5. 强制整数除 900%
  6. 浮点数除 1400%

剖析

  1. 浮点数相乘只须要一条指令 mulsd,而且其指令延时只有 4~5 个周期,实践最快毫无疑问。
  2. 无符号整数除编译为乘法、移位和赋值指令,整数乘法指令 imul 延时约 3~4 个周期,再加上移位和赋值,总用时比浮点数乘略高。
  3. 有符号整数除编译之后指令个数比无符号版本略多,但多进去的移位、加法等指令都很轻量,所以用时很靠近。
  4. 整数乘的用时竟然和编译为乘法的整数除非常靠近我也很意外。整数、浮点数互转指令 cvtsi2sd 和 cvttsd2si 依据 CPU 型号不同有 3~7 的指令延时。当然 CPU 指令执行效率不能只看延时,还得思考多指令并发的状况。然而这 3 条指令相互依赖,无奈并发。
  5. 强制除法指令较慢合乎冀望。32 位整数除法指令 imul 延时约 10~11,如果为 64 位整数甚至高达 57。另外内存拜访(理论状况应该只波及到高速缓存)对速度也会有一些影响。
  6. 最慢的是浮点数除法。其指令 divsd 根据 CPU 型号不同有 14 ~ 20 延时,然而竟然比有内存拜访的强制整数除法还慢有些意外。

本文中没有测试单精度浮点数(float)的状况,因为默认状况下编译器为了精度思考会将 float 转化为 double 计算,后果再转回去,导致 float 运算比 double 还慢。这点能够应用 --ffast-math 防止,然而 quick-bench 没有提供这个选项的配置。另外值得一提的是,如果启用 --ffast-math 编译参数,编译器会把浮点数除编译为浮点数乘

注:所有指令的延时信息都可在此找到:https://www.agner.org/optimiz…

退出移动版