8,027
社区成员
发帖
与我相关
我的任务
分享📚 QCS6490 NPU 系列
① 部署 QAIRT SDK | ② ResNet50 NPU 实战 | ③ Qualcomm AI Hub 指南 | ④ Hexagon CPU 占用实测 | ⑤ 降 CPU 开销实战(本篇)
平台:Radxa Dragon Q6A · Qualcomm QCS6490(Hexagon NPU = HTP v68,4×A78 + 4×A55)
系统:Ubuntu 24.04.4 LTS aarch64 · kernel 6.18.2-3-qcom
环境:QAIRT SDK 2.42.0.251225
实测日期:2026-11-13
上一篇(④)证明了「NPU 推理时 CPU 有占用是正常的」,本篇回答下一个问题:那能不能把它降下来?
能,而且幅度很大 —— 同一模型、同一块板子,每帧 CPU 时间从 4.36 ms 压到 0.60 ms(−86%),吞吐从 140 FPS 涨到 694 FPS。
但真正把开销降下去的,不是 那些听起来很高级的参数(native IO、mmap、ION 共享内存加起来不到 10%),而是三件很朴素的事:
| 手段 | 一句话 | 实测每帧 CPU | 相对基线 |
|---|---|---|---|
| 绑大核 | taskset -c 4-7,一行命令 | 4.36 → 2.06 ms | −53% |
| 全内存 I/O | 输入输出放 /dev/shm | 2.06 → 1.80 ms | −13% |
| batch=4 | 4 帧合并成一次 execute | 2.06 → 0.60 ms | −71% |
| 而反例 | 每帧起一个新进程 | 4.36 → 89.6 ms | +1956% |
下面把 14 组对照实验的数据、机理和踩到的坑一次说清。
优化之前先定位。跑推理的同时用 top -H 看线程级占用:

7 个线程,只有主线程在烧 CPU(45%~63%),其余 6 个线程全是 0.0%。

这条观察决定了后面所有优化手段的边界:

结论:单流推理的 CPU 开销是「单线程串行」的 —— 加核、开线程池都没用,能做的只有三件事:
QCS6490 是 4×Cortex-A78(大)+ 4×Cortex-A55(小)的 big.LITTLE 结构,Linux 默认 schedutil 调频:
| CPU | 最高频率 | 空闲时实测(scaling_cur_freq) |
|---|---|---|
| cpu0~3(A55) | 1958 MHz | 691 MHz(cpu0) |
| cpu4(A78) | 2707 MHz | 1517 MHz |
| cpu7(A78) | 2707 MHz | 2707 MHz |
而 NPU 推理是典型的短时尖峰负载:单帧只有 5~7 ms,跑完就睡。schedutil 的升频有延迟和滞回,根本来不及在这几毫秒内把 A55 拉到最高频,
调度器还可能把线程在小核之间来回迁移,每次迁移都要重建 cache。
所以实测结果就不奇怪了:
不绑核 4.36 ms CPU/帧 (随机落在小核 / 迁移中)
绑小核 0-3 4.43 ms CPU/帧 (稳定跑在低频 A55 上)
绑大核 4-7 2.06 ms CPU/帧 (−53%)
绑单核 cpu7 1.81 ms CPU/帧 (−59%,最稳)
![绑核策略对比:换个 CPU 就少一半 CPU 时间]
(https://i-blog.csdnimg.cn/communtity/93f2369f051541a0ba7523e205bc9fdc.png "#left")
💡 绑单核反而最好:既拿到了 A78 的高频,又完全避免迁移。
既然推理本来就是单线程热点,taskset -c 7是最省事的一行优化。
注意:绑核只影响 CPU 时间(同样的活干得更快),不会改变 NPU 的计算速度。
但墙钟时间也跟着从 7.15 ms 降到 4.88 ms —— 因为这段「等待」本身也是主线程在忙等。
| 项目 | 设定 |
|---|---|
| 模型 | ResNet50 INT8,AIMET 量化,context binary(25.9 MB) |
| 推理单元 | HTP v68,qnn-net-run |
| 样本 | 500 帧/例(首帧的上下文加载被摊薄) |
| 主指标 | 子进程 utime + stime ÷ 帧数 = 每帧 CPU 时间 |
| 副本指标 | 墙钟/帧、占用核数、FPS |
口径与上一篇(④)完全一致,不用
top采样,避免抹平毫秒级尖峰。

| # | 手段 | 墙钟 ms/帧 | CPU ms/帧 | vs 基线 | FPS |
|---|---|---|---|---|---|
| 1 | 基线(不绑核,默认参数) | 7.15 | 4.36 | 1.00× | 140 |
| 2 | 绑小核 A55(0-3) | 7.29 | 4.43 | 1.02× | 137 |
| 3 | 绑大核 A78(4-7) | 4.89 | 2.06 | 0.47× | 204 |
| 4 | 绑单大核(cpu7) | 4.88 | 1.81 | 0.41× | 205 |
| 5 | 大核 + --use_native_output_files | 6.45 | 2.25 | 0.52× | 155 |
| 6 | 大核 + --use_mmap | 4.98 | 2.22 | 0.51× | 201 |
| 7 | 大核 + 输入张量缓存 | 5.33 | 2.47 | 0.57× | 188 |
| 8 | 大核 + ION 共享内存 | 4.75 | 2.03 | 0.47× | 210 |
| 9 | 大核 + rpc_polling_time=0 | 4.61 | 2.18 | 0.50× | 217 |
| 10 | 大核 + 全内存 I/O | 4.45 | 1.80 | 0.41× | 225 |
| 11 | 大核 + batch=4 | 1.44 | 0.60 | 0.14× | 694 |
| 12 | 全开(3+5+9+10) | 4.19 | 1.83 | 0.42× | 239 |
| A1 | 4 进程并发 | 2.94 | 4.53 | 1.04× | 340 |
| A2 | 4 进程并发 + 各绑一个大核 | 3.38 | 2.59 | 0.59× | 296 |
| B | 50 次「单帧进程」 | 215.19 | 89.64 | 20.6× | 4.6 |
| C | 参照:纯读输入(不推理) | 2.07 | 1.24 | 0.28× | — |
两轮独立测量(500 帧/例)的一致性:基线 4.36 / 4.62,大核 2.06 / 1.87,batch=4 0.60 / 0.61。
单例波动约 ±10%,表中取较新的一轮。
B 行的单帧进程数据在 DSP 会话正常时测得(50 次平均);会话异常时会退化到秒级,见「坑 3」。
① 那些「高级」参数几乎是安慰剂。
native 输出、mmap、张量缓存、ION 共享内存,单看都是 2.0~2.5 ms,和只绑大核的 2.06 ms 在噪声范围内。
真正省下来的 2.3 ms 是绑核省下的,不是它们。
② FastRPC 参数调不动。rpc_polling_time 从 9999 µs 调到 0,每帧 CPU 只从 4.11 变到 4.14(不绑核)/ 2.28 到 2.18(绑核,另做的一组同口径 A/B)。
说明 QNN 的 QnnGraph_execute 不是靠忙轮询等 DSP,而是真正阻塞(sys 时间只占 10%~20%)。
想靠调这个参数「让 CPU 闲下来」是行不通的。
③ 每帧起一个新进程 = 灾难。
50 次「启动进程 → 加载 25 MB context → 跑 1 帧 → 退出」平均 215 ms/帧,
是长驻进程(7.15 ms)的 30 倍。
单帧进程 : ████████████████████████████████ 215 ms
长驻进程 : █ 7 ms
把上面几组数据拆成 user / sys 看,能看出一件事:绑核是把两头一起砍,batch 是直接把固定开销抽掉。

taskset -c 7 ./your_infer_app # 绑单大核
# 或
taskset -c 4-7 ./your_infer_app # 绑大核组
−53%,零代码改动,零精度风险。 如果只做一件事,就做这个。
同理,如果是自己写的程序,也可以在主线程里用 pthread_setaffinity_np();
更彻底的是在启动时把 CPU 频率策略改成 performance(需要 root)。
| 手段 | 原理 | 实测 | 结论 |
|---|---|---|---|
--use_native_output_files | 输出不做 uint8→float 反量化 | 2.25 ms | 省不出多少;墙钟反而涨到 6.45 ms,不建议盲开 |
--use_mmap | context binary 用 mmap 载入 | 2.22 ms | 只影响启动,500 帧摊薄后看不见 |
--max_input_cache_tensor_sets=-1 | 复用输入 tensor,不重建 | 2.47 ms | 反而更慢(内存压力),且长序列会吃几百 MB |
--io_tensor_mem_handle_type=ion | 输入输出走 ION 共享 buffer | 2.03 ms | 唯一略有正收益的,但也在噪声内 |
它们不是没用,而是在这个量级上不是瓶颈。
如果你的模型 I/O 是 4K 图像、点云、或者大 batch,这些手段的比重会显著上升。
另外第 12 行「全开」只有 1.83 ms,和单独第 10 行的 1.80 ms 持平 —— 叠加也没有额外收益。
cp input.raw /dev/shm/ && qnn-net-run --input_list /dev/shm/list.txt --output_dir /dev/shm/out
参考量级:纯 cat 读 500 次输入文件(0.60 MB/帧)就要 1.24 ms CPU/帧。
也就是说,读文件这一项就占了基线 4.36 ms 的近 30%(cat 还有 fork/exec 开销,属于上界估计)。
生产环境中更彻底的做法:
通过 backend extensions 可以调三个旋钮:
{
"devices": [{
"soc_model": 60,
"dsp_arch": "v68",
"cores": [{
"perf_profile": "burst",
"rpc_control_latency": 1000,
"rpc_polling_time": 0
}]
}]
}
| 参数 | 含义 | 实测效果 |
|---|---|---|
perf_profile | DVFS 策略(burst / high_performance / …) | 墙钟略降,CPU 时间基本不变 |
rpc_control_latency | RPC 控制线程的保持唤醒窗口(µs) | 无明显变化 |
rpc_polling_time | 轮询等待上限(µs,0~9999) | 0 与 9999 无差别 |
结论:QNN 的 execute 是事件阻塞式等待,不是忙轮询。
这个参数主要影响 RPC 线程的唤醒策略,对 QNN 这条路径收益很小。
(如果你直接用libcdsprpc自己发 RPC,它的权重就大得多。)
这是本篇最强的单项优化:4 帧合并成一次 execute,每帧 CPU 从 2.06 ms 降到 0.60 ms,吞吐 204 → 694 FPS(3.4×)。
# 1) 把 4 帧 raw 拼成一个文件(batch 维度的数据要连续)
cat frame0.raw frame1.raw frame2.raw frame3.raw > batch4.raw
# 2) 每行一个 batch4 文件
qnn-net-run --batch_multiplier=4 --input_list list_batch4.txt ...
为什么省这么多?因为每帧的固定开销(文件读取、tensor 构建、RPC 往返、DSP 侧调度)被 4 帧平摊了:
batch=1: [固定][计算] [固定][计算] [固定][计算] [固定][计算]
batch=4: [固定][计算 计算 计算 计算]
↑ 固定开销只付一次

⚠️ 代价是延迟:攒满 4 帧才发一次 execute,首帧等待从 4.9 ms 变成 5.8 ms。
所以 batch 是「吞吐优化」,不是「延迟优化」。
💡 数据口径提醒:
--batch_multiplier=4时如果 input list 每行只有 1 帧的数据,
qnn-net-run 会把同一份数据复制 4 份 —— 读取量只有真实情况的 1/4,测出来的收益是假的。
上表用的是「真拼接 4 帧」的公平口径,收益依然成立。
| 方式 | 吞吐 | 每帧 CPU | 说明 |
|---|---|---|---|
| 单进程 | 140 FPS | 4.36 ms | — |
| 4 进程并发 | 340 FPS | 4.53 ms | 每帧成本不变,但 4 个流一起跑 |
| 4 进程 + 各绑一个大核 | 296 FPS | 2.59 ms | 每帧成本降了,吞吐受 DSP 调度限制 |
推理是单线程热点,所以开多个流能直接变多倍吞吐:
实测 4 进程 340 FPS(2.4×),代价是 4 个核被占;
跑得快时瓶颈会从 CPU 转到 DSP,再加进程就不再线性了。
对「多路摄像头 / 多路视频流」的场景,这是最直接的扩容方式。
| 模式 | 每帧墙钟 | 每帧 CPU |
|---|---|---|
| 长驻进程(500 帧一次加载) | 7.15 ms | 4.36 ms |
| 每帧一个新进程(加载 25 MB context) | 215 ms | 89.6 ms |
启动一次 qnn-net-run 要加载 25.9 MB 的 context binary 并建立 DSP 会话,约 0.2 秒。
任何「每帧调一次命令行工具」的架构都是不可接受的。
正确做法:进程常驻 + 上下文只加载一次 + 循环 execute(C++/Python 绑定均可)。
严格说:不能全部避免,但可以分层削减。把每个环节拆开看:

| 环节 | 能否搬走 / 消除 | 手段 | 备注 |
|---|---|---|---|
| 读输入 | ⚠️ 只能加速 | tmpfs / shared buffer / 预读 | 数据总要从某处来 |
| 量化、拼 tensor | ✅ 可搬到图内 | 模型 I/O 直接定义成 uint8(--preserve_io / AIMET quantize_io) | 本例输入已是 float32,量化在 DSP 内完成 |
| FastRPC 下发 | ✅ 靠摊薄 | batch | 每帧一次的固定成本 → 每 4 帧一次 |
| 计算 | — 本来就在 DSP | — | — |
| 反量化 | ✅ 可省掉 | --use_native_output_files,下游直接吃 uint8 | ResNet50 只有 1000 个输出值,实测收益≈ 0;输出大的模型才有意义 |
| 写输出 | ✅ 可省掉 | 不落盘,直接内存后处理 | 本例 500 帧 × 4 KB 不大,视频场景要当心 |
| 进程启动 | ✅ 必须消除 | 常驻进程 | 单帧新进程是 30× 的灾难 |
一句话总结:能搬到 DSP 的(量化/反量化)、能摊薄的(RPC)、能省掉的(落盘),都搬走;
剩下的就是「CPU 读数据 + 等 DSP」,这部分只受频率和核影响 —— 所以绑大核收益最大。
taskset -c 7 ./infer_app # 主进程绑 cpu7
# 输入输出都走 /dev/shm,或直接 shared buffer(不落盘)
预期:4.36 → 1.8 ms CPU/帧,墙钟 7.15 → 4.5 ms。
taskset -c 4-7 ./infer_app --batch_multiplier=4 # 攒 4 帧再算(实测值)
预期:每帧 CPU 0.6 ms,吞吐 ~690 FPS。
# 4 路进程,每路绑一个大核
for i in 0 1 2 3; do
taskset -c $((4+i)) ./infer_app --stream $i &
done
预期:吞吐 ≈ 300340 FPS,每帧 CPU 2.64.5 ms。
官方
examples/QNN/SampleApp是这套流程的参考实现。
$ qnn-net-run ... --io_tensor_mem_handle_type=ion --max_input_cache_tensor_sets=-1
rc=17 Graph Execution failure
两者单独用都正常,500 帧规模叠加就失败。
推测是 ION 侧一次性注册的 buffer 总量超限(500 × 588 KB ≈ 294 MB)。
→ --max_input_cache_tensor_sets=-1 慎用,要么改用 --max_input_cache_size_mb,要么干脆别开。
dma_buf 在这个后端上不支持Input output tensor memHandle type = 3. But backend does not support property =1104
--io_tensor_mem_handle_type=dma_buf 直接初始化失败,只有 ion 可用。
做「每帧一个新进程」的对照实验时,中途 kill 了一个正在推理的进程,结果:
$ ps aux | grep qnn-net-run
radxa 5321 ... D+ qnn-net-run ... # D 状态,kill -9 无效
$ qnn-net-run ... # 之后所有推理
real 1m48s # 全部超时
进程卡在内核态等待(D 状态),kill -9 无效,之后所有推理请求全部超时。
本次实验到这里就无法继续,需要重启板子才能恢复。
卡死状态下的对照测量(连跑 10 次单帧进程):
单帧进程(不绑核) 每次墙钟 67589 ms | 每次 CPU 49.6 ms
单帧进程(绑 cpu7) 每次墙钟 61439 ms | 每次 CPU 40.7 ms
墙钟 60 多秒、CPU 只有几十毫秒 —— 说明线程 99.9% 的时间在等内核/远端超时,
不是在做计算。这个特征可以用来区分「DSP 真的卡了」和「只是模型慢」。
教训:
kill 进程,应先让它自然结束;timeout,但不要对推理进程本身用 SIGKILL。--batch_multiplier=4 不会自动帮你把 4 帧数据拼起来。
input list 每行只有 1 帧时,运行时会把这份数据复制 4 份 ——
读 I/O 只有 1/4,测出来的「收益」是假的。必须自己拼接 raw 文件。
taskset -c 7,4.36 → 1.81 ms(−59%),零代码改动;/dev/shm 再省 13%;纯读文件本身就占 1.24 ms/帧;qairt/2.42.0.251225/docs/QAIRT-Docs/QNN/general/htp/htp_backend.htmlrpc_control_latency / rpc_polling_time / weight_sharing_enabled 等 backend extensions 说明)qairt/2.42.0.251225/examples/QNN/SampleApp(常驻进程 + buffer 复用的参考实现)