INT8量化(一):量化表如何产生?
INT8量化的基本原理
在真正进入 ncnn2table 和 ncnn2int8 这些工具之前,先要回答一个更基础的问题:所谓 INT8 量化,到底在做什么?
本质上,量化是在用更低位宽的数据表示原本的浮点数。对推理框架来说,最常见的做法是把模型里的 float32 权重和中间激活,映射到 int8 的整数范围中,再配合缩放系数 scale,尽可能保留原始数值的相对大小关系。这样做的直接收益有两个:一是模型更小,二是整数运算通常更快,尤其是在移动端和嵌入式平台上。
权重量化:先把静态参数压到int8
权重是最适合先量化的一部分,因为它们在模型导出后就是固定的,不依赖输入数据。
假设某一层的浮点权重为 w,我们希望把它映射到 [-127, 127] 这个 int8 范围。最常见的对称量化写法是:
q = round(w / s)
其中 s 是浮点到整数之间的缩放步长。 如果换一种等价写法,也可以表示成:
q = round(w * scale)
其中:scale = 1 / s
在 ncnn 的量化实现里,更常用的是第二种写法,也就是直接保存一个乘法系数 scale。如果某一组权重的裁剪阈值是 T,那么:
scale = 127 / T
于是量化后的整数值可以写成:
q = float2int8(w * scale)
这里的关键点是:必须先确定一个合适的阈值 T。 最简单的办法是直接取这一组权重的绝对值最大值:
T = max(abs(w))
这样可以保证最大的权重恰好映射到 127 或 -127,不会溢出。这种方式简单直接,也是很多后训练量化工具在处理权重时的默认选择。
为什么权重常常按通道(per-channel)量化?
虽然“整层共用一个 scale(per-tensor)”最容易实现,但实际效果往往不够好。原因很简单:不同输出通道的权重分布差异通常很大,有的通道数值范围很宽,有的通道非常集中。如果整层只用一个统一的阈值,那么数值范围小的通道会被量化得很粗,精度损失会比较明显。
所以更常见的策略是:
- 权重按输出通道量化
- 每个输出通道有自己的 scale
这样做的好处是,每个通道都能根据自己的动态范围使用更合适的量化尺度。从效果上看,这通常比per-tensor量化稳定得多,而实现成本也不算高。这也是很多卷积层和全连接层量化实现里的标准做法。
激活值校准:为动态数据估计量化范围
激活校准的基本思路是:
- 准备一批具有代表性的校准数据
- 用原始 FP32 模型做前向推理
- 统计各层激活值的分布
- 根据统计结果,为每个待量化的激活张量选出一个阈值 T
- 再计算对应的量化系数
scale = 127 / T
最后推理时就可以把激活量化成:
q = float2int8(x * scale)
这里的关键不在于“把激活变成 int8”这一步,而在于“阈值 T 选得是否合理”。
为什么在对激活值的量化中,不能像量化权重那样,让T取absmax呢?主要原因在于,激活值的分布和权重不一样,经常会出现少量离群值。如果直接用全局最大值作为阈值,会有两个问题:
- 少数极大值会把量化范围拉得很宽
- 大部分普通值会被压缩到较少的整数区间里
结果就是:虽然没有溢出,但量化后的有效分辨率很低,误差反而更大。
所以对激活值来说,一个更合理的做法通常是:允许少量极端值被裁剪,用更紧凑的范围换取整体分布更好的量化效果。
常见的激活值校准方法
ncnn支持三种校准方法:KL Divergence, ACIQ, EQ,这里我们着重讲解KL Divergence。
KL Divergence
这是一种很经典的校准方法。它的基本思想是:
- 先统计激活绝对值分布的直方图 P
- 枚举不同的裁剪阈值
- 把裁剪后的高精度分布压缩到 128 个离散量化桶中,得到量化后的直方图 Q
- 再把这个量化分布展开回近似分布
- 用 KL 散度比较 P 和 Q 的差异
- 选出差异最小的阈值
它关注的是:量化之后,整体分布形状是否尽量接近原始分布。这类方法通常精度不错,因此在很多 PTQ 工具里都非常常见。
举个例子:
量化前的激活值分布P:
P = [0, 1, 2, 3, 4, 5, 6, 7]
现在我们要把它压缩到T = 2的离散量化bin中,得到Q:
Q = [0 + 1 + 2 + 3, 4 + 5 + 6 + 7] = [6, 22]
再把Q扩展到和P一样的长度:
Q_expand = [0 / 3, 6 / 3, 6 / 3, 6 / 3, 22 / 4, 22 / 4, 22 / 4, 22 / 4] # 注意!如果有0的话是不算在内的
= [0, 2, 2, 2, 5.5, 5.5, 5.5, 5.5]
最后再计算Q_expand与P的KL散度,得到阈值T=2对应的KL散度值。
到这里可以看出,权重和激活虽然最后都会变成 int8,但两者的处理方式并不完全相同。
权重量化更像是一个静态压缩问题:
- 数据固定
- 可直接遍历
- 通常按通道求最大值或阈值
- 量化完成后直接写回模型
而激活量化更像是一个统计估计问题:
- 数据依赖输入
- 必须用代表性样本跑前向
- 要分析分布、决定裁剪范围
- 本质上是在做校准
所以从工程实现上看,很多 PTQ 工具会天然分成两步:
- 第一步:收集激活统计信息,生成 calibration table
- 第二步:根据 table 把模型转换成 int8 形式
比如在ncnn中,就是先通过ncnn2table.cpp收集激活值的信息,生成激活值量化的参数写入table,再通过ncnn2int8.cpp读取table中的量化参数来进行量化。
ncnn2table中的具体实现
理论学完了,我们来看看ncnn中是如何来实现激活值的校准的。
我们先来看看后续代码中需要的class:
QuantBlobStat
class QuantBlobStat
{
public:
QuantBlobStat()
{
threshold = 0.f;
absmax = 0.f;
total = 0;
}
public:
float threshold; // 最后决定裁剪到哪里 (即截断的阈值)
float absmax; // 这个 blob 在校准集上出现过的最大绝对值
// ACIQ的样本数
int total;
// KL用的直方图
std::vector<uint64_t> histogram; // 这是原始的直方图
std::vector<float> histogram_normed; // 归一化后的直方图
};
QuantNet
QuantNet就是一个带统计能力的ncnn net
class QuantNet : public ncnn::Net
{
public:
QuantNet();
std::vector<ncnn::Blob>& blobs;
std::vector<ncnn::Layer*>& layers;
public:
/*
listpath:对于多输入的模型,比如一个模型是双输入:一个输入是图片,一个输入是数组
那么就需要存储两个输入的路径,一个路径是图片的目录,一个路径是npy文件的目录...
*/
std::vector<std::vector<std::string> > listspaths;
std::vector<std::vector<float> > means;
std::vector<std::vector<float> > norms;
std::vector<std::vector<int> > shapes;
std::vector<int> type_to_pixels;
int quantize_num_threads;
int file_type;
public:
int init();
void print_quant_info() const;
int save_table(const char* tablepath);
int quantize_KL();
int quantize_ACIQ();
int quantize_EQ();
public:
std::vector<int> input_blobs; // 整个模型输入
std::vector<int> conv_layers; // 需要量化的层(在layers里的索引)
std::vector<int> conv_bottom_blobs; // 这些层的输入 blob 索引
std::vector<int> conv_top_blobs; // 这些层的输出 blob 索引
// result
std::vector<QuantBlobStat> quant_blob_stats;
std::vector<ncnn::Mat> weight_scales; // 每层权重 scale
std::vector<ncnn::Mat> bottom_blob_scales; // 每层输入激活 scale
};
至于blob是什么,我们来简单说明一下。

