ncnn源码分析_4

ncnn源码分析-4 模型量化源码

ncnn量化分析代码对应的文件主要有两个:ncnn/tools/quantize/ncnn2table.cpp 和 ncnn/tools/quantize/ncnn2int8.cpp

  • ncnn2table.cpp 主要是分析生成相应的量化表, 其中存储了每个层的 scale 值。
  • ncnn2int8.cpp 则是对网络进行 int8量化

1. ncnn2int8

​ 我们首先对 ncnn2int8.cpp 这个文件进行分析。 该文件完成的主要功能是将 float32 类型转化为 int8 类型。主要分为四个步骤:

(1) 读取生成的 table 文件,里面存储了对应的 scale。这本质上就是一个文本文件的读取和解析。

​ readint8_scale_table 负责读取 table 文件, 将 scale 分别存储在 weight_int8scale_table (带 _param\) 和 blobint8scale_table(不带_param\)。 这里只是一个读取文件并解析的过程。

weight( 带_param_)格式示例: scale 个数和通道数相同,是主通道进行量化

blob (不带_param_) 格式示例:

(2) 载入原始的参数和模型文件。net 中的 .param 和 .bin 载入方式

(3) 对卷积层、深度可分离卷积层和全连接层进行量化。quantize_convolution、quantize_convolutiondepthwise、quantize_innerproduct 分别对卷积层、可分离卷积层和全连接层进行量化。 这里其实是构建了一个量化层,然后将原始的fp32类型的权重和scale作为输入, 进行一次forward运算,将结果替换原来的fp32权重

(4) 保存量化后的权重文件

从主函数入手: 分别读取了5个参数, 分别是输入模型文件、参数文件、输出模型文件、输出参数文件和量化表的路径。 然后执行如上所示的四个步骤。

int main(int argc, char** argv)
{
    // 读取相应的参数 
    if (argc != 6)
    {
        fprintf(stderr, "usage: %s [inparam] [inbin] [outparam] [outbin] [calibration table]\n", argv[0]);
        return -1;
    }
    const char* inparam = argv[1];  // 输入模型文件
    const char* inbin = argv[2];  // 输入参数文件
    const char* outparam = argv[3];  // 输出模型文件
    const char* outbin = argv[4];  // 输出参数文件
    const char* int8scale_table_path = argv[5]; // 量化表的路径

    NetQuantize quantizer; // 主要的量化类

    // (1)  读取并解析 scale table 文件
    if (int8scale_table_path)
    {
        bool s2 = read_int8scale_table(int8scale_table_path, quantizer.blob_int8scale_table, quantizer.weight_int8scale_table);
        if (!s2) 
        {
            fprintf(stderr, "read_int8scale_table failed\n");
            return -1;
        }
    }
    // (2) 载入模型文件和参数
    quantizer.load_param(inparam); 
    quantizer.load_model(inbin);
    // (3) 量化: 主要量化三层: conv、convdw 和 fc
    quantizer.quantize_convolution();
    quantizer.quantize_convolutiondepthwise();
    quantizer.quantize_innerproduct();
    // (4) 存储模型和参数文件
    quantizer.save(outparam, outbin);

    return 0;
}

代码的最核心的三个函数: quantize_convolution、quantize_convolutiondepthwise、quantize_innerproduct 分别对卷积层、可分离卷积层和全连接层进行量化,

我们在此以 quantize_convolution 为例:

int NetQuantize::quantize_convolution()
{
    const int layer_count = static_cast<int>(layers.size());
    for (int i = 0; i < layer_count; i++)
    {
        // 查找所有的卷积层
        if (layers[i]->type != "Convolution")
            continue;                
         // 获取卷积层的名称           
        char key[256];
        sprintf(key, "%s_param_0", layers[i]->name.c_str());  
                 
         // 在 blob_int8scale_table 找到该层  /* 其实这里的 blob_int8scale_table 下文并没有用到 */
        std::map<std::string, std::vector<float> >::iterator iter_data = blob_int8scale_table.find(layers[i]->name);
        if (iter_data == blob_int8scale_table.end())
            continue;
        // 在 weight_int8scale_table 找到该层
        std::map<std::string, std::vector<float> >::iterator iter = weight_int8scale_table.find(key);
        if (iter == weight_int8scale_table.end())
        {
            fprintf(stderr, "this layer need to be quantized, but no scale param!\n");
            return -1;
        }
        
        // 卷积层量化 ->  fp32 到 int8
        ncnn::Convolution* convolution = (ncnn::Convolution*)layers[i]; // (1) 获取该卷积层
        fprintf(stderr, "quantize_convolution %s\n", convolution->name.c_str());
        std::vector<float> weight_data_int8_scales = iter->second; //  (2) 获取weight_data_int8_scales
        {
            ncnn::Mat int8_weight_data(convolution->weight_data_size, (size_t)1u); // (3) 结果,和weight的大小一致
            if (int8_weight_data.empty())
                return -100;
            // 这里所谓的量化,即进行了一次简单的前向传播,将原来的float32类型的权重替换为int类型结果
            // 在此之前我们准备的东西有
            // (1) 卷积层的 fp32 的数据 (2) scale 数据 (3) 声明了一个 int8_weight_data的数据
            // 我们的目标就是 (1) + (2) -> (3)
            const int weight_data_size_output = convolution->weight_data_size / convolution->num_output;
            for (int n = 0; n < convolution->num_output; n++)   // 逐卷积核进行量化
            {
                // (4) 创建一个quantize op
                ncnn::Layer* op = ncnn::create_layer(ncnn::LayerType::Quantize); 
                // (5) 把量化表中的scale设置进op里去
                ncnn::ParamDict pd;
                pd.set(0, weight_data_int8_scales[n]);
                op->load_param(pd);
                // (6) blob_allocator
                ncnn::Option opt;
                opt.blob_allocator = int8_weight_data.allocator;
                // (7) weight_data <-> weight_data_n , int8_weight_data <->  int8_weight_data_n 
                const ncnn::Mat weight_data_n = convolution->weight_data.range(weight_data_size_output * n, weight_data_size_output);
                ncnn::Mat int8_weight_data_n = int8_weight_data.range(weight_data_size_output * n, weight_data_size_output);
                // (8) quantitze op前传,计算量化权值 weight_data_n ->  int8_weight_data_n
                op->forward(weight_data_n, int8_weight_data_n, opt); 
                
                delete op;
            }
            convolution->weight_data = int8_weight_data; // (9) 用量化后的权值替换原来的权值
        }
        convolution->int8_scale_term = 2;
    }
    return 0;
}

可以来简单的看一下 quantize 层:

static inline signed char float2int8(float v){
    int int32 = static_cast<int>(round(v)); // 取整数, 然后转化为 int32类型
    if (int32 > 127) return 127;  // 如果大于127, 返回127
    if (int32 < -127) return -127; // 如果小于 -127, 返回 -127
    return (signed char)int32; // 返回 int32 -> int8
}

int Quantize::forward(const Mat& bottom_blob, Mat& top_blob, const Option& opt) const{
    int dims = bottom_blob.dims;

    if (dims == 1){
        int w = bottom_blob.w;

        top_blob.create(w, (size_t)1u, opt.blob_allocator);
        if (top_blob.empty())
            return -100;

        const float* ptr = bottom_blob;
        signed char* outptr = top_blob;

        #pragma omp parallel for num_threads(opt.num_threads)
        for (int i=0; i<w; i++)
        {
            // ! 这一句是最核心的,也是整个量化部分代码的核心
            // 将 float32 乘以 scale, 然后将其转化为 int8 类型
            outptr[i] = float2int8(ptr[i] * scale); 
        }
    }

    if (dims == 2){
        ...
    }

    if (dims == 3){
        ...
    }
    return 0;
}

2. ncnn2table.cpp

ncnn2table.cpp 主要用于量化表的计算,说白了就是使用我们之前说的算法来计算各个参数的 scale,在ncnn2table.cpp下,顶层代码核心就一句话:

/* ncnn2table.cpp */
// filenames: 用来calibration的图片list 
// parampath: 参数文件路径
// binpath: bin 二进制文件路径
// tablepath: 生成的量化表的路径
// pre_param: 参数
post_training_quantize(filenames, parampath, binpath, tablepath, pre_param);

我们接下来重点看post_training_quantize这个函数,该函数做了如下几件事:

>>>> (1) 初始化quantitize_datas (2) 计算最大值 (3) 初始化直方图的间隔 (4) 计算直方图 (5) 计算Scale

