💡 前言

在 NLP 领域,BERT(Bidirectional Encoder Representations from Transformers)已成为文本分类、情感分析、意图识别等任务的标配模型。然而,将训练好的 BERT 模型从云端 GPU 服务器迁移到边缘设备上运行,面临着算力受限、内存有限、功耗敏感等一系列工程挑战。将AI模型部署到端侧设备已成为许多项目的最后一公里。

高通跃龙IQ-9075作为一款高性能的边缘计算平台,集成了强大的Hexagon NPU,非常适合运行BERT等NLP模型

本文将基于我的实际部署经验,手把手示范如何将训练好的PyTorch BERT分类模型,通过ONNX导出INT8量化QNN工具链编译,最终在IQ-9075平台设备上利用Hexagon NPU实现高效推理。通过本文的方案,模型体积可压缩至1/4,推理延迟可降至CPU的1/15
在这里插入图片描述

一、整体部署流程概览

从模型训练完成到设备端运行,整个流程主要分为以下五个阶段:

  1. 环境准备:配置主机开发环境(x86_64)和IQ-9075平台的设备端环境。
  2. 模型导出:将PyTorch模型导出为ONNX格式,并进行简化。
  3. 模型量化:使用QNN工具将FP32模型量化为INT8模型。
  4. 编译部署:将量化后的模型编译为可在NPU上运行的Context Binary,并推送到设备。
  5. 设备推理:在设备端编写推理脚本,调用QNN Runtime执行模型。
    在这里插入图片描述

二、环境准备

本阶段需要在主机端(x86_64架构,用于模型转换)和设备端(IQ-9075平台,用于运行推理)分别进行配置。

2.1 主机开发环境 (x86_64)

建议使用Ubuntu系统,并创建独立的Python虚拟环境。

# 1. 创建 Python 虚拟环境
python3 -m venv bert_deploy_env
source bert_deploy_env/bin/activate

# 2. 安装核心依赖
pip install torch torchvision transformers -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install onnx onnxruntime onnxsim -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install numpy pandas -i https://pypi.tuna.tsinghua.edu.cn/simple

2.2 安装 Qualcomm AI Engine Direct SDK (QNN SDK)

Qualcomm Developer 官网下载 QNN SDK(建议 v2.22.x 或更高版本)。解压后,配置主机端环境变量:

# 假设QNN SDK解压在/opt/qcom/aistack/qnn/2.22.0
export QNN_SDK_ROOT=/opt/qcom/aistack/qnn/2.22.0
export PATH=$QNN_SDK_ROOT/bin/x86_64-linux-clang:$PATH
export LD_LIBRARY_PATH=$QNN_SDK_ROOT/lib/x86_64-linux-clang:$LD_LIBRARY_PATH
export PYTHONPATH=$QNN_SDK_ROOT/lib/python:$PYTHONPATH

2.3 IQ-9075 设备端环境 (Ubuntu 24.04)

在设备上安装基础环境,并从主机拷贝QNN Runtime库。

# 1. 设备端基础环境
sudo apt update && sudo apt install -y python3 python3-pip libstdc++6

# 2. 在主机端执行,拷贝QNN Runtime库到设备
scp -r $QNN_SDK_ROOT/lib/aarch64-ubuntu-gcc9.4/ user@9075-device:/opt/qnn/lib/

# 3. 在设备端配置环境变量(建议写入 .bashrc)
export LD_LIBRARY_PATH=/opt/qnn/lib/aarch64-ubuntu-gcc9.4:$LD_LIBRARY_PATH

三、模型导出:PyTorch → ONNX

我们将训练好的BERT模型导出为ONNX格式,这是进入高通工具链的第一步。

3.1 加载训练好的 BERT 模型

假设你已经有了一个训练好的基于 bert-base-chinese 的二分类模型。

import torch
from transformers import BertTokenizer, BertForSequenceClassification