blob可以理解为激活值。bottom blob就是当前网络的输入激活值,top blob是bottom blob与weight计算后输出的激活值。当前网络的top blob作为下一层网络的bottom blob。
为什么只需要对bottom blob做量化呢?因为与当前网络的weight做计算的是bottom blob。所以,统计直方图也是针对bottom blob的。
QuantNet::init()
扫描整个网络,找出输入层和待量化层,并分配统计结果数组。
int QuantNet::init() // 扫描整个网络,找出输入层和待量化层,并分配统计结果数组
{
// 输入层不用量化, 只是告诉网络外部数据从哪个blob进入
for (int i = 0; i < (int)layers.size(); i++)
{
const ncnn::Layer* layer = layers[i];
if (layer->type == "Input")
{
input_blobs.push_back(layer->tops[0]);
}
}
// 找到所有需要量化的层
for (int i = 0; i < (int)layers.size(); i++)
{
const ncnn::Layer* layer = layers[i];
if (layer->type == "Convolution" || layer->type == "ConvolutionDepthWise" || layer->type == "InnerProduct")
{
conv_layers.push_back(i);
conv_bottom_blobs.push_back(layer->bottoms[0]);
conv_top_blobs.push_back(layer->tops[0]);
}
}
// 待量化层数量
const int conv_layer_count = (int)conv_layers.size();
// 这些待量化层对应的输入blob数量
const int conv_bottom_blob_count = (int)conv_bottom_blobs.size();
quant_blob_stats.resize(conv_bottom_blob_count);
weight_scales.resize(conv_layer_count);
bottom_blob_scales.resize(conv_bottom_blob_count);
return 0;
}
需要注意的是,并不是所有层都需要量化。在ncnn中,目前只有convolution,convolutionDepthWise与InnerProduct需要量化。
if (layer->type == "Convolution" || layer->type == "ConvolutionDepthWise" || layer->type == "InnerProduct")
找到后会记录三类信息:
conv_layers.push_back(i);记录这个层在 layers 数组中的下标。以后要拿层名字、层参数、层权重,都靠这个索引。conv_bottom_blobs.push_back(layer->bottoms[0]);记录这个层的输入 blob 索引。量化激活值时,主要统计这个 blob 的分布。conv_top_blobs.push_back(layer->tops[0]);记录这个层的输出 blob 索引。有些后续步骤会拿它和 int8 输出做对比。
可以理解成,这一步给每个待量化层建了一个索引表。
前置的一些代码我们基本上都看完了,现在来看最重要的部分:怎么选取截断阈值T?
QuantNet::quantize_KL()
先看开头的部分。
int QuantNet::quantize_KL()
{
const int input_blob_count = (int)input_blobs.size();
const int conv_layer_count = (int)conv_layers.size();
const int conv_bottom_blob_count = (int)conv_bottom_blobs.size();
const int file_count = (int)listspaths[0].size();
const int num_histogram_bins = 2048;
std::vector<ncnn::UnlockedPoolAllocator> blob_allocators(quantize_num_threads);
std::vector<ncnn::UnlockedPoolAllocator> workspace_allocators(quantize_num_threads);
...
}
这里很简单,就是记录一些blob,layers的数量。
以及num_histogram_bins,即直方图bin的数量,默认是2048。
计算weight_scales
接下来是一个巨大的for循环:
// initialize conv weight scales
#pragma omp parallel for num_threads(quantize_num_threads)
for (int i = 0; i < conv_layer_count; i++)
{
const ncnn::Layer* layer = layers[conv_layers[i]];
// 如果当前层是卷积层
if (layer->type == "Convolution")
{
const ncnn::Convolution* convolution = (const ncnn::Convolution*)layer;
// 卷积kernel的各种参数
const int num_output = convolution->num_output; // 输出通道数
const int kernel_w = convolution->kernel_w; // 卷积核大小
const int kernel_h = convolution->kernel_h;
const int dilation_w = convolution->dilation_w; // 空洞卷积参数
const int dilation_h = convolution->dilation_h;
const int stride_w = convolution->stride_w; // 步长
const int stride_h = convolution->stride_h;
// 每个输出通道权重数 = 总权重数 / 输出通道数
const int weight_data_size_output = convolution->weight_data_size / num_output;
// 特殊情况使用6bit量化
bool quant_6bit = false;
if (kernel_w == 3 && kernel_h == 3 && dilation_w == 1 && dilation_h == 1 && stride_w == 1 && stride_h == 1)
quant_6bit = true;
weight_scales[i].create(num_output); // 给当前层分配 num_output 个 scale
// 量化权重, per channel 量化。注意,对于卷积层的量化只需要量化output channel
for (int n = 0; n < num_output; n++)
{
// 从整层权重里切出第 n 个输出通道对应的那一段权重
const ncnn::Mat weight_data_n =
convolution->weight_data.range(weight_data_size_output * n, weight_data_size_output);
// 找absmax作为截断阈值
float absmax = 0.f;
for (int k = 0; k < weight_data_size_output; k++)
{
absmax = std::max(absmax, (float)fabs(weight_data_n[k]));
}
if (quant_6bit)
{
weight_scales[i][n] = 31 / absmax;
}
else
{
weight_scales[i][n] = 127 / absmax;
}
}
if (layer->type == "ConvolutionDepthWise")
{
...
}
if (layer->type == "InnerProduct")
{
...
}
}
这里是遍历每一个待量化的层,初始化权重scale。这里只详细介绍了convolution的量化,另外两类大同小异,就不再赘述了。
这个for循环完了之后,weight_scales就全部计算出来了。
统计每个输入激活blob的全局 absmax
接下来开始做真正的激活校准,它会把所有校准样本过一遍网络,但只做一件事:找出每个待量化输入 blob 在整个校准集上出现过的最大绝对值 absmax。
为什么先做这个?因为后面建直方图时,要知道每个值该落到哪个 bin,需要先确定最大范围。
流程是:
- 对每个样本 i 创建一个 Extractor
- 把所有输入 input_blobs[j] 喂进去
- 对每个待量化 blob
conv_bottom_blobs[j]:- ex.extract(…) 取出该层输入激活
- 遍历全部元素,计算这一次样本下的 absmax
- 用 critical 区更新全局 quant_blob_stats[j].absmax
// count the absmax
#pragma omp parallel for num_threads(quantize_num_threads) schedule(static, 1)
for (int i = 0; i < file_count; i++)
{
if (i % 100 == 0)
{
fprintf(stderr, "count the absmax %.2f%% [ %d / %d ]\n", i * 100.f / file_count, i, file_count);
}
ncnn::Extractor ex = create_extractor();
ex.set_light_mode(true);
const int thread_num = ncnn::get_omp_thread_num();
ex.set_blob_allocator(&blob_allocators[thread_num]);
ex.set_workspace_allocator(&workspace_allocators[thread_num]);
// 阶段1:把一次前向所需的所有输入都准备好并送入网络
for (int j = 0; j < input_blob_count; j++)
{
ncnn::Mat in; // 这个输入最终要送进网络的数据张量
// 输入来自图片
if (0 == file_type)
{
const int type_to_pixel = type_to_pixels[j];
const std::vector<float>& mean_vals = means[j];
const std::vector<float>& norm_vals = norms[j];
int pixel_convert_type = ncnn::Mat::PIXEL_BGR;
if (type_to_pixel != pixel_convert_type)
{
pixel_convert_type = pixel_convert_type | (type_to_pixel << ncnn::Mat::PIXEL_CONVERT_SHIFT);
}
in = read_and_resize_image(shapes[j], listspaths[j][i], pixel_convert_type);
in.substract_mean_normalize(mean_vals.data(), norm_vals.data());
}
// 输入来自npy
else
{
in = read_npy(shapes[j], listspaths[j][i]);
}
// 把 in 喂给网络的第 j 个输入 blob
ex.input(input_blobs[j], in);
}
// 阶段2:用校准集数据跑一次当前网络的前向传播
for (int j = 0; j < conv_bottom_blob_count; j++)
{
// 把计算的结果存入out
ncnn::Mat out;
ex.extract(conv_bottom_blobs[j], out);
// count absmax
{
float absmax = 0.f;
const int outc = out.c;
const int outsize = out.w * out.h;
// 遍历out的每一个channel,记录absmax
for (int p = 0; p < outc; p++)
{
const float* ptr = out.channel(p);
for (int k = 0; k < outsize; k++)
{
absmax = std::max(absmax, (float)fabs(ptr[k]));
}
}
#pragma omp critical
{
QuantBlobStat& stat = quant_blob_stats[j];
stat.absmax = std::max(stat.absmax, absmax);
}
}
}
}
当这些准备工作做完之后,我们就可以开始建立直方图了。
建立直方图
直方图就是一个数组,索引代表分布区间,数值代表分布在这个区间内激活值的数量。
// initialize histogram
#pragma omp parallel for num_threads(quantize_num_threads)
for (int i = 0; i < conv_bottom_blob_count; i++)
{
QuantBlobStat& stat = quant_blob_stats[i];
// 直方图就是一个数组
stat.histogram.resize(num_histogram_bins, 0);
stat.histogram_normed.resize(num_histogram_bins, 0);
}
初始化好了之后,现在用校准集跑第二遍前向传播,建立直方图
// build histogram
#pragma omp parallel for num_threads(quantize_num_threads) schedule(static, 1)
for (int i = 0; i < file_count; i++)
{
if (i % 100 == 0)
{
fprintf(stderr, "build histogram %.2f%% [ %d / %d ]\n", i * 100.f / file_count, i, file_count);
}
ncnn::Extractor ex = create_extractor();
ex.set_light_mode(true);
const int thread_num = ncnn::get_omp_thread_num();
ex.set_blob_allocator(&blob_allocators[thread_num]);
ex.set_workspace_allocator(&workspace_allocators[thread_num]);
// 和第一次前向传播一样,分图片和npy格式读入
for (int j = 0; j < input_blob_count; j++)
{
ncnn::Mat in;
if (0 == file_type)
{
const int type_to_pixel = type_to_pixels[j];
const std::vector<float>& mean_vals = means[j];
const std::vector<float>& norm_vals = norms[j];
int pixel_convert_type = ncnn::Mat::PIXEL_BGR;
if (type_to_pixel != pixel_convert_type)
{
pixel_convert_type = pixel_convert_type | (type_to_pixel << ncnn::Mat::PIXEL_CONVERT_SHIFT);
}
in = read_and_resize_image(shapes[j], listspaths[j][i], pixel_convert_type);
in.substract_mean_normalize(mean_vals.data(), norm_vals.data());
}
else
{
in = read_npy(shapes[j], listspaths[j][i]);
}
ex.input(input_blobs[j], in);
}
// 遍历每个待量化的层
for (int j = 0; j < conv_bottom_blob_count; j++)
{
// 对每个待量化 blob 提取输入激活
ncnn::Mat out;
ex.extract(conv_bottom_blobs[j], out);
// count histogram bin
{
const float absmax = quant_blob_stats[j].absmax;
// 每个线程自己的直方图
std::vector<uint64_t> histogram(num_histogram_bins, 0);
const int outc = out.c;
const int outsize = out.w * out.h;
// 遍历out的每一个通道
for (int p = 0; p < outc; p++)
{
const float* ptr = out.channel(p);
// 遍历当前通道的每一个数值
for (int k = 0; k < outsize; k++)
{
if (ptr[k] == 0.f) // 0 要跳过
continue;
// 把当前数值映射到 0-2047,也就是直方图的索引
const int index = std::min((int)(fabs(ptr[k]) / absmax * num_histogram_bins), (num_histogram_bins - 1));
// 统计直方图 该bin的样本数+1
histogram[index] += 1;
}
}
// 把局部 histogram 合并到全局 histogram
#pragma omp critical
{
QuantBlobStat& stat = quant_blob_stats[j];
for (int k = 0; k < num_histogram_bins; k++)
{
stat.histogram[k] += histogram[k];
}
}
}
}
}
这一段的逻辑十分清晰易懂:
- 读样本并喂进网络
- 提取每个 conv_bottom_blobs[j]
- 遍历其所有元素
- 对每个非零值:
- 取绝对值
- 根据 absmax 映射到 [0, 2047] 某个 bin
- histogram[index] += 1
在此之后,所有blob的直方图就已经全部统计完毕。
寻找最佳截断阈值
遍历整个网络里所有待量化层对应的输入blob。
先对每个blob对应的直方图做归一化:
// using kld to find the best threshold value
#pragma omp parallel for num_threads(quantize_num_threads)
for (int i = 0; i < conv_bottom_blob_count; i++)
{
QuantBlobStat& stat = quant_blob_stats[i];
// normalize histogram bin
{
uint64_t sum = 0;
for (int j = 0; j < num_histogram_bins; j++)
{
sum += stat.histogram[j];
}
for (int j = 0; j < num_histogram_bins; j++)
{
stat.histogram_normed[j] = (float)(stat.histogram[j] / (double)sum);
}
}
...
}
寻找最佳截断阈值:
const int target_bin = 128;
int target_threshold = target_bin;
float min_kl_divergence = FLT_MAX;
// threshold就是当前遍历的截断阈值,从128遍历到2047
for (int threshold = target_bin; threshold < num_histogram_bins; threshold++)
{
const float kl_eps = 0.0001f;
// 截断后的分布
std::vector<float> clip_distribution(threshold, kl_eps);
{
for (int j = 0; j < threshold; j++)
{
clip_distribution[j] += stat.histogram_normed[j];
}
// 截断, 尾部之后的bin概率都加到最后一个bin上
for (int j = threshold; j < num_histogram_bins; j++)
{
clip_distribution[threshold - 1] += stat.histogram_normed[j];
}
}
// 一个量化bin覆盖多少个原始bin
const float num_per_bin = (float)threshold / target_bin;
std::vector<float> quantize_distribution(target_bin, 0.f);
{
// 第一个bin: [0, num_per_bin)
{
const float end = num_per_bin;
const int right_lower = (int)floor(end);
const float right_scale = end - right_lower;
if (right_scale > 0)
{
quantize_distribution[0] += right_scale * stat.histogram_normed[right_lower];
}
for (int k = 0; k < right_lower; k++)
{
quantize_distribution[0] += stat.histogram_normed[k];
}
quantize_distribution[0] /= right_lower + right_scale;
}
// 中间的bin
for (int j = 1; j < target_bin - 1; j++)
{
const float start = j * num_per_bin;
const float end = (j + 1) * num_per_bin;
const int left_upper = (int)ceil(start);
const float left_scale = left_upper - start;
const int right_lower = (int)floor(end);
const float right_scale = end - right_lower;
if (left_scale > 0)
{
quantize_distribution[j] += left_scale * stat.histogram_normed[left_upper - 1];
}
if (right_scale > 0)
{
quantize_distribution[j] += right_scale * stat.histogram_normed[right_lower];
}
for (int k = left_upper; k < right_lower; k++)
{
quantize_distribution[j] += stat.histogram_normed[k];
}
quantize_distribution[j] /= right_lower - left_upper + left_scale + right_scale;
}
// 最后一个bin
{
const float start = threshold - num_per_bin;
const int left_upper = (int)ceil(start);
const float left_scale = left_upper - start;
if (left_scale > 0)
{
quantize_distribution[target_bin - 1] += left_scale * stat.histogram_normed[left_upper - 1];
}
for (int k = left_upper; k < threshold; k++)
{
quantize_distribution[target_bin - 1] += stat.histogram_normed[k];
}
quantize_distribution[target_bin - 1] /= threshold - left_upper + left_scale;
}
}
...
}
至于为什么需要分第一个bin,中间的bin,最后一个bin这三段来处理?
因为这段代码里,第一个量化 bin、中间量化 bin、最后一个量化 bin 的边界条件不一样,不能用完全同一套公式硬写,所以拆成了三段。
每个量化 bin 覆盖的原始区间长度是:num_per_bin = threshold / target_bin,但这个区间一般不是整数,比如可能一个量化 bin 覆盖 7.6 个原始 bin。于是每个量化 bin 的左右边界,往往会落在某个原始 bin 的中间,这就需要特殊处理边界上的部分重叠。
第一个量化 bin 只有右边界是半开的
第一个 bin 对应区间:[0, num_per_bin),左边界固定就是 0,不会有“左边界切进某个 bin 中间”的问题,所以只需要处理右边界是不是落在某个原始 bin 中间。所以这里只需要:
- 加上完整覆盖的原始 bin
- 再加上右边那个部分覆盖的 bin
- 按 right_scale 加权。
中间量化 bin 两边都可能是半开的
中间第 j 个 bin 对应区间:[j * num_per_bin, (j + 1) * num_per_bin),左边界可能落在某个原始 bin 中间,右边界也可能落在某个原始 bin 中间。所以它必须同时处理:
-
左边的部分覆盖:left_scale
-
右边的部分覆盖:right_scale
-
中间完全覆盖的那些 bin
最后一个量化 bin 只有左边界是半开的
最后一个 bin 对应区间:[threshold - num_per_bin, threshold),右边界固定就是 threshold,不会再有“右边再切进下一个原始 bin”的问题,所以只需要处理左边界是不是落在某个原始 bin 中间。所以这里只需要:
- 加上左边部分覆盖的 bin,按 left_scale
- 再加上后面完整覆盖的原始 bin
在这之后,我们就已经得到了压缩过后的直方图quantize_distribution。接下来,我们需要把quantize_distribution展开,再与最初的直方图clip_distribution计算KL散度。
重新展开直方图:
// 重新展开后的直方图
std::vector<float> expand_distribution(threshold, kl_eps);
{
// 第一个bin
{
const float end = num_per_bin;
const int right_lower = (int)floor(end);
const float right_scale = end - right_lower;
if (right_scale > 0)
{
expand_distribution[right_lower] += right_scale * quantize_distribution[0];
}
for (int k = 0; k < right_lower; k++)
{
expand_distribution[k] += quantize_distribution[0];
}
}
// 中间的bin
for (int j = 1; j < target_bin - 1; j++)
{
const float start = j * num_per_bin;
const float end = (j + 1) * num_per_bin;
const int left_upper = (int)ceil(start);
const float left_scale = left_upper - start;
const int right_lower = (int)floor(end);
const float right_scale = end - right_lower;
if (left_scale > 0)
{
expand_distribution[left_upper - 1] += left_scale * quantize_distribution[j];
}
if (right_scale > 0)
{
expand_distribution[right_lower] += right_scale * quantize_distribution[j];
}
for (int k = left_upper; k < right_lower; k++)
{
expand_distribution[k] += quantize_distribution[j];
}
}
// 最后一个bin
{
const float start = threshold - num_per_bin;
const int left_upper = (int)ceil(start);
const float left_scale = left_upper - start;
if (left_scale > 0)
{
expand_distribution[left_upper - 1] += left_scale * quantize_distribution[target_bin - 1];
}
for (int k = left_upper; k < threshold; k++)
{
expand_distribution[k] += quantize_distribution[target_bin - 1];
}
}
}
同理,展开的直方图也需要分为三个阶段对不同的bin做处理。
在这之后,我们就可以计算KL散度了:
// kl
const float kl_divergence = compute_kl_divergence(clip_distribution, expand_distribution);
// the best num of bin
if (kl_divergence < min_kl_divergence)
{
min_kl_divergence = kl_divergence;
target_threshold = threshold;
}
最后,别忘了我们这里计算得到的threshold是直方图的索引,还需要把它重新映射回阈值。
// 把index映射回阈值
stat.threshold = (target_threshold + 0.5f) * stat.absmax / num_histogram_bins;
float scale = 127 / stat.threshold;
bottom_blob_scales[i].create(1);
bottom_blob_scales[i][0] = scale;
这里的bottom_blob_scales[i][0] = scale;便是我们的最终目标:当前blob的量化缩放因子。
写入table文件
最后,通过save_table函数把权重的scale与激活值的scale都写入到table文件中。
int QuantNet::save_table(const char* tablepath)
{
FILE* fp = fopen(tablepath, "wb");
if (!fp)
{
fprintf(stderr, "fopen %s failed\n", tablepath);
return -1;
}
const int conv_layer_count = (int)conv_layers.size();
const int conv_bottom_blob_count = (int)conv_bottom_blobs.size();
fprintf(stdout, "param:%d\n", conv_layer_count);
// 写入weight_scale
for (int i = 0; i < conv_layer_count; i++)
{
const ncnn::Mat& weight_scale = weight_scales[i];
fprintf(fp, "%s_param_0 ", layers[conv_layers[i]]->name.c_str());
for (int j = 0; j < weight_scale.w; j++)
{
fprintf(fp, "%f ", weight_scale[j]);
}
fprintf(fp, "\n");
}
// 写入输入blob scale
for (int i = 0; i < conv_bottom_blob_count; i++)
{
const ncnn::Mat& bottom_blob_scale = bottom_blob_scales[i];
fprintf(fp, "%s ", layers[conv_layers[i]]->name.c_str());
for (int j = 0; j < bottom_blob_scale.w; j++)
{
fprintf(fp, "%f ", bottom_blob_scale[j]);
}
fprintf(fp, "\n");
}
fclose(fp);
fprintf(stderr, "ncnn int8 calibration table create success, best wish for your int8 inference has a low accuracy loss...\\(^0^)/...233...\n");
return 0;
}
最后,我们再来看看main函数,把以上所有的步骤都串起来:
int main(int argc, char** argv)
{
if (argc < 5)
{
show_usage();
return -1;
}
for (int i = 1; i < argc; i++)
{
if (argv[i][0] == '-')
{
show_usage();
return -1;
}
}
const char* inparam = argv[1];
const char* inbin = argv[2];
char* lists = argv[3]; // 校准数据列表文件
const char* outtable = argv[4];
ncnn::Option opt;
opt.num_threads = 1;
opt.lightmode = false;
opt.use_fp16_packed = false;
opt.use_fp16_storage = false;
opt.use_fp16_arithmetic = false;
// 加载原始的fp32网络
QuantNet net;
net.opt = opt;
net.load_param(inparam);
net.load_model(inbin);
net.init();
// load lists
net.listspaths = parse_comma_path_list(lists);
std::string method = "kl";
net.file_type = 0;
// 解析额外的 key=value 参数
for (int i = 5; i < argc; i++)
{
// key=value
char* kv = argv[i];
char* eqs = strchr(kv, '=');
if (eqs == NULL)
{
fprintf(stderr, "unrecognized arg %s\n", kv);
continue;
}
// split k v
eqs[0] = '\0';
const char* key = kv;
char* value = eqs + 1;
// load mean norm shape
if (memcmp(key, "mean", 4) == 0)
net.means = parse_comma_float_array_list(value);
if (memcmp(key, "norm", 4) == 0)
net.norms = parse_comma_float_array_list(value);
if (memcmp(key, "shape", 5) == 0)
net.shapes = parse_comma_int_array_list(value);
if (memcmp(key, "pixel", 5) == 0)
net.type_to_pixels = parse_comma_pixel_type_list(value);
if (memcmp(key, "thread", 6) == 0)
net.quantize_num_threads = atoi(value);
if (memcmp(key, "method", 6) == 0)
method = std::string(value);
if (memcmp(key, "type", 4) == 0)
net.file_type = atoi(value);
}
// sanity check
const size_t input_blob_count = net.input_blobs.size();
if (net.listspaths.size() != input_blob_count)
{
fprintf(stderr, "expect %d lists, but got %d\n", (int)input_blob_count, (int)net.listspaths.size());
return -1;
}
if ((0 == net.file_type) && (net.means.size() != input_blob_count))
{
fprintf(stderr, "expect %d means, but got %d\n", (int)input_blob_count, (int)net.means.size());
return -1;
}
if ((0 == net.file_type) && (net.norms.size() != input_blob_count))
{
fprintf(stderr, "expect %d norms, but got %d\n", (int)input_blob_count, (int)net.norms.size());
return -1;
}
if (net.shapes.size() != input_blob_count)
{
fprintf(stderr, "expect %d shapes, but got %d\n", (int)input_blob_count, (int)net.shapes.size());
return -1;
}
if ((0 == net.file_type) && (net.type_to_pixels.size() != input_blob_count))
{
fprintf(stderr, "expect %d pixels, but got %d\n", (int)input_blob_count, (int)net.type_to_pixels.size());
return -1;
}
if (net.quantize_num_threads < 0)
{
fprintf(stderr, "malformed thread %d\n", net.quantize_num_threads);
return -1;
}
// print quantnet config
{
fprintf(stderr, "mean = ");
print_float_array_list(net.means);
fprintf(stderr, "\n");
fprintf(stderr, "norm = ");
print_float_array_list(net.norms);
fprintf(stderr, "\n");
fprintf(stderr, "shape = ");
print_int_array_list(net.shapes);
fprintf(stderr, "\n");
fprintf(stderr, "pixel = ");
print_pixel_type_list(net.type_to_pixels);
fprintf(stderr, "\n");
fprintf(stderr, "thread = %d\n", net.quantize_num_threads);
fprintf(stderr, "method = %s\n", method.c_str());
fprintf(stderr, "---------------------------------------\n");
}
if (method == "kl")
{
net.quantize_KL();
}
else if (method == "aciq")
{
net.quantize_ACIQ();
}
else if (method == "eq")
{
net.quantize_EQ();
}
else
{
fprintf(stderr, "not implemented yet !\n");
fprintf(stderr, "unknown method %s, expect kl / aciq / eq\n", method.c_str());
return -1;
}
net.print_quant_info();
net.save_table(outtable);
return 0;
}
总结来说就是:
- 从输入的命令中读取ncnn格式的模型,校准数据集等所需参数
- 加载网络,根据不同的method执行相应的校准函数,如
quantize_KL() - 计算得到权重与激活值的scale,最后生成
outtable文件,供ncnn2int8.cpp量化时读取。