C++ 与 大模型量化方案:在 C++ 中通过位宽对齐技术实现 4-bit 权重的极速反量化运算

各位编程领域的同仁们,大家好!

今天,我们将深入探讨一个在人工智能,特别是大模型时代背景下,日益重要的技术主题:在 C++ 中通过位宽对齐技术实现 4-bit 权重的极速反量化运算。 随着大模型参数量的爆炸式增长,对存储、计算和带宽的需求也水涨尺高。量化作为一种有效的模型压缩与加速技术,已成为部署大模型的关键环节。而在众多量化方案中,4-bit 量化因其极高的压缩比和在特定场景下可接受的精度损失,正受到越来越多的关注。

我们将从量化的基本原理出发,逐步深入到 4-bit 量化的独特挑战,最终聚焦于如何在 C++ 中,利用底层的位操作和 SIMD 指令集,高效地实现 4-bit 权重的反量化,从而为推理引擎提供极致的性能。

引言:大模型的困境与量化的崛起

近年来,以 GPT 系列、LLaMA、Mixtral 等为代表的超大规模语言模型(LLMs)展现了惊人的智能和泛化能力。然而,这些模型的参数量动辄数十亿、数百亿乃至万亿,带来了严峻的工程挑战:

  1. 内存占用 (Memory Footprint): 一个 7B 参数的模型,如果使用 FP32 (4 字节) 存储,需要大约 28GB 的内存。对于百亿参数的模型,内存需求轻松突破 100GB,这对于消费级硬件甚至许多专业服务器都是巨大的负担。
  2. 带宽瓶颈 (Bandwidth Bottleneck): 在推理过程中,模型权重需要频繁从内存加载到计算单元。巨大的参数量意味着海量的内存访问,内存带宽往往成为制约推理速度的关键瓶颈。
  3. 计算延迟 (Computational Latency): 尽管现代 AI 芯片计算能力强大,但处理如此庞大的参数量仍然需要大量的浮点运算,导致推理延迟增加。
  4. 能耗 (Energy Consumption): 更大的内存占用和计算量意味着更高的能耗,这在边缘设备和数据中心都不可忽视。

为了应对这些挑战,模型量化 (Model Quantization) 应运而生。量化的核心思想是将模型参数(权重、激活值)从高精度浮点数(如 FP32)映射到低精度整数(如 INT8、INT4),甚至二值化 (INT1)。

量化的主要优势:

  • 更小的模型文件尺寸: 降低存储需求,方便分发和部署。
  • 更低的内存带宽需求: 每次从内存读取的数据量减少,加速数据传输。
  • 更快的计算速度: 整数运算通常比浮点运算更快,且在某些硬件上支持更高效的整数 SIMD 指令。
  • 更低的能耗: 减少数据传输和计算量,降低功耗。

在各种量化位宽中,4-bit 量化 尤其引人注目。相较于 8-bit 量化,4-bit 量化能将模型大小进一步减半,理论上也能将内存带宽需求进一步减半。这意味着一个 7B 模型可以压缩到 3.5GB 左右,使得在配备 8GB 或 16GB 显存的消费级 GPU 上运行大模型成为可能。然而,位宽的极度压缩也带来了精度损失的风险,并对反量化(dequantization)的实现提出了更高的性能要求。

量化基础:原理与方案

在深入 4-bit 量化之前,我们先回顾一下量化的基本原理和常见的方案。

量化通常通过一个仿射变换(affine transformation)将浮点数 r 映射到整数 q
q = round(r / S + Z)
其中:

  • S (Scale) 是比例因子,决定了浮点数到整数的缩放比例。
  • Z (Zero Point) 是零点,对应于浮点数 0 的整数值。
  • round() 是舍入函数,将浮点结果转换为整数。

反过来,从量化后的整数 q 恢复到近似的浮点数 r_approx,我们使用反量化公式:
r_approx = S * (q - Z)

量化方案的分类:

  1. 按数据类型:

    • 对称量化 (Symmetric Quantization): 通常将浮点范围对称地映射到整数范围,零点 Z 设为 0。例如,对于 4-bit 有符号整数,范围是 [-8, 7][-7, 7] (根据实现细节,通常 INT_MIN 无法表示)。此时 r_approx = S * q
    • 非对称量化 (Asymmetric Quantization): 将浮点范围映射到包含零点的整数范围。例如,对于 4-bit 无符号整数,范围是 [0, 15]。此时 r_approx = S * (q - Z)。非对称量化通常能更好地覆盖数据分布,但在实现上可能稍复杂。
  2. 按粒度:

    • Per-tensor 量化: 整个张量共享一个 SZ。实现最简单,但精度可能受极端值影响。
    • Per-channel 量化: 每个输出通道(或输入通道,取决于具体层)有独立的 SZ。对权重通常效果更好,因为它能更好地适应不同通道的激活值范围。
    • Per-group 量化: 将张量划分为若干个小块(group),每个 group 有独立的 SZ。这是 4-bit 量化中非常重要的策略,因为它在精度和计算开销之间取得了很好的平衡。对于 4-bit 这种极低位宽,Per-group 量化能显著提高精度,因为它可以为不同的权重分布区域提供更精细的缩放。

4-bit 量化的特殊性:

4-bit 整数只能表示 16 个不同的值。

  • 无符号 (uint4_t): 范围通常是 [0, 15]
  • 有符号 (int4_t): 范围通常是 [-8, 7][-7, 7]

由于表示值的数量非常有限,4-bit 量化对 SZ 的选择更加敏感。Per-tensor 量化对 4-bit 来说通常效果很差,因为一个大的张量中,不同部分的权重分布差异可能很大,用一个 SZ 很难同时兼顾。因此,Per-group 量化 成为 4-bit 权重量化的主流选择。通过将权重矩阵分割成较小的组(例如 32、64、128 或 256 个元素),每个组拥有独立的 SZ,可以显著提高量化精度,同时控制 SZ 的存储开销。

4-bit 权重的存储与反量化挑战

在 C++ 中,我们并没有原生的 4-bit 数据类型。因此,4-bit 权重需要被“打包”存储到更大的数据类型中,通常是 uint8_t (字节)。一个字节可以存储两个 4-bit 整数。

