实战:在嵌入式设备上利用 C++ 实现轻量化量化(Quantization)推理逻辑

各位同仁,下午好!

非常荣幸能在这里与大家共同探讨一个在嵌入式领域日益重要的话题:如何在资源受限的设备上,利用 C++ 实现轻量级的深度学习模型量化推理。随着人工智能技术从云端走向边缘,我们面临的挑战不再仅仅是模型精度,更在于如何将这些强大的智能部署到功耗敏感、内存有限、计算能力紧张的微控制器或小型 SoC 上。量化(Quantization)技术,正是解决这一矛盾的关键利器。

今天的讲座,我将以实战为导向,深入剖析量化的核心原理,并手把手地带领大家理解如何在 C++ 中构建一套高效、轻量级的量化推理逻辑。我们不依赖复杂的第三方框架,而是聚焦于底层实现,这正是嵌入式工程师所需要的精细控制。

1. 边缘智能的基石:为什么我们需要量化?

在深度学习模型训练阶段,我们通常使用浮点数(FP32)进行运算,这提供了极高的精度。然而,当这些模型需要部署到嵌入式设备时,FP32 带来了诸多问题:

  1. 内存占用高昂: 一个 FP32 权重占据 4 字节,而 INT8 仅占 1 字节。对于拥有数百万甚至上亿参数的模型,内存占用会直接决定模型能否加载。
  2. 计算速度慢: 浮点运算单元(FPU)通常比整数运算单元(ALU)复杂,功耗更高,且在许多低功耗微控制器上,FPU 甚至是不存在的或性能有限。整数运算通常可以更快地执行。
  3. 功耗增加: 浮点运算需要更多的晶体管和更复杂的逻辑,这直接导致更高的功耗,对于电池供电设备而言是致命的。
  4. 带宽瓶颈: 传输 FP32 数据需要更高的内存带宽,在缓存有限或总线速度不高的嵌入式系统中,这会成为新的性能瓶颈。

量化,简单来说,就是将模型中的浮点数(如权重、激活值)映射到低比特整数(如 INT8、INT16)表示,从而在牺牲少量精度的情况下,显著提升模型的推理速度、降低内存占用和功耗。这使得原本“庞大”的神经网络,得以在“瘦弱”的嵌入式设备上运行。

我们今天的目标,是掌握后训练静态量化(Post-Training Static Quantization)的核心原理和 C++ 实现。这意味着我们不会在训练过程中引入量化感知训练(Quantization-Aware Training),而是利用一个已训练好的 FP32 模型和少量校准数据,来确定量化参数。这种方法实现简单,无需重新训练,非常适合资源有限的场景。

2. 量化核心概念:从浮点到定点

量化的核心在于找到一种映射关系,将一个连续的浮点范围,映射到一个离散的整数范围。最常见的量化方式是线性量化,其核心公式如下:

  • 从浮点到量化整数: q = round(fp / S + Z)
  • 从量化整数到浮点: fp = (q - Z) * S

其中:

  • fp 是原始的浮点数值。
  • q 是量化后的整数数值。
  • S缩放因子(Scale),一个浮点数,表示一个量化步长对应的浮点范围。
  • Z零点(Zero-Point),一个整数,表示浮点数 0 对应的量化整数值。

round() 函数表示四舍五入到最近的整数。

2.1 缩放因子 (S) 和零点 (Z) 的确定

S 和 Z 是量化过程中最重要的两个参数。它们的确定通常基于待量化张量(如权重或激活)在校准数据集上的统计信息(最大值 max_fp 和最小值 min_fp)。

假设我们要将浮点数映射到 [Q_min, Q_max] 的整数范围(例如,INT8 的 [-128, 127][0, 255])。

  1. 计算缩放因子 S:
    S = (max_fp - min_fp) / (Q_max - Q_min)

  2. 计算零点 Z:
    Z = round(Q_min - min_fp / S)
    或者 Z = round(Q_max - max_fp / S) (两者理论上等价,实际选择一个即可)

    计算出的 Z 必须被钳制(clamp)在 [Q_min, Q_max] 范围内,以确保 0 能够被正确表示。

    Z = clamp(Z, Q_min, Q_max)

示例:INT8 签名整数 ([-128, 127])

假设某个激活张量的浮点范围是 [-1.5, 2.5]
Q_min = -128, Q_max = 127

S = (2.5 - (-1.5)) / (127 - (-128)) = 4.0 / 255 ≈ 0.015686

Z = round(-128 - (-1.5) / 0.015686) = round(-128 + 95.625) = round(-32.375) = -32

所以,该张量的量化参数为 S ≈ 0.015686, Z = -32

2.2 对称量化 vs. 非对称量化

  • 对称量化 (Symmetric Quantization): 零点 Z 通常设为 0(或接近 0)。这意味着浮点范围是对称的,例如 [-max_abs_fp, max_abs_fp]。计算 S 时通常只考虑 max_abs_fp
    S = max_abs_fp / Q_max (例如 127255/2)
    优点:实现简单,尤其在 SIMD 优化时有优势。
    缺点:如果浮点范围不对称,可能会浪费量化范围的精度。常用于权重。

  • 非对称量化 (Asymmetric Quantization): 零点 Z 不为 0。它能够更好地捕捉不对称的浮点范围(例如 ReLU 激活值总是非负的 [0, max_fp])。
    优点:能更充分地利用量化范围,提高精度。
    缺点:计算略复杂。常用于激活值。

