前言
模型量化作为模型压缩优化的一个常见方法,现有的工具链都非常成熟易用。但很少有人了解其原理与实现。
本文总结了一下常见的量化方法,量化算法,量化如何加速,以及量化算法的实现。
量化为什么能加速
首先普遍认为量化的加速原理是,数据规模变小了,所以计算就加速了。亦或者是整数运算会比浮点运算更快。
但是实际上现代硬件对于fp16和fp32,int32的计算速度是差不多的。
真正影响的是速度的主要原因是两个:
- GPU的访存非常昂贵,量化会让计算数据变小,而相应的访存请求也降低了。
- 量化结束之后,因为数据大小降低了,因此可以把多条指令进行合并一条进行处理(向量化计算)。在指令层降低了延迟。(包括取值,译码,执行,全都压缩了)
这里稍微回顾一下指令执行的大概流程。分别是,取指,译码,访存,执行,写回。可以看到基本每一个流程都被优化了。
量化方法
对称量化,非对称量化,整数量化
tensor量化,通道量化
待完善
量化算法
minmax
kl entropy
待完善
量化实现细节
量化乘法
void Mul(float** input_a, float** input_b,
float** output, const int nums) {
for (int i = 0; i < nums; ++i) {
for (int j = 0; j < nums; ++j) {
output[i][j] = input_a[i][j] * input_b[i][j];
}
}
}
带来的问题
- 量化后需要防止溢出。
- scale_c , scale_a , scale_b 各不相同,直接计算的值无法相互映射。
void QMul(char** input_a, char** input_b,
char** output, const int nums,
const float scale_a, const float scale_b, const float scale_c) {
for (int i = 0; i < nums; ++i) {
for (int j = 0; j < nums; ++j) {
output[i][j] = input_a[i][j] * scale_a * input_b[i][j] * scale_b / scale_c;
}
}
}
但此时我们可以看到,三个scale其实都是常量,因此我们可以直接压缩成一个计算
void QMul(char** input_a, char** input_b,
char** output, const int nums,
const float scale_a, const float scale_b, const float scale_c) {
const float scale = scale_a * scale_b / scale_c;
for (int i = 0; i < nums; ++i) {
for (int j = 0; j < nums; ++j) {
output[i][j] = clip(round_fn(input_a[i][j] * input_b[i][j] * scale));
// output[i][j] = input_a[i][j] * input_b[i][j] * scale;
// 增加了取整
}
}
}
最后记得取整即可。
如果遇到不支持浮点运算的芯片。这时候量化就只能整数量化了(移位量化)。可以通过以下方式计算。
void QMul(char** input_a, char** input_b,
char** output, const int nums,
const float scale_a, const float scale_b, const float scale_c) {
const int shift = round(log2(scale_a * scale_b / scale_c));
for (int i = 0; i < nums; ++i) {
for (int j = 0; j < nums; ++j) {
output[i][j] = clip(round_fn(input_a[i][j] * input_b[i][j] << shift));
}
}
}
特殊情况:非对称量化
需要简单的计算,我引用了下面的图片。与对称量化相比会多出1倍多,较为昂贵。
量化加法
按照乘法的计算方式,量化加法可以按照下面方式计算。
void QMul(char** input_a, char** input_b,
char** output, const int nums,
float scale_a, float scale_b, const float scale_c) {
scale_a /= scale_c;
scale_b /= scale_c;
for (int i = 0; i < nums; ++i) {
for (int j = 0; j < nums; ++j) {
output[i][j] = clip(round_fn(input_a[i][j] * scale_a + input_b[i][j] * scale_b));
}
}
}
但是这带来了一个很严重的问题,就是原本加法的计算时间很短,但是通过量化之后,增加了两次乘法,会大大增大处理时间,大多时候甚至不如不量化。
这时候我们会 强制要求 scale_a == scale_b 来缩短一次乘法来达到加速的目的。
既在算量化表的时候,必定保证这两个scale一致,否则会出现性能倒退情况。
被动量化算子
被动量化是指 scale 与要量化的参数无关,是直接使用其他输入的 scale。
常见的被动量化参数有以下:
- bais(Gemm, Conv, Lstm)
- min(Clip), max(Clip)
- padding value(Pad)
- MaxPooling,Reshape,Concat,Spilt等不需要参数的算子
简单说,这些算子不需要计算scale
举个例子就是激活函数。这里的激活函数指的是relu系列的,而relu本质上用就是 clip
clip原实现如下:
void Clip(float** output, float min, float max, const int nums) {
for (int i = 0; i < nums; ++i) {
for (int j = 0; j < nums; ++j) {
output[i][j] = max(output[i][j], min);
output[i][j] = min(output[i][j], max);
}
}
}
但是这里的问题是,在量化的时候,你的输入已经经过量化的了,那么这时候你们的数值范围就不一致了,这时候要怎么办呢。
void QClip(char** output, float min, float max, const int nums
float in_scale, float output_scale) {
for (int i = 0; i < nums; ++i) {
for (int j = 0; j < nums; ++j) {
output[i][j] = max(output[i][j] * in_scale, min) / output_scale;
output[i][j] = min(output[i][j] * in_scale, max) / output_scale;
}
}
}
但是这也遇到了量化加法一样的问题,本身就是复杂度很小的操作,现在又引入了一次乘法和一次除法。
那么这时候我们也可以与加法操作一致。可以**强制要求 in_scale == out_scale **
这时候就可以直接简略到 output[i][j] = max(output[i][j], min / scale)
这个时候就可以看到,只需要量化参数就可以了。
这种情况就是被动量化。
量化gemm
非线性量化
非线性运算量化没有统一的算法,在不同处理器上实现不同。
其算子有如,exp,tanh,sigmoid,softmax,swish,resize