存储示例:
假设我们有两个 4-bit 整数 w0w1,它们的值都在 [0, 15][-8, 7] 范围内。我们可以将它们打包到一个 uint8_t 字节中:
packed_byte = (w1 << 4) | (w0 & 0x0F);
这里,w0 存储在低 4 位,w1 存储在高 4 位。w0 & 0x0F 是为了确保 w0 确实只占用低 4 位(虽然在 C++ 中,如果 w0 是一个 4-bit 值,这个操作通常是冗余的,但保持严谨性有助于理解)。

反量化挑战:

  1. 打包与解包开销: 从一个字节中解包出两个 4-bit 整数需要位操作(位移和位掩码)。如果每次解包一个值,然后进行浮点运算,效率会很低。
  2. 内存带宽: 尽管 4-bit 减少了存储量,但反量化过程中,我们仍然需要将这些压缩数据加载到 CPU/GPU。如何以最高效的方式加载和处理这些数据,是性能优化的核心。
  3. 计算效率: 解包后的 4-bit 整数需要转换为浮点数,然后与 ScaleZeroPoint 进行浮点运算。这涉及到整数到浮点的转换以及浮点乘加操作。

我们的目标是极速反量化,这意味着我们需要最大限度地减少内存访问次数,并充分利用现代 CPU 的并行计算能力,即 SIMD (Single Instruction, Multiple Data) 指令集。

位宽对齐技术与 C++ 极速反量化

“位宽对齐技术”在这里指的是,我们不仅仅是简单地解包 4-bit 值,而是要以其原始打包的字节为单位,一次性处理多个 4-bit 值,以充分利用 CPU 的字长和 SIMD 向量寄存器,减少位操作的开销,并最大化内存吞吐量。 具体来说,就是一次加载一个字节,立即得到两个 4-bit 值,然后并行处理这两个值。更进一步,一次加载一个 16 字节(128 位)或 32 字节(256 位)的 SIMD 向量,一次性解包和处理多个 4-bit 值。

反量化公式:
对于对称量化(简化为 Z=0,常用作权重):FP32 = Scale * INT4
对于非对称量化:FP32 = Scale * (INT4 - ZeroPoint)

我们将主要关注对称量化,因为它在权重上常见且实现相对简洁,但原理可以扩展到非对称量化。

1. 基础标量反量化 (Naive Scalar Dequantization)

最直接的方法是逐个解包 4-bit 值,然后进行反量化。

#include <iostream>
#include <vector>
#include <numeric>
#include <chrono>

// 假设我们有 4-bit 有符号整数,范围是 [-8, 7]
// 为了简化,我们假设量化后的值是 [0, 15],然后反量化时再转换为有符号。
// 或者直接存储为 [0, 15],反量化时减去 8 得到 [-8, 7]。
// 这里我们采用后一种,即 packed_val 范围是 [0, 15],实际值是 packed_val - 8。

// 函数用于将两个 4-bit 值打包到一个 byte 中
uint8_t pack_int4_to_byte(int8_t val1, int8_t val2) {
    // 假设 val1 和 val2 都在 [-8, 7] 范围内
    // 我们先将它们映射到 [0, 15] 范围进行打包
    uint8_t u_val1 = static_cast<uint8_t>(val1 + 8);
    uint8_t u_val2 = static_cast<uint8_t>(val2 + 8);
    return (u_val2 << 4) | (u_val1 & 0x0F);
}

// 函数用于从 byte 中解包两个 4-bit 值
void unpack_byte_to_int4(uint8_t packed_byte, int8_t& val1, int8_t& val2) {
    uint8_t u_val1 = packed_byte & 0x0F;
    uint8_t u_val2 = (packed_byte >> 4) & 0x0F;
    val1 = static_cast<int8_t>(u_val1 - 8);
    val2 = static_cast<int8_t>(u_val2 - 8);
}

// 标量反量化函数(每次处理一个 4-bit 值)
// 这种方式效率很低,因为每次都需要解包
void dequantize_scalar_naive(const uint8_t* packed_weights, const float* scales,
                              int num_weights, int group_size, float* output_fp32) {
    int current_weight_idx = 0;
    for (int i = 0; i < num_weights / 2; ++i) { // 遍历 packed_weights 中的每个 byte
        uint8_t packed_byte = packed_weights[i];

        int8_t val1, val2;
        unpack_byte_to_int4(packed_byte, val1, val2); // 解包两个 4-bit 值

        // 获取对应的 scale。这里假设 scale 是 per-group 的
        // 且 num_weights 是 group_size 的倍数
        float scale_val1 = scales[current_weight_idx / group_size];
        float scale_val2 = scales[(current_weight_idx + 1) / group_size];

        output_fp32[current_weight_idx++] = static_cast<float>(val1) * scale_val1;
        output_fp32[current_weight_idx++] = static_cast<float>(val2) * scale_val2;
    }
}

// 标量反量化函数(每次处理一个 byte,得到两个 4-bit 值,效率更高)
// 这是“位宽对齐”思想的初步体现:一次加载,处理两个
void dequantize_scalar_optimized(const uint8_t* packed_weights, const float* scales,
                                  int num_weights, int group_size, float* output_fp32) {
    // 假设 num_weights 是偶数
    for (int i = 0; i < num_weights / 2; ++i) { // 遍历 packed_weights 中的每个 byte
        uint8_t packed_byte = packed_weights[i];

        // 解包第一个 4-bit 值 (低 4 位)
        int8_t val1 = static_cast<int8_t>((packed_byte & 0x0F) - 8);
        // 解包第二个 4-bit 值 (高 4 位)
        int8_t val2 = static_cast<int8_t>((packed_byte >> 4) - 8);

        // 计算对应的输出索引
        int output_idx_1 = i * 2;
        int output_idx_2 = i * 2 + 1;

        // 获取对应的 scale
        // 注意:这里需要确保 group_size 是 4-bit 值的数量,而不是 packed_byte 的数量
        // 如果 group_size 是 4-bit 值的数量,那么 scales 数组的长度是 num_weights / group_size
        float scale_val1 = scales[output_idx_1 / group_size];
        float scale_val2 = scales[output_idx_2 / group_size];

        output_fp32[output_idx_1] = static_cast<float>(val1) * scale_val1;
        output_fp32[output_idx_2] = static_cast<float>(val2) * scale_val2;
    }
}

