Whisper语音模型INT8量化与边缘部署实战
发布日期: 2026/08/06 阅读总量: 0

一、真实场景:被客户怼了

今年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)420.3%
mel频谱(librosa)9807.7%
Whisper encoder742058.5%
Whisper decoder(beam size=5)420033.1%
后处理230.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 INT8RKNN混合INT8
端到端时延12.8s7.3s5.3s
实时率0.63x1.11x1.53x
普通话CER8.6%10.2%9.1%
模型体积296MB78MB74MB
峰值内存1.2GB780MB450MB
部署难度

结论: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 CERINT8 CER
普通话门店巡检8.1s12.8s5.3s2.42x8.6%9.1%
四川方言访谈7.2s11.4s4.9s2.33x15.2%16.0%
英文客服对话6.8s10.9s4.7s2.32x14.1%14.8%
会议室多人噪声9.3s14.2s5.9s2.41x21.3%22.9%
电话回放录音10.5s16.1s6.8s2.37x28.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。