在嵌入式设备上,为了平衡精度和实现复杂度,我们通常对权重使用对称量化(Z=0),对激活使用非对称量化。但为了简化初始实现,我们本次主要关注非对称量化,其覆盖性更广。

2.3 Per-Tensor vs. Per-Channel Quantization

  • Per-Tensor Quantization: 对整个张量(例如卷积核的所有通道、所有激活)使用一组 (S, Z)
    优点:简单,参数少。
    缺点:精度可能受限,特别是当张量内部不同通道的数值分布差异很大时。
  • Per-Channel Quantization: 对张量的每个通道(例如卷积核的每个输出通道)使用单独的一组 (S, Z)
    优点:精度更高,能更精细地匹配每个通道的数值分布。
    缺点:需要存储更多的 (S, Z) 参数,实现略复杂。

在实践中,权重通常采用 Per-Channel 量化以提高精度,而激活值通常采用 Per-Tensor 量化,因为激活值的动态范围通常变化较小,且 Per-Channel 激活量化会增加计算开销。我们今天的代码示例将主要以 Per-Tensor 量化为基础,因为它更容易理解和实现。

3. C++ 实现核心量化/反量化函数

首先,我们来定义一些辅助函数,用于浮点数和整数之间的转换。我们将以 int8_t 作为目标整数类型。

#include <iostream>
#include <vector>
#include <cmath>
#include <algorithm> // For std::clamp

// 定义量化数据类型和范围
using QuantizedType = int8_t;
const int32_t Q_MIN = -128;
const int32_t Q_MAX = 127;

// 辅助函数:浮点数四舍五入到最近的整数
inline int32_t round_to_nearest_int(float val) {
    return static_cast<int32_t>(std::round(val));
}

/**
 * @brief 将单个浮点数转换为量化整数
 * @param fp_val 浮点数值
 * @param scale 缩放因子 S
 * @param zero_point 零点 Z
 * @return 量化后的 int8_t 值
 */
inline QuantizedType quantize_value(float fp_val, float scale, int32_t zero_point) {
    // q = round(fp / S + Z)
    float q_float = fp_val / scale + zero_point;
    int32_t q_int = round_to_nearest_int(q_float);
    // 钳制到量化类型的有效范围
    return static_cast<QuantizedType>(std::clamp(q_int, Q_MIN, Q_MAX));
}

/**
 * @brief 将单个量化整数转换为浮点数
 * @param q_val 量化整数值
 * @param scale 缩放因子 S
 * @param zero_point 零点 Z
 * @return 原始浮点数值
 */
inline float dequantize_value(QuantizedType q_val, float scale, int32_t zero_point) {
    // fp = (q - Z) * S
    return static_cast<float>(q_val - zero_point) * scale;
}

// 辅助函数:计算张量的量化参数 (S, Z)
struct QuantParams {
    float scale;
    int32_t zero_point;
};

/**
 * @brief 根据浮点张量的 min/max 值计算量化参数
 * @param min_fp 浮点张量的最小值
 * @param max_fp 浮点张量的最大值
 * @return 包含 scale 和 zero_point 的 QuantParams 结构体
 */
QuantParams calculate_quant_params(float min_fp, float max_fp) {
    QuantParams params;
    // 确保 min_fp <= max_fp,避免除零或负范围
    if (min_fp == max_fp) { // 特殊情况:所有值都相同
        params.scale = (min_fp == 0.0f) ? 1.0f : std::abs(min_fp) / (Q_MAX - Q_MIN); // 避免除零
        params.zero_point = 0; // 或者 Q_MIN
        return params;
    }

    params.scale = (max_fp - min_fp) / (Q_MAX - Q_MIN);
    // Z = round(Q_MIN - min_fp / S)
    params.zero_point = round_to_nearest_int(Q_MIN - min_fp / params.scale);
    // 钳制零点到量化范围
    params.zero_point = std::clamp(params.zero_point, Q_MIN, Q_MAX);

    return params;
}