// 模拟数据生成
void generate_test_data(std::vector<int8_t>& fp32_data, std::vector<uint8_t>& packed_weights,
                        std::vector<float>& scales, int num_weights, int group_size) {
    fp32_data.resize(num_weights);
    packed_weights.resize(num_weights / 2);
    scales.resize(num_weights / group_size);

    // 随机生成 FP32 数据作为原始值
    for (int i = 0; i < num_weights; ++i) {
        fp32_data[i] = static_cast<int8_t>((rand() % 16) - 8); // 模拟 [-8, 7] 范围
    }

    // 打包权重
    for (int i = 0; i < num_weights / 2; ++i) {
        packed_weights[i] = pack_int4_to_byte(fp32_data[i * 2], fp32_data[i * 2 + 1]);
    }

    // 随机生成 scales
    for (int i = 0; i < scales.size(); ++i) {
        scales[i] = static_cast<float>(rand() % 100) / 100.0f + 0.1f; // 0.1 - 1.0
    }
}

/*
int main() {
    const int NUM_WEIGHTS = 1024 * 1024; // 1M 权重
    const int GROUP_SIZE = 64; // 每个 group 64 个 4-bit 权重

    std::vector<int8_t> original_fp32_data;
    std::vector<uint8_t> packed_weights_vec;
    std::vector<float> scales_vec;

    generate_test_data(original_fp32_data, packed_weights_vec, scales_vec, NUM_WEIGHTS, GROUP_SIZE);

    std::vector<float> output_fp32(NUM_WEIGHTS);

    std::cout << "Starting scalar optimized dequantization..." << std::endl;
    auto start = std::chrono::high_resolution_clock::now();
    dequantize_scalar_optimized(packed_weights_vec.data(), scales_vec.data(), NUM_WEIGHTS, GROUP_SIZE, output_fp32.data());
    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> duration = end - start;
    std::cout << "Scalar optimized dequantization took: " << duration.count() * 1000 << " ms" << std::endl;

    // 简单验证
    // std::cout << "Output[0]: " << output_fp32[0] << std::endl;
    // std::cout << "Output[1]: " << output_fp32[1] << std::endl;
    // ...

    return 0;
}
*/

上面的 dequantize_scalar_optimized 函数已经初步体现了位宽对齐的思想:一次加载一个 uint8_t,然后解包出两个 4-bit 值并进行处理。这比每次只处理一个 4-bit 值要高效。然而,对于现代 CPU,真正的性能提升来自于 SIMD 指令集。

2. SIMD (SSE/AVX) 极速反量化

SIMD 指令允许 CPU 在一个指令周期内并行处理多个数据元素。对于 4-bit 量化,这意味着我们可以一次性加载多个字节,然后并行地解包其中的所有 4-bit 值,并执行向量化的乘法和加法。

我们将以 Intel SSE/AVX 指令集 为例进行讲解。ARM NEON 也有类似的指令,原理相同。

核心思想:

  1. 批量加载打包数据: 使用 _mm_loadu_si128 (SSE) 或 _mm_loadu_si256 (AVX) 一次加载 16 字节(128 位)或 32 字节(256 位)的 packed 4-bit 数据。
  2. 解包 4-bit 数据:
    • 将加载的字节向量分解为高 4 位和低 4 位。
    • 使用位移和位掩码操作,将这些 4-bit 值扩展到更宽的整数类型(如 8-bit, 16-bit, 32-bit),以便进行 SIMD 运算。
    • 处理 ZeroPoint(如果是非对称量化)。对于对称量化,只需将 [0, 15] 映射到 [-8, 7]
  3. 整数到浮点转换: 将扩展后的整数向量转换为浮点向量。
  4. 向量乘法与加法: 使用 _mm_mul_ps_mm_add_ps 等指令执行反量化公式。
  5. 批量存储结果: 使用 _mm_storeu_ps 存储浮点结果。

SIMD intrinsics 概览 (SSE/AVX):

Intrinsics Type Description
__m128i 128-bit 整数向量
__m256i 256-bit 整数向量
__m128 128-bit 单精度浮点向量
__m256 256-bit 单精度浮点向量
_mm_loadu_si128 从内存加载 128-bit 整数向量 (未对齐)
_mm_loadu_si256 从内存加载 256-bit 整数向量 (未对齐)
_mm_loadu_ps 从内存加载 128-bit 单精度浮点向量 (未对齐)
_mm_loadu_ps 从内存加载 256-bit 单精度浮点向量 (未对齐)
_mm_storeu_ps 将 128-bit 单精度浮点向量存储到内存 (未对齐)
_mm_storeu_ps 将 256-bit 单精度浮点向量存储到内存 (未对齐)
_mm_and_si128 按位 AND (128-bit 整数)
_mm_srli_epi16 16-bit 整数向量逻辑右移
_mm_slli_epi16 16-bit 整数向量逻辑左移
_mm_unpacklo_epi8 解包 8-bit 向量的低位半部分,交错存储到 16-bit 向量中
_mm_unpackhi_epi8 解包 8-bit 向量的高位半部分,交错存储到 16-bit 向量中
_mm_cvtepi16_epi32 将 16-bit 整数向量符号扩展为 32-bit 整数向量
_mm_cvtepi32_ps 将 32-bit 整数向量转换为单精度浮点向量
_mm_mul_ps 单精度浮点向量乘法
_mm_add_ps 单精度浮点向量加法
_mm_set1_epi8 创建一个 128-bit 向量,所有 8-bit 元素都设置为指定值
_mm_set1_ps 创建一个 128-bit 向量,所有浮点元素都设置为指定值

