4、高通AI Engine Direct (QNN) 入门:QNN SDK的架构概览,理解QNN Context、Graph、Tensor的概念,编写第一个QNN推理程序。

好,咱们进入正题。前面几章我们把高通平台的开发环境、编译工具链都捋了一遍。从这章开始,终于要碰最核心的东西——QNN (Qualcomm Neural Networks) SDK。

说实话,我第一次接触QNN的时候,也被它那一堆概念搞得有点晕。什么Context、Graph、Tensor,听着像深度学习框架,但又不太一样。别急,咱们一个一个拆开看。

4.1 QNN SDK 整体架构

先看一张图,把QNN的骨架搭起来。

QNN SDK 架构概览图 QNN SDK 架构概览 用户应用程序 (Your App) QNN API (C/C++ 接口) 核心抽象层 (Context / Graph / Tensor) 管理模型加载、执行图、张量数据 后端调度层 (Backend Dispatch) 自动选择 HTP / GPU / DSP / CPU 后端 硬件执行层 (Hexagon / Adreno / Kryo) HTP (AI加速器) / GPU / DSP / CPU 说明:用户只需调用 QNN API,后端选择和硬件调度由 SDK 自动完成

这张图我建议你多看两眼。QNN的架构其实很清晰:上层API → 核心抽象 → 后端调度 → 硬件执行。你写代码的时候,主要跟中间两层打交道。

关键点:QNN 不是直接操作硬件的。它通过一套统一的 API,把不同硬件(HTP、GPU、DSP)的差异屏蔽掉了。你写的推理代码,在骁龙 8 Gen 2 和 8 Gen 3 上都能跑,只是后端自动选择最优硬件。

4.2 三个核心概念:Context、Graph、Tensor

这三个东西,是QNN编程的基石。我当年刚上手时,花了整整两天才把它们的生命周期理清楚。咱们一个一个说。

4.2.1 QNN Context(上下文)

Context 是什么? 说白了,它是整个推理任务的容器。你加载模型、创建计算图、分配张量,都得在某个 Context 里做。

我个人习惯把 Context 想象成一个沙箱。每个 Context 有自己的资源、自己的后端配置、自己的生命周期。你可以在一个进程里创建多个 Context,它们互不干扰。

属性 说明
生命周期 从 QnnContext_create() 到 QnnContext_free()
后端绑定 每个 Context 可以指定使用哪个后端(如 HTP、GPU)
资源隔离 不同 Context 的 Graph 和 Tensor 互不共享
典型用途 一个 Context 跑一个模型,或者多个模型共享一个 Context

我的经验:如果你同时跑多个模型,建议每个模型单独创建 Context。这样某个模型崩溃时,不会把其他模型也带崩。我曾经在一个项目里把所有模型塞进一个 Context,结果一个模型内存泄漏,整个进程挂了……从那以后,我再也不偷懒了。

4.2.2 QNN Graph(计算图)

Graph 就是模型的计算流程。它由一系列节点(算子)和边(张量流动)组成。你从 ONNX 或 TFLite 导出的模型,加载到 QNN 后,就会变成一个 Graph。

Graph 有几个关键操作:

  • 创建:从模型文件或内存中加载,生成计算图
  • 配置:设置输入输出张量的形状、数据类型
  • 执行:传入输入数据,触发前向推理
  • 销毁:释放图占用的资源

嗯,这里要注意:Graph 是只读的。你创建好之后,不能动态修改它的结构。想改?重新加载模型吧。

4.2.3 QNN Tensor(张量)

Tensor 就是数据容器。在 QNN 里,Tensor 分为三种:

类型 作用 典型数据
Input Tensor 存放输入数据 图像、音频、文本向量
Output Tensor 存放推理结果 分类概率、检测框、特征向量
Intermediate Tensor 中间计算结果(内部使用) 卷积特征图、激活值

Tensor 的属性包括:名称、维度、数据类型、内存地址。你写代码时,主要操作 Input 和 Output Tensor。

避坑指南:我曾经在设置 Tensor 维度时,把 CHW 和 HWC 搞反了。模型跑出来的结果全是乱码。排查了整整一个下午才发现是维度顺序的问题。记住:QNN 默认使用 NHWC(Batch, Height, Width, Channels),和 TensorFlow 一致。如果你从 PyTorch(NCHW)转模型,一定要做维度转换。

4.3 编写第一个 QNN 推理程序

理论说完了,咱们动手写代码。下面是一个完整的 QNN 推理流程,我把它拆成 5 步。

4.3.1 初始化 QNN 后端

