LLM QNN 离线导出流程

按当前仓库代码梳理 MNN LLM QNN 模型从 llm.mnn 到 qnn/*.bin 的离线导出过程

LLM QNN 离线导出流程

本文记录 transformers/llm/export/npu/generate_llm_qnn.py 将 MNN 格式模型导出到 QNN 后端的链路。主要把 llm.mnn / llm.mnn.weight 文件转成 QNN 运行需要的两类产物:

  • qnn/llm.mnn:已经插入 OpType_Plugin(type="QNN") 的主模型;
  • qnn/*.bin:QNN HTP context binary,运行时由 QNN Plugin 直接加载执行。

整体流程分为四步:

1
2
3
4
5
6
generate_llm_qnn.py
  -> convert(args)
    -> makeIO(args)
    -> seperate(args)
    -> compile_qnn(args)
    -> output_qnn(args)

1. 入口参数

脚本入口在 transformers/llm/export/npu/generate_llm_qnn.py,常用参数如下:

参数 说明
--model MNN LLM 模型目录
--soc_id QNN 目标 SoC id
--dsp_arch QNN DSP 架构,例如 v75v79
--mnn_path MNN build 目录,需要包含 generateLlmIOcompilefornpu
--cache_path 临时工作目录,默认 tmp
--chunk_size 写入 config_qnn.json 的 prefill shape,默认 128

脚本大量通过 os.getcwd() 拼路径,所以运行目录很重要。常见执行方式是从仓库根目录进入导出目录后运行。soc_iddsp_arch 需要和目标 SoC / Hexagon 版本对应,可以参考 Qualcomm 官方设备信息

1
2
3
4
5
6
7
8
cd ${MNN_ROOT}
cd transformers/llm/export

python3 npu/generate_llm_qnn.py \
  --model /data/HF_MODELS/Qwen3-1.7B-MNN \
  --soc_id 69 \
  --dsp_arch v79 \
  --mnn_path ../../../build/

模型目录至少需要这些文件,可以直接使用官方提供的模型 Qwen3-1.7B-MNN

1
2
3
4
5
6
Qwen3-1.7B-MNN/
├── config.json
├── llm_config.json
├── tokenizer.txt
├── llm.mnn
└── llm.mnn.weight

npu_convert.py 还会读取 QNN_SDK_ROOT,所以开发机环境里需要先配置 QAIRT / QNN SDK。

2. Step1:生成 IO 样本

LLM QNN 导出从 convert(args) 方法开始。Step1 对应的是 makeIO(args)

1
2
3
4
5
6
# transformers/llm/export/npu/generate_llm_qnn.py:67
def convert(args):
    cache = os.path.join(os.getcwd(), args.cache_path)
    os.makedirs(cache, exist_ok=True)
    print("Step1: Make IO")
    makeIO(args)

makeIO(args) 本身没有解析 llm_config.json,它只是拼出可执行文件 generateLlmIO 的路径(由主库编译产生,详见 QNN 模型导出),并把模型目录、输出目录和 chunk_size 传进去:

1
2
3
4
5
# transformers/llm/export/npu/generate_llm_qnn.py:10
def makeIO(args):
    exe = os.path.join(os.getcwd(), args.mnn_path, "generateLlmIO")
    output = os.path.join(args.cache_path, 'testdir')
    print(os.popen(exe + " " + args.model + " " + output + ' %d' %args.chunk_size).read())

所以 Step1 实际执行的命令可以展开成:

1
2
3
4
5
<cwd>/<mnn_path>/generateLlmIO \
  <model_dir> \
  tmp/testdir \
  <chunk_size>
# 如果没有 tmp/testdir 目录,可能需要手动创建

这里 <model_dir> 是整个 MNN LLM 模型目录,不是单独的 llm.mnn 文件。llm_config.jsonllm.mnnllm.mnn.weight 等文件会由 generateLlmIO 在模型目录内部继续读取。

generate_llm_qnn.py 只关心两个后续会被 compilefornpu 读取的样本目录:

用途 样本目录
prefill testdir/<chunk_size>
decode testdir/1

生成结果会落在 tmp/testdir 下,后续 compilefornpu 会按 testdir/1testdir/<chunk_size> 读取这些输入输出样本。

生成结果包含两组不同长度的输入输出样本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export git:(master) ✗ ../../../build/generateLlmIO /data/HUGGINGFACE/Qwen3-1.7B-Eagle3-MNN tmp/testdir 128
blockSize=128 in main, 148
modelPath.c_str()=s /data/HUGGINGFACE/Qwen3-1.7B-Eagle3-MNN/llm.mnn in main, 152
llmConfigPath.c_str()=s /data/HUGGINGFACE/Qwen3-1.7B-Eagle3-MNN/llm_config.json in main, 153
Can't open file:/sys/devices/system/cpu/cpufreq/boost/affected_cpus
Can't open file:/sys/devices/system/cpu/cpufreq/ondemand/affected_cpus
CPU Group: [ 95  17  83  55  118  27  93  65  5  37  75  47  19  85  57  29  108  67  7  39  101  10  77  49  111  20  87  59  121  30  97  69  33  112  21  88  122  31  98  41  104  13  51  114  23  61  1  124  9  71  43  106  15  81  53  116  25  91  63  3  126  35  73  45  94  117  26  92  64  4  127  36  74  46  109  18  84  56  119  28  54  66  6  38  100  76  48  110  86  58  120  96  68  8  102  11  105  40  103  12  79  50  113  22  89  60  0  123  32  99  70  42  78  14  80  52  115  24  90  62  2  125  34  72  44  107  16  82 ], 1500000 - 2900000
The device supports: i8sdot:0, fp16:0, i8mm: 0, sve2: 0, sme2: 0
Successfully generate tmp/testdir/128/input.mnn and tmp/testdir/128/output.mnn.
Successfully generate tmp/testdir/1/input.mnn and tmp/testdir/1/output.mnn.

输入由 createInputsForLLM(...) 构造,输出由这些输入跑一次 LLM 网络前向得到:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// transformers/llm/engine/tools/generateLlmIO.cpp:117
{
    std::vector<MNN::Express::VARP> inputs;
    std::vector<MNN::Express::VARP> outputs;
    createInputsForLLM(blockSize, hiddenSize, attentionMaskType, false, inputs);
    outputs = net->onForward(inputs);
    saveInputOutputs(net->getInfo(), inputs, outputs, outputDir, blockSize);
}

// transformers/llm/engine/tools/generateLlmIO.cpp:125
{
    std::vector<MNN::Express::VARP> inputs;
    std::vector<MNN::Express::VARP> outputs;
    createInputsForLLM(1, hiddenSize, attentionMaskType, true, inputs);
    outputs = net->onForward(inputs);
    saveInputOutputs(net->getInfo(), inputs, outputs, outputDir, 1);
}

3. Step2:切图

convert(args) 的 Step2 直接调用 seperate(args)

1
2
3
# transformers/llm/export/npu/generate_llm_qnn.py:76
print("Step2: Seperate Model")
seperate(args)

seperate(args) 先定位可执行文件 compilefornpu(由主库编译产生,详见 QNN 模型导出)和原始 llm.mnn,再写一个临时 qnn.json。这里的 seperate 是源码里的函数名拼写,文档里保持不改:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# transformers/llm/export/npu/generate_llm_qnn.py:15
def seperate(args):
    exe = os.path.join(os.getcwd(), args.mnn_path, "compilefornpu")
    model = os.path.join(os.getcwd(), args.model, 'llm.mnn')
    config = {
        "type":"QNN",
        "skips":[],
        "testdir":[],
        "cache":"qnn"
    }
    config['testdir'].append(os.path.join("testdir", '1'))
    config['testdir'].append(os.path.join("testdir", '%d' %args.chunk_size))

写出的配置内容对应下面这样:

1
2
3
4
5
6
7
8
9
{
  "type": "QNN",
  "skips": [],
  "testdir": [
    "testdir/1",
    "testdir/<chunk_size>"
  ],
  "cache": "qnn"
}

然后它在 tmp 目录下执行 compilefornpu

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# transformers/llm/export/npu/generate_llm_qnn.py:29
cache = os.path.join(os.getcwd(), args.cache_path)
with open(os.path.join(cache, 'qnn.json'), 'w') as f:
    f.write(json.dumps(config, indent=4))

process = subprocess.Popen(
    exe + ' ' + model + ' qnn/llm.mnn qnn.json',
    cwd = cache,
    shell=True
)

命令展开后是:

1
2
3
4
<build>/compilefornpu \
  <model_dir>/llm.mnn \
  qnn/llm.mnn \
  qnn.json

compilefornpu 的入口在 tools/cpp/compilefornpu.cpp。这一步会在原图上做三件事:

  • 先根据输入输出 tensor 找出真正需要计算的 op;
  • 再把连续可下沉的 op 区间整理成 SubModuleInfo
  • 最后把每个可下沉区间替换成一个 OpType_Plugin(type="QNN")

代码调用关系如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
generate_llm_qnn.py
└── seperate(args)                                      # 生成切图配置并启动 compilefornpu
    ├── 写 tmp/qnn.json                                # 告诉 compilefornpu 目标后端和测试 shape
    │   ├── type = "QNN"
    │   ├── testdir = ["testdir/1", "testdir/<chunk_size>"]
    │   └── cache = "qnn"
    └── subprocess.Popen(...)
        └── compilefornpu <model_dir>/llm.mnn qnn/llm.mnn qnn.json
            ├── main(argc, argv)                       # 切图工具主入口
            │   ├── 解析 qnn.json                      # 初始化 QNN 离线导出参数
            │   │   ├── gNPUType = MNN_CONVERT_QNN
            │   │   ├── gNeedOffline = true
            │   │   └── gCacheDir = "qnn"
            │   ├── 读取 testdir/*/input.mnn 和 output.mnn # 取得 decode / prefill 两组输入输出
            │   ├── Interpreter::createFromFile(srcMNN)    # 读取原始 llm.mnn
            │   ├── initConstTensorsNoAlloc(...)           # 标记常量 tensor
            │   └── _createSubModuleInfo(...)              # 生成候选子图列表
            │       ├── _collectNeededOps(...)             # 从输出反向追依赖 op
            │       ├── isBreakOp(...)                     # 遇到控制流 / 不支持 op 就切断
            │       ├── _splitSubModuleForShapeConst(...)  # shape 依赖输入内容时补切
            │       └── _computeTensorMask(...)            # 计算子图输入输出边界
            ├── for 每组 testdir 输入 shape                  # 分别处理 decode / prefill
            │   ├── _getSubModuleIO(...)                    # 跑原图收集子图参考 IO
            │   │   └── 记录 KV state shape                 # KV 下沉时写 Plugin state
            │   ├── 过滤不含 Convolution 的子图              # 当前导出只保留卷积子图
            │   └── _compileSubModule(...)                  # 编译子图并生成 Plugin op
            │       ├── RuntimeManager(type = MNN_CONVERT_QNN)
            │       ├── setCache("qnn/graph*")              # 指定 QNN graph 源目录
            │       ├── Module::load(...)
            │       ├── Module::onForward(...)              # 触发 QNN convert backend 导出 graph
            │       └── 生成 OpType_Plugin(type="QNN")      # 用一个 Plugin 表示整段子图
            ├── 用 Plugin 替换原始连续 op 区间               # 改写主图
            ├── _reIndexTensor(...) / _reOrderOp(...)       # 清理 tensor 并重排拓扑
            ├── 多 shape 结果用 _fuse(...) 合并 Plugin attr  # 合并 decode / prefill graph 信息
            ├── 写 qnn/llm.mnn                              # 插入 QNN Plugin 后的主模型
            └── 写 npu_postreat.json                        # 给 npu_convert.py 继续生成 context binary
                └── merge: qnn/graph*.bin -> [qnn/graph*, qnn/graph1_*, ...]

