为什么量化可以使我的网络加速?
Why quantization matters for AI system?(Part 1)
让我们开始一个实验
你一定正在好奇量化是如何加速你的网络的,接下来我们将深入了解这些运算背后的故事。在这里,我希望向你展示量化计算中的那些关键内容,但我们并不会深入到具体的硬件实现细节。如果您对于抱有更深入的兴趣,推荐您访问本文后附的相关文档进行更加深入的了解。
首先,我们给出一个测试程序帮助你来对此进行测试:
#include "ctime"
#include "iostream" // 请你把引号改成尖括号
using std::cout;
__declspec(noinline) float floatSum(const int num_of_element, const float* arr) {
float sum = 0;
for (int i = 0; i < num_of_element; i++) {
sum += arr[i];
}
return sum;
}
__declspec(noinline) int intSum(const int num_of_element, const int* arr) {
int sum = 0;
for (int i = 0; i < num_of_element; i++) {
sum += arr[i];
}
return sum;
}
__declspec(noinline) int charSum(const int num_of_element, const char* arr) {
int sum = 0;
for (int i = 0; i < num_of_element; i++) {
sum += arr[i];
}
return sum;
}
int main(){
constexpr int NUM_OF_ELEMENTS = 100000000;
float* float_array = new float[NUM_OF_ELEMENTS];
int* int_array = new int[NUM_OF_ELEMENTS];
char* char_array = new char[NUM_OF_ELEMENTS];
for (int i = 0; i < NUM_OF_ELEMENTS; i++) {
float_array[i] = 1;
int_array[i] = 1;
char_array[i] = 1;
}
clock_t float_start, float_end;
clock_t int_start, int_end;
clock_t char_start, char_end;
float_start = clock();
float retf = floatSum(/*num_of_element=*/NUM_OF_ELEMENTS, /*arr=*/float_array);
float_end = clock();
int_start = clock();
int reti = intSum(/*num_of_element=*/NUM_OF_ELEMENTS, /*arr=*/int_array);
int_end = clock();
char_start = clock();
char retc = charSum(/*num_of_element=*/NUM_OF_ELEMENTS, /*arr=*/char_array);
char_end = clock();
cout << "Float Sum Time Cost: " << (float)(float_end - float_start) << " ms\n";
cout << "Int Sum Time Cost: " << (float)(int_end - int_start) << " ms\n";
cout << "Char Sum Time Cost: " << (float)(char_end - char_start) << " ms\n";
// 如果没有这一句,编译器会直接删掉 floatSum, intSum, charSum 这些函数,因为他们不存在输出
cout << retf << reti << retc;
}
你需要注意,请将你的编译器优化关闭,再尝试运行上面的程序,否则你将得到截然不同的结果。如果你使用visual studio这样强大的ide,请将代码编译模式调节为release而非debug,从而防止额外的debug指令被编译到程序当中。
运行程序,你将获得这样的结果:
- Float cost: 175ms
- Int cost: 63ms
- Char cost: 53ms
请注意上述结果可能因为编译环境的不同而产生显著差异。
哦,你的浮点运算似乎比整数慢了3倍——这似乎已经说明了问题,在你得出结论之前,请先将编译器的优化选项打开,对于visual studio而言,请调节至/O2,对于gcc或其他编译器,使用编译选项-o3编译。
再次运行程序,你将得到如下结果:
- Float cost: 47ms
- Int cost: 24ms
- Char cost: 9ms
请注意上述结果可能因为编译环境的不同而产生显著差异。
故事变得错综复杂起来了,但是没有关系,在解答你的疑问之前,不妨试试将程序中的+=运算改为/=,再次编译并运行(保持编译优化选项开启),你将得到如下的结果:
再次运行程序,你将得到如下结果:
- Float cost: 236ms
- Int cost: 258ms
- Char cost: 257ms
请注意上述结果可能因为编译环境的不同而产生显著差异。
现在,让我们谈谈量化
在上述的三组实验当中,有的情况下整数运算比浮点运算加速了300%~400%,而有时整数运算却比浮点运算更加缓慢,我想通过这个简单的实验告诉你几个不一样的事实:
- 程序的执行效率是由代码逻辑、编译器、硬件特性三者共同决定的,单纯的将浮点运算换成整数未必可以提升你的计算效率。
- 整数除法运算不一定比浮点运算更快,在第三次实验当中,浮点的除法比整数更快,甚至比int8的除法还要快,这是由X86的硬件特性决定的,其实大部分硬件上浮点除法都跟整数除法一样快。
- Int8的运算不一定比int32更快,这取决于你的编译器和硬件是否针对int8的运算进行了特定优化,例如我们即将提到的向量指令优化。
- 程序的运行速度和硬件平台有直接关系,我使用i7-12700k完成了上述测试,在不同型号CPU上他们可能产生截然不同的结果,在GPU上的结果更是截然不同的。
接下来我们会更加深入的分析上述现象出现的原因,这并不能解答你的所有疑问,但能够为你指明int8量化优化的方向。不必害怕,接下来我们将深入到汇编层级,看看是怎样的原因导致整数加法比浮点加法要快一些:
借助反汇编工具,我们可以观察到测试程序的汇编代码,这反映了程序在CPU上的实际执行过程,你需要注意到这里我们展示的是未经编译器优化的汇编代码:
// 浮点加法:
00CC1028 mov edx,dword ptr [ebp-4]
00CC102B mov eax,dword ptr [arr]
00CC102E movss xmm0,dword ptr [sum]
00CC1033 addss xmm0,dword ptr [eax+edx*4]
00CC1038 movss dword ptr [sum], xmm0
// 整数加法:
00CC1077 mov edx,dword ptr [ebp-4]
00CC107A mov eax,dword ptr [arr]
00CC107D mov ecx,dword ptr [sum]
00CC1080 add ecx,dword ptr [eax+edx*4]
00CC1083 mov dword ptr [sum],ecx
不用害怕,你并不需要了解这些代码的细节,事实上在汇编层面上,浮点加法与整数加法只有三条指令存在区别,即最后mov, add指令;在FP32数据类型上,CPU需要使用浮点版本的 movss, addss 指令完成计算。在这里我可以告诉你的是,movss, addss指令的执行确实比mov, add要慢一些,其中一部分原因是movss, addss在一些CPU上可能会被拆分成多条微指令发射执行,而mov, add则并不需要。附录中我们展示了这些指令的执行细节情况,如果你有兴趣可以继续查阅这些资料:
# | 指令执行器 | 延迟(时钟周期) | 微指令数目 | 注释 |
---|---|---|---|---|
Mov | ALU | 0.5 | 0 | 整数赋值指令 |
Movss | MMX | 2 | 0~2 | 浮点赋值指令 |
Add | ALU | 0.5 | 0 | 整数加法 |
Addss | MMX | 4 | 0~4 | 浮点加法 |
请注意上述结果可能因为编译环境的不同而产生显著差异。
上述代码段是未经编译器优化的,而当开启了编译优化选项后,编译后的程序逻辑将发生大幅度的变化:
// 浮点加法(编译优化):
00811010 addss xmm0,dword ptr [edx-8]
00811015 addss xmm0,dword ptr [edx-4]
0081101A addss xmm0,dword ptr [edx]
0081101E addss xmm0,dword ptr [edx+4]
00811023 addss xmm0,dword ptr [edx+8]
00811028 addss xmm0,dword ptr [edx+0Ch]
0081102D addss xmm0,dword ptr [edx+10h]
00811032 addss xmm0,dword ptr [edx+14h]
00811037 addss xmm0,dword ptr [edx+18h]
0081103C addss xmm0,dword ptr [edx+1Ch]
00811041 add edx,28h
// 整数加法(编译优化):
00811060 movups xmm0,xmmword ptr [edx+eax*4]
00811064 paddd xmm2,xmm0
00811068 movups xmm0,xmmword ptr [edx+eax*4+10h]
0081106D add eax,8
00811070 paddd xmm1,xmm0
00811074 cmp eax,5F5E100h
00811079 jl intSum+10h (0811060h)
同样地,你并不需要了解这些编译的细节。在浮点加法程序当中,编译器执行了循环展开的优化,这让他的循环逻辑看起来十分奇怪——这并不是很重要。而在整数加法程序中,编译器使用了SSE指令集对程序进行了优化,该指令集在一系列128bit宽度的寄存器的基础上,提供了一系列“批处理”指令,从而使得一条指令可以对4个int32,或16个int8进行数值求和。这显然大大提升了程序的执行效率。记住SSE指令集能够使得原来4次运算才能完成的任务,缩短为一次,这就是问题的核心内容。
# | 指令执行器 | 延迟(时钟周期) | 微指令数目 | 注释 |
---|---|---|---|---|
Mov | ALU | 0.5 | 0 | 整数赋值指令 |
Movss | MMX | 2 | 0~2 | 浮点赋值指令 |
Movups | MMX | 2 | 0 | 128位赋值 |
Add | ALU | 0.5 | 0 | 整数加法 |
Addss | MMX | 4 | 0~4 | 浮点加法 |
Paddd | MMX | 2 | 0 | 128位加法 |
注意:int8运算发挥高效的重要原因是这些特殊指令集的运用,在开启编译优化选项后,int8的求和运算直接受益于SSE指令集,此时一次将完成16个int8数值的求和,这使得int8的运算速度大幅度快于fp32。
对于除法而言,我们发现整数除法,浮点除法,int8除法没有任何性能差别,甚至浮点除法最快;这也与硬件有关,事实上x86 cpu中对于除法而言,整数运算没有任何优势。
最后,你还需要知道
除了指令集之外地你还需要认识到,一次计算的完成并非只有指令执行这一个短暂的阶段,还要包括“取指”,“译码”,“访存”等多个过程。下图向你展示了他们的关系:

是的,对于现代计算机而言,访存所需要花费的时间远远高于执行指令所需的时间,但得益于内存排布的合理优化,指令流水线的应用,以及内存的相关访存优化,我们能够大幅度的掩盖程序中的访存延迟。当你仍需注意到,对于int8量化的程序而言,它所需要的访存总量总是浮点程序的四分之一,这也是量化计算高效的一大重要原因。
总结而言,你的程序运行的性能至少将受到以下几个方面因素的影响:
- 编译器,以及相关的编译优化。
- 硬件平台的特性。
- 并行度。
- 访存性能。
- 指令自身的延迟。
- 运算本身的复杂度。
其中对于并行度而言,你需要仔细去了解CPU与GPU实现上的不同,对于CPU而言其可以通过SSE, AVX512等指令集在同一条指令中完成许多任务。而对于GPU而言,其使用线程束来一次性完成很多计算。为了使得讨论方便,我们在这里将这种特性均称为并行度——一个程序的并行度高则它能够在同一时间内完成更多的任务。不同硬件架构上,提高并行度的方法并不完全相同,但PPQ与OPEN PPL已经为你优化了不同平台上的相关实现。
对于FPGA等可变电路芯片而言,情况会更加复杂,在此类芯片上实现FP32的加法器会比INT8的加法器更占面积。因此使用全网INT8量化时,FPGA可以舍弃所有浮点运算单元,从而在有限的片上面积中尽可能多的提供整数运算器,从而提升并行度与计算效率。
最后,对于量化而言,你必须知道:
- 对于乘法、加法运算而言,量化计算能够有效提升其运行效率,这是借由提升了并行度、降低了访存量、以及降低了指令自身的延迟而实现的。很幸运的是,神经网络中大部分运算都是乘加操作。
- 对于除法、取模、开方、指数对数、三角函数等等更复杂的运算而言,量化并不能取得很好的效果。除非你使用了更适合于整数运算的方法去实现它们。