一个 4-bit 反量化 SIMD 循环的简化步骤 (SSE2/SSSE3):

  1. 加载 16 字节的 packed 4-bit 权重:
    __m128i packed_bytes = _mm_loadu_si128((__m128i const*)packed_weights_ptr);
    这 16 个字节包含了 32 个 4-bit 权重。

  2. 分离低 4 位和高 4 位:

    • __m128i low_nibbles = _mm_and_si128(packed_bytes, _mm_set1_epi8(0x0F)); (低 4 位,[0, 15])
    • __m128i high_nibbles = _mm_srli_epi16(packed_bytes, 4); (高 4 位,[0, 15])
      这里需要注意,_mm_srli_epi16 是对 16-bit 元素进行右移。为了避免高 4 位的值被低 4 位污染,我们需要更精细的处理,或者先用 _mm_and_si128 掩码高 4 位,再右移。一个更通用的做法是使用 _mm_and_si128(packed_bytes, _mm_set1_epi8(0xF0)) 提取高 4 位,然后右移 4 位。

    更健壮的分离方法:
    __m128i low_nibbles = _mm_and_si128(packed_bytes, _mm_set1_epi8(0x0F));
    __m128i high_nibbles = _mm_srli_epi32(packed_bytes, 4); // 或者 _mm_srli_epi16
    high_nibbles = _mm_and_si128(high_nibbles, _mm_set1_epi8(0x0F));

    现在 low_nibbleshigh_nibbles 都是 16 字节的向量,每个字节的低 4 位存储着一个原始 4-bit 值(0-15)。

  3. [0, 15] 映射到 [-8, 7] (对于对称量化):
    __m128i zero_point_offset = _mm_set1_epi8(8);
    low_nibbles = _mm_sub_epi8(low_nibbles, zero_point_offset);
    high_nibbles = _mm_sub_epi8(high_nibbles, zero_point_offset);

    现在 low_nibbleshigh_nibbles 向量中的每个字节都存储了一个 int8_t 值,范围是 [-8, 7]

  4. 将 8-bit 整数扩展到 32-bit 浮点数:
    SSE 没有直接从 int8_tfloat 的转换。我们需要分步进行:

    • int8_t -> int16_t (使用 _mm_cvtepi8_epi16_mm_unpacklo_epi8 / _mm_unpackhi_epi8)
    • int16_t -> int32_t (使用 _mm_cvtepi16_epi32)
    • int32_t -> float (使用 _mm_cvtepi32_ps)

    由于 low_nibbleshigh_nibbles 各包含 16 个 int8_t 值,我们可以将它们进一步分成 4 个 __m128 浮点向量(每个包含 4 个浮点数)。

    • 处理 low_nibbles (32个 4-bit 值中的前16个):
      __m128i low_nibbles_lo = _mm_unpacklo_epi8(low_nibbles, _mm_setzero_si128()); // int8_t -> int16_t
      __m128i low_nibbles_hi = _mm_unpackhi_epi8(low_nibbles, _mm_setzero_si128()); // int8_t -> int16_t

      __m128i int_vals_0 = _mm_cvtepi16_epi32(low_nibbles_lo); // int16_t -> int32_t
      __m128i int_vals_1 = _mm_cvtepi16_epi32(low_nibbles_hi); // int16_t -> int32_t

      __m128 float_vals_0 = _mm_cvtepi32_ps(int_vals_0); // int32_t -> float
      __m128 float_vals_1 = _mm_cvtepi32_ps(int_vals_1); // int32_t -> float

    • 处理 high_nibbles (32个 4-bit 值中的后16个):
      __m128i high_nibbles_lo = _mm_unpacklo_epi8(high_nibbles, _mm_setzero_si128());
      __m128i high_nibbles_hi = _mm_unpackhi_epi8(high_nibbles, _mm_setzero_si128());

      __m128i int_vals_2 = _mm_cvtepi16_epi32(high_nibbles_lo);
      __m128i int_vals_3 = _mm_cvtepi16_epi32(high_nibbles_hi);

      __m128 float_vals_2 = _mm_cvtepi32_ps(int_vals_2);
      __m128 float_vals_3 = _mm_cvtepi32_ps(int_vals_3);

    现在我们得到了 4 个 __m128 浮点向量,每个包含 4 个反量化前的浮点值。总共 16 个浮点值,对应于 16 个 4-bit 值(半个 packed_bytes 向量)。
    等一下,我们加载了 16 个字节,每个字节两个 4-bit 值,所以总共是 32 个 4-bit 值。上述处理 low_nibbleshigh_nibbles 各得到了 8 个浮点值,总共 16 个。这不对。low_nibbleshigh_nibbles 各自包含 16 个 int8_t 值。所以,每个都需要拆分成两个 8 元素向量,再扩展。

    正确处理 32 个 4-bit 值:

    1. 加载 16 字节的 packed 4-bit 权重 (32个 4-bit 值):
      __m128i packed_bytes = _mm_loadu_si128((__m128i const*)packed_weights_ptr);

    2. 解包为两个 16 字节的 int8_t 向量 (每个字节含一个 4-bit 值):
      __m128i low_nibbles_as_bytes = _mm_and_si128(packed_bytes, _mm_set1_epi8(0x0F));
      __m128i high_nibbles_as_bytes = _mm_srli_epi16(packed_bytes, 4); // 仅右移16位元素,需要注意
      high_nibbles_as_bytes = _mm_and_si128(high_nibbles_as_bytes, _mm_set1_epi8(0x0F));
      更安全的右移方式是:_mm_and_si128(_mm_srli_epi64(packed_bytes, 4), _mm_set1_epi8(0x0F)),或者更灵活地使用 _mm_shuffle_epi8 来实现。

      更推荐的解包方式,利用 _mm_unpack 系列:

      // 假设 packed_bytes = [B0, B1, B2, ..., B15]
      // 其中 Bi = (Hi << 4) | Li
      // 我们需要 Li 和 Hi 组成的 16字节向量
      __m128i tmp1 = _mm_and_si128(packed_bytes, _mm_set1_epi8(0x0F)); // [L0, L1, ..., L15]
      __m128i tmp2 = _mm_srli_epi16(packed_bytes, 4);                   // [H0, H1, ..., H15] (高位可能有污染)
      tmp2 = _mm_and_si128(tmp2, _mm_set1_epi8(0x0F)); // 确保只有低4位有效
      
      // 交错合并以实现更快的扩展,或直接处理
      // low_nibbles_as_bytes 现在是 [L0, L1, ..., L15] (每个值占用一个字节的低4位)
      // high_nibbles_as_bytes 现在是 [H0, H1, ..., H15] (每个值占用一个字节的低4位)
    3. [0, 15] 映射到 [-8, 7]
      __m128i eight_offset = _mm_set1_epi8(8);
      low_nibbles_as_bytes = _mm_sub_epi8(low_nibbles_as_bytes, eight_offset);
      high_nibbles_as_bytes = _mm_sub_epi8(high_nibbles_as_bytes, eight_offset);

    4. int8_t 向量扩展为 int32_t 浮点向量:
      由于每个 __m128i 包含 16 个 int8_t,而 _mm_cvtepi16_epi32 每次处理 4 个 int16_t (即 8 个 int8_t 扩展而来),所以我们需要将其分成 4 组。

      对于 low_nibbles_as_bytes (前 16 个 4-bit 权重):

      // 将 16个 int8_t 扩展为 8个 int16_t (低8位)
      __m128i low_nibbles_int16_lo = _mm_unpacklo_epi8(low_nibbles_as_bytes, low_nibbles_as_bytes); // 0, L0, 0, L1, ... 0, L7
      // 将 16个 int8_t 扩展为 8个 int16_t (高8位)
      __m128i low_nibbles_int16_hi = _mm_unpackhi_epi8(low_nibbles_as_bytes, low_nibbles_as_bytes); // 0, L8, 0, L9, ... 0, L15
      
      // 将 8个 int16_t 扩展为 4个 int32_t (低4位)
      __m128i int_vals_0 = _mm_cvtepi16_epi32(low_nibbles_int16_lo); // L0, L1, L2, L3
      __m128i int_vals_1 = _mm_cvtepi16_epi32(_mm_srli_si128(low_nibbles_int16_lo, 8)); // L4, L5, L6, L7
      __m128i int_vals_2 = _mm_cvtepi16_epi32(low_nibbles_int16_hi); // L8, L9, L10, L11
      __m128i int_vals_3 = _mm_cvtepi16_epi32(_mm_srli_si128(low_nibbles_int16_hi, 8)); // L12, L13, L14, L15
      
      // 转换为浮点数
      __m128 float_vals_0 = _mm_cvtepi32_ps(int_vals_0);
      __m128 float_vals_1 = _mm_cvtepi32_ps(int_vals_1);
      __m128 float_vals_2 = _mm_cvtepi32_ps(int_vals_2);
      __m128 float_vals_3 = _mm_cvtepi32_ps(int_vals_3);

      high_nibbles_as_bytes 执行相同的操作,得到 float_vals_4float_vals_7
      总共 8 个 __m128 向量,每个包含 4 个浮点数,总计 32 个浮点数,对应于加载的 32 个 4-bit 权重。

    5. 加载 Scale 值并进行向量乘法:
      由于 Scale 是 per-group 的,并且 group_size 可能大于 32,我们需要根据当前的权重索引来确定要加载哪些 Scale
      例如,如果 group_size=64,那么 32 个权重可能来自同一个 group,或者跨越两个 group。
      __m128 scales_vec = _mm_set1_ps(scales[current_group_idx]);
      float_vals_0 = _mm_mul_ps(float_vals_0, scales_vec);
      … (对所有 8 个浮点向量重复)

    6. 存储结果:
      _mm_storeu_ps(output_ptr + 0, float_vals_0);
      _mm_storeu_ps(output_ptr + 4, float_vals_1);
      … (对所有 8 个浮点向量重复)
      output_ptr += 32; (每次处理 32 个浮点数)