// 示例用法
void test_quant_dequant() {
    float data[] = {-1.5f, 0.0f, 1.0f, 2.5f, -0.7f, 0.3f};
    int num_elements = sizeof(data) / sizeof(data[0]);

    // 找到 min/max
    float min_fp = data[0];
    float max_fp = data[0];
    for (int i = 1; i < num_elements; ++i) {
        if (data[i] < min_fp) min_fp = data[i];
        if (data[i] > max_fp) max_fp = data[i];
    }

    std::cout << "Original FP data: ";
    for (int i = 0; i < num_elements; ++i) {
        std::cout << data[i] << " ";
    }
    std::cout << std::endl;
    std::cout << "Min FP: " << min_fp << ", Max FP: " << max_fp << std::endl;

    QuantParams params = calculate_quant_params(min_fp, max_fp);
    std::cout << "Calculated Scale: " << params.scale << ", Zero Point: " << params.zero_point << std::endl;

    std::vector<QuantizedType> quantized_data(num_elements);
    std::vector<float> dequantized_data(num_elements);

    std::cout << "Quantized data: ";
    for (int i = 0; i < num_elements; ++i) {
        quantized_data[i] = quantize_value(data[i], params.scale, params.zero_point);
        std::cout << static_cast<int>(quantized_data[i]) << " "; // 转换为 int 打印
    }
    std::cout << std::endl;

    std::cout << "Dequantized data: ";
    for (int i = 0; i < num_elements; ++i) {
        dequantized_data[i] = dequantize_value(quantized_data[i], params.scale, params.zero_point);
        std::cout << dequantized_data[i] << " ";
    }
    std::cout << std::endl;
}

// int main() {
//     test_quant_dequant();
//     return 0;
// }

这段代码展示了如何计算 SZ,以及如何进行单个数值的量化和反量化。在实际模型中,我们会有大量的张量,需要对整个张量进行这样的操作。

4. 量化推理的核心挑战:整数运算乘法累加 (MAC)

深度学习模型中,最主要的计算是矩阵乘法(在全连接层)和卷积(在卷积层),它们本质上都包含了大量的“乘法累加”(Multiply-Accumulate, MAC)操作。例如,两个浮点矩阵 AB 相乘得到 CC_ij = sum_k(A_ik * B_kj)

如果我们直接将量化后的 int8_t 值反量化回 float,再进行浮点乘法,那我们量化的意义何在?我们希望全程使用整数运算。

考虑两个量化张量 A_qB_q 相乘,得到 C_q
根据反量化公式:
A_fp = (A_q - Z_A) * S_A
B_fp = (B_q - Z_B) * S_B

那么,乘积的浮点值 P_fp = A_fp * B_fp = (A_q - Z_A) * S_A * (B_q - Z_B) * S_B

累加和 Sum_fp = sum(P_fp) = sum((A_q - Z_A) * (B_q - Z_B) * S_A * S_B)
Sum_fp = (S_A * S_B) * sum((A_q - Z_A) * (B_q - Z_B))

我们希望将 Sum_fp 再次量化到 C_q
C_q = round(Sum_fp / S_C + Z_C)

代入 Sum_fp
C_q = round((S_A * S_B) * sum((A_q - Z_A) * (B_q - Z_B)) / S_C + Z_C)
C_q = round( (S_A * S_B / S_C) * sum((A_q - Z_A) * (B_q - Z_B)) + Z_C)

这里的 sum((A_q - Z_A) * (B_q - Z_B)) 是一个纯整数的乘法累加操作,我们可以用 int32_tint64_t 来累积。
关键在于 (S_A * S_B / S_C) 这一项,它是一个浮点数。我们如何避免浮点乘法?

这就是整数化乘数 (Integer Multiplier) 和右移 (Right Shift) 技术登场的地方。
我们可以将 M_0 = S_A * S_B / S_C 表示为一个整数 M 乘以一个 2 的负幂次 2^-N
M_0 ≈ M / 2^N

那么,C_q = round( (M / 2^N) * sum_of_products + Z_C )
C_q = round( (M * sum_of_products) >> N + Z_C )

这里的 sum_of_productsint32_t 累加的结果。M * sum_of_products 可能会溢出 int32_t,所以通常需要 int64_t 来存储中间结果。

具体步骤:

  1. *计算中间乘积 `M_0 = S_A S_B / S_C`。** 这是一个浮点数。
  2. M_0 转换为定点表示 (M, N)
    • 找到一个合适的 N (右移位数),通常在 [0, 31] 之间,使得 M_0 * 2^N 能够被表示为 int32_tint64_t
    • M = round(M_0 * 2^N)
    • 这个过程通常在离线完成,MN 作为量化参数的一部分被存储。
  3. 在推理时执行:
    • 计算 int32_t 累加和 acc = sum((A_q - Z_A) * (B_q - Z_B))
    • acc 提升到 int64_t
    • 执行 int64_t rescaled_acc = acc * M;
    • 执行 int32_t final_val = (rescaled_acc >> N) + Z_C;
    • 钳制 final_valQ_MIN, Q_MAX

这种方法允许我们在不使用浮点运算的情况下完成矩阵乘法的缩放和重新量化。

4.1 量化矩阵乘法实现(以全连接层为例)

我们假设输入矩阵 A (形状 (batch_size, input_features)),权重矩阵 W (形状 (input_features, output_features)),偏差 B (形状 (output_features))。
输出 O (形状 (batch_size, output_features))。

O = A @ W + B

在量化版本中:

  • A_q:量化输入
  • W_q:量化权重
  • B_q:量化偏差 (通常是 int32_t 类型,因为它们直接加到累加和上)
  • Z_A, S_A:输入量化参数
  • Z_W, S_W:权重量化参数
  • Z_O, S_O:输出量化参数
  • Z_B, S_B:偏差量化参数 (需要特别处理,使其与 S_A * S_W 同一量级)