#include "QnnInterface.h"
#include "QnnTypes.h"

// 1. 加载后端库
QnnInterface_t* qnnInterface = nullptr;
Qnn_ErrorHandle_t err = QnnInterface_loadFromPath(
    "/usr/lib/libQnnHtp.so",  // HTP 后端库路径
    &qnnInterface
);
if (err != QNN_SUCCESS) {
    // 处理错误
}

4.3.2 创建 Context

// 2. 创建 Context
Qnn_ContextHandle_t context = nullptr;
QnnContext_createConfig_t config = QNN_CONTEXT_CREATE_CONFIG_INIT;
config.profileEvents = false;  // 关闭性能分析

err = qnnInterface->contextCreate(
    &context,
    &config
);
if (err != QNN_SUCCESS) {
    // 处理错误
}

4.3.3 加载模型并创建 Graph

// 3. 从文件加载模型,创建 Graph
QnnGraph_Handle_t graph = nullptr;
QnnGraph_createConfig_t graphConfig = QNN_GRAPH_CREATE_CONFIG_INIT;

err = qnnInterface->graphCreate(
    context,
    "my_first_graph",
    &graphConfig,
    &graph
);

// 加载序列化模型(假设是 QNN 的 .bin 格式)
err = qnnInterface->graphLoadFromBinary(
    graph,
    modelBuffer,   // 模型数据指针
    modelSize      // 模型数据大小
);

4.3.4 准备输入输出 Tensor

// 4. 获取输入输出 Tensor 信息
Qnn_Tensor_t inputTensor, outputTensor;
uint32_t inputCount = 0, outputCount = 0;

qnnInterface->graphGetInputTensors(graph, &inputTensor, &inputCount);
qnnInterface->graphGetOutputTensors(graph, &outputTensor, &outputCount);

// 假设输入是 224x224 的 RGB 图像
inputTensor.type = QNN_TENSOR_TYPE_INPUT;
inputTensor.rank = 4;
inputTensor.dimensions[0] = 1;   // batch
inputTensor.dimensions[1] = 224; // height
inputTensor.dimensions[2] = 224; // width
inputTensor.dimensions[3] = 3;   // channels
inputTensor.dataType = QNN_DATATYPE_FLOAT_32;

// 分配内存
float* inputData = (float*)malloc(1 * 224 * 224 * 3 * sizeof(float));
inputTensor.clientBuf.data = inputData;
inputTensor.clientBuf.dataSize = 1 * 224 * 224 * 3 * sizeof(float);

4.3.5 执行推理并获取结果

// 5. 执行推理
err = qnnInterface->graphExecute(
    graph,
    &inputTensor,
    1,           // 输入数量
    &outputTensor,
    1            // 输出数量
);

if (err == QNN_SUCCESS) {
    // 读取输出结果
    float* outputData = (float*)outputTensor.clientBuf.data;
    // 处理 outputData...
}

// 6. 清理资源
free(inputData);
qnnInterface->graphDestroy(graph);
qnnInterface->contextFree(context);
qnnInterface->interfaceFree();

完整流程总结:加载后端 → 创建 Context → 创建 Graph → 准备 Tensor → 执行推理 → 清理资源。这个流程在所有 QNN 项目里都是通用的,只是模型加载方式和 Tensor 配置会变。

4.4 常见问题与调试技巧

写第一个程序时,你大概率会遇到下面这些问题。我帮你提前踩过坑了。

问题 原因 解决方法
加载后端失败 库路径不对,或缺少依赖 检查 LD_LIBRARY_PATH,确认 libQnnHtp.so 存在
Graph 创建失败 模型文件损坏或不兼容 用 qnn-model-tool 验证模型文件
推理结果全零 输入数据未正确填充,或 Tensor 维度错误 打印输入数据前几个值,确认数据有效
内存泄漏 未释放 Tensor 或 Context 使用 valgrind 检查,确保每个 create 对应一个 free

调试小技巧:QNN 提供了 QNN_LOG_LEVEL 环境变量。设置 export QNN_LOG_LEVEL=3 可以打印详细的调试日志,包括每个算子的执行时间、内存分配情况。我第一次调通推理程序,就是靠这个日志定位到 Tensor 维度设置错误的。

好了,这一章的内容就到这里。你理解了 Context、Graph、Tensor 这三个核心概念,也亲手写了一个完整的推理程序。接下来,你可以试着换一个模型(比如 MobileNet 或 YOLO),按照同样的流程跑一遍。相信我,多跑几个模型,这些概念就刻在脑子里了。