model_path = "./bert_classifier_model"  # 你的模型路径
tokenizer = BertTokenizer.from_pretrained(model_path)
model = BertForSequenceClassification.from_pretrained(model_path, num_labels=2)
model.eval()

3.2 导出 ONNX

导出时需要特别注意,BERT模型的输入包含 input_idsattention_masktoken_type_ids,并且需要设置动态轴以适应不同的batch size。

# 构造 dummy 输入
dummy_input = tokenizer(
    "今天心情很好",
    return_tensors="pt",
    max_length=128,
    padding="max_length",
    truncation=True
)

torch.onnx.export(
    model,
    (dummy_input["input_ids"],
     dummy_input["attention_mask"],
     dummy_input["token_type_ids"]),
    "bert_classifier.onnx",
    input_names=["input_ids", "attention_mask", "token_type_ids"],
    output_names=["logits"],
    dynamic_axes={
        "input_ids": {0: "batch_size"},
        "attention_mask": {0: "batch_size"},
        "token_type_ids": {0: "batch_size"},
        "logits": {0: "batch_size"}
    },
    opset_version=17,
    do_constant_folding=True
)
print("ONNX 模型导出完成: bert_classifier.onnx")

3.3 验证与简化 ONNX 模型

使用 onnxonnxsim 对模型进行检查和简化,这能减少后续量化编译时的图复杂度。

import onnx
from onnxsim import simplify

onnx_model = onnx.load("bert_classifier.onnx")
onnx.checker.check_model(onnx_model)

# 简化模型
simplified_model, check = simplify(onnx_model)
assert check, "ONNX 简化失败"
onnx.save(simplified_model, "bert_classifier_sim.onnx")
print("ONNX 模型验证通过并已简化")

为什么要简化? onnxsim 会折叠常量、消除冗余算子,减少后续量化和编译时的图复杂度,对部署效率有显著帮助。

四、模型量化:FP32 → INT8

在边缘设备上,FP32的BERT模型体积大、速度慢。通过INT8量化,可以大幅缩小模型体积并利用NPU加速,而精度损失通常可控制在1%以内。

4.1 准备校准数据集

量化需要一组有代表性的校准数据来统计激活值的动态范围。通常准备100~500条样本即可。

import numpy as np
import os

calibration_texts = [
    "产品质量非常好,值得购买",
    "快递太慢了,包装也破损了",
    "性价比很高,推荐给朋友",
    "客服态度很差,不会再来了",
    # ... 更多代表性样本
]

calib_data_dir = "./calibration_data"
os.makedirs(calib_data_dir, exist_ok=True)

for i, text in enumerate(calibration_texts):
    encoded = tokenizer(
        text,
        max_length=128,
        padding="max_length",
        truncation=True,
        return_tensors="np"
    )
    np.save(f"{calib_data_dir}/input_ids_{i}.npy", encoded["input_ids"])
    np.save(f"{calib_data_dir}/attention_mask_{i}.npy", encoded["attention_mask"])
    np.save(f"{calib_data_dir}/token_type_ids_{i}.npy", encoded["token_type_ids"])

4.2 使用 QNN 工具链进行量化

首先,你需要一个脚本(generate_input_list.py)来生成校准文件的列表,或者手动创建 input_list.txt,内容类似如下:

./calibration_data/input_ids_0.npy
./calibration_data/input_ids_1.npy
...

然后使用 qnn-onnx-converter 工具进行转换和量化。

# 使用 qnn-onnx-converter 转换并量化
qnn-onnx-converter \
    --input_network bert_classifier_sim.onnx \
    --output_path bert_classifier_quantized.cpp \
    --input_list input_list.txt \
    --act_bitwidth 8 \
    --weight_bitwidth 8 \
    --bias_bitwidth 32 \
    --use_per_channel_quantization

关键参数说明:

  • --act_bitwidth 8 / --weight_bitwidth 8:激活值和权重量化为INT8。
  • --bias_bitwidth 32:偏置保持INT32以减少累计误差。
  • --use_per_channel_quantization:逐通道量化,对Transformer结构尤为重要。