完整 SSE/AVX 反量化代码示例:

#include <iostream>
#include <vector>
#include <numeric>
#include <chrono>
#include <immintrin.h> // For SSE/AVX intrinsics

// 假设我们有 4-bit 有符号整数,范围是 [-8, 7]
// 存储时映射到 [0, 15],反量化时减去 8 得到 [-8, 7]。

// 标量打包函数 (用于测试数据生成)
uint8_t pack_int4_to_byte(int8_t val1, int8_t val2) {
    uint8_t u_val1 = static_cast<uint8_t>(val1 + 8);
    uint8_t u_val2 = static_cast<uint8_t>(val2 + 8);
    return (u_val2 << 4) | (u_val1 & 0x0F);
}

// 模拟数据生成
void generate_test_data(std::vector<int8_t>& original_int4_data, std::vector<uint8_t>& packed_weights,
                        std::vector<float>& scales, int num_weights, int group_size) {
    original_int4_data.resize(num_weights);
    packed_weights.resize(num_weights / 2); // 每个字节存储两个 4-bit 权重
    scales.resize((num_weights + group_size - 1) / group_size); // 向上取整

    // 随机生成 int4 数据作为原始值 (范围 [-8, 7])
    for (int i = 0; i < num_weights; ++i) {
        original_int4_data[i] = static_cast<int8_t>((rand() % 16) - 8); 
    }

    // 打包权重
    for (int i = 0; i < num_weights / 2; ++i) {
        packed_weights[i] = pack_int4_to_byte(original_int4_data[i * 2], original_int4_data[i * 2 + 1]);
    }

    // 随机生成 scales
    for (int i = 0; i < scales.size(); ++i) {
        scales[i] = static_cast<float>(rand() % 100) / 100.0f + 0.1f; // 0.1 - 1.0
    }
}

// ----------------------------------------------------------------------------------------------------
// Scalar Optimized Dequantization (from previous section)
void dequantize_scalar_optimized(const uint8_t* packed_weights, const float* scales,
                                  int num_weights, int group_size, float* output_fp32) {
    for (int i = 0; i < num_weights / 2; ++i) {
        uint8_t packed_byte = packed_weights[i];

        int8_t val1 = static_cast<int8_t>((packed_byte & 0x0F) - 8);
        int8_t val2 = static_cast<int8_t>((packed_byte >> 4) - 8);

        int output_idx_1 = i * 2;
        int output_idx_2 = i * 2 + 1;

        float scale_val1 = scales[output_idx_1 / group_size];
        float scale_val2 = scales[output_idx_2 / group_size];

        output_fp32[output_idx_1] = static_cast<float>(val1) * scale_val1;
        output_fp32[output_idx_2] = static_cast<float>(val2) * scale_val2;
    }
}

