跳至内容
Ludovico's Blog
返回

QAIRT 2.42 适配 Streaming Spatial Speech Enhancement Network 的 GRU

本文记录 QAIRT 2.42 在 QCS8550 / HTP v73 上适配流式 GRU 网络的过程,覆盖 GRU 语义、图规模、后端执行稳定性和非 GRU 算子的 layout 错误。

从 ONNX 图的输入、节点命名和数据流判断,本文的目标模型可称为 Streaming Spatial Speech Enhancement Network:输入是复数频谱实部/虚部、DOA 与跨帧 cache;前端构造频谱和空间特征,decoder 使用 intra/inter 双路径 RNN,输出增强后的两路复数频谱和新的流式状态。

本文所有结论基于 QAIRT 2.42.0.251225、FP16 图、QCS8550 HTP v73 的实际转换和板端执行。

先读图,不要先猜模型

原始图有 8 个输入:

输入shape含义推断
x_real, x_imag[1, 2, 257]两路复数频谱的实部与虚部
doa[1]到达角或空间先验
gru_state[2, 257, 12]编码端循环状态
attn_k_cache, attn_v_cache[257, 10, 12]流式 attention cache
tconv1_r_cache[1, 257, 12]实部时序卷积 cache
tconv1_i_cache[1, 257, 2]虚部时序卷积 cache

图中可见 Cos/Sin、成对实虚部计算、Einsum 外积、feat_embscm_embintra_rnninter_rnn。其中 scm 可以合理理解为空间协方差矩阵(spatial covariance matrix)特征分支;intra/inter 是典型 dual-path RNN 的命名。

模型按帧执行:每次调用消费上一帧 cache,输出下一帧 cache。接口 layout 参与状态传递语义。

六个 GRU 的形状

模型共有 6 个 ONNX GRU,且全部使用 linear_before_reset=1

节点direction逻辑 shapehidden
GRU_emb/GRUforward[seq=1, batch=257, input=24]12
GRU_emb/GRU_1forward[1, 257, 12]12
intra_rnn/rnn1/GRUbidirectional[257, 1, 6]3
intra_rnn/rnn2/GRUbidirectional[257, 1, 6]3
inter_rnn/rnn1/GRUforward[1, 257, 6]6
inter_rnn/rnn2/GRUforward[1, 257, 6]6

部署策略由 sequence 长度和 batch 大小决定:

4 个短 GRU: sequence = 1, batch = 257
2 个长双向 GRU: sequence = 257, batch = 1

前者展开一个 cell 的代价有限;后者若按 257 个时间步、双向、两层展开,图规模会急剧增长。

默认 GRU lowering 先在 Streaming Acoustic Echo Cancellation Network 上暴露问题

QAIRT 的 ONNX converter 默认先按时间维 unroll,再将 cell 展开为 MatMul/Add/Sigmoid/Tanh/Mul/Sub 等基础算子。

相关逻辑位于:

converters/common/converter_ir/op_graph_optimizations.py
  unroll_gru_time_steps()
  expand_gru_op_structure()

较短的 Streaming Acoustic Echo Cancellation Network 在 HTP 上出现默认 GRU lowering 数值错误。处理方式是在 ONNX 层按 linear_before_reset=1 公式手工展开 GRU,并用 ONNX Runtime 验证等价性。

Streaming Spatial Speech Enhancement Network 需要单独评估图规模。

为什么不能把 Streaming Spatial Speech Enhancement Network 的所有 GRU 都手工展开

手工展开长双向 GRU 时,每个时间步都要构造 update/reset/candidate 三组门计算,还需要状态串联、slice、concat 和方向处理。两个 seq=257 的双向 GRU 展开后会形成数万级基础算子。

该图在数学上可行,部署成本较高:

目标是保留单时间步 native QNN Gru cell,避免继续展开 cell 内部算子。

从 QAIRT 源码找到两个独立开关

源码里 GRU 的行为由三个开关控制:

multi_time_steps_gru
unroll_gru_time_steps
expand_gru_op_structure

三个开关分别控制不同阶段:

multi_time_steps_gru
  保留多时间步 GRU,由后端处理整个 sequence

unroll_gru_time_steps
  将 sequence 拆成 T 个单时间步 Gru cell

expand_gru_op_structure
  将每个 Gru cell 继续展开为 MatMul/Add/Sigmoid/Tanh/... 基础算子

官方 ONNX 路径最终会展开 GRU。实验使用 converter 入口副本,仅修改强制设置:

args.unroll_gru_time_steps = True
args.expand_gru_op_structure = False

这样会保留 native 的单步 QNN Gru,但 sequence=257 的 GRU 仍会成为 257 个 cell;一个双向 GRU 则是 514 个 cell。

Multi-time-step 与完整 native 图的执行边界

最初使用不 unroll 的多时间步 native GRU。转换、compose 和 context prepare 可以通过,板端在 Executing Graphs 阶段失败;相关实验出现 HTP skel DMA error 1100。短序列 probe 同样出现明显误差。故障覆盖前向、双向和 linear_before_reset 配置。

随后改为:

长双向 GRU: unroll = True, expand = False

即 257 个 native single-step cell。单独抽取的长双向 GRU 在 v73 上精度正常,说明单步 native cell 的语义没有问题。

6 个 GRU 全部改为 native cell 后,完整图包含 1,032 个 cell。context 可以生成,执行阶段仍可能失败。context 生成和 qnn-net-run 执行需要作为独立关卡验证。

