浅谈底层常数优化及编译器优化

转载来源:洛谷日报

测试环境

zhaojinxi@ubuntu:~ $ lsb_release -a
No LSB modules are available.
Distributor ID:	Ubuntu
Description:	Ubuntu 18.04.3 LTS
Release:	18.04
Codename:	bionic
zhaojinxi@ubuntu:~ $ g++ --version
g++ (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

乘法优化

考虑这样两段代码:

#include <cstdio>
int main () {
    int x;
    scanf ("%d", &x);
    x *= 2;
    printf ("%d", x);
    return 0;
}
#include <cstdio>
int main () {
    int x;
    scanf ("%d", &x);
    x <<= 1;
    printf ("%d", x);
    return 0;
}

可能有人觉得第二段代码快一些,实际上呢?

汇编代码是完全一样的!x *= 2x <<= 1 的汇编代码为:

movl    -12(%rbp), %eax
addl    %eax, %eax
movl    %eax, -12(%rbp)

x += x

x *= 4 的汇编代码与 x <<= 2 的汇编代码也是一样的,都为直接左移。

如果乘数不是 2 的整数次幂呢?

例如在快读中经常使用的 x *= 10

movl    -12(%rbp), %edx
movl    %edx, %eax
sall    2, %eax; 你谷markdown有毒……实际上2前面应该有一个美元符号
addl    %edx, %eax
addl    %eax, %eax
movl    %eax, -12(%rbp)

相当于:

int y = x;
y <<= 2;
y += x;
y += y;
x = y;

而在开启 O2 优化后的汇编代码为:

movl    4(%rsp), %eax
leal    (%rax,%rax,4), %edx
addl    %edx, %edx
movl    %edx, 4(%rsp)

这就相当于:

x = x + x * 4;
x += x;

x = (x << 3) + (x << 1) 呢?

movl    -12(%rbp), %eax
leal    0(,%rax,8), %edx
movl    -12(%rbp), %eax
addl    %eax, %eax
addl    %edx, %eax
movl    %eax, -12(%rbp)

这几条指令就相当于是:

int y = x * 8;
x += x;
x += y;

注意到汇编代码中第 1 行和第 3 行两次从内存中读取数据到寄存器 %eax,而第 2 行的命令并没有修改寄存器 %eax 的值,所以第 3 行的命令没有任何作用,说明编译器被我们绕晕了,这就会降低效率。

开了 O2 优化之后:

movl    4(%rsp), %eax
leal    (%rax,%rax), %edx
leal    (%rdx,%rax,8), %edx
movl    %edx, 4(%rsp)

也就是:

int y = x + x;
x = y + x * 8;

这三行汇编代码与不开 O2 优化的代码相比区别不是很大,但这里没有重复从内存中读取数据,说明编译器吸氧后变清醒了

除法优化

有两段代码:

#include <cstdio>
int main () {
    unsigned x;
    scanf ("%u", &x);
    x /= 2;
    printf ("%u", x);
    return 0;
}
#include <cstdio>
int main () {
    unsigned x;
    scanf ("%u", &x);
    x >>= 1;
    printf ("%u", x);
    return 0;
}

它们理论上是等价的,实际上确实是等价的,汇编代码一样:

movl    -12(%rbp), %eax
shrl    %eax
movl    %eax, -12(%rbp)

因为 x 是无符号整型,所以除以 2 可以直接右移,但有符号数就不一样了,考虑这样的代码:

#include <cstdio>
int main () {
    int x;
    scanf ("%d", &x);
    x >>= 1;
    printf ("%d", x);
    return 0;
}
#include <cstdio>
int main () {
    int x;
    scanf ("%d", &x);
    x /= 2;
    printf ("%d", x);
    return 0;
}

第一段代码中 x >>= 1 的汇编代码与无符号整型右移的汇编代码几乎一样,只是把逻辑右移改为了算数右移,但第二段代码中 x /= 2 的汇编代码多了 3 行:

 movl   -12(%rbp), %eax
+movl   %eax, %edx
+shrl   31, %edx; 实际上31前面应该有一个美元符号
+addl   %edx, %eax
 sarl   %eax
 movl   %eax, -12(%rbp)

这段代码相当于:

int y = x;
y = (unsigned)y >> 31;
x += y;
x >>= 1;

因为当 x 为负数时,x >> 1x / 2 并不等价。例如当 x = -5 时,x >> 1 的结果为 -3,而 x / 2 的结果为 -2,所以有符号数的除法需要编译器做额外的修正。

如果除数不是 2 的整数次幂呢?

因为做除法的代价很高,所以编译器还会想办法优化。

下面是 x /= 3 的汇编代码(x 为有符号整型):

movl    -12(%rbp), %ecx
movl    1431655766, %edx; 实际上这个数字前面应该有一个美元符号
movl    %ecx, %eax
imull   %edx
movl    %ecx, %eax
sarl    31, %eax; 实际上31前面应该有一个美元符号
subl    %eax, %edx
movl    %edx, %eax
movl    %eax, -12(%rbp)

注意到其中有一个常数 1431655766,这就是编译器对除法的优化。因为 1431655766×32=4294967296=2321431655766 \times 3 - 2 = 4294967296 = 2^{32} ,所以 1431655766 大约是 2322^{32}13\dfrac{1}{3} ,那么除以 3 就等价于乘 1431655766 后取高 32 位,再进行修正。

而无符号数又玄学一些。 下面是 x 为无符号整型时 x /= 3 的汇编代码:

movl    -12(%rbp), %eax
movl    -1431655765, %edx; 实际上这个数字前面应该有一个美元符号
mull    %edx
movl    %edx, %eax
shrl    %eax
movl    %eax, -12(%rbp)

这里与 x 相乘的常数为 -1431655765。将这个数转为 32 位无符号整型得到 2863311531。将这个数与前面的 1431655766 对比可以发现:

1431655766=232×13+232863311531×12=232×13+16\begin{aligned} 1431655766 & = 2^{32} \times \dfrac{1}{3} + \dfrac{2}{3} \\ 2863311531 \times \dfrac{1}{2} & = 2^{32} \times \dfrac{1}{3} + \dfrac{1}{6} \end{aligned}

所以,取一个无符号数乘 2863311531 后的高 32 位再右移一位,是比直接乘 1431655766 更接近原数的 13\dfrac{1}{3} ,所以编译器选择了 2863311531,这样在最后就不需要再修正。

取模优化

无符号整型对 2 的整数次幂取模等价于和比模数小 1 的数按位与。例如 x % 2 的汇编代码如下:

movl    -12(%rbp), %eax
andl    1, %eax; 实际上1前面应该有一个美元符号
movl    %eax, -12(%rbp)

但当 x 为有符号整数时,需要进行修正:

 movl   -12(%rbp), %eax
+cltd
+shrl   31, %edx; 实际上31前面应该有一个美元符号
+addl   %edx, %eax
 andl   1, %eax; 实际上1前面应该有一个美元符号
+subl   %edx, %eax
 movl   %eax, -12(%rbp)

if-else 语句和 ?: 运算符

对于下面的两段代码:

if (x == 1) {
    printf ("1");
}
else {
    printf ("2");
}
x == 1 ? printf ("1") : printf ("2");

网上有人说,三目运算符比 if 语句快,所以第二段代码就比第一段代码快。

实际上汇编代码是一模一样的,所以运行时间也是一样的。

GCC 提供了一个内建函数 __builtin_expect,通过分支预测提高效率,将接下来运行的可能性较大的代码放在靠前的位置,减少指令跳转,这样保证了空间局部性,可以减少 cache miss。但经过测试后发现,__builtin_expect 仅在开启 O2 优化下有效。

例如下面的代码:

#include <cstdio>
#include <cstdlib>
int main () {
    srand (2333);
    int cnt1 = 0, cnt2 = 0;
    for (int i = 0; i < 1000000000; ++i) {
        int t = rand ();
        if (t < 10) {
            ++cnt1;
        }
        else {
            ++cnt2;
        }
    }
    printf ("%d %d\n", cnt1, cnt2);
}
#include <cstdio>
#include <cstdlib>
int main () {
    srand (2333);
    int cnt1 = 0, cnt2 = 0;
    for (int i = 0; i < 1000000000; ++i) {
        int t = rand ();
        if (__builtin_expect (t < 10, false)) {
            ++cnt1;
        }
        else {
            ++cnt2;
        }
    }
    printf ("%d %d\n", cnt1, cnt2);
}

使用 __builtin_expect (t < 10, false) 后,编译器把 else 后的语句放到了紧跟着前面的语句的位置,而当 t < 10true 时才进行跳转。

但函数名以下划线开头说明这不是 C++ 标准规定的函数,这也意味着某些 OI 比赛不支持。

短路表达式优化

在由 &&|| 逻辑运算符组成的逻辑表达式中,C++ 规定,只对能够确定表达式值所需要的最少数目的表达式进行计算。即当计算一个子表达式的值后,可确定整个表达式的值时,后面的表达式便不必再计算了,这种表达式称为短路表达式。

即:

  • 在对表达式 A && B 求值时,先对表达式 A 求值。若表达式 Afalse,则整个表达式的值为 false,不对表达式 B 求值。
  • 在对表达式 A || B 求值时,先对表达式 A 求值。若表达式 Atrue,则整个表达式的值为 true,不对表达式 B 求值。

对于下面两段代码:

#include <cstdio>
#include <cstdlib>
int main () {
    srand (2333);
    int cnt;
    for (int i = 0; i < 1000000000; ++i) {
        int t = rand ();
        if ((double)t / RAND_MAX < 0.01 && t != 0) {
            ++cnt;
        }
    }
    printf ("%d", cnt);
    return 0;
}
#include <cstdio>
#include <cstdlib>
int main () {
    srand (2333);
    int cnt;
    for (int i = 0; i < 1000000000; ++i) {
        int t = rand ();
        if (t != 0 && (double)t / RAND_MAX < 0.01) {
            ++cnt;
        }
    }
    printf ("%d", cnt);
    return 0;
}

两段代码是等价的,但效率不同。因为 (double)t / RAND_MAX < 0.01false 的概率比 t != 0false 的概率大,所以把 (double)t / RAND_MAX < 0.01 放在前面会快一些虽然也快不了多少

但如果逻辑运算符左右的表达式需要用很长的时间求值,那么合理安排顺序可以节省很多时间。

自增自减运算符优化

自增、自减运算符 ++-- ,前置表示先对操作数进行自增自减操作,再使用操作数;后置表示先使用操作数的值,再对操作数进行自增自减操作。

即前置是运算后直接使用操作数,而后置是先复制出操作数的值,对操作数进行运算后再使用复制出的值。所以前置比后置快。

对于下面这两段代码:

#include <cstdio>
int main () {
    int x = 0;
    for (int i = 1; i <= 10; ++i) {
        x += i;
    }
    printf ("%d\n", x);
}
#include <cstdio>
int main () {
    int x = 0;
    for (int i = 1; i <= 10; i++) {
        x += i;
    }
    printf ("%d\n", x);
}

for 循环中,第一段代码使用了前置自增运算符,第二段代码使用了后置自增运算符,所以第一段比第二段快?

但因为这里仅仅只是对变量自增,并没有使用 i++++i 的值,所以编译器生成的汇编代码是相同的,两段代码的效率完全相同。

而如果使用了自增或自减之后表达式的值,那么汇编代码就不一样了。不过因为是内置类型,所以即使是这样,也并不会使效率有太大的差距。

但如果是 STL 中迭代器的自增自减操作,那么差距就大了。因为 STL 并不内建于编译器,只是标准库中的普通 C++ 代码,所以编译器不能对其进行优化,也就是说:

for (std::vector<int>::iterator it = v.begin (); it != v.end (); ++it)
for (std::vector<int>::iterator it = v.begin (); it != v.end (); it++)

这两段代码的效率是不一样的,因为这里的 ++itit++,相当于是调用了 std::vector<int>::iterator& std::vector<int>::iterator::operator++ ()std::vector<int>::iterator std::vector<int>::iterator::operator++ (int) 两个函数。所以,STL 迭代器尽量不要使用后置自增自减。不过,对于版本较新的编译器,有可能会针对 STL 的特殊性做优化,因为虽然这是两个不同的函数,但不使用值时效果是一样的。

register 优化

register 说明符仅在声明于块作用域或函数形参列表中的对象时允许使用。它指示自动存储期,其正是这种声明的缺省情况。另外,此关键词的存在可用于提示优化器将此变量的值存储于 CPU 寄存器。此关键词于 C++11 被弃用。

——cppreference

人话:

register 不能用于声明全局变量。它的作用是告诉编译器这个变量会多次使用,可以不在内存中开辟空间而直接使用 CPU 寄存器。在 C++11 标准中,register 被停止使用。但为了使以前的代码能够正常工作,所以保留了 register 声明局部变量的作用,即于 C++11 之前 auto 的作用相同。

另外,在 C 语言中,使用 register 声明的变量不允许取地址操作;而在 C++ 中,使用 register 声明的变量可以进行取地址操作,此时编译器会忽略 register

所以,在 C++11 之前, register 还是有一点作用的反正加了没坏处

inline 优化

inline 关键词的本意是作为给优化器的指示器,以指示优先采用函数的内联替换而非进行函数调用,即并不执行将控制转移到函数体内的函数调用 CPU 指令,而是代之以执行函数体的一份副本而无需生成调用。这会避免函数调用的开销(传递实参及返回结果),但它可能导致更大的可执行文件,因为函数体必须被复制多次。

因为关键词 inline 的含义是非强制的,编译器拥有对任何未标记为 inline 的函数使用内联替换的自由,和对任何标记为 inline 的函数生成函数调用的自由。这些优化选择不改变上述关于多个定义和共享静态变量的规则。

——cppreference

可以看出,C++ 标准对编译器的限制是很宽松的。因为 inline 本身只是用来指示优化的,所以编译器完全可以忽略 inline 而使用自己的优化策略。

例如下面两段代码:

#include <cstdio>
int f (int a) {
    return a + 1;
}
int main () {
    int a;
    scanf ("%d", &a);
    printf ("%d\n", f (a));
    return 0;
}
#include <cstdio>
inline int f (int a) {
    return a + 1;
}
int main () {
    int a;
    scanf ("%d", &a);
    printf ("%d\n", f (a));
    return 0;
}

汇编代码中,都生成了 f 函数,并且都使用 call 指令调用了 f 函数。可见第二段代码中的 inline 并没有起作用。

而在开启 O2 优化后,汇编代码中都没有调用 f 函数,而是直接在 main 函数中执行加一操作。区别在于第一段代码仍然生成了 f 函数,而第二段代码没有生成 f 函数。

这就会在处理多个文件时产生问题。例如对于下面这段代码:

int f (int);
int g (int a) {
    return f (a) + 1;
}

将这段代码单独存为一个文件。在开启 O2 优化的情况下,将这段代码和前面的第一段代码一起编译,不会产生任何问题;但如果将这段代码和前面的第二段代码一起编译,编译器就会报错。因为第二段代码中的 f 函数并没有生成,所以在这段代码中就会找不到 f 函数的定义而编译失败当然在 OI 里就不用管了

内联汇编

在汇编语言中,用助记符代替机器指令的操作码,用地址符号或标号代替指令或操作数的地址。所以,汇编语言本质就是机器指令。

快速乘

例题:64 位整数乘法 怎么内联汇编都能有例题

a*b mod p,其中 1a,b,p10181 \leqslant a,b,p \leqslant 10^{18}

虽然这道题的结果在 long long 范围内,但乘法的过程中爆了 long long,所以并不能直接计算,而要使用龟速乘。

但实际上,两个 64 位整数相乘,CPU 是算到了 128 位的,然后编译器把高 64 位给扔掉了。

那么可以考虑使用汇编语言完成计算。

先上代码:

#include <cstdio>
int main () {
    unsigned long long a, b, p, ans;
    scanf ("%llu%llu%llu", &a, &b, &p);
    __asm__ ("mulq %2;divq %3" : "=d" (ans) : "a" (a), "m" (b), "m" (p));
    printf ("%llu\n", ans);
    return 0;
}

其中 __asm__ 表示内联汇编,后面括号里用冒号分隔汇编代码、输出操作数和输入操作数。其中第一个冒号前面的字符串就是汇编代码,其中的 %2%3 是占位符,用于和 C++ 表达式对应。

汇编代码后面的 "=d" (ans)"a" (a)"m" (b) 表示相应的 C++ 表达式,按照出现顺序分别对应 %0%1%2 等。前面的字符串是对操作数的限制。= 表示输出操作数,d 表示与寄存器 rdx 关联,a 表示与寄存器 rax 关联,m 表示直接使用内存地址。

汇编代码中的 mulq 表示 64 位整数乘法,两个乘数分别是寄存器 rax 和后面的参数,这也是为什么要将变量 a 与寄存器 rax 相关联的原因。而乘法结果放在 rdx:rax 中,即高 64 位放在 rdx 中,低 64 位放在 rax 中。

divq 表示 128 位整数除以 64 位整数,被除数是 rdx:rax,除数是后面的参数。而商和余数分别放在 raxrdx 中,这也是为什么要将 ansrdx 关联的原因。

但是如果连 __int128 都不允许使用的话内联汇编就还是算了吧

扩栈

众所周知, 在一些评测环境下如果递归过深,会因为栈空间不足导致运行时错误。那么就可以使用汇编扩栈。

(32位系统的扩栈方法只需把下面代码中的 movq 改为 movlrsp 改为 esp 即可)

64位系统扩栈方法:

const int SIZE = 64 << 20; // 即 64MB
char buf[SIZE];
int main () {
    char *p;
    __asm__ ("movq %%rsp, %0;movq %1, %%rsp" : "=m" (p) : "p" (buf + SIZE)); // 注意这样做并不能保证一定不会出错,因为栈空间的分配方式并不是确定的。不过因为大部分操作系统及编译器都使用从高到低的方式,所以通常情况下可以这样写。
    // do something
    __asm__ ("movq %0, %%rsp" :: "p" (p));
    return 0;
}

汇编代码中的 %%rsp 表示 rsp 寄存器,储存堆栈指针。后面 "p" (buf + SIZE) 中的限制字符串 "p" 表示把后面的 C++ 表达式按指针来处理。因为数组名可以看作是首元素的指针,所以 buf + SIZE 就是 buf 数组末尾元素的下一个元素的指针。

这段代码首先把系统堆栈指针的值存下来,然后把内存中一段 64MB 的空间用作堆栈,而在 main 函数的末尾再把原来系统堆栈指针存回 rsp 寄存器中。

不过更短一点的写法是这样:

int main () {
    int SIZE = 64 << 20;
    __asm__ ("movq %0, %%rsp" :: "p" ((char*)malloc (SIZE) + SIZE)); // (char*)malloc (SIZE) 也可以换成 new char[SIZE]
    // do something
    exit (0);
}

为什么 main 最后要写 exit (0)?改成 return 0 行不行?

恭喜你,RE 快乐。

return 是函数的退出,exit 是进程的退出。因为 main 函数是程序的入口,所以 main 函数中的 returnexit 等价。

但是,main 函数真的是程序的入口吗?

实际上,一个 C++ 程序启动时,会先进行一些初始化操作,然后调用 main 函数,在 main 函数结束后再执行清理操作。所以 main 函数本身就存于系统栈中。因为没有在 return 前把原来系统堆栈指针存回 rsp 寄存器中,所以此时 main 函数返回就会产生错误。而 exit (0) 就跳过了 main 函数储存返回值并返回的过程,直接进行清理操作并结束进程。

另外,静态存储区的变量会在进程最后的清理操作时进行析构并释放,而在堆中分配的空间如果没有手动释放,会在进程结束后由操作系统释放。同时,线程结束后寄存器的清理操作也由操作系统来完成。因为没有把原来系统堆栈指针存回 rsp 寄存器中,所以如果使用静态存储区的空间就会导致在寄存器清理前空间就被释放,因此这里只能用堆中分配空间。

话说我好像跑题了

不过这么搞总比魔改算法快亿点(((

总结

  1. 使用无符号类型比有符号类型更快;
  2. 使用前置自增自减运算符比后置自增自减运算符更快;
  3. register 在 C++11 之前可能还会有点用;
  4. inline 几乎没用;
  5. 能不用汇编就别用了毕竟这东西容易带来 Bug

posted @ 2020-07-12 06:29  hyskr  阅读(978)  评论(0编辑  收藏  举报