(1) 初始化quantitize_datas

​ 没什么好说的,每一个层有一个QuantizeData对象,初始化 num_bins=2048,也就是原始的fp32分布Po,其统计直方图一共有2048个bins

std::vector<QuantizeData> quantize_datas;
for (size_t i = 0; i < net.conv_names.size(); i++)
{
    std::string layer_name = net.conv_names[i];

    QuantizeData quantize_data(layer_name, 2048);
    quantize_datas.push_back(quantize_data);
}

(2) 计算最大值

​ 遍历所有图片,计算每个blob的最大激活值,这里找的是绝对值最大的那个

    for (size_t i = 0; i < image_list.size(); i++) // 遍历calibration数据
    {
        std::string img_name = image_list[i];
        if ((i + 1) % 100 == 0)
        {
            fprintf(stderr, "    %d/%d\n", static_cast<int>(i + 1), static_cast<int>(size));
        }

        cv::Mat bgr = cv::imread(img_name, cv::IMREAD_COLOR);
        if (bgr.empty())
        {
            fprintf(stderr, "cv::imread %s failed\n", img_name.c_str());
            return -1;
        }

        ncnn::Mat in = ncnn::Mat::from_pixels_resize(bgr.data, swapRB ? ncnn::Mat::PIXEL_BGR2RGB : ncnn::Mat::PIXEL_BGR, bgr.cols, bgr.rows, width, height);
        in.substract_mean_normalize(mean_vals, norm_vals);

        ncnn::Extractor ex = net.create_extractor();
        ex.input(net.input_names[0].c_str(), in);

        for (size_t j = 0; j < net.conv_names.size(); j++)
        {
            std::string layer_name = net.conv_names[j];
            std::string blob_name = net.conv_bottom_blob_names[layer_name];

            ncnn::Mat out;
            ex.extract(blob_name.c_str(), out); // 前传网络,相当于caffe的forwardTo,拿到blob数据

            for (size_t k = 0; k < quantize_datas.size(); k++)
            {
                if (quantize_datas[k].name == layer_name)
                {
                    quantize_datas[k].initial_blob_max(out); // 统计最大值
                    break;
                }
            }
        }
    }
    
// 被调函数:
int QuantizeData::initial_blob_max(ncnn::Mat data)
{
    const int channel_num = data.c;
    const int size = data.w * data.h;

    for (int q = 0; q < channel_num; q++)
    {
        const float *data_n = data.channel(q);
        for (int i = 0; i < size; i++)
        {
            max_value = std::max(max_value, std::fabs(data_n[i]));
        }
    }

    return 0;
}

(3) 初始化直方图间隔

也很简单,遍历每个层,初始化直方图间隔=最大激活值/2048

    // step 2 histogram_interval
    printf("    ====> step 2 : generate the histogram_interval.\n");
    for (size_t i = 0; i < net.conv_names.size(); i++)
    {
        std::string layer_name = net.conv_names[i];

        for (size_t k = 0; k < quantize_datas.size(); k++)
        {
            if (quantize_datas[k].name == layer_name)
            {
                quantize_datas[k].initial_histogram_interval();

                fprintf(stderr, "%-20s : max = %-15f interval = %-10f\n", quantize_datas[k].name.c_str(), quantize_datas[k].max_value, quantize_datas[k].histogram_interval);
                break;
            }
        }
    }
    
// 被调函数    
int QuantizeData::initial_histogram_interval()
{
    histogram_interval = max_value / static_cast<float>(num_bins);

    return 0;
}

(4) 计算直方图