偏差处理:
偏差通常是浮点数,在累加和之后、重新量化之前加入。为了在整数域进行加法,我们需要将浮点偏差 bias_fp 量化到与 sum((A_q - Z_A) * (W_q - Z_W)) 相同的量级。

bias_q_val = round(bias_fp / (S_A * S_W))

然后,我们可以将 bias_q_val 作为 int32_t 累加到 sum_of_products 中。注意,这里 bias_q_val 没有零点,因为它是直接加到原始的定点累加和上,而不是一个独立的量化值。

完整的量化矩阵乘法伪代码:

// 假设输入 A_q, 权重 W_q, 偏差 B_q (int32_t) 及其量化参数都已准备好
// 假设输出 O_q 的量化参数 O_scale, O_zero_point 也已准备好
// 并且 M_0_int, shift_N 是从 (S_A * S_W / S_O) 转换而来

void quantized_matrix_multiply(
    const QuantizedType* A_q, int32_t Z_A, float S_A,
    const QuantizedType* W_q, int32_t Z_W, float S_W,
    const int32_t* B_q_int32, // 已经量化到 S_A * S_W 刻度上的偏差
    QuantizedType* O_q, int32_t Z_O, float S_O,
    int M_int, int N_shift, // M_int = round(S_A * S_W / S_O * 2^N_shift), N_shift is the exponent
    int batch_size, int input_features, int output_features)
{
    for (int i = 0; i < batch_size; ++i) { // 遍历批次中的每个样本
        for (int j = 0; j < output_features; ++j) { // 遍历输出特征
            int64_t acc = 0; // 使用 int64_t 累加,防止溢出

            // 矩阵乘法核心部分
            for (int k = 0; k < input_features; ++k) { // 遍历输入特征
                // (A_q - Z_A) * (W_q - Z_W)
                int32_t a_val_shifted = A_q[i * input_features + k] - Z_A;
                int32_t w_val_shifted = W_q[k * output_features + j] - Z_W;
                acc += static_cast<int64_t>(a_val_shifted) * w_val_shifted;
            }

            // 添加偏差 (已经预量化到 int32_t)
            acc += B_q_int32[j];

            // 重新量化到输出刻度
            // C_q = round( (M / 2^N) * sum_of_products + Z_C )
            // C_q = round( (M * sum_of_products) >> N + Z_C )
            int64_t rescaled_acc = acc * M_int;
            int32_t final_val = static_cast<int32_t>((rescaled_acc >> N_shift) + Z_O);

            // 钳制并存储结果
            O_q[i * output_features + j] = static_cast<QuantizedType>(std::clamp(final_val, Q_MIN, Q_MAX));
        }
    }
}

关于 M_int 和 N_shift 的计算:

这是一个关键步骤,需要离线完成。
M_0 = S_A * S_W / S_O

// 辅助函数:将浮点数乘数 M0 转换为整数乘数 M_int 和右移位数 N_shift
// 目标:M0 ≈ M_int / (2^N_shift)
// 确保 M_int 在 int32_t 范围内,且 N_shift 足够大以保持精度
// 这里的实现是一个简化版本,实际中会考虑更多精度和溢出边界
struct FixedPointMultiplier {
    int32_t multiplier;
    int32_t shift; // 通常为正数,表示右移
};

FixedPointMultiplier convert_float_to_fixed_point_multiplier(float M0) {
    FixedPointMultiplier fpm;
    if (M0 == 0.0f) {
        fpm.multiplier = 0;
        fpm.shift = 0;
        return fpm;
    }

    // 假设我们希望 N_shift 在 0 到 31 之间,通常选择一个较高的值以保留精度
    // 比如,我们尝试让 M_int 尽可能大,但不超过 int32_t 的最大值
    // 或者,固定 N_shift 为一个经验值,例如 24 或 31。
    // 这里我们使用一个简单的策略,固定 N_shift 为 24,让 M_int 尽可能大
    // 更复杂的策略会迭代 N_shift 来找到最佳匹配
    int32_t N_shift = 24; // 经验值,可以调整

    float M_float = M0 * (1LL << N_shift); // 1LL << N_shift 是 2^N_shift
    fpm.multiplier = round_to_nearest_int(M_float);
    fpm.shift = N_shift;

    // 确保 multiplier 不为负,如果 M0 为负,则需要特殊处理或限制为非负
    // 在量化中,S都是正数,所以M0通常为正
    if (fpm.multiplier < 0) {
        std::cerr << "Warning: Fixed-point multiplier became negative. M0=" << M0 << std::endl;
        fpm.multiplier = 0; // 或其他错误处理
    }

    return fpm;
}

// 偏差的预量化
// bias_fp_val 是浮点偏差,S_A, S_W 是输入和权重的缩放因子
int32_t quantize_bias(float bias_fp_val, float S_A, float S_W) {
    // 偏差需要乘以 (1 / (S_A * S_W)) 才能与累加和在同一尺度
    return round_to_nearest_int(bias_fp_val / (S_A * S_W));
}