五、模型编译与部署

5.1 编译为 QNN Context Binary

将量化后的模型编译为可在Hexagon NPU上高效运行的二进制文件。

# 1. 编译模型库
qnn-model-lib-generator \
    -c bert_classifier_quantized.cpp \
    -b bert_classifier_quantized.bin \
    -t aarch64-ubuntu-gcc9.4

# 2. 生成 Context Binary(指定 HTP 后端)
qnn-context-binary-generator \
    --model bert_classifier_quantized.so \
    --backend $QNN_SDK_ROOT/lib/aarch64-ubuntu-gcc9.4/libQnnHtp.so \
    --output_dir ./compiled_model \
    --binary_file bert_classifier.serialized.bin

5.2 部署文件到设备

将编译好的模型文件和QNN依赖库传输到IQ-9075设备上。

# 模型文件
scp ./compiled_model/bert_classifier.serialized.bin user@9075-device:/home/user/models/

# QNN Runtime 库(如果之前没传全)
scp $QNN_SDK_ROOT/lib/aarch64-ubuntu-gcc9.4/libQnnHtp.so user@9075-device:/opt/qnn/lib/
scp $QNN_SDK_ROOT/lib/aarch64-ubuntu-gcc9.4/libQnnHtpV75Stub.so user@9075-device:/opt/qnn/lib/
scp $QNN_SDK_ROOT/lib/hexagon-v75/unsigned/libQnnHtpV75Skel.so user@9075-device:/opt/qnn/lib/

六、设备端推理

6.1 使用 Python API 推理

在IQ-9075设备上,我们可以使用ONNX Runtime配合QNN Execution Provider进行推理。

import onnxruntime as ort
import numpy as np
from transformers import BertTokenizer

# 加载tokenizer
tokenizer = BertTokenizer.from_pretrained("./bert_tokenizer")

sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL

# 创建推理Session,指定QNN Provider
session = ort.InferenceSession(
    "bert_classifier_qnn.onnx",  # 注意:这里是你的ONNX模型,或者是通过QNN加载的接口,实际QNN推荐直接加载serialized bin
    sess_options=sess_options,
    providers=["QNNExecutionProvider"],
    provider_options=[{
        "backend_path": "/opt/qnn/lib/libQnnHtp.so",
        "htp_performance_mode": "burst",
        "htp_graph_finalization_optimization_mode": "3",
        "enable_htp_fp16_precision": "0"  # 0 表示使用INT8
    }]
)

def predict(text: str) -> dict:
    encoded = tokenizer(
        text,
        max_length=128,
        padding="max_length",
        truncation=True,
        return_tensors="np"
    )

    outputs = session.run(
        ["logits"],
        {
            "input_ids": encoded["input_ids"].astype(np.int64),
            "attention_mask": encoded["attention_mask"].astype(np.int64),
            "token_type_ids": encoded["token_type_ids"].astype(np.int64)
        }
    )

    logits = outputs[0]
    probs = np.exp(logits) / np.sum(np.exp(logits), axis=-1, keepdims=True)
    pred_label = np.argmax(probs, axis=-1)[0]

    label_map = {0: "负面", 1: "正面"}
    return {
        "label": label_map[pred_label],
        "confidence": float(probs[0][pred_label]),
        "probabilities": {label_map[i]: float(probs[0][i]) for i in range(2)}
    }

# 测试
result = predict("这款手机拍照效果非常出色,电池续航也很好")
print(f"预测结果: {result['label']}(置信度: {result['confidence']:.4f})")

6.2 使用QNN C++ Runtime API(高性能生产环境)

对于追求极致延迟的场景,推荐使用C++ API直接调用QNN Runtime。核心流程如下:

  1. 加载后端库(libQnnHtp.so)。
  2. serialized.bin 创建QNN Context。
  3. 设置输入Tensor(input_ids, attention_mask, token_type_ids)。
  4. 执行图推理。
  5. 读取输出 Tensor(logits)
  6. 后处理:softmax → 分类结果