​ 再前传一次,遍历每个blob,向每个bin中投票,计算出直方图,得到原始fp32分布

    // step 3 histogram
    printf("    ====> step 3 : generate the histogram.\n");
    for (size_t i = 0; i < image_list.size(); i++)
    {
        std::string img_name = image_list[i];

        if ((i + 1) % 100 == 0)
            fprintf(stderr, "          %d/%d\n", (int)(i + 1), (int)size);
            
        cv::Mat bgr = cv::imread(img_name, cv::IMREAD_COLOR);
        if (bgr.empty())
        {
            fprintf(stderr, "cv::imread %s failed\n", img_name.c_str());
            return -1;
        }

        ncnn::Mat in = ncnn::Mat::from_pixels_resize(bgr.data, swapRB ? ncnn::Mat::PIXEL_BGR2RGB : ncnn::Mat::PIXEL_BGR, bgr.cols, bgr.rows, width, height);
        in.substract_mean_normalize(mean_vals, norm_vals);

        ncnn::Extractor ex = net.create_extractor();
        ex.input(net.input_names[0].c_str(), in);

        for (size_t j = 0; j < net.conv_names.size(); j++)
        {
            std::string layer_name = net.conv_names[j];
            std::string blob_name = net.conv_bottom_blob_names[layer_name];

            ncnn::Mat out;
            ex.extract(blob_name.c_str(), out);

            for (size_t k = 0; k < quantize_datas.size(); k++)
            {
                if (quantize_datas[k].name == layer_name)
                {
                    quantize_datas[k].update_histogram(out);
                    break;
                }
            }
        }
    }
    
// 被调函数      
int QuantizeData::update_histogram(ncnn::Mat data)
{
    const int channel_num = data.c;
    const int size = data.w * data.h;

    for (int q = 0; q < channel_num; q++)
    {
        const float *data_n = data.channel(q);
        for (int i = 0; i < size; i++)
        {
            if (data_n[i] == 0)
                continue;

            const int index = std::min(static_cast<int>(std::abs(data_n[i]) / histogram_interval), 2047);

            histogram[index]++;
        }
    }

    return 0;
}

(5) 计算Scale

    // step4 kld
    printf("    ====> step 4 : using kld to find the best threshold value.\n");
    for (size_t i = 0; i < net.conv_names.size(); i++)
    {
        std::string layer_name = net.conv_names[i];
        std::string blob_name = net.conv_bottom_blob_names[layer_name];
        fprintf(stderr, "%-20s ", layer_name.c_str());

        for (size_t k = 0; k < quantize_datas.size(); k++)
        {
            if (quantize_datas[k].name == layer_name)
            {
                quantize_datas[k].get_data_blob_scale();
                fprintf(stderr, "bin : %-8d threshold : %-15f interval : %-10f scale : %-10f\n",
                        quantize_datas[k].threshold_bin,
                        quantize_datas[k].threshold,
                        quantize_datas[k].histogram_interval,
                        quantize_datas[k].scale);

                fprintf(fp, "%s %f\n", layer_name.c_str(), quantize_datas[k].scale);

                break;
            }
        }
    }

// 被调函数 
float QuantizeData::get_data_blob_scale()
{   
    normalize_histogram();   // 直方图归一化
    threshold_bin = threshold_distribution(histogram);   // 计算最后有多少个bins
    threshold = (threshold_bin + 0.5) * histogram_interval;   // 之后很容易就能找到Threshold
    scale = 127 / threshold;   // Scale也很简单就能z好到
    return scale;
}

其实说了半天,最核心的就在get_data_blob_scale这个函数里,函数分为3步

a. 直方图归一化

int QuantizeData::normalize_histogram()
{
    const size_t length = histogram.size();
    float sum = 0;

    for (size_t i = 0; i < length; i++)
        sum += histogram[i];

    for (size_t i = 0; i < length; i++)
        histogram[i] /= sum;

    return 0;
}

b. 使用KL散度计算最后用多少个bins比较合适

int QuantizeData::threshold_distribution(const std::vector<float> &distribution, const int target_bin = 128)
{
    ...
    // 这里length就是原始分布Po的长度,NCNN默认2048。这里的threshold实际上是num_bins,可以换算成T
    for(int threshold = target_bin; threshold < length; threshold++)   // target_bin=128,length = 2048
    {
         // ①. 计算截断的fp32分布P
         // ②. 计算int8分布Q
         // ③. 计算扩展分布Q_expand
        // ④. 计算KL散度
        // ⑤. 比大小
    }
}

①. 计算截断的fp32分布P也很简单:

float threshold_sum = 0;
for (int threshold=target_bin; threshold<length; threshold++) 
{
    threshold_sum += distribution[threshold];   // 128以上的所有数据和
}

