# code

INT8量化(一):量化表如何产生?

📅 2026-03-09 ✍️ Layla

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量化稳定得多,而实现成本也不算高。这也是很多卷积层和全连接层量化实现里的标准做法。


激活值校准:为动态数据估计量化范围

激活校准的基本思路是:

  1. 准备一批具有代表性的校准数据
  2. 用原始 FP32 模型做前向推理
  3. 统计各层激活值的分布
  4. 根据统计结果,为每个待量化的激活张量选出一个阈值 T
  5. 再计算对应的量化系数
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是什么,我们来简单说明一下。

b0f028f6f4ef590d767ab0ebb073fbc3

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,需要先确定最大范围。

流程是:

  1. 对每个样本 i 创建一个 Extractor
  2. 把所有输入 input_blobs[j] 喂进去
  3. 对每个待量化 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];
                }
            }
        }
    }
}

这一段的逻辑十分清晰易懂:

  1. 读样本并喂进网络
  2. 提取每个 conv_bottom_blobs[j]
  3. 遍历其所有元素
  4. 对每个非零值:
    • 取绝对值
    • 根据 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量化时读取。

# 研究讨论 / Discussions