表格:FP32 vs. INT8 运算对比

特性 FP32 (传统) INT8 (量化) 备注
数据类型 浮点数 整数 (int8_t) 节省内存、提高传输效率
内存占用 4 字节/参数 1 字节/参数 模型大小显著减小
计算单元 浮点运算单元 (FPU) 整数运算单元 (ALU) ALU 通常更快、更省电
乘法累加 float * float + float int32_t * int32_t 累加到 int64_t 避免 FPU,利用整数乘法和位移
精度损失 无 (训练时) 少量 (量化过程引入) 需要校准和验证精度
SIMD 优化 浮点 SIMD (e.g., ARM NEON FP) 整数 SIMD (e.g., ARM NEON INT8) INT8 SIMD 指令通常效率更高
部署复杂性 相对简单 (直接部署) 复杂 (需量化、管理 S/Z/M/N 参数、实现整数算子) 额外开发工作量
典型应用场景 云端、高性能 GPU 嵌入式、边缘设备、低功耗场景 针对硬件特性优化

5. 构建轻量级 C++ 量化推理框架

为了更好地组织代码,我们将构建一个简化的推理框架,包含 Tensor、Layer 和 Model 抽象。

5.1 Tensor 类:数据与量化参数的封装

Tensor 类将存储量化后的数据,以及其对应的 scalezero_point

// Tensor.h
#pragma once
#include <vector>
#include <numeric>
#include <algorithm>
#include <cstdint> // For int8_t, int32_t
#include <iostream>

using QuantizedType = int8_t;
const int32_t Q_MIN_VAL = -128;
const int32_t Q_MAX_VAL = 127;

// 辅助函数:浮点数四舍五入到最近的整数
inline int32_t round_to_nearest_int(float val) {
    return static_cast<int32_t>(std::round(val));
}

struct QuantParams {
    float scale;
    int32_t zero_point;
};

// 计算量化参数 (S, Z)
QuantParams calculate_quant_params(float min_fp, float max_fp);

class Tensor {
public:
    std::vector<QuantizedType> data;
    std::vector<int32_t> dims; // 存储维度信息
    QuantParams params;

    // 构造函数
    Tensor() : params({1.0f, 0}) {} // 默认非量化状态

    Tensor(const std::vector<int32_t>& d) : dims(d), params({1.0f, 0}) {
        size_t total_elements = 1;
        for (int32_t dim : d) {
            total_elements *= dim;
        }
        data.resize(total_elements);
    }

    Tensor(const std::vector<int32_t>& d, QuantParams p) : dims(d), params(p) {
        size_t total_elements = 1;
        for (int32_t dim : d) {
            total_elements *= dim;
        }
        data.resize(total_elements);
    }

    // 从浮点数据量化
    void quantize_from_float(const std::vector<float>& fp_data) {
        if (fp_data.empty()) return;

        float min_fp = fp_data[0];
        float max_fp = fp_data[0];
        for (float val : fp_data) {
            if (val < min_fp) min_fp = val;
            if (val > max_fp) max_fp = val;
        }
        params = calculate_quant_params(min_fp, max_fp);

        data.resize(fp_data.size());
        for (size_t i = 0; i < fp_data.size(); ++i) {
            data[i] = quantize_value(fp_data[i], params.scale, params.zero_point);
        }
    }

    // 反量化到浮点数据
    std::vector<float> dequantize_to_float() const {
        std::vector<float> fp_data(data.size());
        for (size_t i = 0; i < data.size(); ++i) {
            fp_data[i] = dequantize_value(data[i], params.scale, params.zero_point);
        }
        return fp_data;
    }

    // 获取元素总数
    size_t num_elements() const {
        size_t total_elements = 1;
        for (int32_t dim : dims) {
            total_elements *= dim;
        }
        return total_elements;
    }

    // 获取特定索引的量化值
    QuantizedType get_quant_value(size_t index) const {
        return data[index];
    }

    // 设置特定索引的量化值
    void set_quant_value(size_t index, QuantizedType val) {
        data[index] = val;
    }

private:
    // 单个值量化
    inline QuantizedType quantize_value(float fp_val, float scale, int32_t zero_point) const {
        float q_float = fp_val / scale + zero_point;
        int32_t q_int = round_to_nearest_int(q_float);
        return static_cast<QuantizedType>(std::clamp(q_int, Q_MIN_VAL, Q_MAX_VAL));
    }

    // 单个值反量化
    inline float dequantize_value(QuantizedType q_val, float scale, int32_t zero_point) const {
        return static_cast<float>(q_val - zero_point) * scale;
    }
};

// 实现 calculate_quant_params
QuantParams calculate_quant_params(float min_fp, float max_fp) {
    QuantParams params;
    if (min_fp == max_fp) {
        params.scale = (min_fp == 0.0f) ? 1.0f / Q_MAX_VAL : std::abs(min_fp) / Q_MAX_VAL; // 避免除零
        params.zero_point = (min_fp >= 0.0f) ? Q_MIN_VAL : 0; // 尽量让0点靠近
        return params;
    }

    params.scale = (max_fp - min_fp) / (Q_MAX_VAL - Q_MIN_VAL);
    params.zero_point = round_to_nearest_int(Q_MIN_VAL - min_fp / params.scale);
    params.zero_point = std::clamp(params.zero_point, Q_MIN_VAL, Q_MAX_VAL);
    return params;
}