for (int threshold=target_bin; threshold<length; threshold++) 
{

    std::vector<float> t_distribution(distribution.begin(), distribution.begin()+threshold);
    
    t_distribution[threshold-1] += threshold_sum;   // P的最后一个bin加上被截断的所有概率,得到截断的fp32分布P
    threshold_sum -= distribution[threshold];       // 是通过减法来保证数值正确性的,很巧秒
    ...

②. 计算int8分布Q,注意Q是从Po得来的,而不是从P得来的,大于T的部分并不会加进最后一个bin内(存疑,不理解);另外,当发生了4舍5入时,会有特殊处理

std::vector<float> quantize_distribution(target_bin);  // 量化后分布Q,长度是128
 
fill(quantize_distribution.begin(), quantize_distribution.end(), 0);

const float num_per_bin = static_cast<float>(threshold) / target_bin;  // 其实就是当前T下的Scale 
for (int i=0; i<target_bin; i++) 
{
    const float start = i * num_per_bin;
    const float end = start + num_per_bin;

    const int left_upper = ceil(start);
    if (left_upper > start) 
    {   // 这里的意思是,如果发生了5入,则需要将舍掉的那个bin按比例加进来
        const float left_scale = left_upper - start;
        quantize_distribution[i] += left_scale * distribution[left_upper - 1];
    }

    const int right_lower = floor(end);

    if (right_lower < end) 
    {

        const float right_scale = end - right_lower;
        quantize_distribution[i] += right_scale * distribution[right_lower];
    }

    for (int j=left_upper; j<right_lower; j++) 
    {
        quantize_distribution[i] += distribution[j];
    }
}

③. 计算Q_expand,统计count时0不算在内,注意不管是统计数量还是上采样的过程中,都有4舍5入相关的问题

// get Q
std::vector<float> expand_distribution(threshold, 0);

for (int i=0; i<target_bin; i++) 
{
    const float start = i * num_per_bin;
    const float end = start + num_per_bin;

    float count = 0;

    const int left_upper = ceil(start);
    float left_scale = 0;
    if (left_upper > start) 
    {
        left_scale = left_upper - start;
        if (distribution[left_upper - 1] != 0) 
        {
            count += left_scale;
        }
    }

    const int right_lower = floor(end);
    float right_scale = 0;
    if (right_lower < end) 
    {
        right_scale = end - right_lower;
        if (distribution[right_lower] != 0) 
        {
            count += right_scale;
        }
    }

    for (int j=left_upper; j<right_lower; j++) 
    {
        if (distribution[j] != 0) 
        {
            count++;
        }
    }

    const float expand_value = quantize_distribution[i] / count;

    if (left_upper > start) 
    {
        if (distribution[left_upper - 1] != 0) 
        {
            expand_distribution[left_upper - 1] += expand_value * left_scale;  // 上采样过程中一样有四舍五入的问题
        }
    }
    if (right_lower < end) 
    {
        if (distribution[right_lower] != 0) 
        {
            expand_distribution[right_lower] += expand_value * right_scale;
        }
    }
    for (int j=left_upper; j<right_lower; j++) 
    {
        if (distribution[j] != 0) 
        {
            expand_distribution[j] += expand_value;
        }
    }
}

④. 计算KL散度,注意当Q为0时,KL散度只加一(存疑,不理解)

float kl_divergence = compute_kl_divergence(t_distribution, expand_distribution);

float QuantizeData::compute_kl_divergence(const std::vector<float> &dist_a, const std::vector<float> &dist_b) 
{
    const int length = dist_a.size();
    assert(dist_b.size() == length);
    float result = 0;

    for (int i=0; i<length; i++) 
    {
        if (dist_a[i] != 0) 
        {
            if (dist_b[i] == 0) 
            {
                result += 1;   // Q为0时,KL散度只加一
            } 
            else 
            {
                result += dist_a[i] * log(dist_a[i] / dist_b[i]);
            }
        }
    }

    return result;
}

⑤. 轻松愉悦的找最大值

if (kl_divergence < min_kl_divergence) 
{
    min_kl_divergence = kl_divergence;   
    target_threshold = threshold;   // 实际上是num_bins
}

c. 计算 Threshold 和 bins

至此,我们得到了KL散度最小的桶数num_bins,可以通过下述公式得到T和Scale,就三行:

threshold = (static_cast<float>(threshold_bin) + 0.5f) * histogram_interval;
scale = 127 / threshold;
return scale;

最后就是将Scale数据存下来,得到量化表了。

总结一下以上的代码逻辑:


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!