// ----------------------------------------------------------------------------------------------------
// SSE/AVX Dequantization
// 每次处理 16 字节 (128 位) 的 packed_weights,即 32 个 4-bit 权重
void dequantize_simd_sse(const uint8_t* packed_weights, const float* scales,
                         int num_weights, int group_size, float* output_fp32) {
    // 确保 num_weights 是 32 的倍数,以便 SIMD 处理
    // 实际应用中需要处理尾部不完整的向量
    int num_iters = num_weights / 32; 

    // Constants for unpacking and zero-point adjustment
    const __m128i low_nibble_mask = _mm_set1_epi8(0x0F); // 00001111b
    const __m128i eight_offset_epi8 = _mm_set1_epi8(8); // For converting [0,15] to [-8,7]

    for (int i = 0; i < num_iters; ++i) {
        // Load 16 bytes (128 bits) of packed 4-bit weights
        // This contains 32 individual 4-bit weights
        __m128i packed_bytes = _mm_loadu_si128((const __m128i*)(packed_weights + i * 16));

        // 1. Extract low nibbles (L0, L1, ..., L15)
        __m128i low_nibbles_as_bytes = _mm_and_si128(packed_bytes, low_nibble_mask);

        // 2. Extract high nibbles (H0, H1, ..., H15)
        // Shift right by 4 bits. _mm_srli_epi16 shifts each 16-bit word.
        // We need to ensure that the higher nibbles are properly isolated.
        // A more robust way to get high nibbles:
        __m128i high_nibbles_as_bytes = _mm_srli_epi16(packed_bytes, 4); 
        high_nibbles_as_bytes = _mm_and_si128(high_nibbles_as_bytes, low_nibble_mask);

        // 3. Adjust for zero point (map [0,15] to [-8,7])
        low_nibbles_as_bytes = _mm_sub_epi8(low_nibbles_as_bytes, eight_offset_epi8);
        high_nibbles_as_bytes = _mm_sub_epi8(high_nibbles_as_bytes, eight_offset_epi8);

        // Now we have two __m128i vectors, each containing 16 signed 8-bit integers.
        // We need to convert these 8-bit integers to float.
        // Process low_nibbles_as_bytes (first 16 weights)
        // Convert 16x int8 -> 8x int16 (lo) -> 4x int32 -> 4x float
        // Convert 16x int8 -> 8x int16 (hi) -> 4x int32 -> 4x float

        // Split low_nibbles_as_bytes into two 8-element int8_t groups for int16 conversion
        __m128i low_nibbles_int16_part0 = _mm_cvtepi8_epi16(low_nibbles_as_bytes); // L0..L7 as int16
        __m128i low_nibbles_int16_part1 = _mm_cvtepi8_epi16(_mm_srli_si128(low_nibbles_as_bytes, 8)); // L8..L15 as int16

        // Split high_nibbles_as_bytes into two 8-element int8_t groups for int16 conversion
        __m128i high_nibbles_int16_part0 = _mm_cvtepi8_epi16(high_nibbles_as_bytes); // H0..H7 as int16
        __m128i high_nibbles_int16_part1 = _mm_cvtepi8_epi16(_mm_srli_si128(high_nibbles_as_bytes, 8)); // H8..H15 as int16

        // Convert 4x int16 -> 4x int32 -> 4x float for each part
        // Resulting in 4 float vectors (each 4 floats) for low nibbles, and 4 for high nibbles

        // Low nibbles (L0-L3, L4-L7, L8-L11, L12-L15)
        __m128 float_vals_L0_L3 = _mm_cvtepi32_ps(_mm_cvtepi16_epi32(low_nibbles_int16_part0));
        __m128 float_vals_L4_L7 = _mm_cvtepi32_ps(_mm_cvtepi16_epi32(_mm_srli_si128(low_nibbles_int16_part0, 8)));
        __m128 float_vals_L8_L11 = _mm_cvtepi32_ps(_mm_cvtepi16_epi32(low_nibbles_int16_part1));
        __m128 float_vals_L12_L15 = _mm_cvtepi32_ps(_mm_cvtepi16_epi32(_mm_srli_si128(low_nibbles_int16_part1, 8)));

        // High nibbles (H0-H3, H4-H7, H8-H11, H12-H15)
        __m128 float_vals_H0_H3 = _mm_cvtepi32_ps(_mm_cvtepi16_epi32(high_nibbles_int16_part0));
        __m128 float_vals_H4_H7 = _mm_cvtepi32_ps(_mm_cvtepi16_epi32(_mm_srli_si128(high_nibbles_int16_part0, 8)));
        __m128 float_vals_H8_H11 = _mm_cvtepi32_ps(_mm_cvtepi16_epi32(high_nibbles_int16_part1));
        __m128 float_vals_H12_H15 = _mm_cvtepi32_ps(_mm_cvtepi16_epi32(_mm_srli_si128(high_nibbles_int16_part1, 8)));

        // Get scales for the current 32 weights
        // The current 32 weights start at output_idx = i * 32
        // They might span across multiple groups if group_size < 32
        // Or they might all be in the same group if group_size >= 32

        // Simplification: Assume group_size is a multiple of 32 or larger
        // For example, if group_size = 64, then first 32 weights use scales[current_group_idx]
        // next 32 weights use scales[current_group_idx] or scales[current_group_idx+1]

        // More robust scale handling:
        // Need to load appropriate scales for each block of 4 floats.
        // This is the trickiest part for per-group quantization with SIMD.
        // We'll load 8 scale values (for 32 weights, 4 weights per scale value in the most granular case for SIMD)
        // Assuming group_size is a multiple of 4 (number of floats in a __m128)

        int current_output_idx = i * 32;

        __m128 scale_vec_0 = _mm_set1_ps(scales[current_output_idx / group_size]);
        __m128 scale_vec_1 = _mm_set1_ps(scales[(current_output_idx + 4) / group_size]);
        __m128 scale_vec_2 = _mm_set1_ps(scales[(current_output_idx + 8) / group_size]);
        __m128 scale_vec_3 = _mm_set1_ps(scales[(current_output_idx + 12) / group_size]);
        __m128 scale_vec_4 = _mm_set1_ps(scales[(current_output_idx + 16) / group_size]);
        __m128 scale_vec_5 = _mm_set1_ps(scales[(current_output_idx + 20) / group_size]);
        __m128 scale_vec_6 = _mm_set1_ps(scales[(current_output_idx + 24) / group_size]);
        __m128 scale_vec_7 = _mm_set1_ps(scales[(current_output_idx + 28) / group_size]);

        // Perform multiplication
        float_vals_L0_L3 = _mm_mul_ps(float_vals_L0_L3, scale_vec_0);
        float_vals_L4_L7 = _mm_mul_ps(float_vals_L4_L7, scale_vec_1);
        float_vals_L8_L11 = _mm_mul_ps(float_vals_L8_L11, scale_vec_2);
        float_vals_L12_L15 = _mm_mul_ps(float_vals_L12_L15, scale_vec_3);
        float_vals_H0_H3 = _mm_mul_ps(float_vals_H0_H3, scale_vec_4);
        float_vals_H4_H7 = _mm_mul_ps(float_vals_H4_H7, scale_vec_5);
        float_vals_H8_H11 = _mm_mul_ps(float_vals_H8_H11, scale_vec_6);
        float_vals_H12_H15 = _mm_mul_ps(float_vals_H12_H15, scale_vec_7);

        // Store results
        float* current_output_ptr = output_fp32 + current_output_idx;
        _mm_storeu_ps(current_output_ptr + 0, float_vals_L0_L3);
        _mm_storeu_ps(current_output_ptr + 4, float_vals_L4_L7);
        _mm_storeu_ps(current_output_ptr + 8, float_vals_L8_L11);
        _mm_storeu_ps(current_output_ptr + 12, float_vals_L12_L15);
        _mm_storeu_ps(current_output_ptr + 16, float_vals_H0_H3);
        _mm_storeu_ps(current_output_ptr + 20, float_vals_H4_H7);
        _mm_storeu_ps(current_output_ptr + 24, float_vals_H8_H11);
        _mm_storeu_ps(current_output_ptr + 28, float_vals_H12_H15);
    }
    // Handle remaining elements if num_weights is not a multiple of 32 (scalar fallback or separate SIMD loop)
}

