为什么量化可以使我的网络加速?

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中对于除法而言,整数运算没有任何优势。


最后,你还需要知道

除了指令集之外地你还需要认识到,一次计算的完成并非只有指令执行这一个短暂的阶段,还要包括“取指”,“译码”,“访存”等多个过程。下图向你展示了他们的关系:

Image
Instruction Execution Procedure.

是的,对于现代计算机而言,访存所需要花费的时间远远高于执行指令所需的时间,但得益于内存排布的合理优化,指令流水线的应用,以及内存的相关访存优化,我们能够大幅度的掩盖程序中的访存延迟。当你仍需注意到,对于int8量化的程序而言,它所需要的访存总量总是浮点程序的四分之一,这也是量化计算高效的一大重要原因。

总结而言,你的程序运行的性能至少将受到以下几个方面因素的影响:

  1. 编译器,以及相关的编译优化。
  2. 硬件平台的特性。
  3. 并行度。
  4. 访存性能。
  5. 指令自身的延迟。
  6. 运算本身的复杂度。

其中对于并行度而言,你需要仔细去了解CPU与GPU实现上的不同,对于CPU而言其可以通过SSE, AVX512等指令集在同一条指令中完成许多任务。而对于GPU而言,其使用线程束来一次性完成很多计算。为了使得讨论方便,我们在这里将这种特性均称为并行度——一个程序的并行度高则它能够在同一时间内完成更多的任务。不同硬件架构上,提高并行度的方法并不完全相同,但PPQ与OPEN PPL已经为你优化了不同平台上的相关实现。

对于FPGA等可变电路芯片而言,情况会更加复杂,在此类芯片上实现FP32的加法器会比INT8的加法器更占面积。因此使用全网INT8量化时,FPGA可以舍弃所有浮点运算单元,从而在有限的片上面积中尽可能多的提供整数运算器,从而提升并行度与计算效率。


最后,对于量化而言,你必须知道:

  • 对于乘法、加法运算而言,量化计算能够有效提升其运行效率,这是借由提升了并行度、降低了访存量、以及降低了指令自身的延迟而实现的。很幸运的是,神经网络中大部分运算都是乘加操作。
  • 对于除法、取模、开方、指数对数、三角函数等等更复杂的运算而言,量化并不能取得很好的效果。除非你使用了更适合于整数运算的方法去实现它们。