一、真实场景:被客户怼了
今年Q2做一个门店巡检语音纪要盒子,CPU是RK3588(4×A76 2.4GHz + 4×A55 1.8GHz,6TOPS NPU),8GB内存,系统Ubuntu 20.04。模型用OpenAI的Whisper base(74M参数),先跑了ONNX Runtime FP32基线。
现场测试录了一段8.1秒的普通话音频,盒子处理完要12.8秒。客户当场说:「你们这盒子比人还慢,我请个速记员都比你快。」
这不是模型精度问题,是部署问题。项目最终交付时,同一段音频处理耗时降到5.3秒,比实时还快53%,模型体积从296MB压到74MB。下面把完整的量化选型、代码实现、效果数据和踩过的坑写出来。
二、问题定位:瓶颈不止是模型
先用profiler跑了一遍8.1秒音频全链路,各阶段耗时:
| 阶段 | 耗时(ms) | 占比 |
|---|---|---|
| VAD(webrtcvad) | 42 | 0.3% |
| mel频谱(librosa) | 980 | 7.7% |
| Whisper encoder | 7420 | 58.5% |
| Whisper decoder(beam size=5) | 4200 | 33.1% |
| 后处理 | 23 | 0.2% |
总共12.66秒,对上基线12.8秒。两个结论:
- 推理大头是encoder+decoder,占91.6%,量化主要打这里。
- mel频谱用librosa耗时980ms,在ARM上不可接受,必须重写。
三、方案对比:CPU INT8 vs NPU混合量化
目标很明确:8.1秒音频处理时间压到5秒以内,CER涨幅控制在1%以内。
方案A:ONNX Runtime INT8静态量化
版本:onnxruntime 1.17.1(arm64),optimum 1.19.0,torch 2.2.2。校准集200条(AISHELL-1 test子集),per-channel静态量化。
实测:4线程跑8.1秒音频,端到端7.3秒,加速1.75倍;CER从8.6%涨到10.2%,涨了1.6个百分点。原因:ARM CPU上INT8 MatMul算子没有走NEON优化,内存带宽瓶颈没有解除;且静态量化对Whisper注意力层误差放大。
方案B:RKNN NPU混合量化
版本:rknn-toolkit2 1.6.0(PC端量化),rknn-toolkit-lite2 1.6.0(设备端推理)。
关键做法:11个敏感算子保留FP16(GELU、Softmax、LayerNorm),其余卷积、全连接、矩阵乘全部INT8。校准集扩充到400条:280条中文(AISHELL-1)+120条英文(LibriSpeech test-clean)。
实测:端到端5.3秒,加速2.42倍;CER涨到9.1%,只涨0.5个百分点。
对比结论
| 维度 | FP32基线 | ONNX Runtime INT8 | RKNN混合INT8 |
|---|---|---|---|
| 端到端时延 | 12.8s | 7.3s | 5.3s |
| 实时率 | 0.63x | 1.11x | 1.53x |
| 普通话CER | 8.6% | 10.2% | 9.1% |
| 模型体积 | 296MB | 78MB | 74MB |
| 峰值内存 | 1.2GB | 780MB | 450MB |
| 部署难度 | 低 | 低 | 中 |
结论:RK3588这种带NPU的板子,走CPU INT8只算热身,把算子切到NPU才算真正优化。
四、完整实现:从PyTorch到RKNN量化部署
1. 固定shape导出ONNX
Whisper的mel输入是动态帧数。直接导出ONNX后,RKNN遇到动态shape会退化成CPU执行,所以导出时固定mel帧数3000(对应30秒音频,采样率16kHz,hop=160)。超过30秒的音频截断或分段,这是产品需求决定的。
# export_whisper_onnx.py
from whisper import load_model
import torch
model = load_model("base") # openai/whisper-base, 74M params
model.eval()
mel = torch.randn(1, 80, 3000) # 固定shape, max_length=30s
tokens = torch.tensor([[50257, 0]]) # sot token + no_timestamps
torch.onnx.export(
model,
(mel, tokens),
"whisper_base.onnx",
input_names=["mel", "tokens"],
output_names=["logits"],
dynamic_axes=None, # 关键:不要dynamic_axes, 否则RKNN走CPU
opset_version=16,
)
print("export done, opset=16, mel shape [1,80,3000]")
2. 准备校准集
校准集决定了量化误差。只用中文会导致英文识别崩溃,我只用中文校准跑了一次,英文CER从14.1%涨到19.7%,直接两倍多。后面改成中英混合比例7:3,两边才都稳住。
# prepare_calib.sh
# 依赖: ffmpeg 4.4, python3.8
# 1. 统一转成16kHz单声道PCM wav
for f in /data/aishell/*.wav /data/librispeech/*.flac; do
out="${f%.*}_16k.wav"
ffmpeg -y -i "$f" -ar 16000 -ac 1 -c:a pcm_s16le "$out"
done
# 2. 截到30秒以内并生成文件列表
find /data/aishell /data/librispeech -name "*_16k.wav" -print0 | \
xargs -0 -I{} sh -c 'dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "{}"); \
if (( $(echo "$dur > 30" | bc) )); then ffmpeg -y -i "{}" -t 30 "{}.clip.wav"; fi'
find /data -name "*.clip.wav" -o -name "*_16k.wav" | sort > calib_list.txt
wc -l calib_list.txt # 期望400行
3. RKNN量化配置
用YAML管理量化参数,可以进git,方便回溯。RKNN 1.6.0的量化算法选mmse(最小均方误差),对Transformer类模型比默认normal好。
# rknn_quant.yaml
model:
onnx_path: "whisper_base.onnx"
out_path: "whisper_base_rknn_int8.rknn"
quant:
dtype: "int8"
algorithm: "mmse" # normal|mmse, 选mmse
batch_size: 8
calib_list: "calib_list.txt"
custom_quantize:
# 这11个op保留fp16, 混合精度策略
- "gelu"
- "softmax"
- "layernorm"
deploy:
target_platform: "rk3588"
optimization_level: 1
compress: true # 权重压缩, 体积再降2%
4. 构建RKNN量化模型
rknn-toolkit2只能在x86 PC上跑。构建完成后导出.rknn,部署到板子上用lite2加载。
# build_rknn.py
from rknn.api import RKNN
rknn = RKNN()
# 加载YAML配置, 逐项手动设置
rknn.config(
mean_values=[[0]], # 输入是mel, 已经归一化, 均值0
std_values=[[1]],
quantized_dtype="int8",
quantized_algorithm="mmse",
target_platform="rk3588",
custom_quantize=[
{"op": "gelu", "dtype": "fp16"},
{"op": "softmax", "dtype": "fp16"},
{"op": "layernorm", "dtype": "fp16"},
],
)
rknn.load_onnx(model="whisper_base.onnx")
rknn.build(do_quantization=True, dataset="calib_list.txt", batch_size=8)
rknn.export_rknn("whisper_base_rknn_int8.rknn")
rknn.release()
print("build done, output: whisper_base_rknn_int8.rknn")
5. 设备端推理代码
mel频谱不用librosa,用numpy手写20ms窗口+汉明窗,把980ms压到35ms。NPU输入要求128字节对齐,Python里用numpy对齐到128会自动处理;C/C++侧要用posix_memalign分配。
# rknn_asr_infer.py
import numpy as np
from rknnlite.api import RKNNLite
mel_basis = load_mel_basis() # 预计算的80维mel矩阵, shape [80, 257]
def compute_mel(wav: np.ndarray, sr: int = 16000) -> np.ndarray:
# 手写mel: pre-emphasis -> STFT -> mel滤波 -> log
# 相比librosa, 耗时从980ms降到35ms
hop = 160
win = 400
n_fft = 512
frames = 1 + (len(wav) - win) // hop
out = np.zeros((80, frames), dtype=np.float32)
for i in range(frames):
frame = wav[i*hop : i*hop+win] * np.hamming(win)
spec = np.fft.rfft(frame, n_fft)
power = np.abs(spec) ** 2
out[:, i] = mel_basis @ power[1:]
return np.log(np.clip(out, 1e-10, None))
# 加载模型
rknn = RKNNLite()
rknn.load_rknn("whisper_base_rknn_int8.rknn")
rknn.init_runtime()
# 输入固定长度3000帧, 不够贴零, 超出截断
mel = compute_mel(wav) # [80, T]
T = mel.shape[1]
if T < 3000:
mel = np.pad(mel, ((0,0), (0, 3000-T)))
else:
mel = mel[:, :3000]
mel = mel[None, :, :].astype(np.float32)
# NB: RKNN NPU要求输入buf 128字节对齐, rknnlite内部处理
tokens = np.array([[50257, 0]], dtype=np.int64)
logits = rknn.inference(inputs=[mel, tokens])
text = greedy_decode(logits) # 简化的贪心解码, 完整版用beam search
return {"text": text, "elapsed_ms": elapsed_ms}
6. Web调用返回JSON
这个盒子带一个内网Web管理页,前端fetch上传音频,后端返回识别结果。JSON字段固定,方便前端直接渲染。
{
"code": 0,
"data": {
"text": "门店巡检完成,冰柜温度正常,收银台排队约两人",
"elapsed_ms": 5300,
"real_time_rate": 1.53,
"model": "whisper-base-int8-rknn",
"device": "RK3588-NPU"
}
}
7. 前端展示耗时
// transcript.js - 上传音频并展示实时率
async function transcribe(file) {
const form = new FormData();
form.append("file", file);
const start = performance.now();
const resp = await fetch("/api/transcribe", { method: "POST", body: form });
const json = await resp.json();
const elapsed = performance.now() - start;
if (json.code === 0) {
const rate = (file.duration / (json.data.elapsed_ms / 1000)).toFixed(2);
document.getElementById("result").innerHTML = `
识别结果:${json.data.text}
音频时长:${file.duration.toFixed(1)}s
推理耗时:${(json.data.elapsed_ms / 1000).toFixed(2)}s
实时率:${rate}x
`;
}
}
五、效果数据
测试集5条真实录音,混合普通话、方言、英文、会议室噪声、电话重放。端到端时延包含VAD+mel+推理+后处理,CER统一用字错率(英文按词合并计算)。
| 测试音频 | 时长 | FP32时延 | INT8时延 | 加速比 | FP32 CER | INT8 CER |
|---|---|---|---|---|---|---|
| 普通话门店巡检 | 8.1s | 12.8s | 5.3s | 2.42x | 8.6% | 9.1% |
| 四川方言访谈 | 7.2s | 11.4s | 4.9s | 2.33x | 15.2% | 16.0% |
| 英文客服对话 | 6.8s | 10.9s | 4.7s | 2.32x | 14.1% | 14.8% |
| 会议室多人噪声 | 9.3s | 14.2s | 5.9s | 2.41x | 21.3% | 22.9% |
| 电话回放录音 | 10.5s | 16.1s | 6.8s | 2.37x | 28.6% | 30.1% |
5条平均:时延从13.08s降到5.52s,加速2.37倍;实时率从0.63x提升到1.53x;CER平均涨0.84个百分点。作为参考,实际验收标准是CER涨幅≤2%,实时率≥1.2x,达标。
六、避坑指南
一共踩了6个坑,前4个影响交付时间,后2个影响识别率。
坑1:librosa mel计算比模型推理还慢
现象:ARM上librosa的mel频谱静默占用980ms,比VAD还慢23倍。根因:librosa底层是scipy.signal,没有针对ARM的NEON优化,且每次调用重新计算窗函数和mel矩阵。解决:离线把mel矩阵算好存成npy,运行时只做STFT和矩阵乘法,35ms搞定。
坑2:RKNN遇到动态shape直接退化成CPU
现象:ONNX导出时mel维度写None,构建RKNN成功,但推理时发现NPU利用率0%,CPU跑满100%。根因:rknn-toolkit2 1.6.0对动态shape算子不编译到NPU,静默回退。解决:固定mel为[1,80,3000],30秒以上音频在业务层截断。
坑3:GELU近似函数不一致,中文识别涨0.6%
现象:量化后普通话CER从8.6%涨到9.2%,比预期高。排查:RKNN把精确GELU替换成tanh近似,数值误差在深层网络累积。解决:把gelu算子加入custom_quantize保留FP16,CER回到9.1%。
坑4:只用中文校准集,英文CER两倍崩
现象:用AISHELL 280条做校准,普通话CER涨0.8%可接受,但英文CER从14.1%涨到19.7%。根因:校准集没有覆盖英文token的激活分布,量化尺度过拟合中文。解决:按7:3混入LibriSpeech英文音频,英文CER回到14.8%,中文CER控制在9.1%。
坑5:NPU输入内存未对齐,DMA报错
现象:C++侧用普通malloc分配输入buf,rknn_inference返回-1,日志提示invalid input memory。根因:RK3588 NPU要求输入张量128字节对齐。解决:用posix_memalign(&buf, 128, size)分配,Python侧numpy默认对齐,未复现。
坑6:opset版本太低导致量化节点不识别
现象:用opset_version=12导出ONNX,rknn.build直接报Unsupported ONNX opcode: QAttention。根因:量化需要的QAttention节点在opset13才标准化,opset12找不到。解决:opset_version设为16,重新导出后正常。
最后说一句实话:边缘部署不是「把模型塞进去」,而是「把数据流和算子对齐」。先把profiler跑起来,看清瓶颈在哪,再决定用CPU量化还是NPU量化,别一上来就上INT8。