端侧推理
前面几篇的约束是「GPU 上有多少显存、带宽多宽」。换到手机、车机、摄像头、可穿戴设备上,问题会变成另一组:模型能不能装进内存?首次启动要多久?连续跑十分钟后会不会因发热降频?某个算子没被 NPU 支持时,系统是报错还是悄悄回退到 CPU?
这些共同构成端侧推理(On-device Inference)。它围绕有限资源、异构硬件、碎片化平台重新设计一条推理链路,靠在服务端程序上交叉编译拿不到。
端侧、边缘、云端的范围
这三个词经常被混用,范围并不相同:
| 层次 | 指什么 | 资源约束 |
|---|---|---|
| 端侧(Device) | 手机、平板、PC、汽车、机器人、摄像头、可穿戴、IoT | 通常最强 |
| 边缘侧(Edge) | 还可能包括基站、门店服务器、边缘网关 | 中等 |
| 云端(Cloud) | 数据中心 | 算力、显存、散热最充足 |
把模型放到端上的四个常见理由:
- 低延迟 —— 摄像头检测、语音唤醒、驾驶辅助不能等网络往返
- 离线可用 —— 弱网或断网环境仍能工作
- 隐私 —— 原始图片、语音、文本留在本地
- 成本 —— 高频轻量请求本地完成,减少云端计算与带宽开销
代价是工程约束从「主要考虑吞吐和成本」扩展为同时考虑延迟、内存、包体积、功耗、温度、精度和兼容性。
四类约束
内存与存储
端侧模型要同时占模型文件、权重、激活、临时工作区、运行时本身的内存。操作系统还会限制单个 App 的可用内存,所以「物理内存 8 GB」不等于模型能用 8 GB。
权重占用的一阶估算:
(
| 精度 | 理论权重大小 |
|---|---|
| FP16/BF16 | 6 GB |
| INT8 | 3 GB |
| INT4 | 1.5 GB |
这只是理论下界,实际还要加量化尺度、文件对齐、KV Cache、激活与工作区。
Decoder-only LLM 的 KV Cache:
(
这解释了端上一个常见现象:模型权重能加载,不代表能稳定支持很长的上下文。 和服务端是同一道题 —— 见 10-KV Cache 与推理优化 里那条判据:显存瓶颈常常出在 KV 上,权重只占小头。
功耗与散热
端侧不能只看一次推理的峰值速度。CPU/GPU 短时间可能很快,但持续运行后会因温度与功耗限制降频。可靠的实时应用要关注稳态性能,而不是记录刚启动时最好的一次。
硬件碎片化
两台同为 Android 的手机可能有不同的 CPU 指令集、GPU 驱动和 NPU 工具链。同一个计算图在设备 A 上可以完整委托给 NPU,在设备 B 上可能有若干算子回退到 CPU。
应用生命周期
端侧推理和 App 生命周期绑定:模型加载会不会阻塞 UI?切后台时是否释放内存?模型更新失败能否回滚?这些问题不属于模型结构,却决定功能能否上线。
从模型到 App 的三层与一条链路
训练框架 PyTorch / TF
│ 导出:torch.export / ONNX
▼
中间表示(IR)── 描述计算图与权重,ONNX 是这一层的名字
│ 图变换:常量折叠 · 算子融合 · 量化 · Layout 转换
▼
后端编译 / 委托(划分子图)
│
├─▶ 命中的子图 ──────▶ 硬件后端:CPU / GPU / NPU / DSP
└─▶ 不命中的算子 ────▶ 默认 CPU Kernel(回退)
│
▼
端侧程序:.pte / .onnx / .tflite / .mlpackage
│ 由 Runtime 加载:ExecuTorch / ONNX Runtime / LiteRT / ncnn / MNN ...
▼
Android / iOS / C++ App这条链上每一段的产物都不同:导出固定的是计算图结构与动态形状的声明方式,图变换改的是节点数与算子组合,委托决定的则是运行时真正执行它的设备。
三个容易混淆的概念必须分开:
| 层 | 职责 | 例子 |
|---|---|---|
| 模型格式 / 中间表示(IR) | 描述计算图和权重 | ONNX |
| Runtime | 加载模型、管理内存、发起执行 | ExecuTorch、ONNX Runtime、ncnn |
| Backend / Delegate | 真正执行一部分计算 | XNNPACK、Core ML、Vulkan、Qualcomm QNN |
Runtime 不一定亲自算每个算子。它可以把支持的子图交给硬件后端,把剩余算子留给默认 CPU Kernel。这个机制叫图分区(Partitioning)/ 委托(Delegation):
完整计算图(假设 60 个算子)
│
▼
图分区器(Partitioner)── 按各后端声明的算子表逐节点判定归属
│
├─ 子图 A:算子 1–40 全部命中 NPU 算子集 ──▶ 整块委托给 NPU
├─ 子图 B:算子 41–43 混入不支持的算子 ──▶ 交默认 CPU Kernel
└─ 子图 C:算子 44–60 又命中 ──▶ 再委托给 NPU
│
▼
合并输出 ── 每跨一次 A→B→C 的边界,就要做一次设备间数据搬运与 Layout 转换切成三段比切一段多付的不止 CPU 算的那几个算子,还有两次边界搬运;分段的代价随边界条数增长,所以「启用了 NPU」之后还要看它到底吃下了几个子图。
「启用了 NPU」不等于「整个模型都运行在 NPU 上」。 端侧优化首先要确认实际分区结果 —— 这是后面所有排查的起点。
四种计算资源
| 单元 | 特点 | 优化重点 / 注意 |
|---|---|---|
| CPU | 算子覆盖和调试体验最好,兼容性最强的兜底路径 | ARM NEON / SVE 等 SIMD 指令、线程数与大小核调度、Cache 命中率与内存布局、XNNPACK/oneDNN 优化 Kernel;避免线程过多造成抢占与功耗上升 |
| GPU | 并行度高,适合大规模计算;接口有 Vulkan / Metal / OpenCL | 小算子摊不开调度开销;CPU↔GPU 的数据转换与同步要花时间;驱动质量与精度支持因设备而异;GPU 加速不保证比高度优化的 ARM CPU 更快 |
| NPU | 针对矩阵计算、卷积和低精度推理优化,能效高 | 约束在算子覆盖、Tensor Layout、动态形状、量化规则。图上出现不支持节点就会触发切分与后端切换 |
| DSP | 适合音频、传感器等低功耗常驻任务 | 算力未必最高,但能在低功耗下持续处理规则数据流 |
选型没有「永远最快」的答案。 正确方法是先用 CPU 建立准确性基线,再分别测 GPU/NPU/DSP 的覆盖率、端到端延迟与稳态功耗。
选不到「永远最快」的那一个,但可以先排除。把四种单元各自的胜出条件与落败条件摊开:
先固定模型与测试协议,再用 CPU 建立准确性基线,然后逐个测覆盖率与稳态功耗
CPU ── 兜底路径,算子覆盖与调试体验最好
├─ 胜出:模型小、算子碎、后端覆盖率低、需要逐算子定位精度问题
└─ 落败:线程开太多反而因抢占与功耗上升而变慢
GPU ── 并行度高,适合大规模计算
├─ 胜出:算子粒度大、有成熟的 Vulkan / Metal 驱动
└─ 落败:小算子摊不开调度开销;CPU 与 GPU 之间的数据转换与同步吃掉收益
NPU ── 矩阵与低精度推理能效最高
├─ 胜出:算子全覆盖、静态形状、量化方式命中它的 Kernel
└─ 落败:图上出现不支持节点就触发切分,边界搬运比省下的算力还贵
DSP ── 低功耗常驻
├─ 胜出:音频、传感器这类规则的连续数据流
└─ 通常不适合通用张量计算Runtime 怎么选
| Runtime | 主要入口 | 常见目标 | 适合场景 |
|---|---|---|---|
| ExecuTorch | PyTorch / torch.export | XNNPACK、Vulkan、Core ML、QNN | 保持 PyTorch 工作流,或需要多硬件后端 |
| ONNX Runtime Mobile | ONNX | Android、iOS、多种 Execution Provider | 模型来源多、重视跨框架互操作 |
| LiteRT | TFLite / LiteRT | Android、iOS、GPU/NPU Delegate | TensorFlow / Google AI Edge 生态 |
| Core ML | Core ML Model | Apple CPU / GPU / Neural Engine | iOS / macOS 原生应用 |
| ncnn | pnnx / ONNX 转换 | ARM CPU、Vulkan | 轻依赖 C++、移动视觉与嵌入式 |
| MNN | ONNX / TFLite 等转换 | CPU、GPU、端侧 LLM | 国内移动端、传统模型与 LLM 综合 |
| llama.cpp | GGUF | CPU、Metal、Vulkan | 本地与端侧 LLM 推理 |
| MLC LLM | 编译生成目标模型库 | Android、iOS、WebGPU、Vulkan | 跨平台、编译器驱动的 LLM 部署 |
这张表用于定位,不是性能排行榜。实际表现取决于模型结构、设备、精度、后端覆盖率与版本。选型先固定模型与测试协议,再在目标设备上实测。
四个优化抓手
模型压缩
结构化剪枝、蒸馏、低秩分解、或直接换更小的架构。压缩不仅减少计算量,也可能改变算子结构,因此要结合目标硬件的 Kernel 支持来评估。
量化
| 维度 | 选项 |
|---|---|
| 时机 | PTQ(训练后量化,成本低)vs QAT(量化感知训练,精度更好) |
| 范围 | Weight-only(只量化权重,常用于 LLM)vs 权重与激活都量化(压缩更充分,更依赖硬件) |
| 粒度 | Per-tensor vs Per-channel |
**量化不等于必然加速。** 硬件没有对应低精度 Kernel、或计算图频繁执行量化与反量化时,**延迟可能不降反升**。评估量化必须同时报告模型大小、延迟、内存与精度变化。
这条在服务端同样成立,见 06-vLLM 部署、参数与服务特性。唯一确定会加速的那一类是 Weight-only 对 Decode 的作用 —— 因为 Decode 的瓶颈是搬权重(见 01-推理性能指标与瓶颈定位)。
图与算子优化
常量折叠;Conv-BN、Linear-Activation 等算子融合;删除冗余的 Cast / Transpose / Reshape;NCHW/NHWC Layout 转换;静态形状特化;自定义算子或替换不受支持的算子。
重点在于减少内存访问、调度开销和后端切换 —— 图上的节点数本身不是目标。
内存规划
- 复用生命周期不重叠的激活 Buffer
- 用内存映射按需加载大模型权重
- 控制 LLM 上下文长度与 KV Cache 精度
- 避免在预处理、Runtime、UI 层重复复制 Tensor
- 分片加载并设置明确的内存失败降级策略
怎么做可信的 Benchmark
只报告一次「平均耗时」几乎没有意义。 一份能复现的端侧 Benchmark 至少要记录:
| 类别 | 字段 |
|---|---|
| 环境 | 设备型号/SoC/内存/系统版本、Runtime/模型/驱动版本、实际执行后端与图分区结果、线程数、输入尺寸、Batch Size、动态形状范围 |
| 性能 | 冷启动与模型加载时间、预热后 P50/P90/P99 延迟、吞吐、峰值内存与稳定运行内存、模型文件与 Runtime 包体积、功耗与温度、持续运行后的性能变化 |
| 精度 | 任务指标或与原始模型的最大误差 |
| LLM 附加 | TTFT、TPOT、Prefill 与 Decode 各自的 tokens/s、最大稳定上下文长度、峰值内存与是否触发系统内存回收 |
实验协议:
- 固定输入样本和随机种子
- 先跑原始框架结果,保存精度基线
- 模型加载与推理分开计时
- 预热若干次,再采集至少数十次推理
- 同时报中位数与尾延迟
- 连续运行一段时间,观察温度与降频
- 检查每个子图实际使用的后端
- 保存脚本、配置与原始结果,不只保存结论
用于归档的实验记录字段:
device: "设备型号 / SoC / 内存"
os: "系统与版本"
model: "模型名、版本与文件哈希"
runtime: "运行时、版本与执行后端"
precision: "FP16 / INT8 / INT4 ..."
input: "输入尺寸、Batch Size、上下文长度"
threads: 4
warmup_runs: 10
measured_runs: 100
delegate_coverage: "委托子图比例或未支持算子"
latency_ms: "P50 / P90 / P99"
peak_memory_mb: 0
power_and_temperature: "测量工具、初始值、稳态值"
accuracy: "任务指标或与基线的最大误差"模型文件哈希、Runtime 版本、后端覆盖率这三个字段尤其重要 —— 它们避免「同名模型其实不是同一份文件」和「号称使用 NPU、实际大量回退 CPU」这两类误判。
比较对象必须控制变量。 不同输入尺寸、线程数、量化精度或后端覆盖率得出的数字不能横向比。
最小导出链路:ExecuTorch + XNNPACK
ExecuTorch 把 PyTorch 模型导出为轻量的 .pte 程序,通过 Delegate 把计算交给 XNNPACK、Core ML、Vulkan、Qualcomm QNN 等后端。第一次练习建议先用 CPU 后端 XNNPACK —— 它不要求额外的芯片厂商 SDK,便于把「模型导出问题」和「NPU 工具链问题」分开排查。
import torch
from executorch.backends.xnnpack.partition.xnnpack_partitioner import XnnpackPartitioner
from executorch.exir import to_edge_transform_and_lower
class TinyClassifier(torch.nn.Module):
def __init__(self) -> None:
super().__init__()
self.features = torch.nn.Sequential(
torch.nn.Conv2d(3, 8, kernel_size=3, stride=2, padding=1),
torch.nn.ReLU(),
torch.nn.Conv2d(8, 16, kernel_size=3, stride=2, padding=1),
torch.nn.ReLU(),
torch.nn.AdaptiveAvgPool2d((1, 1)),
)
self.classifier = torch.nn.Linear(16, 10)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.classifier(torch.flatten(self.features(x), 1))
model = TinyClassifier().eval() # 端侧导出前必须 eval(),否则 Dropout/BatchNorm 仍是训练行为
example_inputs = (torch.randn(1, 3, 224, 224),) # 示例输入同时定义图的输入形状与类型
exported_program = torch.export.export(model, example_inputs)
edge_program = to_edge_transform_and_lower(
exported_program,
partitioner=[XnnpackPartitioner()],
)
executorch_program = edge_program.to_executorch()
with open("tiny_classifier_xnnpack.pte", "wb") as file:
file.write(executorch_program.buffer)三步分别是:torch.export.export 捕获计算图;XnnpackPartitioner 标记并降低 XNNPACK 支持的子图;to_executorch 生成端侧 Runtime 加载的 .pte。
生成文件不等于部署完成。 还要验证:导出前后输出误差是否在允许范围;哪些算子被委托、哪些仍由默认 Kernel 执行;.pte 与 Runtime 的最终包体积;真实设备上的加载时间、延迟与内存;多次与持续运行的稳定性。
换到 Qualcomm NPU、Apple Neural Engine 或 Vulkan GPU 时流程不变,替换 Partitioner 并按对应后端文档准备 SDK、编译选项与设备环境。
示例基于 ExecuTorch 1.3 文档的导出接口,API 会随版本演进。实践中应**固定 PyTorch、ExecuTorch 与目标后端版本**。
五个高频故障与排查顺序
| 症状 | 排查方向 |
|---|---|
| 导出失败 | 先定位不支持的 Python 控制流、动态形状或自定义算子,再考虑模型改写或注册自定义算子。不要在不知道失败节点的情况下反复换 opset 或 Runtime 版本 |
| 输出精度不一致 | 按顺序缩小范围:① 对齐预处理与后处理 ② 比较导出前后 FP32 输出 ③ 再比量化前后 ④ 最后比不同硬件后端 ⑤ 必要时保存中间 Tensor,定位第一个明显误差的算子 |
| NPU 比 CPU 还慢 | 检查 NPU 实际覆盖多少算子、是否出现 CPU↔NPU 频繁数据转换、输入规模是否太小摊不开编译与调度开销、Benchmark 是否把加载/首次编译算进推理时间、量化类型是否真命中 NPU Kernel |
| 首次运行特别慢 | 模型反序列化、权重重排、图编译、GPU Pipeline 创建。冷启动与热启动分别测,并缓存可复用的编译结果 |
| 连续运行越来越慢 | 观察温度、CPU/GPU 频率、线程调度、电量模式。延迟随温度恶化时,优化目标要从峰值速度转向能效与稳态速度 |
很多「Runtime 精度问题」实际上来自 RGB/BGR、归一化、Resize 或 NCHW/NHWC 不一致 —— 预处理对齐要放在排查的第一步。
精度不一致是端侧最长的一条排查线。关键在于按范围从大到小逐层排除,不要跳步 —— 直接从「换个后端试试」入手,会把前后处理的错误带进后面的每一层比较。
输出与原始模型不一致
└─ 按范围从大到小逐层排除
├─ ① 预处理与后处理对齐了吗?
│ RGB 还是 BGR · 归一化的均值方差与缩放 · Resize 的插值方式 · NCHW 还是 NHWC
│ └─ 不一致 ──▶ 停在这里先修前后处理(这一类占比最高)
├─ ② 导出前后的 FP32 输出一致吗?
│ └─ 不一致 ──▶ 问题在图变换:算子融合、常量折叠、被替换掉的不支持算子
├─ ③ 量化前后一致吗?
│ └─ 不一致 ──▶ 校准集没覆盖该输入分布 / per-tensor 精度不足 / 敏感层需保留高精度
├─ ④ 换后端(CPU 与 NPU、GPU 互比)一致吗?
│ └─ 不一致 ──▶ 后端 Kernel 本身的精度差异,报给厂商或把该子图降级回 CPU
└─ ⑤ 以上都一致,输出仍有差异
──▶ 保存中间 Tensor,定位第一个明显偏离的算子,再回到 ② ③ 对应那层选型路径
模型从哪来?
├─ PyTorch ─┬─ 保持 PyTorch 工作流? → 是 → ExecuTorch
│ └─ 否 → ONNX Runtime / ncnn / MNN
├─ TensorFlow → LiteRT
├─ 端侧 LLM ─┬─ CPU / GGUF 生态 → llama.cpp
│ └─ 跨平台编译与 GPU → MLC LLM
└─ Apple 原生 → Core ML这只是缩小候选范围。最终选择仍要通过目标设备上的正确性、后端覆盖率、延迟、内存、功耗、包体积六项测试决定。
相关
- 10-KV Cache 与推理优化 —— 端侧 KV Cache 与服务端是同一道显存题
- 01-推理性能指标与瓶颈定位 —— TTFT/TPOT 与 Weight-only 量化加速的机理
- 06-vLLM 部署、参数与服务特性 —— 量化「不一定加速」这条判据在服务端的同一形态
- 05-文本分词与子词算法:BPE、WordPiece 与 Unigram —— 端侧包体积里分词器也占一份
参考
- https://docs.pytorch.org/executorch/stable/
- https://docs.pytorch.org/executorch/stable/getting-started-architecture
- https://onnxruntime.ai/docs/tutorials/mobile/
- https://ai.google.dev/edge/litert
- https://developer.apple.com/documentation/coreml
- https://github.com/Tencent/ncnn | Alibaba MNN:https://github.com/alibaba/MNN
- https://github.com/ggml-org/llama.cpp/blob/master/docs/android.md | MLC LLM:https://llm.mlc.ai/docs/
- https://www.qualcomm.com/developer/software/qualcomm-ai-engine-direct-sdk
- https://mlcommons.org/benchmarks/inference-mobile/
- https://caomaolufei.github.io/AIInfraGuide/guides/模块四-推理优化/第12章-端侧推理/121-端侧推理基础
YJ