// AVX2 version for 256-bit vectors (processes 32 bytes = 64 4-bit weights at once)
// Requires more register juggling but potentially faster
void dequantize_simd_avx2(const uint8_t* packed_weights, const float* scales,
                          int num_weights, int group_size, float* output_fp32) {
    // Ensure num_weights is a multiple of 64
    int num_iters = num_weights / 64; 

    const __m256i low_nibble_mask_256 = _mm256_set1_epi8(0x0F);
    const __m256i eight_offset_epi8_256 = _mm256_set1_epi8(8);

    for (int i = 0; i < num_iters; ++i) {
        __m256i packed_bytes_256 = _mm256_loadu_si256((const __m256i*)(packed_weights + i * 32)); // Load 32 bytes

        __m256i low_nibbles_as_bytes_256 = _mm256_and_si256(packed_bytes_256, low_nibble_mask_256);
        __m256i high_nibbles_as_bytes_256 = _mm256_srli_epi16(packed_bytes_256, 4); 
        high_nibbles_as_bytes_256 = _mm256_and_si256(high_nibbles_as_bytes_256, low_nibble_mask_256);

        low_nibbles_as_bytes_256 = _mm256_sub_epi8(low_nibbles_as_bytes_256, eight_offset_epi8_256);
        high_nibbles_as_bytes_256 = _mm256_sub_epi8(high_nibbles_as_bytes_256, eight_offset_epi8_256);

        // Split 256-bit (32x int8) into two 128-bit (16x int8)
        __m128i low_nibbles_lo_128 = _mm256_extracti128_si256(low_nibbles_as_bytes_256, 0); // first 16 int8
        __m128i low_nibbles_hi_128 = _mm256_extracti128_si256(low_nibbles_as_bytes_256, 1); // last 16 int8

        __m128i high_nibbles_lo_128 = _mm256_extracti128_si256(high_nibbles_as_bytes_256, 0); // first 16 int8
        __m128i high_nibbles_hi_128 = _mm256_extracti128_si256(high_nibbles_as_bytes_256, 1); // last 16 int8

        // Now we have 4x __m128i vectors, each containing 16 int8_t values.
        // We can reuse the SSE logic for converting these 16 int8_t values to 16 floats.
        // This will result in 4*4 = 16 float vectors (each 4 floats) for 64 total floats.

        // Convert low_nibbles_lo_128 (16 int8_t) to 16 floats
        __m128i low_nibbles_lo_int16_part0 = _mm_cvtepi8_epi16(low_nibbles_lo_128); 
        __m128i low_nibbles_lo_int16_part1 = _mm_cvtepi8_epi16(_mm_srli_si128(low_nibbles_lo_128, 8)); 

        __m128 float_vals_L0_L3 = _mm_cvtepi32_ps(_mm_cvtepi16_epi32(low_nibbles_lo_int16_part0));
        __m128 float_vals_L4_L7 = _mm_cvtepi32_ps(_mm_cvtepi16_epi32(_mm_srli_si128(low_nibbles_lo_int16_part0, 8)));
        __m128 float_vals_L8_L11 = _mm_cvtepi32_ps(_mm_cvtepi16_epi32(low_nibbles_lo_int16_part1));
        __m128 float_vals_L12_L15 = _mm_cvtepi32_ps(_mm_cvtepi16_epi32(_mm_srli_si128(low_nibbles_lo_int16_part1, 8)));

        // ... repeat for low_nibbles_hi_128, high_nibbles_lo_128, high_nibbles_hi_128
        // This will produce 16 __m128 float vectors (64 floats total)
        // For brevity, let's just show the first set and generalize.

        // Placeholder for other float vectors (L16-L31, H0-H31)
        // ... (similar conversion logic as above, but for other 3 __m128i source vectors)

        // Load scales (need 16 __m128 scale vectors for 64 weights if group_size=4)
        int current_output_idx = i * 64;
        __m128 scale_vecs[16];
        for (int k = 0; k < 16; ++k) {
            scale_vecs[k] = _mm_set1_ps(scales[(current_output_idx + k * 4) / group_size]);
        }

        // Multiply and store
        float* current_output_ptr = output_fp32 + current_output_idx;
        _mm_storeu_ps(current_output_ptr + 0, _mm_mul_ps(float_vals_L0_L3, scale_vecs[0]));
        _mm_storeu_ps(current_output_ptr + 4, _mm_mul_ps(float_vals_L4_L7, scale_vecs[1]));
        // ... (repeat for all 16 float vectors)
    }
    // Handle remaining elements
}

