【C/C++】空间换时间,使用函数调用的方式间接对除法操作进行优化
最近用godbolt看汇编代码时,发现对整型常量作为除数时,编译器会把除法操作优化成乘法与移位操作,证明见Labor of Division。之后搜了一下,发现除法操作的耗时相当的大。
有一篇博文放了某个特定处理器执行不同指令的延时核吞吐量的数据
指令的延时与吞吐量 Instruction latency and throughput
- 延时(latency)是指一条指令从开始执行到执行结果就绪(可以被另一条指令使用)所花费的时间,以时钟周期为单位。执行一条依赖链(dependency chain)所花的时间是该依赖链内所有指令的延时的总和。
- 吞吐量(throughput)是指在一个时钟周期之内,同一类指令所能执行的次数。由于CPU在指令的处理上采用了pipeline等各种优化方式,而pipeline的特点就是就算是多条相同的指令也可以同时执行,因此通常有latency > 1/throughput而非相等。
以Core2处理器为例,其浮点加法的latency为3个时钟周期,throughput为1。这意味着在一条依赖链内,处理器需要用3个时钟周期来执行浮点加法,然后才能去执行该依赖链内的下一条指令;对于不在这条依赖链内的指令,如果同样是是浮点加法指令,只需在1个时钟周期之后即可开始执行。
如下是一些指令的典型的延时与吞吐量表格,为了更好地对比,列出的是1/throughput,指的是一条指令在开始执行之后,间隔多久(平均值)才能开始执行另一条同类型并且不在同一依赖链的指令。
Instruction latency 1/throughput Interger move 1 0.33-0.5 Interger addition 1 0.33-0.5 Interger boolean 1 0.33-1 Interger shift 1 0.33-1 Interger multiplication 3-10 1-2 Interger division 20-80 20-40 Floating point addition 3-6 1 Floating point multiplication 4-8 1-2 Floating point division 20-45 20-45 Interger vector addition (XMM) 1-2 0.5-2 Interger vector multiplication (XMM) 3-7 1-2 Floating point vector addition (XMM) 3-5 1-2 Floating point vector multiplication (XMM) 4-7 1-4 Floating point vector division (XMM) 20-60 20-60 Memory read (cache) 3-4 0.5-1 Memory write (cache) 3-4 1 Jump or call 0 1-2 各CPU更具体的latency与throughput可以去查看Agner Optimize的Manual 4: “Instruction tables”。
从上面的数据可以看到,各种类型的除法操作比其他操作都慢了至少一个数量级,因此如何让CPU不执行除法指令变的相当重要。现在的编译器,无论是否开启编译器优化,只要识别到除数是一个整型常量,会将除法操作转化成乘法与移位操作。但是如果除数不是一个常量,即使开到-O3,编译器也无可奈何。在这我有个想法,通过switch打表的方法,先判断除数是多少,再去执行这个除数对应的乘法优化的操作。
对应的汇编代码如下:
因为判断的case都是连续的值,因此可以直接通过计算偏移量,一步操作(line 9)就能找到需要跳转到的代码块(.L2 ~ .L8)。这样就能将case中列出来的除数对应的除法操作给优化掉。但是这样做的空间开销特别大,如果要将所有的INT_32给放到switch中,二进行程序的空间会直接爆炸,假设一个case会增加8 * 64bits的二进制空间,那么2^32个case就是256GB的空间,显然不现实。
通过上述分析,可以得出结论,做一个通用的映射去优化除法操作不现实。但是假设有这样一种场景,某一段区间内的数值被选作除数的概率较大,则可以对这部分的除法进行特定的优化,如我仅当除数在 0~511 这个范围内,才进行打表优化操作。这样的话,对这些值的除法操作的时间性能会有提升,但是其他区间的数值反而会有负面影响,额外带来了函数调用的开销,和查表跳转的开销。
接下来的实验会比较这种做法对实际运行时间的影响如何。
主要比较不同大小(512、1024)的打表操作对除法操作的影响。
观察了一下汇编代码,与没设置编译优化相比,开了-O3能稍微优化一下函数调用的开销,然后在循环内的i++操作,可以穿插到除法操作的范围内,应该是为了更好的流水线吧。
#include <bits/stdc++.h>
using namespace std;
using namespace chrono;
#define N9 1000000000
#define N8 100000000
int div_512(int i, int j) {
switch (j) {
case 1 : return i / 1;
case 2 : return i / 2;
case 3 : return i / 3;
case 4 : return i / 4;
case 5 : return i / 5;
case 6 : return i / 6;