5.2 Layer 抽象基类

所有网络层都将继承这个基类,实现 forward 方法。

// Layer.h
#pragma once
#include "Tensor.h" // 包含 Tensor 类

class Layer {
public:
    virtual ~Layer() = default;
    virtual Tensor forward(const Tensor& input) = 0;
};

5.3 QuantizedLinearLayer (全连接层)

实现量化矩阵乘法。这里我们需要预先计算好权重和偏差的量化参数,以及 M_intN_shift

// QuantizedLinearLayer.h
#pragma once
#include "Layer.h"
#include <numeric>
#include <limits> // For numeric_limits

// 将浮点数乘数 M0 转换为整数乘数 M_int 和右移位数 N_shift
struct FixedPointMultiplier {
    int32_t multiplier;
    int32_t shift; // 通常为正数,表示右移
};

FixedPointMultiplier convert_float_to_fixed_point_multiplier(float M0);

// 偏差的预量化函数
int32_t quantize_bias_for_mac(float bias_fp_val, float S_A, float S_W);

class QuantizedLinearLayer : public Layer {
public:
    Tensor weights; // 量化权重
    std::vector<int32_t> biases_q_int32; // 量化偏差 (int32_t)
    QuantParams output_params; // 输出张量的量化参数
    FixedPointMultiplier rescaled_output_fpm; // 用于输出重新量化的 M_int, N_shift

    QuantizedLinearLayer(
        const std::vector<float>& fp_weights, // 原始浮点权重
        const std::vector<float>& fp_biases,  // 原始浮点偏差
        const std::vector<int32_t>& weight_dims, // 权重维度
        const QuantParams& activation_input_params, // 激活输入 (前一层输出) 的量化参数
        const QuantParams& current_layer_output_params // 本层输出的量化参数
    ) : weights(weight_dims), output_params(current_layer_output_params) {
        // 1. 量化权重
        weights.quantize_from_float(fp_weights);

        // 2. 预量化偏差
        biases_q_int32.resize(fp_biases.size());
        for (size_t i = 0; i < fp_biases.size(); ++i) {
            biases_q_int32[i] = quantize_bias_for_mac(fp_biases[i], activation_input_params.scale, weights.params.scale);
        }

        // 3. 计算输出重新量化的 M_int 和 N_shift
        // M0 = S_input * S_weights / S_output
        float M0 = activation_input_params.scale * weights.params.scale / output_params.scale;
        rescaled_output_fpm = convert_float_to_fixed_point_multiplier(M0);
    }

    Tensor forward(const Tensor& input) override {
        // 假设 input 是 (batch_size, input_features)
        // 权重是 (input_features, output_features)
        // 偏差是 (output_features)
        // 输出是 (batch_size, output_features)

        int batch_size = input.dims[0];
        int input_features = input.dims[1];
        int output_features = weights.dims[1]; // 权重是 (input_features, output_features)

        Tensor output({batch_size, output_features}, output_params);

        for (int i = 0; i < batch_size; ++i) { // 遍历批次中的每个样本
            for (int j = 0; j < output_features; ++j) { // 遍历输出特征
                int64_t acc = 0; // 使用 int64_t 累加,防止溢出

                for (int k = 0; k < input_features; ++k) { // 遍历输入特征
                    int32_t a_val_shifted = input.get_quant_value(i * input_features + k) - input.params.zero_point;
                    int32_t w_val_shifted = weights.get_quant_value(k * output_features + j) - weights.params.zero_point;
                    acc += static_cast<int64_t>(a_val_shifted) * w_val_shifted;
                }

                // 添加偏差 (已经预量化到 int32_t)
                acc += biases_q_int32[j];

                // 重新量化到输出刻度
                int64_t rescaled_acc = acc * rescaled_output_fpm.multiplier;
                int32_t final_val = static_cast<int32_t>((rescaled_acc >> rescaled_output_fpm.shift) + output_params.zero_point);

                output.set_quant_value(i * output_features + j, static_cast<QuantizedType>(std::clamp(final_val, Q_MIN_VAL, Q_MAX_VAL)));
            }
        }
        return output;
    }
};

