模型量化综述

前言

模型量化作为模型压缩优化的一个常见方法,现有的工具链都非常成熟易用。但很少有人了解其原理与实现。
本文总结了一下常见的量化方法,量化算法,量化如何加速,以及量化算法的实现。

量化为什么能加速

首先普遍认为量化的加速原理是,数据规模变小了,所以计算就加速了。亦或者是整数运算会比浮点运算更快。

但是实际上现代硬件对于fp16和fp32,int32的计算速度是差不多的。
真正影响的是速度的主要原因是两个:

  1. GPU的访存非常昂贵,量化会让计算数据变小,而相应的访存请求也降低了。
  2. 量化结束之后,因为数据大小降低了,因此可以把多条指令进行合并一条进行处理(向量化计算)。在指令层降低了延迟。(包括取值,译码,执行,全都压缩了)

这里稍微回顾一下指令执行的大概流程。分别是,取指,译码,访存,执行,写回。可以看到基本每一个流程都被优化了。

量化方法

对称量化,非对称量化,整数量化

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];
        }
    }
}

带来的问题

  1. 量化后需要防止溢出。
  2. 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倍多,较为昂贵。

QQ20231126-224233

量化加法

按照乘法的计算方式,量化加法可以按照下面方式计算。

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。
常见的被动量化参数有以下:

  1. bais(Gemm, Conv, Lstm)
  2. min(Clip), max(Clip)
  3. padding value(Pad)
  4. 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

qgemm

非线性量化

非线性运算量化没有统一的算法,在不同处理器上实现不同。
其算子有如,exp,tanh,sigmoid,softmax,swish,resize

参考资料