3.1 QNN 离线模式

compilefornpu 的命令格式是:

1
2
3
4
5
6
7
8
// tools/cpp/compilefornpu.cpp:836
int main(int argc, const char* argv[]) {
    if (argc < 3) {
        MNN_PRINT("Usage: ./compilefornpu src.mnn dst.mnn npu.json\n");
        return 0;
    }
    const char* srcMNN = argv[1];
    const char* dstMNN = argv[2];

qnn.json 里写的是 "type": "QNN" 时,代码会把后端类型设成 MNN_CONVERT_QNN,并打开 gNeedOffline

1
2
3
4
5
6
7
8
9
// tools/cpp/compilefornpu.cpp:859
gNPUName = document["type"].GetString();
if (gNPUName == "QNN") {
    MNN_PRINT("Convert for QNN, QualComn's NPU\n");
    gNPUType = MNN_CONVERT_QNN;
    gNeedOffline = true;
    gOfflieSrc = "";
    gOfflieDst = "bin";
}

gNeedOffline = true 之后,_compileSubModule(...) 会通过一次 Module::onForward() 触发 QNN convert 后端导出 graph 材料。

3.2 读取 testdir

generate_llm_qnn.py 写入了两组 testdir

1
2
3
4
"testdir": [
  "testdir/1",
  "testdir/<chunk_size>"
]

compilefornpu 会逐个读取里面的 input.mnnoutput.mnn

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// tools/cpp/compilefornpu.cpp:898
if (document.HasMember("testdir")) {
    auto testdir = document["testdir"].GetArray();
    for (auto iter = testdir.Begin(); iter != testdir.End(); iter++) {
        std::string dirname = iter->GetString();
        auto subinputs = MNN::Express::Variable::load((dirname + "/input.mnn").c_str());
        inputs.emplace_back(subinputs);
        inputNames.clear();
        for (int i=0; i<subinputs.size(); ++i) {
            inputNames.emplace_back(subinputs[i]->name());
        }
        auto outputs = MNN::Express::Variable::load((dirname + "/output.mnn").c_str());
        outputNames.clear();
        for (int i=0; i<outputs.size(); ++i) {
            outputNames.emplace_back(outputs[i]->name());
        }
    }
}

这里的 input.mnn / output.mnn 提供了:

  • 主图输入名;
  • 主图输出名;
  • decode / prefill 两组真实输入 shape 和数据;

3.3 生成候选子图列表

compilefornpu 使用 _createSubModuleInfo(...) 生成需要转换的候选子图列表,流程如下。

3.3.1 反向收集 op

切图的第一步是 _collectNeededOps(...)。它先给输入和输出 tensor 打标记,再从 outputIndexes 反向追溯数据依赖,直到遇到 inputIndexes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// tools/cpp/compilefornpu.cpp:261
static std::vector<SubModuleInfo> _createSubModuleInfo(
    const Net* net,
    const std::set<int>& inputIndexes,
    const std::set<int>& outputIndexes,
    const std::set<int>& noComputeIndexes,
    std::shared_ptr<Schedule::ScheduleInfo> sharedConst) {
    std::vector<SubModuleInfo> submodule;
    auto selectOps = _collectNeededOps(net, inputIndexes, outputIndexes);
    // ...
}

// tools/cpp/compilefornpu.cpp:116
static std::vector<int> _collectNeededOps(
    const MNN::Net* net,
    const std::set<int>& inputIndexes,
    const std::set<int>& outputIndexes) {
    // 0: not set, 1: output, 2:input
    std::vector<int> tensorMask(net->tensorName()->size());
    ...
    for (auto v : outputIndexes) {
        tensorMask[v] = 1;
    }
    for (auto v : inputIndexes) {
        tensorMask[v] = 2;
    }

接着沿着输出到输入的方向反复传播标记:如果某个 op 的输出已经被标成需要保留,就把这个 op 标成需要计算,并继续把它的输入 tensor 标成依赖项:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// tools/cpp/compilefornpu.cpp:134
do {
    change = false;
    for (int i=0; i<opMask.size(); ++i) {
        ...
        if (nullptr != op->outputIndexes()) {
            for (int j=0; j<op->outputIndexes()->size(); ++j) {
                auto index = op->outputIndexes()->data()[j];
                if (tensorMask[index] == 1) {
                    opMask[i] = 1;
                    change = true;
                }
            }
        }
        if (nullptr != op->inputIndexes() && opMask[i]) {
            for (int j=0; j<op->inputIndexes()->size(); ++j) {
                auto index = op->inputIndexes()->data()[j];
                if (tensorMask[index] != 2) {
                    tensorMask[index] = 1;
                }
            }
        }
    }
} while (change);

3.3.2 按 break op 切图

_createSubModuleInfo(...) 会按原图 op 顺序扫描上一步采集的算子 selectOps:普通 op 缓存到 current.opList;遇到 break op,就先把前面的连续算子保存成一个子模块,再把这个 break op 单独保存成一个 SubModuleInfo

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// tools/cpp/compilefornpu.cpp:267
for (int si=0; si<selectOps.size(); ++si) {
    auto i = selectOps[si];
    auto op = net->oplists()->GetAs<Op>(i);
    if (isBreakOp(op)) {
        if (current.opList.size() > 0) {
            submodule.emplace_back(std::move(current));
        }
        SubModuleInfo controlOp;
        controlOp.opList = {i};
        controlOp.isBreak = true;
        ...
        submodule.emplace_back(std::move(controlOp));
        continue;
    }
    current.opList.emplace_back(i);
}

isBreakOp(...) 的判别规则是,控制流算子属于 break op,例如 OpType_WhileOpType_If 等:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// tools/cpp/compilefornpu.cpp:102
static bool isBreakOp(const Op* op) {
    bool isWhileControlflow = false;
    if (op->type() == OpType_While && op->main_as_WhileParam() != nullptr) {
        isWhileControlflow = true;
    }
    if (op->type() == OpType_If || isWhileControlflow || op->type() == OpType_Where || op->type() == OpType_Segment || op->type() == OpType_Unique || op->type() == OpType_NonMaxSuppressionV2) {
        return true;
    }
    if (!_npuSupportOp(op)) {
        return true;
    }
    return false;
}

对当前 QNN LLM 导出,需要注意 Attention + kv_cache。默认配置下,使用 KV cache 的注意力算子会被 _npuSupportOp(...) 判成不支持,随后被 isBreakOp(...) 切断:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// tools/cpp/compilefornpu.cpp:89
static bool _npuSupportOp(const Op* op) {
    if (gMaxKVSize > 0) {
        return true;
    }
    if (op->type() == OpType_Attention) {
        auto attn = op->main_as_AttentionParam();
        if (nullptr != attn && attn->kv_cache()) {
            return false;
        }
    }
    return true;
}

3.3.3 补切动态 shape

第一次切图主要看 op 类型。接下来还会用 _splitSubModuleForShapeConst(...) 做第二次切分:

1
2
// tools/cpp/compilefornpu.cpp:295
submodule = _splitSubModuleForShapeConst(submodule, net, sharedConst);

这一步处理的是“shape 推导依赖输入内容”的 op。判断入口在 _findBreakIndex(...)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// tools/cpp/compilefornpu.cpp:214
int inputNum = op->inputIndexes()->size();
auto dims = SizeComputer::needInputContent(op, inputNum);
for (auto index : dims) {
    if (index < inputNum) {
        if (constMask[op->inputIndexes()->data()[index]] != 1) {
            res.emplace_back(v);
            break;
        }
    }
}

含义是:如果某个 op 的 shape 计算需要读取输入 tensor 的内容,并且这个输入不是常量,就把这个 op 作为切分点。这样可以避免把动态 shape / 内容相关 shape 逻辑直接塞进 NPU 子图里。

3.3.4 计算子图边界

SubModuleInfo 里真正描述切图边界的是:

1
2
3
4
5
6
7
8
// tools/cpp/compilefornpu.cpp:56
struct SubModuleInfo {
    std::vector<int> opList;
    std::vector<int> inputs;
    std::vector<int> outputs;
    std::vector<int> tensorMask;
    bool isBreak = false;
};

每个连续子图的 inputs / outputs 都是根据子图内部 op 的 tensor 使用情况计算出来。_computeTensorMask(...) 会先给 tensor 打标记,含义如下:

1
2
3
1: 这个 tensor 被当前子图消费,是子图输入
2: 这个 tensor 由当前子图产生,是子图输出
3: 这个 tensor 既被当前子图消费又由当前子图产生,是子图内部中间 tensor

对应代码在 _createSubModuleInfo(...) 里:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// tools/cpp/compilefornpu.cpp:299
if (!m.isBreak) {
    _computeTensorMask(m, net);
    for (int i=0; i<m.tensorMask.size(); ++i) {
        if (1 == m.tensorMask[i]) {
            if (noComputeIndexes.find(i) != noComputeIndexes.end()) {
                continue;
            }
            m.inputs.emplace_back(i);
            continue;
        }
        if (2 == m.tensorMask[i]) {
            m.outputs.emplace_back(i);
            continue;
        }
        if (3 == m.tensorMask[i]) {
            if (outputIndexes.find(i) != outputIndexes.end()) {
                m.outputs.emplace_back(i);
            }
        }
    }
}

产生的边界规则可以写成:

  • 子图消费、但不是子图内部产生的 tensor,是子图输入;
  • 子图产生、并且需要给后续 op 或最终输出使用的 tensor,是子图输出;
  • 子图内部产生又内部消费的 tensor,是中间 tensor,默认不暴露;
  • 如果某个中间 tensor 被后续子图需要,代码会把它补进前一个子图的 outputs,对应逻辑如下:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// tools/cpp/compilefornpu.cpp:348
for (int sub=0; sub < moduleIndex; ++sub) {
    if (submodule[sub].tensorMask.empty()) {
        continue;
    }
    if (submodule[sub].tensorMask[index] == 2) {
        find = true;
        break;
    }
    if (submodule[sub].tensorMask[index] == 3) {
        submodule[sub].outputs.emplace_back(index);
        submodule[sub].tensorMask[index] = 2;
        find = true;
        break;
    }
}

compilefornpu 切图时不按层数、名字或者固定 pattern 切分,而是按 tensor 的数据依赖关系确定子图边界。

3.4 过滤非卷积子图

_createSubModuleInfo(...) 得到的是候选子图。还有一个过滤条件:如果候选子图里没有 OpType_Convolution,就重新标记成 isBreak = true

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// tools/cpp/compilefornpu.cpp:1068
bool hasConvolution = false;
for (auto opIndex : subModulesInfo[i].opList) {
    auto op = net->oplists()->GetAs<Op>(opIndex);
    if (op->type() == OpType_Convolution) {
        hasConvolution = true;
        break;
    }
}
if (!hasConvolution) {
    subModulesInfo[i].isBreak = true;
}

这个过滤的效果是:只转换含有卷积算子的子图,其他子图直接保留在主图中。

3.5 收集子图 IO

切图完成后,主流程会按每一组 testdir 输入跑所有子图:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// tools/cpp/compilefornpu.cpp:1040
for (int inputIndex=0; inputIndex < inputs.size(); ++inputIndex) {
    std::map<int, MNN::Express::VARP> stackes;
    ...
    std::vector<SubModuleIO> moduleIO(subModulesInfo.size());
    for (int i=0; i<subModulesInfo.size(); ++i) {
        auto& current = subModulesInfo[i];
        std::vector<MNN::Express::VARP> subInputs;
        for (auto index : current.inputs) {
            subInputs.emplace_back(stackes[index]);
        }
        moduleIO[i] = _getSubModuleIO(subInputs, current, bufferPair.first, bufferPair.second, srcMNN);
        for (int j=0; j<current.outputs.size(); ++j) {
            stackes.insert(std::make_pair(current.outputs[j], moduleIO[i].outputs[j]));
        }
    }

这里的 stackes 是一个 tensor index -> VARP 表。每跑完一个子图,就把它的输出放回 stackes,给后续子图继续使用。

_getSubModuleIO(...) 会用原始 MNN 模型加载当前子图的输入输出名,然后跑 CPU/default 路径得到参考输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// tools/cpp/compilefornpu.cpp:399
static SubModuleIO _getSubModuleIO(
    std::vector<MNN::Express::VARP> inputs,
    const SubModuleInfo& info,
    const void* buffer,
    size_t bufferSize,
    std::string srcpath) {
    ...
    std::shared_ptr<MNN::Express::Module> m(
        MNN::Express::Module::load(inputNames, outputNames, (const uint8_t*)buffer, bufferSize, rtmgr),
        MNN::Express::Module::destroy);
    ...
    auto outputs = m->onForward(inputs);
    io.inputs = inputs;
    io.outputs.resize(outputs.size());
    for (int i=0; i<outputs.size(); ++i) {
        io.outputs[i] = MNN::Express::_Clone(outputs[i], true);
    }
    return io;
}

如果子图里有带 kv_cacheAttention,这里还会通过 callback 记录 KV state 的形状:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// tools/cpp/compilefornpu.cpp:419
MNN::TensorCallBackWithInfo beforeCallBack = [&](const std::vector<MNN::Tensor*>& ntensors, const MNN::OperatorInfo* info) {
    auto opName = info->name();
    if (info->type() != "Attention") {
        return true;
    }
    if (attentionNames.find(opName) != attentionNames.end()) {
        auto query = ntensors[0];
        auto key = ntensors[1];
        auto value = ntensors[2];
        int seq_len = query->length(1);
        auto numHead = query->length(2);
        auto headDim = query->length(3);
        auto kvNumHead = key->length(2);
        std::vector<int> kvDims = {kvNumHead, 1, 1, headDim};
        io.kvcache.emplace_back(kvDims);
    }
    return true;
};

3.6 编译并替换 Plugin

真正触发 QNN 导出的是 _compileSubModule(...)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// tools/cpp/compilefornpu.cpp:450
static std::unique_ptr<MNN::OpT> _compileSubModule(
    const SubModuleIO& io,
    SubModuleInfo& info,
    const void* buffer,
    size_t bufferSize,
    const std::string& path,
    std::string srcpath,
    const std::string& targetNpuPath,
    float& cpuTotal,
    float& npuTotal,
    int shapeIndex,
    std::string graphicName) {

该方法的核心调用在下面这段,其中 config.type = MNN_CONVERT_QNN

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// tools/cpp/compilefornpu.cpp:470
MNN::ScheduleConfig config;
config.type = gNPUType;
std::shared_ptr<MNN::Express::Executor::RuntimeManager> rtmgr(
    MNN::Express::Executor::RuntimeManager::createRuntimeManager(config));
rtmgr->setExternalFile((srcpath + ".weight").c_str());
rtmgr->setCache(path.c_str());
rtmgr->setHint(MNN::Interpreter::KVCACHE_SIZE_LIMIT, gMaxKVSize);
MNN::Express::Module::Config mdconfig;
mdconfig.shapeMutable = false;
std::shared_ptr<MNN::Express::Module> m(
    MNN::Express::Module::load(inputNames, outputNames, (const uint8_t*)buffer, bufferSize, rtmgr, &mdconfig),
    MNN::Express::Module::destroy);
auto predict = m->onForward(io.inputs);
if (gNeedOffline) {
    break;
}

这里的 onForward(io.inputs) 不只是为了拿一次输出。对于 QNN 离线导出,它的主要作用是驱动一次 StaticModule 前向,让 QNN convert 后端根据当前子图输入 shape 生成子图 graph 材料。因为 gNeedOffline == true,触发导出后会直接 break,不会继续做误差检查和测速。

导出完成后,_compileSubModule(...) 会创建一个新的 Plugin op:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// tools/cpp/compilefornpu.cpp:532
std::unique_ptr<MNN::OpT> op(new OpT);
op->inputIndexes = info.inputs;
op->outputIndexes = info.outputs;
op->name = targetNpuPath;
op->type = MNN::OpType_Plugin;
op->main.type = MNN::OpParameter_Plugin;
op->main.value = new MNN::PluginT;
auto extra = op->main.AsPlugin();
extra->type = gNPUName;

这个 Plugin 保留了原子图的输入输出 tensor index,相当于用一个 op 占住原来整段子图的输入输出边界。写入的关键 attr 包括:

attr 作用
path 最终 QNN binary 的相对路径,例如 qnn/graph0.bin
inputs Plugin 输入对应的 QNN tensor 名,一般是 t<tensor_index>
outputs Plugin 输出对应的 QNN tensor 名
allGraphName .bin 内多个 graph 的名字顺序
allInputShape 当前 graph 对应的输入 shape
o_<shapeIndex>_<outputIndex> 当前 shape 下输出 tensor 的形状、类型和 format
state KV cache state 信息,存在 KV 下沉时使用

inputs / outputs 为例,代码直接用子图边界 tensor index 生成 QNN tensor 名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// tools/cpp/compilefornpu.cpp:547
attr->key = "inputs";
attr->list.reset(new ListValueT);
attr->list->s.resize(inputNames.size());
for (int i=0; i<inputNames.size(); ++i) {
    attr->list->s[i] = std::string("t") + std::to_string(info.inputs[i]);
}

// tools/cpp/compilefornpu.cpp:625
attr->key = "outputs";
attr->list.reset(new ListValueT);
attr->list->s.resize(outputNames.size());
for (int i=0; i<outputNames.size(); ++i) {
    attr->list->s[i] = std::string("t") + std::to_string(info.outputs[i]);
}

随后发生主图替换:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// tools/cpp/compilefornpu.cpp:1124
for (int moduleIndex=0; moduleIndex<subModulesInfo.size(); ++moduleIndex) {
    auto moduleInfo = subModulesInfo[moduleIndex];
    if (moduleInfo.isBreak) {
        continue;
    }
    for (auto& index : moduleInfo.opList) {
        dstNet->oplists[index].reset();
    }
    dstNet->oplists[moduleInfo.opList[0]] = std::move(npuOps[moduleIndex]);
}

替换关系如下:

1
2
3
4
5
原始连续 op 区间:
  opA -> opB -> opC

替换后:
  Plugin(type="QNN", inputs=原区间输入, outputs=原区间输出)

然后再调用 _reIndexTensor(...)_reOrderOp(...) 清理未使用 tensor,并重新拓扑排序:

1
2
3
// tools/cpp/compilefornpu.cpp:1140
_reIndexTensor(dstNet.get());
_reOrderOp(dstNet.get());

3.7 onForward 生成 graph 材料

_compileSubModule(...) 里的 onForward(io.inputs) 走的是一次真实的 MNN 模块执行入口,但在 MNN_CONVERT_QNN 后端下,执行的重点不是计算数值结果,而是触发 QNN graph 的构建和记录。

完整调用关系可以看成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
_compileSubModule(...)
├── RuntimeManager(type = MNN_CONVERT_QNN)
├── rtmgr->setCache("qnn/graph0")
│   └── QnnRuntime::onSetCachePath(...)
│       └── QNNConvertor::OutputDir = "qnn/graph0"
├── Module::load(...)
└── m->onForward(io.inputs)
    └── StaticModule::onForward(...)
        ├── Variable::compute(inputs)
        ├── StaticModule::_resize(inputs)
        │   └── Session::resize()
        │       ├── Pipeline::encode(...)
        │       │   └── 把 ScheduleInfo 整理成 command buffer
        │       └── Pipeline::allocMemory(...)
        │           ├── QnnBackend::onResizeBegin()
        │           │   └── graphCreate(...)
        │           │       └── QNNConvertor::RecordBegin(...)
        │           ├── Execution::onResize(...)
        │           │   └── QNNCommonExecution::onEncode(...)
        │           │       ├── tensorCreateGraphTensor(...)
        │           │       │   └── QNNConvertor::RecordTensor(...)
        │           │       └── graphAddNode(...)
        │           │           └── QNNConvertor::RecordNode(...)
        │           └── QnnBackend::onResizeEnd()
        │               └── graphFinalize(...)
        │                   └── QNNConvertor::RecordEnd()
        └── StaticModule::_execute()
            └── Session::run()
                └── QNN convert 模式下 graphExecute(...) 不走真实 QNN 执行

创建 RuntimeManager 时,config.type = gNPUType。QNN 导出场景下,这个类型就是 MNN_CONVERT_QNNsetCache(path) 传入的是当前子图 graph 源目录,例如 qnn/graph0

1
2
3
4
5
6
7
// tools/cpp/compilefornpu.cpp:470
MNN::ScheduleConfig config;
config.type = gNPUType;
std::shared_ptr<MNN::Express::Executor::RuntimeManager> rtmgr(
    MNN::Express::Executor::RuntimeManager::createRuntimeManager(config));
rtmgr->setExternalFile((srcpath + ".weight").c_str());
rtmgr->setCache(path.c_str());

setCache(path) 最终会进入 QnnRuntime::onSetCachePath(...),并把这个路径记录到 QNNConvertor::OutputDir。后续所有 RecordTensor(...) / RecordNode(...) 写文件时都会落到这个目录:

1
2
3
4
5
6
7
8
9
// source/backend/qnn/backend/QNNBackend.cpp:1726
bool QnnRuntime::onSetCachePath(const char* path, int mode) {
#ifdef ENABLE_QNN_CONVERT_MODE
    MNN_ASSERT(path != nullptr);
    QNNConvertor::OutputDir = std::string(path);
    MNNCreateDir(path);
#endif
    return true;
}

onForward(io.inputs) 进入 StaticModule 后,先根据 io.inputs 里的真实输入张量更新子图输入 shape。_resize(...) 里会把外部 VARP 对应的 tensor 形状同步到 mInputTensors,一旦 shape 变化就标记 Session 需要 resize:

1
2
3
4
5
6
// express/module/StaticModule.cpp:530
if (_resizeTensor(mInputTensors[i], inputTensor, mSession.get(), nullptr)) {
    mSession->setNeedResize();
}
...
code = mSession->resize();

所以 testdir/1testdir/<chunk_size> 会在这里进入 Session::resize(),分别生成 decode / prefill 两份 graph 材料。

Session::resize() 接着会先调用 encode,再调用 allocMemory

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// source/core/Session.cpp:285
if (mNeedResize) {
    bool debug = mCallBackMode == Interpreter::Session_Debug;
    for (auto& iter : mPipelines) {
        auto error = iter->encode(debug, permitCodegen);
        ...
    }
    mNeedResize = false;
    mNeedMalloc = true;
    firstMalloc = true;
}
if (mNeedMalloc) {
    for (auto& iter : mPipelines) {
        auto error = iter->allocMemory(firstMalloc, forbidReplace);
        ...
    }
}

Pipeline::encode(...) 的作用是把 ScheduleInfo 里的 op / tensor 依赖整理成 MNN 内部的 command buffer。后续 allocMemory(...) 会基于这些 command 创建后端算子 Execution,并调用每个 ExecutiononResize(...)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// source/core/Pipeline.cpp:537
static ErrorCode _createExecutions(
    Schedule::PipelineInfo& mInfo,
    const std::string& externalFile,
    std::vector<std::shared_ptr<BufferStorage>>& extraStorage) {
    ...
    iter.execution.reset(OpCommonUtils::createExecutionWithExternal(
        mBackend.get(), iter.inputs, iter.outputs, iter.op, &loader, tmpStorage));
}

// source/core/Pipeline.cpp:962
mBackend->onResizeBegin();
...
auto code = iter.execution->onResize(iter.workInputs, iter.workOutputs);
...
auto code = mBackend->onResizeEnd();

在 QNN 后端下,这些 Execution 通常就是对应的 QNN 执行器,例如 QNNConvolution。后续会在 onResize() 里把它们编码成 QNN graph 节点。

onResize() 前后还会配合两个 backend 生命周期回调:QnnBackend::onResizeBegin() 创建 QNN graph;QnnBackend::onResizeEnd() finalize graph:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// source/backend/qnn/backend/QNNBackend.cpp:1141
void QnnBackend::onResizeBegin() {
    clean();
    createContextAndGraph();
    return;
}

// source/backend/qnn/backend/QNNBackend.cpp:1147
ErrorCode QnnBackend::onResizeEnd() {
    buildOutputCast();
    buildOutputDequant();
    finalizeGraph();
    ...
}

QNN convert 模式的“记录动作”是通过 QNN API 函数表替换实现的。QNN_INTERFACE_VER_TYPE 本身是一张函数表,QnnBackend 后续调用 graphCreate(...)graphAddNode(...)tensorCreateGraphTensor(...) 时,都是从 mQnnInterface 这张表里取函数。

在普通 QNN 推理模式下,createQnnContext() 会从真实 QNN SDK 里拿 interface provider;在 ENABLE_QNN_CONVERT_MODE 下,qnnInterface 会被设置成 MNN 自己定义的 gQnnConvertorInterface

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// source/backend/qnn/backend/QNNBackend.cpp:43
#ifndef ENABLE_QNN_CONVERT_MODE
{
    QnnInterface_t** interfaceProviders = nullptr;
    uint32_t numProviders = 0;
    if (QNN::QnnInterface_getProviders((const QnnInterface_t***)&interfaceProviders, &numProviders) != QNN_SUCCESS) {
        ...
    }
    ...
    qnnInterface = interfaceProviders[pIdx]->QNN_INTERFACE_VER_NAME;
}
#else
    qnnInterface = QNN::gQnnConvertorInterface;
#endif

QnnRuntime::create(...) 会把 gContext.interface 传进 QnnRuntime,构造函数里再保存到 mQnnInterface

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// source/backend/qnn/backend/QNNBackend.cpp:1711
QnnRuntime* QnnRuntime::create(const Backend::Info& info) {
    if (QNN::gContext.deviceHandle == nullptr){
        QNN::createQnnContext();
    }
    return new QnnRuntime(info, gContext.interface, gContext.logHandle, gContext.backendHandle, gContext.deviceHandle);
}

// source/backend/qnn/backend/QNNBackend.cpp:1635
QnnRuntime::QnnRuntime(
    const Backend::Info& info,
    QNN_INTERFACE_VER_TYPE qnnInterface,
    Qnn_LogHandle_t qnnLogHandle,
    Qnn_BackendHandle_t qnnBackendHandle,
    Qnn_DeviceHandle_t qnnDeviceHandle) {
    ...
    mQnnInterface = qnnInterface;
}

所以 QnnBackend 里的调用代码不需要区分“真实 SDK”还是“离线导出”。它仍然写成:

1
2
3
4
5
// source/backend/qnn/backend/QNNBackend.cpp:1489
CALL_QNN(mRuntime->mQnnInterface.graphAddNode(mQnnGraphHandle, opConfig));

// source/backend/qnn/backend/QNNBackend.cpp:1532
CALL_QNN(mRuntime->mQnnInterface.tensorCreateGraphTensor(mQnnGraphHandle, staticTensor));

区别只在 mQnnInterface 指向哪张函数表。gQnnConvertorInterface 把这些 QNN API 槽位填成了 convertor 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// source/backend/qnn/convertor/QNNConvertorInterface.cpp:96
QNN_INTERFACE_VER_TYPE gQnnConvertorInterface = {
    ...
    /* graphCreate */                            QnnConvertorGraph_Create,
    /* graphAddNode */                           QnnConvertorGraph_AddNode,
    /* graphFinalize */                          QnnConvertorGraph_Finalize,
    ...
    /* tensorCreateGraphTensor */                QnnConvertorTensor_CreateGraphTensor,
    ...
};

真实 QNN SDK 返回的 interface 和 gQnnConvertorInterface 是同一个类型:QNN_INTERFACE_VER_TYPE。这个类型本质上是一组函数指针槽位,只要每个槽位填进去的函数签名匹配,后续调用方就不需要知道这个函数来自真实 QNN SDK,还是来自 MNN 的 convertor。

可以用一个简化版结构理解:

1
2
3
4
5
6
struct QnnInterfaceLike {
    Qnn_ErrorHandle_t (*graphCreate)(...);
    Qnn_ErrorHandle_t (*graphAddNode)(Qnn_GraphHandle_t graphHandle, Qnn_OpConfig_t opConfig);
    Qnn_ErrorHandle_t (*graphFinalize)(...);
    Qnn_ErrorHandle_t (*tensorCreateGraphTensor)(Qnn_GraphHandle_t graph, Qnn_Tensor_t* tensor);
};

真实 QNN SDK 会提供一张类似这样的表:

1
2
3
4
5
6
QnnInterfaceLike realQnnInterface = {
    QnnGraph_Create,
    QnnGraph_AddNode,
    QnnGraph_Finalize,
    QnnTensor_CreateGraphTensor,
};

而 MNN convert 模式自己构造了一张同类型的表:

1
2
3
4
5
6
QnnInterfaceLike convertInterface = {
    QnnConvertorGraph_Create,
    QnnConvertorGraph_AddNode,
    QnnConvertorGraph_Finalize,
    QnnConvertorTensor_CreateGraphTensor,
};

QnnBackend 后续只持有 mQnnInterface,调用时等价于:

1
2
auto fn = mRuntime->mQnnInterface.graphAddNode;
fn(mQnnGraphHandle, opConfig);

如果 mQnnInterface 保存的是 gQnnConvertorInterface,这里的 fn 就是 QnnConvertorGraph_AddNode,调用链等价于:

1
2
3
4
mRuntime->mQnnInterface.graphAddNode(...)
    == gQnnConvertorInterface.graphAddNode(...)
    == QnnConvertorGraph_AddNode(...)
    -> QNNConvertor::RecordNode(opConfig)

这些 convertor 函数不会真正调用 QNN SDK,而是转成 QNNConvertor::Record*

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// source/backend/qnn/convertor/QNNConvertorInterface.cpp:58
Qnn_ErrorHandle_t QnnConvertorGraph_Create(...) {
    *graphHandle = NOTNULL;
    QNNConvertor::RecordBegin(graphName);
    return QNN_SUCCESS;
}

// source/backend/qnn/convertor/QNNConvertorInterface.cpp:76
Qnn_ErrorHandle_t QnnConvertorGraph_AddNode(Qnn_GraphHandle_t graphHandle, Qnn_OpConfig_t opConfig) {
    QNNConvertor::RecordNode(opConfig);
    return QNN_SUCCESS;
}

// source/backend/qnn/convertor/QNNConvertorInterface.cpp:90
Qnn_ErrorHandle_t QnnConvertorTensor_CreateGraphTensor(Qnn_GraphHandle_t graph, Qnn_Tensor_t* tensor) {
    QNNConvertor::RecordTensor(tensor);
    return QNN_SUCCESS;
}

QNN convert 模式复用了原来的 backend 建图路径,同时把 QNN SDK 函数表换成了 convertor 函数表。

每个 QNN ExecutiononResize(...) 会继续调用自己的 onEncode(...),把当前 MNN op 翻译成 QNN tensor 和 QNN node。公共入口在 QNNCommonExecution

1
2
3
4
5
6
7
8
9
// source/backend/qnn/execution/QNNCommonExecution.cpp:19
ErrorCode QNNCommonExecution::onResize(
    const std::vector<Tensor *> &inputs,
    const std::vector<Tensor *> &outputs) {
    this->setNodeName(mOp, inputs, outputs);
    ErrorCode result = this->onEncode(inputs, outputs);
    ...
    return NO_ERROR;
}

例如卷积 op 会通过注册器创建 QNNConvolution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// source/backend/qnn/execution/QNNConvolution.cpp:748
class QNNConvolutionCreator : public QnnBackend::Creator {
public:
    virtual QNNCommonExecution * onCreate(
        const std::vector<Tensor*>& inputs,
        const std::vector<Tensor*>& outputs,
        const MNN::Op* op,
        Backend* backend) const override {
        ...
        return new QNNConvolution(backend, op);
    }
};

REGISTER_QNN_OP_CREATOR(QNNConvolutionCreator, OpType_Convolution)

在具体 onEncode(...) 里,权重、bias、临时 tensor 会通过 createStaticTensor(...) / createStaticFloatTensor(...) 注册成 QNN tensor,算子本身会通过 addNodeToGraph(...) 注册成 QNN node:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// source/backend/qnn/execution/QNNCommonExecution.cpp:68
void QNNCommonExecution::createStaticTensor(
    const std::string & name,
    Qnn_DataType_t dataType,
    const std::vector<uint32_t> & dimensions,
    const void * buffer,
    Qnn_QuantizeParams_t quantizeParam) {
    std::string tensorName = mNodeName + "_" + name;
    std::shared_ptr<QNNTensorWrapper> tensorWrapper =
        QNNTensorWrapper::createStaticTensor(tensorName, dataType, dimensions, buffer, quantizeParam);
    mBackend->addStaticTensorToGraph(tensorWrapper->getNativeTensor());
    ...
}

// source/backend/qnn/execution/QNNCommonExecution.cpp:167
mBackend->addNodeToGraph(
    mOpConfigVersion,
    mNodeName.c_str(),
    mPackageName.c_str(),
    mNodeType.c_str(),
    mParams,
    mInputs,
    mOutputs);

这些调用最终进入 QnnBackend,再通过 mQnnInterface 转到 convertor 函数表里的 tensorCreateGraphTensor(...)graphAddNode(...)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// source/backend/qnn/backend/QNNBackend.cpp:1472
void QnnBackend::addNodeToGraph(
    Qnn_OpConfigVersion_t version,
    const char* nodeName,
    const char* packageName,
    const char* nodeType,
    std::vector<Qnn_Param_t> & params,
    std::vector<Qnn_Tensor_t> & inputs,
    std::vector<Qnn_Tensor_t> & outputs) {
    ...
    CALL_QNN(mRuntime->mQnnInterface.graphAddNode(mQnnGraphHandle, opConfig));
}

// source/backend/qnn/backend/QNNBackend.cpp:1530
void QnnBackend::addStaticTensorToGraph(Qnn_Tensor_t * staticTensor) {
    MNN_ASSERT(staticTensor->v1.type == QNN_TENSOR_TYPE_STATIC);
    CALL_QNN(mRuntime->mQnnInterface.tensorCreateGraphTensor(mQnnGraphHandle, staticTensor));
}

QNNConvertorRecordBegin(...) / RecordTensor(...) / RecordNode(...) / RecordEnd(...) 会把这些调用整理成内部 QNNCommand。每个 QNNCommand 再交给 Translate(cmd),写进当前 graph 的 .cpp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// source/backend/qnn/convertor/QNNConvertor.cpp:57
void QNNConvertor::RecordBegin(const char* graphName) {
    MNN_ASSERT(!(QNNConvertor::OutputDir.empty()));
    QNNTranslator::GraphNameSymbol = GetLastDirName(QNNConvertor::OutputDir);
    ...
    std::string cppFilePath = MNNFilePathConcat(
        QNNConvertor::OutputDir,
        QNNTranslator::GraphNameSymbol + ".cpp");
    QNNConvertor::CppFilePointer = std::fopen(cppFilePath.c_str(), "w");
}

// source/backend/qnn/convertor/QNNConvertor.cpp:79
void QNNConvertor::RecordTensor(const Qnn_Tensor_t * tensor) {
    ...
    QNNConvertor::Translate(cmd);
    if (cmd.commandTensor.type == Qnn_Convertor_Tensor_t::TENSOR_STATIC) {
        QNNConvertor::DumpBuffer(
            cmd.commandTensor.name,
            cmd.commandTensor.clientBuf.data,
            cmd.commandTensor.clientBuf.dataSize);
    }
}

// source/backend/qnn/convertor/QNNConvertor.cpp:124
void QNNConvertor::RecordNode(const Qnn_OpConfig_t & opConfig) {
    ...
    QNNConvertor::Translate(cmd);
}

每个记录函数都包括Translate(cmd)方法,其本身分两步:

  1. 调用 QNNTranslator::TranslateCommand(cmd),把 QNNCommand 翻译成一组 C++ 源码行;
  2. 把这些源码行追加到 CppBuffer,再通过 fwrite(...) 写入 CppFilePointer
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// source/backend/qnn/convertor/QNNConvertor.cpp:159
void QNNConvertor::Translate(const QNNCommand & cmd) {
    std::vector<std::string> cppLines = QNNTranslator::TranslateCommand(cmd);
    for (const std::string& line : cppLines) {
        QNNConvertor::CppBuffer.append(line);
        QNNConvertor::CppBuffer.push_back('\n');
    }
    size_t written = std::fwrite(
        QNNConvertor::CppBuffer.data(),
        1,
        QNNConvertor::CppBuffer.size(),
        QNNConvertor::CppFilePointer);
    ...
    QNNConvertor::CppBuffer.clear();
}

TranslateCommand(...) 按命令类型分发:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// source/backend/qnn/convertor/QNNConvertor.cpp:195
std::vector<std::string> QNNTranslator::TranslateCommand(const QNNCommand & cmd) {
    switch (cmd.type) {
        case QNNCommandTypeBegin:
            return QNNTranslator::TranslateBegin();
        case QNNCommandTypeTensor:
            return QNNTranslator::TranslateTensor(cmd.commandTensor);
        case QNNCommandTypeNode:
            return QNNTranslator::TranslateNode(cmd.commandNode);
        case QNNCommandTypeEnd:
            return QNNTranslator::TranslateEnd();
        ...
    }
}

这几类命令对应的输出内容是:

命令 翻译函数 写入 .cpp 的内容
QNNCommandTypeBegin TranslateBegin() #includeQnnModel_composeGraphs(...) 函数头、QnnModel graph* 初始化
QNNCommandTypeTensor TranslateTensor(...) Qnn_Tensor_t 定义、维度、数据类型、量化参数、addTensor(...)
QNNCommandTypeNode TranslateNode(...) node 的 params / inputs / outputs 数组,以及 graph.addNode(...)
QNNCommandTypeEnd TranslateEnd() getGraphInfoFromModels(...)QnnModel_freeGraphsInfo(...) 和函数收尾

以 tensor 和 node 为例,TranslateTensor(...) 会生成 Qnn_Tensor_t 并在需要时写入 addTensor(...)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// source/backend/qnn/convertor/QNNConvertor.cpp:254
std::vector<std::string> QNNTranslator::TranslateTensor(const QNNCommandTensor& cmdT) {
    ...
    result.push_back("  Qnn_Tensor_t " + tensorNameSymbol + " = QNN_TENSOR_INIT;");
    ...
    result.push_back("  " + tensorNameSymbol + ".v1.name = \"" + sName +"\";");
    result.push_back("  " + tensorNameSymbol + ".v1.type = " + QNNTranslator::MapTensorType(cmdT.type) + ";");
    result.push_back("  " + tensorNameSymbol + ".v1.dataType = " + QNNTranslator::MapDataType(cmdT.dataType) + ";");
    ...
    if (shouldBeAdded) {
        result.push_back("  VALIDATE(" + QNNTranslator::GraphNameSymbol + ".addTensor(\"" + sName + "\", " + tensorNameSymbol + "), err);");
    }
    return result;
}

TranslateNode(...) 会生成 params_*inputs_*outputs_*,再写一行 addNode(...)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// source/backend/qnn/convertor/QNNConvertor.cpp:303
std::vector<std::string> QNNTranslator::TranslateNode(const QNNCommandNode& cmdN) {
    ...
    APPEND_VECTOR(result, QNNTranslator::TranslateNodeParamArray(...));
    APPEND_VECTOR(result, QNNTranslator::TranslateNodeInputArray(...));
    APPEND_VECTOR(result, QNNTranslator::TranslateNodeOutputArray(...));
    result.push_back("  VALIDATE(" + QNNTranslator::GraphNameSymbol + ".addNode(QNN_OPCONFIG_VERSION_1, \"" + sName + "\", \"" + std::string(cmdN.packageName) + "\", \"" + std::string(cmdN.typeName) + "\",");
    ...
    return result;
}

.raw 文件不是 Translate(cmd) 写的,而是 RecordTensor(...) 遇到 TENSOR_STATIC 时额外调用 DumpBuffer(...) 写出来:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// source/backend/qnn/convertor/QNNConvertor.cpp:117
if (cmd.commandTensor.type == Qnn_Convertor_Tensor_t::TENSOR_STATIC) {
    QNNConvertor::DumpBuffer(
        cmd.commandTensor.name,
        cmd.commandTensor.clientBuf.data,
        cmd.commandTensor.clientBuf.dataSize);
}

// source/backend/qnn/convertor/QNNConvertor.cpp:173
void QNNConvertor::DumpBuffer(const char * name, const void * buffer, size_t size) {
    std::string dataPath = MNNFilePathConcat(QNNConvertor::OutputDir, std::string(name) + ".raw");
    FILE* fp = std::fopen(dataPath.c_str(), "wb");
    ...
    std::fwrite(buffer, 1, size, fp);
}

所以 Step2 生成的 graph 源目录可以这样理解:

  • graph*.cppRecordBegin/RecordTensor/RecordNode/RecordEnd -> Translate(cmd) 逐段写出来的 QNN model 源码;
  • *.rawRecordTensor(...) 遇到 static tensor 时,把常量 buffer 单独 dump 出来的二进制数据。

因此这一句代码:

1
2
// tools/cpp/compilefornpu.cpp:479
auto predict = m->onForward(io.inputs);

会让 QNN 后端看到具体输入 / 输出 tensor 形状、常量权重和每个 op 的参数,并把这些信息记录成 qnn/graph*/graph*.cppqnn/graph*/*.raw

3.8 合并多 shape

因为 testdir 有 decode 和 prefill 两组输入,main 会为每组输入都生成一个 dstNet

1
2
3
4
5
// tools/cpp/compilefornpu.cpp:1040
for (int inputIndex=0; inputIndex < inputs.size(); ++inputIndex) {
    ...
    allNets.emplace_back(std::move(dstNet));
}

第一组 shape 生成 graph0,第二组 shape 会生成类似 graph1_0 的 graph 名:

1
2
3
4
5
6
7
8
// tools/cpp/compilefornpu.cpp:1094
if (inputIndex == 0) {
    srcPath = gCacheDir + "/" + gGraphName +  std::to_string(npuIndex);
    graphicName = gGraphName +  std::to_string(npuIndex);
} else {
    srcPath = gCacheDir + "/" + gGraphName + std::to_string(inputIndex) + "_" +  std::to_string(npuIndex);
    graphicName = gGraphName + std::to_string(inputIndex) + "_" +  std::to_string(npuIndex);
}

最后 _fuse(...) 会把后续 shape 的 Plugin attr 合并回第一组 dstNet

1
2
3
4
5
6
// tools/cpp/compilefornpu.cpp:1145
auto dstNet = allNets[0].get();
for (int i=1; i<allNets.size(); ++i) {
    _fuse(dstNet, allNets[i].get());
    allNets[i].reset();
}

_fuse(...) 会跳过 inputs / outputs,只追加 allGraphNameallInputShapeo_<shapeIndex>_<outputIndex> 这类 shape 相关 attr:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// tools/cpp/compilefornpu.cpp:690
for (auto&& srcAttr : src->attr) {
    if (srcAttr->key == "inputs" || srcAttr->key == "outputs") {
        continue;
    }
    auto dstIter = dstKeys.find(srcAttr->key);
    if (dstIter == dstKeys.end()) {
        dst->attr.emplace_back(std::move(srcAttr));
        continue;
    }
    if (dstIter->second->list != nullptr && srcAttr->list != nullptr) {
        dstIter->second->list->s.insert(dstIter->second->list->s.end(), srcAttr->list->s.begin(), srcAttr->list->s.end());
        dstIter->second->list->i.insert(dstIter->second->list->i.end(), srcAttr->list->i.begin(), srcAttr->list->i.end());
    }
}

这一步结束后,qnn/llm.mnn 里的同一个 Plugin 会同时保存 decode / prefill 两组 graph:

1
2
3
4
5
Plugin(type="QNN")
├── path: qnn/graph0.bin
├── allGraphName: [graph0, graph1_0, ...]
├── allInputShape: [decode 输入 shape..., prefill 输入 shape...]
└── o_0_0 / o_1_0 / ...: 不同 shape 下的输出描述

3.9 产物

Step2 结束时,真正落盘的产物有三类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
tmp/
├── qnn/
│   ├── llm.mnn                 # 插入 Plugin(type="QNN") 后的主图
│   ├── graph0/                 # 第一组 shape 的 QNN graph 源目录
│   │   ├── graph0.cpp
│   │   └── *.raw
│   └── graph1_0/               # 第二组 shape 的 QNN graph 源目录
│       ├── graph1_0.cpp
│       └── *.raw
└── npu_postreat.json           # Step3 合成 context binary 的 merge 配置

4. npu_postreat.json 的作用

compilefornpu 还会写 npu_postreat.json,里面最重要的是 merge

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "type": "QNN",
  "merge": {
    "qnn/graph0.bin": [
      "qnn/graph0",
      "qnn/graph1_0"
    ]
  },
  "cache": "qnn"
}

merge 的含义是:

1
最终生成的 QNN binary -> 参与合并的 graph 源目录列表

一个 Plugin 可能对应多组 shape。离线阶段先为每个 shape 生成 graph 源目录,再通过 merge 合成一个 context binary。运行时通过 shapeIndex 选择 .bin 里的具体 graph handle。

5. Step3:生成 context binary

convert(args) 的 Step3 调用 compile_qnn(args)

1
2
3
# transformers/llm/export/npu/generate_llm_qnn.py:81
print("Step3: Compile to QNN")
compile_qnn(args)

compile_qnn(args) 会从 args.mnn_path 反推出源码树里的 source/backend/qnn/npu_convert.py 路径,并在 tmp 目录下执行它:

1
2
3
4
5
6
7
8
9
# transformers/llm/export/npu/generate_llm_qnn.py:39
def compile_qnn(args):
    exe = os.path.join(os.getcwd(), args.mnn_path, "..", "source", "backend", "qnn", "npu_convert.py")
    cache = os.path.join(os.getcwd(), args.cache_path)
    process = subprocess.Popen(
        "python3 " + exe + ' npu_postreat.json %d ' %args.soc_id + ' ' + args.dsp_arch,
        cwd = cache,
        shell=True
    )

命令展开后是:

1
2
3
4
python3 <repo>/source/backend/qnn/npu_convert.py \
  npu_postreat.json \
  <soc_id> \
  <dsp_arch>

到 Step3 时,compilefornpu 已经把 MNN 子图导出成 QNN graph 的源码材料。npu_convert.py 会继续调用 QNN SDK,把这些材料编译成运行时可直接加载的 HTP context binary。

整体调用关系是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
npu_postreat.json
└── merge: qnn/graph0.bin -> [qnn/graph0, qnn/graph1_0, ...]  # 一个 Plugin 的多组 shape graph
    ├── for each graph 源目录
    │   ├── tar *.raw -> graph*.bin                            # 打包 graph 依赖的 raw 常量数据
    │   ├── qnn-model-lib-generator
    │   │   ├── -c graph*.cpp                                  # QNN graph C++ 源码
    │   │   ├── -b graph*.bin                                  # 上一步打包的 raw 数据
    │   │   └── -> x86_64-linux-clang/libgraph*.so             # 生成 QNN model library
    │   └── 记录 graph name 和 lib 路径
    └── qnn-context-binary-generator
        ├── --model libgraph0.so,libgraph1_0.so,...             # 多个 shape graph 一起写入 context
        ├── --backend <QNN_SDK_ROOT>/lib/.../libQnnHtp.so        # 目标 HTP backend
        ├── --config_file ./context_config.json                 # 指向 HTP backend extension 配置
        ├── --binary_file graph0
        └── --output_dir qnn
            └── qnn/graph0.bin                                  # 最终 Plugin 加载的 context binary

5.1 读取后处理配置

npu_convert.py 一开始从环境变量和命令参数读取输入:

1
2
3
4
5
6
# source/backend/qnn/npu_convert.py:8
qnn_sdk = os.environ["QNN_SDK_ROOT"]
with open(sys.argv[1]) as f:
    post_treat = json.load(f)
soc_id = int(sys.argv[2])
dsp_arch = sys.argv[3]

sys.argv[1] 就是 npu_postreat.jsonsoc_iddsp_arch 来自导出命令参数,用来写入 HTP backend 的设备配置。

随后脚本定位 QNN SDK 的两个工具,并取出 merge

1
2
3
4
5
# source/backend/qnn/npu_convert.py:15
qnn_bin_path = os.path.join(qnn_sdk, 'bin', 'x86_64-linux-clang')
qnnModelLibGenerator = os.path.join(qnn_bin_path, 'qnn-model-lib-generator')
qnnContextBinaryGenerator = os.path.join(qnn_bin_path, 'qnn-context-binary-generator')
merges = post_treat["merge"]

merge 的 key 是最终要生成的 context binary 路径,例如 qnn/graph0.bin;value 是这个 binary 内要合并的 graph 源目录,例如 qnn/graph0qnn/graph1_0

5.2 写 QNN context 配置

脚本会生成两个配置文件。

第一个是 context_config.json,它只负责告诉 qnn-context-binary-generator 去哪里加载 HTP backend extension:

1
2
3
4
5
6
7
8
# source/backend/qnn/npu_convert.py:23
context_config = {
    "backend_extensions": {
        "shared_library_path": os.path.join(qnn_sdk, "lib","x86_64-linux-clang","libQnnHtpNetRunExtensions.so"),
        "config_file_path": "./htp_backend_extensions.json"
    }
}
htp_so = os.path.join(qnn_sdk, 'lib','x86_64-linux-clang','libQnnHtp.so')

第二个是 htp_backend_extensions.json 的主体配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# source/backend/qnn/npu_convert.py:33
htp_backend_extensions = {
    "graphs": [
        {
            "vtcm_mb": 8,
            "O": 3.0,
            "fp16_relaxed_precision": 1,
            "hvx_threads": 4
        }
    ],
    "devices": [
        {
            "soc_id": soc_id,
            "dsp_arch": dsp_arch,
            "cores": [
                {
                    "core_id": 0,
                    "perf_profile": "burst",
                    "rpc_control_latency": 100
                }
            ]
        }
    ],
    "context": {
        "weight_sharing_enabled": True
    }
}

这些配置会进入 QNN context binary 生成过程。graphs 里是 HTP graph 编译参数;devices 里绑定目标 SoC 和 DSP 架构;weight_sharing_enabled 表示同一个 context 里的多个 graph 可以共享权重。

5.3 生成每个 graph 的 model library

接下来脚本遍历 post_treat["merge"]。每个 key 对应一个最终 .bin,每个 src 对应一个 graph 源目录:

1
2
3
4
5
6
7
# source/backend/qnn/npu_convert.py:60
for key in post_treat["merge"]:
    srcs = merges[key]
    dst = key
    dstname = key.split('/')
    dstname = dstname[len(dstname)-1]
    dstname = dstname.replace('.bin', '')

qnn/graph0.bin -> [qnn/graph0, qnn/graph1_0] 为例,dstname 会变成 graph0。这个名字后续会作为 qnn-context-binary-generator --binary_file graph0 的参数。

然后对每个 graph 源目录打包 .raw

1
2
3
4
5
6
7
8
# source/backend/qnn/npu_convert.py:69
for i,src in enumerate(srcs):
    graphname = src.split('/')
    graphname = graphname[len(graphname)-1]
    graphs.append(graphname)
    workdir = os.path.join(os.getcwd(), src)
    workdirs.append(workdir)
    print(subprocess.run("tar -cf " + graphname + '.bin' + ' *.raw', cwd=workdir, capture_output=True, text=True, shell=True))

graphnamegraph0graph1_0 这样的名字。workdir 目录里已经有 Step2 导出的 graphname.cpp 和若干 .raw 文件,.raw 是 QNN graph 依赖的常量数据。脚本先把这些 .raw 打包成 graphname.bin,给下一步的 qnn-model-lib-generator 使用。

随后调用 qnn-model-lib-generator

1
2
3
4
# source/backend/qnn/npu_convert.py:80
compile_cmd = 'python3 ' + qnnModelLibGenerator + ' -c ' + os.path.join(workdir, graphname + '.cpp') + ' -b ' + os.path.join(workdir, graphname + '.bin') + ' -t x86_64-linux-clang -o ' + workdir
print(os.popen(compile_cmd).read())
libs.append(os.path.join(workdir, 'x86_64-linux-clang', 'lib' + graphname + '.so'))

这一步的输入是:

  • graphname.cpp:Step2 由 QNN convert backend 导出的 graph 构建代码;
  • graphname.bin:刚才打包出来的 raw 常量数据;
  • x86_64-linux-clang:生成 host 侧工具可加载的 model library。

输出是 workdir/x86_64-linux-clang/libgraphname.so。这个 .so 还不是运行时最终加载的产物,它只是 qnn-context-binary-generator 的输入。

5.4 合成 context binary

同一个 key 下的所有 graph 源目录都编译出 libgraph*.so 后,脚本会把 graph 名字写入 htp_backend_extensions.json

1
2
3
4
# source/backend/qnn/npu_convert.py:85
htp_backend_extensions['graphs'][0]['graph_names'] = graphs
with open('htp_backend_extensions.json', 'w') as f:
    f.write(json.dumps(htp_backend_extensions, indent=4))

graphs 顺序很重要。compilefornpu 写入 Plugin attr 时,也会保存同一组 allGraphName

1
2
3
4
// tools/cpp/compilefornpu.cpp:556
attr->key = "allGraphName";
attr->list.reset(new ListValueT);
attr->list->s = {graphicName};

多 shape 合并时,_fuse(...) 会把后续 shape 的 allGraphName 追加进去。运行时 QNN Plugin 会读取 allGraphName,并按这个顺序重新排列 context binary 里的 graph 信息:

1
2
3
4
5
6
7
8
9
// source/backend/qnn/backend/QNNBackend.cpp:913
std::vector<std::string> allGraphName;
auto allGraphNameAttr = ctx->getAttr("allGraphName");
if (allGraphNameAttr && allGraphNameAttr->list() && allGraphNameAttr->list()->s()) {
    auto graphNames = allGraphNameAttr->list()->s();
    for (int i = 0; i < graphNames->size(); ++i) {
        allGraphName.push_back(graphNames->GetAsString(i)->str());
    }
}

最后脚本把所有 libgraph*.so 拼成逗号分隔的 --model 参数,调用 qnn-context-binary-generator

1
2
3
4
5
6
7
# source/backend/qnn/npu_convert.py:88
libsStr = ""
for i in range(0, len(libs)):
    if i > 0:
        libsStr+=','
    libsStr += libs[i]
print(os.popen(qnnContextBinaryGenerator + ' --model ' + libsStr + ' --backend '+ htp_so + ' --binary_file ' + dstname + ' --config_file ./context_config.json ' + ' --output_dir ' + cache_dir).read())

展开后大致是:

1
2
3
4
5
6
qnn-context-binary-generator \
  --model <tmp>/qnn/graph0/x86_64-linux-clang/libgraph0.so,<tmp>/qnn/graph1_0/x86_64-linux-clang/libgraph1_0.so \
  --backend <QNN_SDK_ROOT>/lib/x86_64-linux-clang/libQnnHtp.so \
  --binary_file graph0 \
  --config_file ./context_config.json \
  --output_dir qnn

输出目录由 npu_postreat.json 里的 "cache": "qnn" 决定:

1
2
3
4
# source/backend/qnn/npu_convert.py:19
cache_dir = 'res'
if 'cache' in post_treat:
    cache_dir = post_treat['cache']

最终产物会写到:

1
tmp/qnn/graph0.bin

这就是 Plugin attr 里的 path: qnn/graph0.bin 对应的文件。

5.5 运行时使用 binary

运行时 QNN Plugin 初始化时,会从 Plugin attr 里读 pathallGraphName,然后调用 RawExecutorWrapper::compileModel(...)

1
2
3
4
5
// source/backend/qnn/backend/QNNBackend.cpp:911
auto path = MNNFilePathConcat(ctx->dir_path(), ctx->getAttr("path")->s()->str());
...
mRawExecutor.reset(new RawExecutorWrapper());
return mRawExecutor->compileModel(path, binaryOffset, binarySize, allGraphName);

compileModel(...) 会读取 context binary,并通过 QNN API 从 binary 里恢复 context 和 graph handle:

1
2
3
4
5
6
// source/backend/qnn/backend/QNNBackend.cpp:797
CALL_QNN(QNN::gContext.systemInterface.systemContextGetBinaryInfo(systemContextHandle, buffer, size, &binaryInfo,&binarySize));
copyMetadataToGraphsInfo(binaryInfo, mGraphsInfo, mGraphCount);

// source/backend/qnn/backend/QNNBackend.cpp:816
CALL_QNN(QNN::gContext.interface.contextCreateFromBinary(QNN::gContext.backendHandle, QNN::gContext.deviceHandle, mQnnContextConfig, buffer, size, &mQnnContextHandle, mQnnProfileHandle));

然后按 allGraphName 的顺序取出每个 graph handle:

1
2
3
4
5
6
7
8
9
// source/backend/qnn/backend/QNNBackend.cpp:826
for (int i = 0; i < mGraphCount; ++i) {
    auto it = graphInfoMap.find(allGraphName[i]);
    MNN_ASSERT(it != graphInfoMap.end());
    sortedGraphsInfo[i] = it->second;
}
for (int i = 0; i < mGraphCount; i++) {
    CALL_QNN(QNN::gContext.interface.graphRetrieve(mQnnContextHandle, mGraphsInfo[i]->graphName, &(mQnnGraphHandleVec[i])));
}

所以 Step3 生成的 qnn/graph0.bin 不是普通权重文件,而是一个 QNN HTP context binary。它里面可以包含多个 graph,分别对应 decode / prefill 等不同输入 shape。执行时,Plugin 通过 shapeIndex 选择 mQnnGraphHandleVec[shapeIndex] 去调用 QNN graph。

6. Step4:移动产物并写 config_qnn.json

最后 convert(args) 调用 output_qnn(args) 做收尾:

1
2
3
# transformers/llm/export/npu/generate_llm_qnn.py:85
print("Step4: Move result file to ", args.model)
output_qnn(args)

output_qnn(args) 先把临时目录里的 qnn 移到模型目录(tmp/qnn -> <model_dir>/qnn):

1
2
3
4
5
# transformers/llm/export/npu/generate_llm_qnn.py:47
def output_qnn(args):
    if os.path.exists(os.path.join(args.model, 'qnn')):
        shutil.rmtree(os.path.join(args.model, 'qnn'))
    shutil.move(os.path.join(args.cache_path, 'qnn'), os.path.join(args.model, 'qnn'))

然后脚本直接写出一个新的 config_qnn.json,不是读取 config.json 后做增量修改:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# transformers/llm/export/npu/generate_llm_qnn.py:51
config_npu = {
    "llm_model": "qnn/llm.mnn",
    "backend_type": "cpu",
    "thread_num": 1,
    "precision": "low",
    "chunk_limits":[args.chunk_size, 1],
    "memory": "low",
    "sampler_type": "penalty",
    "penalty": 1.1
}
with open(os.path.join(args.model, "config_qnn.json"), 'w') as f:
    f.write(json.dumps(config_npu, indent = 4))
shutil.rmtree(args.cache_path)

其中最关键的字段是:

1
2
3
4
{
  "llm_model": "qnn/llm.mnn",
  "chunk_limits": [128, 1]
}

实际运行时还需要确认 config_qnn.json 里有正常的运行配置,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "llm_model": "qnn/llm.mnn",
  "backend_type": "cpu",
  "thread_num": 1,
  "precision": "low",
  "chunk_limits": [128, 1],
  "memory": "low",
  "sampler_type": "penalty",
  "penalty": 1.1
}

这里 backend_type 写成 cpu 是正常的。外层 MNN 主图仍然由 CPU backend 执行;真正下沉到 QNN 的部分已经变成 Plugin(type="QNN"),运行时会由 Plugin 机制加载 qnn/*.bin 并调用 QNN graph。

7. 最终目录结构

完成后,模型目录会变成类似这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Qwen3-1.7B-MNN/
├── config.json
├── config_qnn.json
├── llm_config.json
├── tokenizer.txt
├── llm.mnn
├── llm.mnn.weight
└── qnn/
    ├── llm.mnn
    ├── graph0.bin
    └── graph*.bin

整个离线执行链路如下:

1
2
3
4
generate_llm_qnn.py 负责编排;
generateLlmIO 负责造多 shape 样本;
compilefornpu 负责切图、导出 QNN graph 材料并改写 MNN 主图;
npu_convert.py 负责调用 QNN SDK 生成最终 HTP context binary。