RDK S100 上部署 Paraformer 中文语音识别:从 WAV 输入到 INT16 HBM 推理
大家好,分享一个已合入 RDK Model Zoo rdk_s 分支的中文语音识别样例:Paraformer-large-contextual。
样例地址:
https://github.com/D-Robotics/rdk_model_zoo/tree/rdk_s/samples/speech/paraformer
该样例面向 RDK S100(Nash-e),支持直接输入 16 kHz WAV 文件,完成中文语音转写。模型运行时采用三段 INT16 HBM,并保留 Paraformer 原始的 Encoder → Predictor → CIF → Decoder 推理顺序。
1. 模型能做什么?
Paraformer 是非自回归端到端语音识别模型。它并行预测 token 对齐的声学表示,再通过 Decoder 输出文本,因此适合对低延迟有要求的离线语音转写场景。
本样例可用于:
- 中文语音转文字;
- 离线语音控制;
- 会议录音转写;
- 本地语音内容检索;
- 基于 FunASR 上游模型继续微调后的端侧部署。
模型来源:
- FunASR:
https://github.com/modelscope/FunASR - ModelScope 模型:
iic/speech_paraformer-large-contextual_asr_nat-zh-cn-16k-common-vocab8404 - 参考论文:Paraformer: Fast and Accurate Parallel Transformer for Non-autoregressive End-to-End Speech Recognition
2. 推理流程
用户只需要提供 16 kHz WAV,样例内部完成原始前处理与 RDK 推理:
16 kHz WAV
↓
FunASR WavFrontend
fbank + LFR + CMVN
↓
[1, 400, 560] 固定 shape 特征
↓
Encoder INT16 HBM
↓
Predictor INT16 HBM
↓
CPU CIF
↓
Decoder INT16 HBM
↓
中文识别文本
其中:
- FunASR 只负责复现原模型的 WAV 前处理;
- Paraformer HBM pipeline 才是 RDK 上实际执行识别的模型;
- CIF 包含数据依赖的动态索引逻辑,因此保留在 CPU 执行;
- Python 与 C++ Runtime 都支持从 WAV 开始,用户不需要自行准备
.npy特征。
当前固定输入为 [1, 400, 560],单条音频有效长度约为 24 秒。更长音频建议先在应用侧做分段。
3. 快速体验
3.1 获取代码
git clone https://github.com/D-Robotics/rdk_model_zoo.git
cd rdk_model_zoo
git checkout rdk_s
cd samples/speech/paraformer
3.2 下载模型
在 RDK S100 板端执行:
cd model
bash download_model.sh
脚本会将 3 个 HBM 阶段、词表和 WAV 前处理资源安装到:
/opt/hobot/model/s100/basic/paraformer/
包含:
paraformer_large_encoder_400x560_s100.hbm
paraformer_large_predictor_400x512_s100.hbm
paraformer_large_decoder_400x512_s100.hbm
tokens.json
am.mvn
paraformer_config.yaml
3.3 运行内置 WAV 样例
Python Runtime:
cd ../runtime/python
bash run.sh
C++ Runtime:
cd ../cpp
bash run.sh
首次执行 Python Runtime 时,脚本会在样例目录下创建隔离虚拟环境,并自动安装已验证的 CPU torch/torchaudio 2.6.0、FunASR 和 SoundFile,不修改系统 Python 包。
4. 如何识别自己的 WAV?
准备如下目录:
my_audio/
├── manifest.json
└── audio/
├── demo_001.wav
└── demo_002.wav
其中 manifest.json 至少包含 utt_id:
[
{
"utt_id": "demo_001"
},
{
"utt_id": "demo_002",
"text": "可选的参考转写文本"
}
]
注意事项:
- 文件名必须与
utt_id对应,例如audio/demo_001.wav; - WAV 必须是 16 kHz;
text是可选项,C++ Runtime 在有参考文本时会计算 CER;- 不需要准备
.npy特征文件。
运行命令:
# Python
cd runtime/python
bash run.sh /path/to/my_audio
# C++
cd runtime/cpp
bash run.sh /path/to/my_audio
Python 会直接完成 WAV 前处理和三段 HBM 推理。C++ 脚本会在临时目录自动生成特征,再执行 hbUCP/hbDNN 推理;生成的 .npy 仅是内部 bridge,不是用户输入。
5. Python 与 C++ Runtime 的区别
| Runtime | 适合场景 | 特点 |
|---|---|---|
| Python | 原型验证、调试 | 使用 hbm_runtime,代码路径直观,输出 WAV 前处理与各阶段时延 |
| C++ | 生产部署、低延迟 | 使用 hbUCP + hbDNN,保留 BPU 对齐内存处理,CPU CIF 开销更低 |
两者都使用相同的 HBM、词表、WAV 前处理配置和 CIF mask 逻辑。
英文 BPE 子词中的 @@ 是续接标记,Python 与 C++ Runtime 已在最终输出阶段自动去除并合并子词,因此终端结果不会显示 @@。
6. 已完成验证结果
以下是已完成的 AISHELL dev 300 条验证记录:
| 指标 | Python hbm_runtime |
C++ UCP |
|---|---|---|
| CER | 3.13% | 3.13% |
| Encoder | 33.63 ms | 33.15 ms |
| Predictor | 1.44 ms | 1.00 ms |
| CPU CIF | 3.41 ms | 0.38 ms |
| Decoder | 7.12 ms | 6.29 ms |
| HBM pipeline | 45.61 ms | 40.81 ms |
| RTF | 0.008 | 0.007 |
说明:表中的 HBM pipeline 统计 Encoder、Predictor、CPU CIF 和 Decoder,不包含 WAV 前处理。当前 Python Runtime 会额外输出 frontend_ms,以及包含前处理的端到端时间。
7. 完整量化策略与复现流程
以下内容包含从上游 FunASR 模型导出到 S100 三段 INT16 HBM、主机 CER 评测与板端部署的完整流程。即使不打开仓库内文档,也可以按本节复现。
以下流程与样例的量化文档一致,完整覆盖固定 shape 导出、ONNX 图手术、真实语音校准、三段 INT16 编译、CPU CIF、端到端评测和板端部署。正文中的小节编号按本帖第 7 节重新编排。
量化文件索引:
| 类别 | 文件 | 用途 |
|---|---|---|
| 固定 shape 导出 | 01_reexport_fixed_shape.py |
固定 Encoder 输入及 Predictor 的 token 相关 shape 后重新导出 ONNX |
| ONNX 图手术 | 02_extract_decoder.py ~ 08_extract_encoder.py |
拆分三段网络、消除不被编译器支持的动态图结构 |
| 校准与评测 | 09_gen_calib_features.py、10_gen_real_calib.py、11_eval_pipeline.py |
生成真实校准特征、执行端到端 CER 评测 |
| CIF 参考实现 | cif_numpy.py |
在 CPU 上执行动态 fire 与 token 聚合 |
| 编译配置 | configs/encoder_int16.yaml、configs/predictor_int16.yaml、configs/decoder_int16.yaml |
分别编译 Encoder、Predictor 与 Decoder 的 INT16 HBM |
本目录包含完整的 Paraformer 固定 shape INT16 量化与部署流程。所有命令均在本目录执行,生成的 ONNX、校准数据、日志和 HBM 为本地构建产物,不应提交到仓库。
模型:iic/speech_paraformer-large-contextual_asr_nat-zh-cn-16k-common-vocab8404
目标平台:地瓜机器人 S100 (nash-e) BPU
工具链:ai_toolchain_ubuntu_22_s100_s600_cpu:v3.7.0(HMCT / hbdk4 4.7.5)
量化方案:全 INT16 (Encoder / Predictor / Decoder) + CPU CIF
板端 300 条 AISHELL dev CER:3.13%(vs FP32 baseline 5.20%,实际优于 HMCT 主机仿真 5.02%)
板端端到端延迟:Pythonhbm_runtime45.6 ms/utt · C++ UCP 40.8 ms/utt · RTF ≈ 0.007
7.1 概述
Paraformer-large-contextual 是一款非自回归 ASR 模型,包含 4 个逻辑模块:Encoder、Predictor、CIF (Continuous Integrate-and-Fire)、Decoder。本文将说明如何将该模型部署到地瓜机器人 S100 BPU 平台。
关键要点:
| 模块 | 上 BPU? | 原因 |
|---|---|---|
| Encoder | 纯 Conv+MatMul+LayerNorm,标准 transformer | |
| Predictor | 简单 Conv+Linear+Sigmoid(12 节点) | |
| CIF | 含 GatherND(dynamic_index)、boolean mask、num_fires 数据依赖 shape,hbdk4 无法编译 |
|
| Decoder | 15 层 transformer + Contextual bias attention |
CIF 在 CPU 上仅需 1-2 毫秒,不是瓶颈。
输入约束(固定 shape):
| 张量 | Shape | 说明 |
|---|---|---|
speech |
[1, 400, 560] fp32 |
fbank+LFR (m=7, n=6),400 帧 ≈ 24 秒音频上限 |
bias_embed |
[1, 1, 512] fp32 |
Contextual 热词嵌入(0 = 无热词) |
max_label_len |
100 | CIF 最多产出 100 个 token(monkey-patch 强制固定) |
7.2 环境准备
7.2.1 拉取工具链 Docker(OpenExplorer)
docker pull ai_toolchain_ubuntu_22_s100_s600_cpu:v3.7.0
启动容器(推荐):
docker run --rm -it \
-u $(id -u):$(id -g) \
--entrypoint /bin/bash \
-v /path/to/workspace:/workspace \
-w /workspace \
ai_toolchain_ubuntu_22_s100_s600_cpu:v3.7.0
7.2.2 下载 Paraformer 模型
pip install modelscope funasr
python -c "
from funasr import AutoModel
AutoModel(model='iic/speech_paraformer-large-contextual_asr_nat-zh-cn-16k-common-vocab8404')
"
模型会被下载到 ~/.cache/modelscope/hub/models/。将其复制到工作目录,例如 ./models/paraformer/。
7.2.3 准备校准数据集
关键:校准数据必须与部署时的输入分布一致。 使用真实语音(如 AISHELL-1 dev 子集,50 条即可),提取 fbank+LFR 特征后保存:
import numpy as np, torch, glob, os
from pathlib import Path
import soundfile as sf
from funasr import AutoModel
model = AutoModel(model="./models/paraformer", disable_update=True)
frontend = model.kwargs["frontend"]
CALIB_DIR = "./calib_data/speech"
os.makedirs(CALIB_DIR, exist_ok=True)
for i, wav_path in enumerate(sorted(glob.glob("./aishell_dev/**/*.wav", recursive=True))[:50]):
wav_np, sr = sf.read(wav_path); assert sr == 16000
wav_t = torch.from_numpy(wav_np).float().unsqueeze(0)
feats, _ = frontend(wav_t, torch.tensor([wav_t.shape[1]]))
feats_np = feats[0].detach().cpu().numpy() # [T, 560]
if feats_np.shape[0] >= 400:
feats_400 = feats_np[:400]
else:
feats_400 = np.pad(feats_np, ((0, 400-feats_np.shape[0]), (0, 0)))
feats_400 = feats_400[None, ...].astype(np.float32)
np.save(f"{CALIB_DIR}/calib_{i:03d}.npy", feats_400)
7.3 模型切分策略
speech ──► [Encoder hbm] ──► enc_out
│
┌───────────────┼───────────────┐
▼ ▼ ▼
[Predictor hbm] ┌─ CIF (CPU) ─┐ [Decoder hbm]
│ │ │ ▲
alphas ───────┤ │────────┤ pre_acoustic_embeds
│ │ │ │ token_num
└──────────┴─────────────┘ │
bias_embed
│
▼
logits [1,100,8404]
三个 hbm 全部采用 INT16 全量化 + max calibration。CIF 用 numpy 在 CPU 上运行,接收 predictor 输出 (alphas, Concat_5_output_0) 并生成 decoder 所需的 pre_acoustic_embeds 和 token_num。
7.4 FunASR 导出改造
原始 FunASR 的 CIF 实现含有数据依赖的 shape,hbdk4 无法编译。需要对 CIF 进行 monkey-patch:
7.4.1 强制静态 max_label_len
原代码 max_label_len = floor(alphas.sum(-1)).max() 让下游张量 shape 数据依赖。改写:
# reexport_fixed_shape.py(核心 patch)
MAX_LABEL_LEN = 100 # 固定 CIF 最大输出 token 数
def patched_cif_v1_export(hidden, alphas, threshold: float):
"""替换 FunASR 原始 cif_v1_export,去除动态 shape + 兼容 hbdk4。"""
device = hidden.device; dtype = hidden.dtype
batch_size, len_time, hidden_size = hidden.size()
frames = torch.zeros(batch_size, len_time, hidden_size, dtype=dtype, device=device)
fires = torch.zeros(batch_size, len_time, dtype=dtype, device=device)
prefix_sum = torch.cumsum(alphas, dim=1, dtype=torch.float64).to(torch.float32)
prefix_sum_floor = torch.floor(prefix_sum)
disl_prefix_sum = torch.roll(prefix_sum, 1, dims=1)
disl_prefix_sum_floor = torch.floor(disl_prefix_sum)
disl_prefix_sum_floor[:, 0] = 0
dislocation_diff = prefix_sum_floor - disl_prefix_sum_floor
fire_idxs = dislocation_diff > 0
fires = fires.masked_fill(fire_idxs, 1.0)
fires = fires + prefix_sum - prefix_sum_floor
# 关键:所有 .unsqueeze(-1) 改成 .reshape(...),绕开 hbdk4 type_inf bug
alphas_r = alphas.reshape(batch_size, len_time, 1)
prefix_sum_hidden = torch.cumsum(alphas_r.repeat((1, 1, hidden_size)) * hidden, dim=1)
frames = prefix_sum_hidden[fire_idxs]
shift_frames = torch.roll(frames, 1, dims=0)
batch_len = fire_idxs.sum(1)
batch_idxs = torch.cumsum(batch_len, dim=0)
shift_batch_idxs = torch.roll(batch_idxs, 1, dims=0)
shift_batch_idxs[0] = 0
shift_frames[shift_batch_idxs] = 0
remains = fires - torch.floor(fires)
remains_sel = remains[fire_idxs]
hidden_sel = hidden[fire_idxs]
remains_sel_2d = remains_sel.reshape(-1, 1) # 关键:不用 unsqueeze
remain_frames = remains_sel_2d.repeat((1, hidden_size)) * hidden_sel
shift_remain_frames = torch.roll(remain_frames, 1, dims=0)
shift_remain_frames[shift_batch_idxs] = 0
frames = frames - shift_frames + shift_remain_frames - remain_frames
max_label_len: int = MAX_LABEL_LEN
frame_fires = torch.zeros(batch_size, max_label_len, hidden_size, dtype=dtype, device=device)
indices = torch.arange(max_label_len, device=device).expand(batch_size, -1)
batch_len_clamped = torch.clamp(batch_len, max=max_label_len)
batch_len_2d = batch_len_clamped.reshape(batch_size, 1) # 关键:不用 unsqueeze
frame_fires_idxs = indices < batch_len_2d
num_slots = int(frame_fires_idxs.sum().item())
frame_fires[frame_fires_idxs] = frames[:num_slots]
return frame_fires, fires
import funasr.models.paraformer.cif_predictor as cifp
cifp.cif_v1_export = torch.jit.script(patched_cif_v1_export)
7.4.2 强制固定 pre_token_length
import funasr.models.contextual_paraformer.export_meta as cp_em
def patched_export_backbone_forward(self, speech, speech_lengths, bias_embed):
batch = {"speech": speech, "speech_lengths": speech_lengths}
enc, enc_len = self.encoder(**batch)
mask = self.make_pad_mask(enc_len)[:, None, :]
pre_acoustic_embeds, _pre_token_length, _, _ = self.predictor(enc, mask)
B = pre_acoustic_embeds.shape[0]
# 强制 pre_token_length = MAX_LABEL_LEN,让 decoder mask 尺寸静态
pre_token_length_fixed = torch.full((B,), MAX_LABEL_LEN, dtype=torch.int32,
device=pre_acoustic_embeds.device)
decoder_out, _ = self.decoder(enc, enc_len, pre_acoustic_embeds,
pre_token_length_fixed, bias_embed)
decoder_out = torch.log_softmax(decoder_out, dim=-1)
return decoder_out, pre_token_length_fixed
cp_em.export_backbone_forward = patched_export_backbone_forward
7.4.3 触发 ONNX 导出
from funasr import AutoModel
m = AutoModel(model="./models/paraformer", disable_update=True)
m.export(quantize=False, opset_version=15)
# 产出 model.onnx + model_eb.onnx
为什么 Unsqueeze 要改 Reshape?
hbdk4 4.7.5 在 CIF 分支的 remains[fire_idxs].unsqueeze(-1) 上会触发 type_inf 异常。将所有 .unsqueeze(...) 改成等价的 .reshape(...) 后,输出 ONNX 用的是 Reshape 算子,hbdk4 可以正常导出。
7.5 ONNX 图手术流水线
从 FunASR 原始 ONNX 到 hbdk4-ready 4 输入 decoder-only,共 7 步图手术:
model.onnx (FunASR 导出) ← FunASR AutoModel.export()
│
▼
extract_decoder_predictor.py ← 抽 /decoder/* 子图
│ · 非 /decoder/ 全部作为 boundary
│ · 5 输入 → decoder_only.onnx
▼
convert_gather_int64_to_int32.py ← HMCT 崩在 INT64 Gather → 转 INT32
│
▼
topsort.py ← onnx.checker 要求拓扑序
│
▼
fold_range_ops.py ← hbdk4 拒绝 Range → 展开为 Constant
│
▼
shape_freeze.py (onnxsim) ← 固定 shape 传播 + 常量折叠
│ 2903 → 1214 nodes
▼
axis_normalize + fold /predictor/Gather → Const(1)
│ · 消除 dynamic-repeats 的 Tile
│ · negative axis → positive
▼
decoder_only_final.onnx (1137 nodes) ✅ hbdk4 export 通过
同时需要抽出 encoder_only.onnx 和 predictor_only.onnx(步骤类似,边界不同)。
常见问题一览:
| 问题 | 症状 | 解决 |
|---|---|---|
| INT64 Gather | HMCT adjust_multi_output_use_quant_info_pass_fail |
145 个 Constant INT64→INT32 |
| Range 算子 | hbdk4: Operator Range should be optimized |
ORT 探针取值 → Constant |
| Unsqueeze type_inf | hbdk4 type_inf 异常(CIF remains.unsqueeze(-1)) |
改写为 .reshape(-1,1)(见 §4.1) |
| GatherND 动态索引 | size of last dimension of index cannot be dynamic |
CIF 整体切到 CPU;decoder 用 pre_acoustic_embeds 作 graph input |
| Tile 动态 repeats | cannot create ArrayAttr from OpResult |
折 /predictor/Gather_output_0 (batch=1) 为 Constant |
| Split axis=-1 | hbir slice 推断 shape 错误 | 所有 negative axis 归一化到 positive |
7.6 量化编译(三个 HBM)
7.6.1 统一编译参数
三个 hbm 都用同一套 compiler_parameters + quant_config:
compiler_parameters:
optimize_level: O2
compile_mode: latency
core_num: 1 # S100 单 BPU 核
jobs: 32 # 编译并行度,按机器 CPU 核数调整
cache_mode: disable
# 通用 quant_config 片段
quant_config:
model_config:
all_node_type: int16
activation:
calibration_type: max
7.6.2 Encoder
YAML (encoder_int16.yaml):
model_parameters:
onnx_model: ./encoder_nomask.onnx
march: nash-e
output_model_file_prefix: paraformer_encoder_int16
working_dir: ./encoder_int16_output
input_parameters:
input_name: speech
input_shape: 1x400x560
input_type_rt: featuremap
input_type_train: featuremap
input_layout_train: NCHW
separate_batch: false
calibration_parameters:
cal_data_dir: ./calib_data/speech # 50 条真实 fbank+LFR 特征
calibration_type: max
quant_config:
model_config:
all_node_type: int16
activation:
calibration_type: max
compiler_parameters:
optimize_level: O2
compile_mode: latency
core_num: 1
jobs: 32
编译:
hb_compile -c configs/encoder_int16.yaml
产物指标:
| 项 | 值 |
|---|---|
| hbm 大小 | 211.5 MB |
| 静态延迟 | 32.52 ms (FPS 30.75) |
| 静态内存 | 211 MB |
| 动态内存 | 3.6 MB |
| DDR 占用 | 499 MB |
| 编译时间 | ~55 min (jobs=32) |
7.6.3 Predictor
Predictor 的校准数据是 encoder 的 FP32 输出(不是随机数据)。先在 FP32 pipeline 上跑 50 条真实音频,把 encoder 输出存到 real_calib/encoder_after_norm_Add_1_output_0/。
YAML:
model_parameters:
onnx_model: ./predictor_only.onnx
march: nash-e
output_model_file_prefix: predictor_int16
working_dir: ./predictor_int16_output
input_parameters:
input_name: /encoder/after_norm/Add_1_output_0
input_shape: 1x400x512
input_type_rt: featuremap
input_type_train: featuremap
input_layout_train: NCHW
separate_batch: false
calibration_parameters:
cal_data_dir: ./real_calib/encoder_after_norm_Add_1_output_0
calibration_type: max
quant_config:
model_config:
all_node_type: int16
activation:
calibration_type: max
compiler_parameters:
optimize_level: O2
compile_mode: latency
core_num: 1
jobs: 32
产物指标:
| 项 | 值 |
|---|---|
| hbm 大小 | ~4 MB |
| 静态延迟 | ~0.35 ms (FPS ~2877) |
| 输出 | /predictor/Add_output_0 [1,401] (alphas) + /predictor/Concat_5_output_0 [1,401,512] |
7.6.4 Decoder
Decoder 的校准数据必须包含 4 个真实分布输入(encoder 输出、frame_fires、token_num、bias_embed)。跑完整 FP32 pipeline 提取,存入 real_calib/ 对应子目录。
YAML:
model_parameters:
onnx_model: ./decoder_only_final.onnx
march: nash-e
output_model_file_prefix: decoder_int16
working_dir: ./decoder_int16_output
input_parameters:
input_name: /encoder/after_norm/Add_1_output_0;token_num;bias_embed;onnx::Shape_8609
input_shape: 1x400x512;1;1x1x512;1x100x512
input_type_rt: featuremap;featuremap;featuremap;featuremap
input_type_train: featuremap;featuremap;featuremap;featuremap
input_layout_train: NCHW;NCHW;NCHW;NCHW
separate_batch: false
calibration_parameters:
cal_data_dir: ./real_calib/encoder_after_norm_Add_1_output_0;./real_calib/token_num;./real_calib/bias_embed;./real_calib/shape_8609
calibration_type: max
quant_config:
model_config:
all_node_type: int16
activation:
calibration_type: max
compiler_parameters:
optimize_level: O2
compile_mode: latency
core_num: 1
jobs: 32
产物指标:
| 项 | 值 |
|---|---|
| hbm 大小 | 73.5 MB |
| 静态延迟 | 5.77 ms (FPS 173) |
| DDR 占用 | 127 MB |
| 输入 | 4 个:encoder_out, token_num, bias_embed, pre_acoustic_embeds |
| 输出 | logits [1, 100, 8404] |
7.7 CIF(CPU NumPy)实现
CIF 在 CPU 用 numpy 实现,位于 encoder+predictor 与 decoder 之间:
import numpy as np
MAX_LABEL_LEN = 100
def cif_numpy(alphas, concat5, real_T):
"""
Args:
alphas: [1, 401] fp32 — predictor 输出(激活权重)
concat5: [1, 401, 512] fp32 — predictor 拼接输出(decoder cross-attn 用)
real_T: int — 真实 LFR 帧数(用于 mask 掉 padding 区域的虚假 fire)
Returns:
frame_fires: [1, 100, 512] fp32 — pre_acoustic_embeds
token_num: [1] int32
"""
alphas = alphas.copy()
if real_T is not None and real_T < alphas.shape[1]:
alphas[:, real_T:] = 0.0 # ★ 关键:不做这一步 CER 从 5% 涨到 44%
B, T = alphas.shape
H = concat5.shape[-1]
prefix_sum = np.cumsum(alphas.astype(np.float64), axis=1).astype(np.float32)
prefix_sum_floor = np.floor(prefix_sum)
disl_ps_floor = np.floor(np.roll(prefix_sum, 1, axis=1))
disl_ps_floor[:, 0] = 0
fire_idxs = (prefix_sum_floor - disl_ps_floor) > 0
fires = np.zeros_like(prefix_sum)
fires[fire_idxs] = 1.0
fires = fires + prefix_sum - prefix_sum_floor
prefix_sum_hidden = np.cumsum(
alphas[..., None].astype(np.float64) * concat5.astype(np.float64),
axis=1).astype(np.float32)
frames = prefix_sum_hidden[fire_idxs]
shift_frames = np.roll(frames, 1, axis=0)
batch_len = fire_idxs.sum(axis=1)
batch_idxs = np.cumsum(batch_len)
shift_batch_idxs = np.roll(batch_idxs, 1); shift_batch_idxs[0] = 0
shift_frames[shift_batch_idxs] = 0
remains = fires - np.floor(fires)
remain_frames = remains[fire_idxs][:, None] * concat5[fire_idxs]
shift_remain_frames = np.roll(remain_frames, 1, axis=0)
shift_remain_frames[shift_batch_idxs] = 0
frames = frames - shift_frames + shift_remain_frames - remain_frames
frame_fires = np.zeros((B, MAX_LABEL_LEN, H), dtype=np.float32)
indices = np.arange(MAX_LABEL_LEN)[None, :]
batch_len_clamped = np.clip(batch_len, None, MAX_LABEL_LEN)
slot_mask = indices < batch_len_clamped[:, None]
num_slots = int(slot_mask.sum())
frame_fires[slot_mask] = frames[:num_slots]
return frame_fires, batch_len_clamped.astype(np.int32)
7.8 端到端推理 Pipeline
import numpy as np
import soundfile as sf
import torch, json
# 加载 vocab
vocab = json.load(open("./models/paraformer/tokens.json", encoding="utf-8"))
# 加载 3 个 hbm 模型(示意;实际板端用 hrt/hbrt4 API)
encoder = load_hbm("./encoder_int16.hbm")
predictor = load_hbm("./predictor_int16.hbm")
decoder = load_hbm("./decoder_int16.hbm")
def paraformer_infer(wav_path):
# 1. Frontend (CPU): fbank + LFR
wav, sr = sf.read(wav_path); assert sr == 16000
feats = extract_fbank_lfr(wav) # [T, 560]
real_T = feats.shape[0] # 真实帧数(用于 CIF mask)
speech = pad_or_truncate(feats, 400)[None, ...].astype(np.float32) # [1, 400, 560]
# 2. Encoder (BPU INT16)
enc_out = encoder.run(speech) # [1, 400, 512]
# 3. Predictor (BPU INT16)
alphas, concat5 = predictor.run(enc_out) # [1,401], [1,401,512]
# 4. CIF (CPU numpy)
pre_acoustic, token_num = cif_numpy(alphas, concat5, real_T) # [1,100,512], [1]
# 5. Decoder (BPU INT16)
bias_embed = np.zeros((1, 1, 512), dtype=np.float32) # 无热词
logits = decoder.run(enc_out, token_num, bias_embed, pre_acoustic) # [1,100,8404]
# 6. Postprocess: argmax + tokenizer decode
tn = int(token_num[0])
ids = np.argmax(logits[0, :tn], axis=-1)
return "".join(vocab[int(i)] for i in ids
if not (vocab[int(i)].startswith("<") and vocab[int(i)].endswith(">")))
7.9 WER 实测结果
7.9.1 评估配置
- 数据集:AISHELL dev(
speech_asr_aishell_devsets)300 条样本,覆盖 40 speakers - 参考文本:AISHELL 官方转写
- 指标:CER(字级 Levenshtein / 总参考字数)
- Pipeline:fbank+LFR → 3 个 hbm(板端 hbm_runtime / UCP)/ 对应 ptq_model.onnx(主机仿真)+ CPU CIF
7.9.2 结果
| 环境 | 采样 | CER | 相对 FP32 |
|---|---|---|---|
| FP32 baseline(原始 ONNX) | 300 条 | 5.20% | — |
| 主机仿真 INT16(HMCT ptq_model) | 300 条 | 5.02% | -0.18 pp |
| 板端实测 INT16 · Python hbm_runtime | 300 条 | 3.13% |
-2.07 pp |
| 板端实测 INT16 · C++ UCP | 300 条 | 3.13% |
-2.07 pp |
结论:INT16 全量化不但没损失精度,板端硬件路径反而比主机 HMCT 仿真更精确(3.13% vs 5.02%)——原因是 HMCT 的 QDQ 仿真节点引入了额外的舍入路径,而板端 hbm 直接用真实 BPU 定点算子实现。Python hbm_runtime 与 C++ UCP 输出 逐 bit 一致。
7.9.3 样本对比(板端前 5 条)
| # | GT | 板端 INT16 输出 |
|---|---|---|
| 1 | 广州市房地产中介协会分析 | ✓ 完全正确 |
| 2 | 新地王的诞生迅速搅热南沙土地市场 | 新地王的诞生迅速绞热南沙土地市场(1 字替换) |
| 3 | 证监会已依法移送公安机关处理 | ✓ 完全正确 |
| 4 | 公开表示汽车是终极移动设备 | ✓ 完全正确 |
| 5 | 医用的有天然的合成的等 | 医用的由天然岛合成的岛(3 字替换) |
主要错误:CIF 边界字符漏掉、少量同音替换。均属正常量化误差范围。
7.10 板端部署与实测延迟
7.10.1 HBM 上板
将编译好的 3 个 hbm + 词表拷贝到板端(S100,Ubuntu 22.04 aarch64):
cd model && bash download_model.sh
7.10.2 板端单模型延迟测试(hrt_model_exec perf)
板端自带命令行工具,可直接测每个 hbm 的纯 BPU 延迟:
# thread=1 → 单帧纯 BPU 延迟
hrt_model_exec perf --model_file encoder_int16.hbm --thread_num 1 --frame_count 200
hrt_model_exec perf --model_file predictor_int16.hbm --thread_num 1 --frame_count 500
hrt_model_exec perf --model_file decoder_int16.hbm --thread_num 1 --frame_count 200
板端 perf 单帧延迟(S100,单 BPU 核):
| 模块 | perf 延迟 | 编译静态估算 | FPS |
|---|---|---|---|
| Encoder INT16 | 33.11 ms | 32.52 ms | 30.18 |
| Predictor INT16 | 0.67 ms | 0.35 ms | 1462 |
| Decoder INT16 | 6.12 ms | 5.77 ms | 162.8 |
S100 单核 BPU 已被饱和;--thread_num 8 只会并发排队,单帧延迟不变。
7.10.3 板端端到端 Pipeline 常驻推理
板端目录布局:
<rdk_model_zoo>/samples/speech/paraformer/
├── model/ # 执行 download_model.sh,安装三段 HBM、tokens 和前端资源
├── runtime/python/ # hbm_runtime 实现:paraformer.py + main.py + run.sh
├── runtime/cpp/ # UCP/hbDNN 实现:CMakeLists.txt + src/paraformer.cpp + run.sh
└── <AUDIO_DATA_DIR>/
├── audio/*.wav # 16 kHz 原始语音
└── manifest.json # [{utt_id, text}, ...]
7.10.3.1 Python 常驻(推荐做原型验证)
用地瓜官方 hbm_runtime Python 包(详见 Python 接口手册):
# 板端已预装 hobot-dnn,可直接 import
pip3 list | grep hbm-runtime
# hbm-runtime 0.1.0.post1
cd runtime/python
bash run.sh <AUDIO_DATA_DIR> # 默认全 manifest
N_UTT=30 bash run.sh <AUDIO_DATA_DIR> # 或限定 30 条
当前 Python Runtime 仅包含两个代码文件:paraformer.py 负责 FunASR WAV 前处理、三段 HBM、CPU CIF 和解码;main.py 负责 manifest 调度、时延统计,并提供给 C++ Runtime 使用的内部特征生成模式。用户直接执行 run.sh 即可,无需自行调用 hbm_runtime 接口或准备 .npy 特征。
# main.py 的正常模式:WAV → 前处理 → Encoder → Predictor → CIF → Decoder
python3 main.py \
--manifest <AUDIO_DATA_DIR>/manifest.json \
--audio-dir <AUDIO_DATA_DIR>/audio
# 仅供 runtime/cpp/run.sh 内部调用:WAV → build/eval/feats/*.npy
python3 main.py \
--manifest build/eval/manifest.json \
--audio-dir <AUDIO_DATA_DIR>/audio \
--output-dir build/eval/feats \
--preprocess-only
--preprocess-only 仅用于 C++ Runtime bridge:它在临时目录写入特征和 feat_length,C++ 可执行程序仍保持原有 hbUCP/hbDNN pipeline。普通用户不需要手动使用该模式。
Python 300 条验证记录(实现见 runtime/python/paraformer.py 与 runtime/python/main.py):
| 阶段 | 延迟 |
|---|---|
| Encoder (BPU) | 33.63 ms |
| Predictor (BPU) | 1.44 ms |
| CIF (CPU numpy) | 3.41 ms |
| Decoder (BPU) | 7.12 ms |
| 端到端 | 45.61 ms/utt |
| RTF | ~0.008 |
wall-clock:300 条 14.0 秒(46.7 ms/utt 含 numpy I/O)
上述 45.61 ms/utt 是历史验证的 HBM pipeline 指标(Encoder、Predictor、CPU CIF、Decoder),不包含 WAV 前处理。当前 main.py 会单独输出 frontend_ms、Average HBM pipeline 和 Average end-to-end,因此从 WAV 直接运行时的端到端时间会额外包含前处理耗时,不应与历史 HBM pipeline 指标直接混用。
7.10.3.2 C++ UCP 常驻(推荐上产)
用底层 hbUCP + hbDNN C API 直接对接 libhbucp.so / libdnn.so。见 runtime/cpp/src/paraformer.cpp 完整实现,编译:
cd runtime/cpp
N_UTT=300 bash run.sh <AUDIO_DATA_DIR> # 跑 300 条 CER
runtime/cpp/run.sh 会先调用 ../python/main.py --preprocess-only 将 WAV 写入 build/eval/feats/,再启动 C++ 可执行程序。该 .npy 目录为运行时临时 bridge,用户只需要提供 WAV 和 manifest。
关键 API 调用序列:
#include <hobot/dnn/hb_dnn.h>
#include <hobot/hb_ucp.h>
#include <hobot/hb_ucp_sys.h>
// 一次加载 3 个 hbm
hbDNNPackedHandle_t packed;
const char* files[] = {"/opt/hobot/model/s100/basic/paraformer/paraformer_large_encoder_400x560_s100.hbm",
"/opt/hobot/model/s100/basic/paraformer/paraformer_large_predictor_400x512_s100.hbm",
"/opt/hobot/model/s100/basic/paraformer/paraformer_large_decoder_400x512_s100.hbm"};
hbDNNInitializeFromFiles(&packed, files, 3);
// 每个模型的 handle + 张量属性 + 预分配 BPU 内存(hbUCPMalloc)
hbDNNHandle_t enc_h; hbDNNGetModelHandle(&enc_h, packed, "paraformer_encoder_int16");
hbDNNTensor enc_in, enc_out;
hbDNNGetInputTensorProperties(&enc_in.properties, enc_h, 0);
hbUCPMalloc(&enc_in.sysMem, enc_in.properties.alignedByteSize, 0);
// ... 类似给 predictor / decoder 的所有输入输出预分配
// 每次推理循环
memcpy(enc_in.sysMem.virAddr, speech_data, N); // 需考虑 aligned stride
hbUCPMemFlush(&enc_in.sysMem, HB_SYS_MEM_CACHE_CLEAN);
hbUCPTaskHandle_t task;
hbDNNInferV2(&task, &enc_out, &enc_in, enc_h);
hbUCPSchedParam sched;
HB_UCP_INITIALIZE_SCHED_PARAM(&sched);
sched.backend = HB_UCP_BPU_CORE_ANY;
hbUCPSubmitTask(task, &sched);
hbUCPWaitTaskDone(task, 0);
hbUCPReleaseTask(task);
hbUCPMemFlush(&enc_out.sysMem, HB_SYS_MEM_CACHE_INVALIDATE);
// 读出 enc_out.sysMem.virAddr,同样处理 predictor / CIF / decoder
Aligned stride 处理:BPU 内存按 16 字节对齐(例如 [1,400,560] fp32 的 stride 是 [921600, 2304, 4] 而非 [896000, 2240, 4])。memcpy 平摊拷贝会数据错位,必须按行 padding;示例代码里的 Model::copy_tensor() 处理了 fast/slow 两种路径。
C++ UCP 300 条验证记录(实现见 runtime/cpp/src/paraformer.cpp):
| 阶段 | Python | C++ UCP | 加速 |
|---|---|---|---|
| Encoder (BPU) | 33.63 | 33.15 | ≈ |
| Predictor (BPU) | 1.44 | 1.00 | ≈ |
| CIF (CPU) | 3.41 | 0.38 | 9x |
| Decoder (BPU) | 7.12 | 6.29 | ≈ |
| 端到端 | 45.61 | 40.81 ms/utt | 1.12x |
| CER | 3.13% | 3.13% | 一致 |
wall-clock:300 条 13.4 秒(44.7 ms/utt);一次模型加载 ~1.85 s
C++ 版本主要优势:CPU 侧 CIF 快 9x(numpy 开销比手写循环大),BPU 部分相同(都通过同一底层库)。
7.10.4 结论
部署选型:
- 原型 / 调试:Python
hbm_runtime(runtime/python/paraformer.py与runtime/python/main.py)— 40+ 行代码即可上手 - 生产 / 追求极致延迟:C++ UCP(
runtime/cpp/src/paraformer.cpp)— 40.8 ms/utt,RTF 0.007
7.11 常见问题与经验
7.11.1 校准数据必须匹配真实分布
症状:INT16 pipeline CER = 100%(decoder 所有位置 argmax 到 </s>),FP16 输出全 NaN。
根因:使用 np.random.randn 生成校准数据,encoder 输出在 N(0,1) 空间,但实际部署时 encoder 输出范围只在 [-0.4, 0.3],INT16 量化 scale 完全错位。
修复:跑 FP32 pipeline 在 50 条真实音频上,捕获真实的 enc_out / concat5 / alphas / frame_fires / token_num / bias_embed,用作校准数据。
教训:校准数据的分布必须严格匹配部署时的输入分布。用随机数据是最常见的量化失败原因。
7.11.2 Padding 会产生虚假 CIF fire
症状:FP32 pipeline CER = 44.4%,输出前 N 字正确,后面全是垃圾字符。
根因:encoder 的 make_pad_mask 被常量折叠成全 1(fixed shape 编译要求),padding 区域的 encoder 输出未被 mask,predictor 在这些位置也生成非零 alphas → CIF 虚假 fire。
修复:CIF 前 alphas[:, real_T:] = 0,其中 real_T 是真实 LFR 帧数。必须做这一步,否则 CER 从 5% 升到 44%。
7.11.3 生成真实校准数据(脚本示例)
# gen_real_calib.py
import os, glob
import numpy as np
import onnxruntime as ort
enc = ort.InferenceSession("encoder_nomask.onnx", providers=["CPUExecutionProvider"])
pred = ort.InferenceSession("predictor_only.onnx", providers=["CPUExecutionProvider"])
BASE = "./real_calib"
for name in ["encoder_after_norm_Add_1_output_0", "predictor_Concat_5_output_0",
"predictor_Add_output_0", "shape_8609", "token_num", "bias_embed"]:
os.makedirs(f"{BASE}/{name}", exist_ok=True)
for i, feat in enumerate(sorted(glob.glob("./calib_data/speech/*.npy"))[:50]):
speech = np.load(feat) # [1, 400, 560]
enc_out = enc.run(None, {"speech": speech})[0]
pr = dict(zip([o.name for o in pred.get_outputs()],
pred.run(None, {"/encoder/after_norm/Add_1_output_0": enc_out})))
alphas = pr["/predictor/Add_output_0"]
concat5 = pr["/predictor/Concat_5_output_0"]
frame_fires, token_num = cif_numpy(alphas, concat5, real_T=None) # 校准用完整长度
bias = np.zeros((1, 1, 512), dtype=np.float32)
np.save(f"{BASE}/encoder_after_norm_Add_1_output_0/{i:03d}.npy", enc_out)
np.save(f"{BASE}/predictor_Concat_5_output_0/{i:03d}.npy", concat5)
np.save(f"{BASE}/predictor_Add_output_0/{i:03d}.npy", alphas)
np.save(f"{BASE}/shape_8609/{i:03d}.npy", frame_fires)
np.save(f"{BASE}/token_num/{i:03d}.npy", token_num)
np.save(f"{BASE}/bias_embed/{i:03d}.npy", bias)
7.12 已知限制
- Contextual embedder:热词编码器 (
bias_encoder) 仍是 FP32 ONNX,未上 BPU。本文示例走全 zeros bias(等同于关闭 Contextual)。启用热词需单独编 embedder hbm。 - 音频长度上限:当前固定 T=400 帧(≈24 秒)。<24s 音频用 padding +
alphas[real_T:]=0;>24s 需分段处理。
7.13 最终配置汇总
| 模块 | 量化 | hbm 大小 | 板端 perf 单帧 | Python 常驻 | C++ UCP 常驻 |
|---|---|---|---|---|---|
| Encoder | INT16 all | 211.5 MB | 33.11 ms | 33.63 ms | 33.15 ms |
| Predictor | INT16 all | ~4 MB | 0.67 ms | 1.44 ms | 1.00 ms |
| CIF | CPU numpy | — | — | 3.41 ms | 0.38 ms |
| Decoder | INT16 all | 73.5 MB | 6.12 ms | 7.12 ms | 6.29 ms |
| 端到端 | — | ~289 MB | ~41 ms | 45.61 ms | 40.81 ms |
| CER | — | — | — | 3.13% | 3.13% |
精度总览:
| 环境 | AISHELL dev 300 条 CER |
|---|---|
| FP32 baseline (原始 ONNX) | 5.20% |
| INT16 主机仿真 (HMCT ptq_model) | 5.02% |
| INT16 板端实测 (Python / C++ UCP) | 3.13% |
部署路径:
- 原型验证 →
runtime/python/paraformer.py与runtime/python/main.py(Pythonhbm_runtime) - 生产部署 →
runtime/cpp/src/paraformer.cpp(hbUCP+hbDNNC API)
8. 相关文档
- 模型下载:
samples/speech/paraformer/model/README_cn.md - Python Runtime:
samples/speech/paraformer/runtime/python/README_cn.md - C++ Runtime:
samples/speech/paraformer/runtime/cpp/README_cn.md - 量化与转换:
samples/speech/paraformer/conversion/README_cn.md - 评测说明:
samples/speech/paraformer/evaluator/README_cn.md
欢迎大家在 RDK S100 上体验,也欢迎反馈不同麦克风、不同中文场景和长音频分段场景下的使用情况。