// 实现 FixedPointMultiplier 转换函数
FixedPointMultiplier convert_float_to_fixed_point_multiplier(float M0) {
    FixedPointMultiplier fpm;
    if (M0 == 0.0f) {
        fpm.multiplier = 0;
        fpm.shift = 0;
        return fpm;
    }

    // 搜索合适的 N_shift,使得 M_int 尽可能大且不超过 int32_t 限制
    // 同时避免 M_int 变得过小而损失精度
    // 经验值:通常 N_shift 在 20 到 31 之间效果较好
    // 这里的策略是寻找一个 N_shift,使得 M_int 尽可能接近 INT32_MAX / 2
    // 这样在乘法时有足够的 headroom,同时保留精度
    int target_shift = 24; // 默认值

    // 寻找最佳 N_shift,一种更鲁棒的方法
    for (int shift_candidate = 0; shift_candidate < 31; ++shift_candidate) {
        float M_candidate_float = M0 * (1ULL << shift_candidate);
        if (M_candidate_float > 0 && M_candidate_float < std::numeric_limits<int32_t>::max()) {
             target_shift = shift_candidate;
        } else if (M_candidate_float >= std::numeric_limits<int32_t>::max()) {
            // 如果已经溢出,就用前一个 shift_candidate
            break;
        }
    }

    float M_float = M0 * (1ULL << target_shift);
    fpm.multiplier = round_to_nearest_int(M_float);
    fpm.shift = target_shift;

    // 确保 multiplier 是正数(S_A, S_W, S_O 都是正数,M0 应该也是正数)
    if (fpm.multiplier < 0) {
        std::cerr << "Error: Fixed-point multiplier became negative. M0=" << M0 << std::endl;
        fpm.multiplier = 1; // Fallback to 1
        fpm.shift = 0;
    }
    // 确保 multiplier 不为 0,除非 M0 本身就是 0
    if (fpm.multiplier == 0 && M0 != 0.0f) {
        std::cerr << "Error: Fixed-point multiplier became zero for non-zero M0. M0=" << M0 << std::endl;
        fpm.multiplier = 1; // Fallback to 1
        fpm.shift = 0;
    }

    return fpm;
}

// 偏差的预量化函数实现
int32_t quantize_bias_for_mac(float bias_fp_val, float S_A, float S_W) {
    // 偏差需要乘以 (1 / (S_A * S_W)) 才能与累加和在同一尺度
    // round_to_nearest_int 确保四舍五入
    return round_to_nearest_int(bias_fp_val / (S_A * S_W));
}

重要提示:
convert_float_to_fixed_point_multiplier 函数的实现是量化推理中的一个难点。精确地将浮点数 M0 转换为 M_int / 2^N_shift 需要仔细选择 N_shift,以平衡精度和 M_int 的溢出风险。上述代码提供了一种基本的启发式方法,在实际应用中,通常会使用更复杂的算法,例如 TensorFlow Lite 或 ONNX Runtime Mobile 中采用的策略,它们会迭代搜索 N_shift,使得 M_int 落在某个特定范围内(例如 [2^30, 2^31-1]),以最大化精度。

5.4 QuantizedReLULayer (ReLU激活层)

ReLU 激活函数 max(0, x) 在量化后非常简单。由于 ReLU 强制非负,如果输入是签名 INT8,输出也应该是签名 INT8,但其 zero_point 可能与输入不同,或者为了简化,可以保持 zero_point 不变。

// QuantizedReLULayer.h
#pragma once
#include "Layer.h"

class QuantizedReLULayer : public Layer {
public:
    // ReLU层的输出参数与输入参数通常是相同的,因为只是截断负值
    // 但如果前一层的输出 Z_O 不是 0,那么 ReLU 后的 0 可能会对应不同的量化值
    // 最简单的方式是:ReLU 不改变 Scale 和 ZeroPoint
    // 但如果激活的 min/max 范围因为 ReLU 改变了 (min_fp 变成 0),则需要重新计算 S, Z。
    // 在我们的轻量化方案中,我们假设 ReLU 仅是数值截断,不改变量化参数,
    // 或在离线时校准 ReLU 后的张量,得到其 S/Z。
    // 这里我们假设输入和输出的 QuantParams 相同。
    QuantizedReLULayer() {} // ReLU不需要额外的参数

    Tensor forward(const Tensor& input) override {
        // ReLU 不改变 Tensor 的维度和量化参数
        Tensor output(input.dims, input.params);

        // ReLU: max(0, q_val - Z_input) + Z_input
        // 如果 Z_input = 0, 就是 max(0, q_val)
        // 更准确地,如果 Z_input != 0, 那么 0 对应的量化值是 Z_input。
        // 任何小于 Z_input 的值都应该被钳制到 Z_input。
        for (size_t i = 0; i < input.num_elements(); ++i) {
            output.set_quant_value(i, static_cast<QuantizedType>(std::max(input.get_quant_value(i), input.params.zero_point)));
        }
        return output;
    }
};

5.5 Model

将所有层串联起来,实现推理流程。

// Model.h
#pragma once
#include "Layer.h"
#include <vector>
#include <memory> // For std::unique_ptr

class Model {
public:
    std::vector<std::unique_ptr<Layer>> layers;

    void add_layer(std::unique_ptr<Layer> layer) {
        layers.push_back(std::move(layer));
    }