int main() {
    const int NUM_WEIGHTS = 1024 * 1024; // 1M 权重
    const int GROUP_SIZE = 64; // 每个 group 64 个 4-bit 权重

    std::vector<int8_t> original_int4_data;
    std::vector<uint8_t> packed_weights_vec;
    std::vector<float> scales_vec;

    generate_test_data(original_int4_data, packed_weights_vec, scales_vec, NUM_WEIGHTS, GROUP_SIZE);

    std::vector<float> output_fp32_scalar(NUM_WEIGHTS);
    std::vector<float> output_fp32_sse(NUM_WEIGHTS);
    // std::vector<float> output_fp32_avx2(NUM_WEIGHTS); // If AVX2 implemented fully

    std::cout << "Starting scalar optimized dequantization..." << std::endl;
    auto start = std::chrono::high_resolution_clock::now();
    dequantize_scalar_optimized(packed_weights_vec.data(), scales_vec.data(), NUM_WEIGHTS, GROUP_SIZE, output_fp32_scalar.data());
    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> duration_scalar = end - start;
    std::cout << "Scalar optimized dequantization took: " << duration_scalar.count() * 1000 << " ms" << std::endl;

    std::cout << "Starting SSE dequantization..." << std::endl;
    start = std::chrono::high_resolution_clock::now();
    dequantize_simd_sse(packed_weights_vec.data(), scales_vec.data(), NUM_WEIGHTS, GROUP_SIZE, output_fp32_sse.data());
    end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> duration_sse = end - start;
    std::cout << "SSE dequantization took: " << duration_sse.count() * 1000 << " ms" << std::endl;

    // For AVX2, you'd need a more complete implementation for the 64-float processing
    // std::cout << "Starting AVX2 dequantization..." << std::endl;
    // start = std::chrono::high_resolution_clock::now();
    // dequantize_simd_avx2(packed_weights_vec.data(), scales_vec.data(), NUM_WEIGHTS, GROUP_SIZE, output_fp32_avx2.data());
    // end = std::chrono::high_resolution_clock::now();
    // std::chrono::duration<double> duration_avx2 = end - start;
    // std::cout << "AVX2 dequantization took: " << duration_avx2.count() * 1000 << " ms" << std::endl;

    // Optional: Verify correctness
    // for (int i = 0; i < NUM_WEIGHTS; ++i) {
    //     if (std::abs(output_fp32_scalar[i] - output_fp32_sse[i]) > 1e-6) {
    //         std::cout << "Mismatch at index " << i << ": Scalar=" << output_fp32_scalar[i] 
    //                   << ", SSE=" << output_fp32_sse[i] << std::endl;
    //         break;
    //     }
    // }
    // std::cout << "Verification complete." << std::endl;

    return 0;
}

编译指令示例 (GCC/Clang):
g++ -O3 -march=native -Wall your_program.cpp -o your_program

  • -O3: 开启最高优化等级。
  • -march=native: 告诉编译器自动检测当前 CPU 支持的最新指令集,并生成相应的代码(例如 SSE4.2, AVX, AVX2, AVX512)。这对于使用 intrinsics 至关重要。
  • -Wall: 开启所有警告。

性能分析:
通过 SIMD 指令,我们一次性处理的数据量大大增加。

  • 内存访问: 每次加载 16 字节(SSE)或 32 字节(AVX2)的 packed weights,而不是逐字节或逐个 4-bit 值访问。这显著减少了内存访问次数,更好地利用了缓存行,降低了带宽瓶颈。
  • 并行计算: 所有的位操作、整数加减、整数到浮点转换、浮点乘法都在向量寄存器上并行执行,大大提高了计算吞吐量。
  • 流水线: 现代 CPU 的指令流水线可以更好地利用 SIMD 指令,进一步提升效率。

Per-group Scale 的处理复杂性:
在上述 SIMD 示例中,处理 per-group Scale 是一个挑战。因为 __m128 寄存器一次处理 4 个浮点数。如果 group_size 是 4 的倍数,比如 4, 8, 16, 32, 64 等,我们可以相对容易地为每个 4 浮点数的块加载相同的 Scale 值。如果 group_size 更小或不是 4 的倍数,则可能需要更复杂的 _mm_shuffle_ps 或多次加载/混合操作来构建正确的 Scale 向量,这会增加指令开销。在实际的大模型量化库中,通常会选择一个合适的 group_size (例如 32 或 64) 来平衡精度和 SIMD 效率。

实际应用中的考量

  1. 尾部处理 (Tail Processing): num_weights 不一定是 SIMD 向量长度的倍数 (例如 32 或 64)。对于剩余的少量权重,通常采用标量循环处理,或者专门的 SIMD 尾部处理函数。
  2. 平台兼容性: SSE、AVX、AVX2、AVX512 是 Intel/AMD 平台上的指令集。对于 ARM 平台,需要使用 NEON intrinsics。可以使用宏定义 (#ifdef __AVX2__) 或运行时检测 (__get_cpuid()) 来选择最佳的实现。
  3. 内存对齐: 尽管 _mm_loadu_si128_mm_storeu_ps 支持未对齐访问,但数据如果能 16 字节或 32 字节对齐,通常会获得更好的性能。在分配内存时可以使用 _aligned_mallocposix_memalign
  4. 精度损失: 4-bit 量化带来的精度损失是不可避免的。选择合适的量化算法(如 GPTQ, AWQ, LLM.int8() 等)、per-group 粒度和 group_size 至关重要。在某些场景下,还需要结合 QAT (Quantization-Aware Training) 来弥补精度损失。
  5. Zero Point (零点): 上述示例采用了对称量化,ZeroPoint 隐含为 0 (通过 val - 8 映射)。如果是非对称量化,则需要显式地在反量化公式中包含 ZFP32 = S * (INT4 - Z)。在 SIMD 中,Z 也可以被 _mm_set1_epi8(zero_point_val) 加载为向量,并用 _mm_sub_epi8 进行处理。
  6. 与其他操作的融合: 在推理引擎中,反量化通常是矩阵乘法 (GEMM) 的前置步骤。为了进一步优化,可以将反量化操作与 GEMM 的第一步(例如,将量化权重与量化激活值相乘)融合,避免中间结果的存储和加载,提高缓存利用率。例如,一些库会实现 int4_to_fp32_mmul 这样的融合核。

结语与展望

我们今天深入探讨了在 C++ 中实现 4-bit 权重极速反量化运算的关键技术。从量化的基本原理,到 4-bit 存储的挑战,再到如何利用位宽对齐思想和 SIMD 指令集(SSE/AVX)实现高性能反量化,我们看到了底层优化对于大模型推理性能的巨大影响。

4-bit 量化是大模型走向更广泛部署的关键一步,它使得在资源受限的环境中运行大型模型成为可能。通过精心设计的 C++ 代码和对 SIMD 指令集的熟练运用,我们可以显著加速反量化过程,从而有效降低内存带宽和计算延迟,为未来更轻量、更高效的 AI 应用铺平道路。随着硬件指令集的不断演进(如 AMX、FP8 支持),以及软件优化技术的不断成熟,大模型的部署将变得更加普及和高效。

发表回复

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