最终 GRU 策略:短 GRU 展开,长双向 GRU 保留 native cell

最终采用混合策略:

sequence=1 的 4 个 forward GRU
  -> 手工展开为基础算子

sequence=257 的 2 个 bidirectional GRU
  -> QAIRT unroll 为单时间步 native QNN Gru cell
  -> 不再展开 cell 内部结构

该策略对应两组 shape 的后端行为:短 GRU 使用基础算子展开;长 GRU 使用 native cell,避免数万级基础算子图。

FP16 精度仍然差:不要继续怀疑 GRU,做逐层分析

混合图执行后仍有大误差,部分 cache cosine 接近 0,中间 tensor 出现 NaN。该误差量级超过 FP16 舍入误差范围。

使用 target-side context 的中间输出能力进行逐层比对:

qnn-context-binary-generator --set_output_tensors tensor_a,tensor_b
qnn-net-run --retrieve_context ...

每次选择一组 ONNX 中间 tensor,导出 QNN 结果,再与同一输入下的 ONNX Runtime 对比。比较目标是定位第一个错误 tensor

首个错误:Einsum 的 rank-5 broadcast Mul

最早看到明显偏差的位置在 scm_relu/PRelu。该节点的 slope 为负数,一度怀疑 HTP 的 PRelu 或 opset 16 parser 有问题。

把 PRelu 等价改写为:

y = Relu(x) + slope * (x - Relu(x))

后,误差仍然存在。继续向前导出中间 tensor,发现 SCM Conv 前的特征已经坏了,进一步定位到先前为兼容性手工展开的 4 个 Einsum:

...ct,...et -> ...tce

最初写法是:

Transpose + Unsqueeze + Mul

在 ONNX Runtime 完全正确,但 HTP 对两个 rank-5 tensor 的 broadcast Mul 做了错误的 layout lowering。四个外积结果的 cosine 都已明显失真,后续 Add/Sub 开始出现 NaN。

这些张量本质上是 outer product:

[1, 257, 1, 2, 1] @ [1, 257, 1, 1, 2]
-> [1, 257, 1, 2, 2]

Mul 换成等价的 batched MatMul

Transpose + Unsqueeze + MatMul

ONNX Runtime 输出保持等价,HTP 特征分支恢复正常。经验:ONNX broadcast 合法时,HTP layout lowering 仍需验证;高 rank outer product 使用 MatMul 表达。

另四个 bfc,bfc->bf Einsum 则安全地改写为:

Mul + ReduceSum(axis=-1)

最后一个陷阱:流式 passthrough cache 的 I/O layout

模型有三个输出只是输入 cache 的 passthrough:

new_attn_k_cache = Identity(attn_k_cache)
new_attn_v_cache = Identity(attn_v_cache)
new_tconv1_i_cache = Identity(tconv1_i_cache)

即使传入:

--preserve_io layout --preserve_onnx_output_order

QAIRT 仍可能先删除 Identity,使输出暴露为内部 NFC layout:

ONNX shape错误暴露的 QNN shape
[257, 10, 12][257, 12, 10]
[1, 257, 2][1, 2, 257]

数值本身并未错误,但应用必须额外 transpose 才能按 ONNX 语义读取。这会把接口兼容问题伪装成精度问题。

修复是把三个 Identity 改为等价但不会被 Identity pass 删除的输出锚点:

Identity(x)  ->  Add(x, 0)

之后 --preserve_io layout 能在图出口插入 NFC -> NCF 转置,QNN 输出 shape 与 ONNX 完全一致。

最终验证

最终图使用 FP16 native I/O:

--float_bitwidth 16
--preserve_io layout
--preserve_onnx_output_order

没有使用 --preserve_io datatype。这样外部 I/O 是 FP16,不会额外插入 FLOAT32 -> FLOAT16 Convert 节点。context 在 QCS8550/v73 目标端生成,使用 qnn-net-run --retrieve_context 验证。

同一组 deterministic mock 输入下,QNN 输出直接按 ONNX shape 读取:

输出max absMAEcosine
output0.00624966620.000518888580.999999081
new_gru_state0.00574314590.000437086860.999999691
new_attn_k_cache0.000976324080.000141022710.999999428
new_attn_v_cache0.00148248670.000140480100.999999560
new_tconv1_r_cache0.0108237710.000558436210.999999374
new_tconv1_i_cache0.000969409940.000145681481.000000000

这些是合理的 FP16 relaxed-precision 误差,不再有 layout 造成的伪大误差。

可复用的排查顺序

排查顺序:

先按 GRU 的 seq/batch 分组
  -> 评估完全展开后的图规模
  -> 查看 converter 是否有 unroll 和 expand 的独立开关
  -> 单算子验证 native cell,再验证完整图
  -> FP16 偏差异常时立即逐层比对
  -> 找第一个错误 tensor
  -> 高 rank broadcast 外积优先改为 MatMul
  -> 对 streaming cache 检查“值、shape、物理 layout”三件事

QAIRT/HTP 上的 GRU 可用性由 sequence 长度、单步 unroll、cell 展开方式、图内其他算子的 layout 和目标端 context prepare 共同决定。converter、context 和板端执行应分别验证。


分享:

上一篇
Agent 时代的基础设施:从 lark-cli 看执行 Harness 应该怎么建
下一篇
MNN QNN 里为什么 chunk size 会影响 PPL