    // 执行推理
    std::vector<float> predict(const std::vector<float>& input_fp_data, const std::vector<int32_t>& input_dims, QuantParams input_params) {
        // 1. 量化输入
        Tensor current_tensor(input_dims, input_params);
        current_tensor.quantize_from_float(input_fp_data); // 内部会根据输入数据计算 S/Z

        // 2. 逐层推理
        for (const auto& layer : layers) {
            current_tensor = layer->forward(current_tensor);
        }

        // 3. 反量化输出
        return current_tensor.dequantize_to_float();
    }
};

6. 嵌入式部署的实践考量

6.1 内存布局与数据加载

在嵌入式设备上,模型权重和偏差通常存储在 Flash 中,运行时加载到 RAM。
为了高效访问,所有 Tensor 的数据都应该使用扁平化的一维 std::vector 或 C 风格数组来表示。多维张量通过索引计算来访问元素:index = d0 * stride0 + d1 * stride1 + ...

6.2 编译优化与 SIMD 指令

  • 编译器标志: 务必使用 -O3 优化级别。对于 ARM 处理器,添加 —march=armv7-a (或更高版本,如 armv8-a/arm64) 和 -mfpu=neon (或 -mfloat-abi=hard) 来启用 NEON SIMD 指令集。
  • NEON/SSE 优化: 这是性能提升的关键。上述的 quantized_matrix_multiply 核心循环中的 acc += static_cast<int64_t>(a_val_shifted) * w_val_shifted; 可以利用 SIMD 指令并行处理多个 int8_t 乘法。
    例如,ARM NEON 提供了 vmlaq_s8 (vector multiply-accumulate signed 8-bit) 等指令。这些指令可以一次性处理 8 对甚至 16 对 int8_t 的乘法,并将结果累加到 int32_tint64_t 向量中。
    手动编写 NEON Intrinsics 或依赖编译器的自动矢量化(需要特定数据对齐和循环模式)是常见的做法。对于追求极致性能的场景,手写汇编或 Intrinsics 是不可避免的。
    例如,vmovl.s8 可以将 8 个 int8_t 扩展为 8 个 int16_tvmull.s8 执行 8 对 int8_t 乘法并输出 int16_t,然后 vaddl.s16int16_t 累加到 int32_t。这些操作显著加速了 MAC 过程。

6.3 校准数据集与精度验证

  • 校准数据: 在量化前,需要使用一个小的、具有代表性的数据集(校准数据集)来运行 FP32 模型,收集每个张量(特别是激活张量)的 min_fpmax_fp 值。这些值用于计算 SZ
  • 精度验证: 量化会引入精度损失。务必在量化后,使用验证数据集来评估量化模型的性能。通常,INT8 量化可以保持 95% 以上的 FP32 精度。如果精度下降严重,可能需要调整量化策略(如改为 Per-Channel 量化、引入少量 FP32 层、或进行量化感知训练)。

6.4 交叉编译与工具链

嵌入式开发通常涉及交叉编译。确保你的 C++ 代码能够被目标设备的交叉编译工具链(例如 ARM GNU Toolchain)成功编译。这意味着避免使用目标设备不支持的 C++11/14/17/20 特性,并正确配置编译器标志。

7. 进阶展望

我们今天讨论的是最基础的后训练静态量化。在更复杂的场景中,还有一些进阶技术值得关注:

  • Per-Channel Quantization: 特别是对于卷积层的权重,每个输出通道拥有独立的 (S, Z) 可以显著提高精度。这要求在 QuantizedLinearLayer (或 QuantizedConv2DLayer) 中存储和管理多组 (S, Z)(M, N) 参数。
  • 混合精度量化 (Hybrid Quantization): 并非所有层都必须量化到 INT8。某些对精度敏感的层可以保留 FP32 或使用 INT16,以在性能和精度之间取得更好的平衡。
  • 动态量化 (Dynamic Quantization): 激活值在推理时动态计算其 min_fpmax_fp,然后进行量化。这可以减少对校准数据的依赖,但会增加运行时开销,通常不适合极致资源受限的嵌入式设备。
  • 硬件加速器: 许多现代嵌入式 SoC 包含了专门的神经网络处理器 (NPU)、数字信号处理器 (DSP) 或 AI 加速器。我们的 C++ 实现是软件层面的优化,但硬件加速器能够提供数量级上的性能提升。了解如何将量化模型卸载到这些硬件上,是未来嵌入式 AI 部署的重要方向。

8. 总结与展望

量化技术是实现深度学习模型在嵌入式设备上高效部署的必由之路。通过将浮点运算转换为整数运算,我们可以显著降低模型的内存占用、计算复杂度和功耗。C++ 作为一种高性能的系统编程语言,为我们提供了实现精细量化推理逻辑所需的底层控制能力。

从理解 ScaleZero-Point 的核心概念,到掌握整数化乘数和位移的技巧,再到构建一套简化的量化推理框架,每一步都旨在帮助大家在实际项目中进行实践。虽然量化会带来一定的精度损失,但通过仔细的校准、参数选择和硬件优化(如 SIMD),我们可以在大多数应用中找到性能与精度之间的最佳平衡点。

希望今天的讲座能为大家在嵌入式 AI 的道路上点亮一盏明灯,期待大家能在各自的实践中,将这些知识转化为真实的生产力。谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注