七、性能对比与精度验证

7.1 各推理后端性能对比

以下是在IQ-9075上使用不同后端、不同精度推理BERT-base(序列长度128)的典型性能数据:
在这里插入图片描述

推理后端 精度 模型大小 延迟(128 tokens) 吞吐量 功耗
Hexagon NPU INT8 ~108 MB ~12 ms ~83 句/秒
Hexagon NPU FP16 ~210 MB ~25 ms ~40 句/秒
Adreno GPU FP32 ~420 MB ~85 ms ~12 句/秒 中高
Kryo CPU FP32 ~420 MB ~180 ms ~6 句/秒

结论INT8量化 + Hexagon NPU 是最佳部署方案,延迟降至CPU的1/15,模型体积压缩至1/4。

7.2 量化前后精度对比

建议编写脚本对比量化前后的模型精度,确保满足业务要求。

from sklearn.metrics import classification_report

def evaluate_model(session, tokenizer, test_data, label_map):
    predictions = []
    labels = []
    for text, label in test_data:
        encoded = tokenizer(text, max_length=128, padding="max_length", truncation=True, return_tensors="np")
        outputs = session.run(["logits"], {
            "input_ids": encoded["input_ids"].astype(np.int64),
            "attention_mask": encoded["attention_mask"].astype(np.int64),
            "token_type_ids": encoded["token_type_ids"].astype(np.int64)
        })
        pred = np.argmax(outputs[0], axis=-1)[0]
        predictions.append(pred)
        labels.append(label)

    print(classification_report(labels, predictions, target_names=list(label_map.values())))
    return accuracy_score(labels, predictions)

# ... 加载FP32和INT8的session ...
acc_fp32 = evaluate_model(session_fp32, tokenizer, test_data, label_map)
acc_int8 = evaluate_model(session_int8, tokenizer, test_data, label_map)
print(f"FP32精度: {acc_fp32:.4f}, INT8精度: {acc_int8:.4f}, 差异: {abs(acc_fp32 - acc_int8):.4f}")

八、常见调优策略

8.1 量化前后精度对比脚本

import numpy as np
from sklearn.metrics import accuracy_score, f1_score, classification_report
def evaluate_model(session, tokenizer, test_data, label_map):
    predictions = []
    labels = []

    for text, label in test_data:
        encoded = tokenizer(
            text, max_length=128, padding="max_length",
            truncation=True, return_tensors="np"
        )
        outputs = session.run(["logits"], {
            "input_ids": encoded["input_ids"].astype(np.int64),
            "attention_mask": encoded["attention_mask"].astype(np.int64),
            "token_type_ids": encoded["token_type_ids"].astype(np.int64)
        })
        pred = np.argmax(outputs[0], axis=-1)[0]
        predictions.append(pred)
        labels.append(label)
    
    print(classification_report(labels, predictions, target_names=list(label_map.values())))
    return accuracy_score(labels, predictions)

分别加载 FP32 和 INT8 模型进行对比
acc_fp32 = evaluate_model(session_fp32, tokenizer, test_data, label_map)
acc_int8 = evaluate_model(session_int8, tokenizer, test_data, label_map)
print(f"FP32 精度: {acc_fp32:.4f}")
print(f"INT8 精度: {acc_int8:.4f}")
print(f"精度差异: {abs(acc_fp32 - acc_int8):.4f}")

8.2 常见调优策略

如果INT8量化后精度下降超过预期,可以尝试以下策略:

  1. 混合精度量化:对Embedding层和最后的分类头保持FP16,仅对Transformer Encoder层使用INT8。
  2. 增加校准数据:确保校准数据覆盖了各类别的典型样本,更具代表性。
  3. 量化感知训练(QAT):在训练阶段模拟量化噪声,让模型提前适应,从源头提升量化后精度。
  4. 调整量化粒度:从per-tensor量化切换到更精细的per-channel量化。
Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